jm

Odin for scripts: small packages and a runner, on core: only
Log | Files | Refs | README

sh.odin (9703B)


      1 /*
      2 Package sh runs external commands tersely.
      3 
      4 Two entry styles. A shell string goes through the platform shell, so pipes,
      5 globs and redirects work:
      6 
      7 	rev  := must(sh.out("git rev-parse HEAD"))
      8 	files := must(sh.lines("ls *.odin"))
      9 	if !sh.ok("test -d build") { ... }
     10 	sh.run("just build")   // inherits the terminal; returns the exit code
     11 
     12 An argv slice bypasses the shell, so arguments need no quoting:
     13 
     14 	r := sh.exec({"git", "log", "-1", "--format=%s", subject})
     15 	if !r.ok { die("%s", sh.error(r)) }
     16 
     17 The shell is /bin/sh on Unix and cmd.exe on Windows unless Opts.shell says
     18 otherwise. Cmd's quoting differs from sh; scripts that must run on both
     19 either use the argv form or ask for Shell.Pwsh, which behaves the same on all
     20 three wherever PowerShell 7 is installed.
     21 */
     22 package sh
     23 
     24 import "core:os"
     25 import "core:path/filepath"
     26 import "core:strings"
     27 import "core:time"
     28 
     29 Result :: struct {
     30 	// The command as the caller gave it; a joined argv for the exec forms.
     31 	cmd:       string,
     32 	stdout:    string,
     33 	stderr:    string,
     34 	// Exit code, or the signal number when the process was killed on Unix.
     35 	code:      int,
     36 	// Started, exited normally, and returned 0.
     37 	ok:        bool,
     38 	// Set when the process could not be started at all.
     39 	err:       os.Error,
     40 	// Set when the process ran past Opts.timeout and was killed; what it
     41 	// wrote before then is kept.
     42 	timed_out: bool,
     43 }
     44 
     45 Shell :: enum {
     46 	Default, // sh on Unix, cmd.exe on Windows
     47 	Sh,
     48 	Cmd,
     49 	Pwsh,
     50 }
     51 
     52 Opts :: struct {
     53 	// Working directory; "" keeps the current one.
     54 	dir:     string,
     55 	// Full environment as KEY=VALUE; nil inherits the parent's.
     56 	env:     []string,
     57 	// Fed to the child's stdin; "" closes stdin.
     58 	stdin:   string,
     59 	shell:   Shell,
     60 	// How long the child may run before it is killed; 0 is no limit. Only
     61 	// the capturing forms honour it.
     62 	timeout: time.Duration,
     63 }
     64 
     65 // capture runs cmd through the shell and returns everything it produced.
     66 capture :: proc(cmd: string, opts := Opts{}, allocator := context.allocator) -> Result {
     67 	argv := shell_argv(cmd, opts.shell, context.temp_allocator)
     68 	r := exec(argv, opts, allocator)
     69 	r.cmd = cmd
     70 	return r
     71 }
     72 
     73 // out runs cmd and returns its stdout with trailing whitespace removed.
     74 out :: proc(
     75 	cmd: string,
     76 	opts := Opts{},
     77 	allocator := context.allocator,
     78 ) -> (
     79 	s: string,
     80 	success: bool,
     81 ) {
     82 	r := capture(cmd, opts, allocator)
     83 	return strings.trim_right_space(r.stdout), r.ok
     84 }
     85 
     86 // lines runs cmd and returns its stdout split into lines, without a trailing
     87 // empty line.
     88 lines :: proc(
     89 	cmd: string,
     90 	opts := Opts{},
     91 	allocator := context.allocator,
     92 ) -> (
     93 	result: []string,
     94 	success: bool,
     95 ) {
     96 	r := capture(cmd, opts, allocator)
     97 	return split_lines(r.stdout, allocator), r.ok
     98 }
     99 
    100 // ok runs cmd and reports whether it exited with 0. Output is discarded.
    101 ok :: proc(cmd: string, opts := Opts{}) -> bool {
    102 	return capture(cmd, opts, context.temp_allocator).ok
    103 }
    104 
    105 // run runs cmd with the terminal attached, so the user sees its output live.
    106 run :: proc(cmd: string, opts := Opts{}) -> (code: int, success: bool) {
    107 	argv := shell_argv(cmd, opts.shell, context.temp_allocator)
    108 	return exec_run(argv, opts)
    109 }
    110 
    111 // exec runs argv directly and captures stdout and stderr.
    112 exec :: proc(argv: []string, opts := Opts{}, allocator := context.allocator) -> (r: Result) {
    113 	r.cmd = strings.join(argv, " ", allocator)
    114 	desc := os.Process_Desc {
    115 		working_dir = opts.dir,
    116 		command     = argv,
    117 		env         = opts.env,
    118 	}
    119 	stdin, stdin_path := stdin_file(opts.stdin)
    120 	defer cleanup_stdin(stdin, stdin_path)
    121 	desc.stdin = stdin
    122 
    123 	state, stdout, stderr, timed_out, err := capture_process(desc, opts.timeout, allocator)
    124 	if err != nil {
    125 		r.err = err
    126 		r.code = -1
    127 		r.stderr = os.error_string(err)
    128 		return
    129 	}
    130 	r.stdout = string(stdout)
    131 	r.stderr = string(stderr)
    132 	r.code = state.exit_code
    133 	r.timed_out = timed_out
    134 	r.ok = !timed_out && state.exited && state.success && state.exit_code == 0
    135 	return
    136 }
    137 
    138 // capture_process runs the process with both streams captured, as
    139 // os.process_exec does, and kills it when it runs past the timeout. What
    140 // the child wrote before then is returned with the state.
    141 capture_process :: proc(
    142 	desc: os.Process_Desc,
    143 	timeout: time.Duration,
    144 	allocator := context.allocator,
    145 ) -> (
    146 	state: os.Process_State,
    147 	stdout, stderr: []byte,
    148 	timed_out: bool,
    149 	err: os.Error,
    150 ) {
    151 	stdout_r, stdout_w := os.pipe() or_return
    152 	defer os.close(stdout_r)
    153 	stderr_r, stderr_w := os.pipe() or_return
    154 	defer os.close(stderr_r)
    155 
    156 	process: os.Process
    157 	{
    158 		// The write ends are closed on this side whatever happens, so the
    159 		// read ends see EOF once the child is done.
    160 		defer os.close(stdout_w)
    161 		defer os.close(stderr_w)
    162 		child := desc
    163 		child.stdout = stdout_w
    164 		child.stderr = stderr_w
    165 		process = os.process_start(child) or_return
    166 	}
    167 
    168 	out := make([dynamic]byte, allocator)
    169 	errs := make([dynamic]byte, allocator)
    170 	buf: [4096]u8 = ---
    171 	started := time.now()
    172 	stdout_done, stderr_done := false, false
    173 	for err == nil && (!stdout_done || !stderr_done) {
    174 		moved := false
    175 		if !stdout_done {
    176 			has_data, herr := os.pipe_has_data(stdout_r)
    177 			err = herr
    178 			n := 0
    179 			if err == nil && has_data {
    180 				n, err = os.read(stdout_r, buf[:])
    181 				moved = n > 0
    182 			}
    183 			switch err {
    184 			case nil:
    185 				append(&out, ..buf[:n])
    186 			case .EOF, .Broken_Pipe:
    187 				stdout_done = true
    188 				err = nil
    189 			}
    190 		}
    191 		if err == nil && !stderr_done {
    192 			has_data, herr := os.pipe_has_data(stderr_r)
    193 			err = herr
    194 			n := 0
    195 			if err == nil && has_data {
    196 				n, err = os.read(stderr_r, buf[:])
    197 				moved = moved || n > 0
    198 			}
    199 			switch err {
    200 			case nil:
    201 				append(&errs, ..buf[:n])
    202 			case .EOF, .Broken_Pipe:
    203 				stderr_done = true
    204 				err = nil
    205 			}
    206 		}
    207 		if timeout > 0 && time.since(started) > timeout {
    208 			_ = os.process_kill(process)
    209 			timed_out = true
    210 			break
    211 		}
    212 		if !moved {
    213 			// Nothing to read yet: yield rather than spin.
    214 			time.sleep(time.Millisecond)
    215 		}
    216 	}
    217 	stdout, stderr = out[:], errs[:]
    218 	if err != nil {
    219 		state, _ = os.process_wait(process, timeout = 0)
    220 		if !state.exited {
    221 			_ = os.process_kill(process)
    222 			state, _ = os.process_wait(process)
    223 		}
    224 		return
    225 	}
    226 	state, err = os.process_wait(process)
    227 	return
    228 }
    229 
    230 // exec_run runs argv with the terminal attached.
    231 exec_run :: proc(argv: []string, opts := Opts{}) -> (code: int, success: bool) {
    232 	desc := os.Process_Desc {
    233 		working_dir = opts.dir,
    234 		command     = argv,
    235 		env         = opts.env,
    236 		stdout      = os.stdout,
    237 		stderr      = os.stderr,
    238 		stdin       = os.stdin,
    239 	}
    240 	if opts.stdin != "" {
    241 		stdin, stdin_path := stdin_file(opts.stdin)
    242 		defer cleanup_stdin(stdin, stdin_path)
    243 		desc.stdin = stdin
    244 		return wait(desc)
    245 	}
    246 	return wait(desc)
    247 }
    248 
    249 // which finds name on PATH the way the shell would, honouring PATHEXT on
    250 // Windows. A name containing a separator is checked as given.
    251 which :: proc(name: string, allocator := context.allocator) -> (path: string, found: bool) {
    252 	if strings.contains_any(name, filepath.SEPARATOR_CHARS) {
    253 		if os.is_file(name) {
    254 			return strings.clone(name, allocator), true
    255 		}
    256 		return "", false
    257 	}
    258 	path_env, has_path := os.lookup_env("PATH", context.temp_allocator)
    259 	if !has_path {
    260 		return "", false
    261 	}
    262 	for dir in strings.split_iterator(&path_env, LIST_SEPARATOR) {
    263 		if dir == "" {
    264 			continue
    265 		}
    266 		for ext in executable_extensions() {
    267 			file := strings.concatenate({name, ext}, context.temp_allocator)
    268 			candidate, _ := filepath.join({dir, file}, context.temp_allocator)
    269 			if os.is_file(candidate) {
    270 				return strings.clone(candidate, allocator), true
    271 			}
    272 		}
    273 	}
    274 	return "", false
    275 }
    276 
    277 // error renders a failed Result as one message: the command, the exit code,
    278 // and whatever it wrote to stderr.
    279 error :: proc(r: Result, allocator := context.allocator) -> string {
    280 	b := strings.builder_make(allocator)
    281 	if r.err != nil {
    282 		strings.write_string(&b, "cannot start: ")
    283 		strings.write_string(&b, r.cmd)
    284 		strings.write_string(&b, ": ")
    285 		strings.write_string(&b, os.error_string(r.err))
    286 		return strings.to_string(b)
    287 	}
    288 	strings.write_string(&b, "command failed (exit ")
    289 	strings.write_int(&b, r.code)
    290 	strings.write_string(&b, "): ")
    291 	strings.write_string(&b, r.cmd)
    292 	stderr := strings.trim_right_space(r.stderr)
    293 	if stderr != "" {
    294 		strings.write_byte(&b, '\n')
    295 		strings.write_string(&b, stderr)
    296 	}
    297 	return strings.to_string(b)
    298 }
    299 
    300 // ---- internals ----------------------------------------------------------
    301 
    302 LIST_SEPARATOR :: ";" when ODIN_OS == .Windows else ":"
    303 
    304 wait :: proc(desc: os.Process_Desc) -> (code: int, success: bool) {
    305 	p, err := os.process_start(desc)
    306 	if err != nil {
    307 		return -1, false
    308 	}
    309 	state, werr := os.process_wait(p)
    310 	if werr != nil {
    311 		return -1, false
    312 	}
    313 	return state.exit_code, state.exited && state.success && state.exit_code == 0
    314 }
    315 
    316 // stdin_file spools text into a temp file and returns it opened for reading,
    317 // so the child can consume more than a pipe buffer without deadlock.
    318 stdin_file :: proc(text: string) -> (f: ^os.File, path: string) {
    319 	if text == "" {
    320 		return nil, ""
    321 	}
    322 	tmp, err := os.create_temp_file("", "sh-stdin-*", {.Read})
    323 	if err != nil {
    324 		return nil, ""
    325 	}
    326 	if _, werr := os.write_string(tmp, text); werr != nil {
    327 		os.close(tmp)
    328 		return nil, ""
    329 	}
    330 	if _, serr := os.seek(tmp, 0, .Start); serr != nil {
    331 		os.close(tmp)
    332 		return nil, ""
    333 	}
    334 	return tmp, strings.clone(os.name(tmp), context.temp_allocator)
    335 }
    336 
    337 cleanup_stdin :: proc(f: ^os.File, path: string) {
    338 	if f == nil {
    339 		return
    340 	}
    341 	os.close(f)
    342 	if path != "" {
    343 		os.remove(path)
    344 	}
    345 }
    346 
    347 split_lines :: proc(s: string, allocator := context.allocator) -> []string {
    348 	trimmed := strings.trim_right(s, "\r\n")
    349 	if trimmed == "" {
    350 		return nil
    351 	}
    352 	parts, _ := strings.split_lines(trimmed, allocator)
    353 	for &p in parts {
    354 		p = strings.trim_right(p, "\r")
    355 	}
    356 	return parts
    357 }