commit 12abde45be05f4a725b7755c7bf389b23211c5f5
parent 2300ca1fa3f4d6f68a2eac3c0988ca67c32a316b
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Wed, 23 Sep 2026 21:18:56 -0300
sh: run external commands through the shell or by argv
A script is mostly other programs, and Odin's core:os/os2 process API
takes a dozen lines per call. out, lines, ok and run take a shell string
so pipes, globs and redirects work; exec and exec_run take an argv slice
so arguments need no quoting. capture is the general form: stdin, a
working directory, extra environment and a timeout that kills a child
that runs too long. which finds a program on PATH, quote makes a word
safe for the platform shell, and error turns a failed result into one
line for die. The shell is /bin/sh on Unix and cmd.exe on Windows unless
Opts.shell picks PowerShell, which behaves the same everywhere it is
installed.
Diffstat:
6 files changed, 582 insertions(+), 0 deletions(-)
diff --git a/sh/sh.odin b/sh/sh.odin
@@ -0,0 +1,357 @@
+/*
+Package sh runs external commands tersely.
+
+Two entry styles. A shell string goes through the platform shell, so pipes,
+globs and redirects work:
+
+ rev := must(sh.out("git rev-parse HEAD"))
+ files := must(sh.lines("ls *.odin"))
+ if !sh.ok("test -d build") { ... }
+ sh.run("just build") // inherits the terminal; returns the exit code
+
+An argv slice bypasses the shell, so arguments need no quoting:
+
+ r := sh.exec({"git", "log", "-1", "--format=%s", subject})
+ if !r.ok { die("%s", sh.error(r)) }
+
+The shell is /bin/sh on Unix and cmd.exe on Windows unless Opts.shell says
+otherwise. Cmd's quoting differs from sh; scripts that must run on both
+either use the argv form or ask for Shell.Pwsh, which behaves the same on all
+three wherever PowerShell 7 is installed.
+*/
+package sh
+
+import "core:os"
+import "core:path/filepath"
+import "core:strings"
+import "core:time"
+
+Result :: struct {
+ // The command as the caller gave it; a joined argv for the exec forms.
+ cmd: string,
+ stdout: string,
+ stderr: string,
+ // Exit code, or the signal number when the process was killed on Unix.
+ code: int,
+ // Started, exited normally, and returned 0.
+ ok: bool,
+ // Set when the process could not be started at all.
+ err: os.Error,
+ // Set when the process ran past Opts.timeout and was killed; what it
+ // wrote before then is kept.
+ timed_out: bool,
+}
+
+Shell :: enum {
+ Default, // sh on Unix, cmd.exe on Windows
+ Sh,
+ Cmd,
+ Pwsh,
+}
+
+Opts :: struct {
+ // Working directory; "" keeps the current one.
+ dir: string,
+ // Full environment as KEY=VALUE; nil inherits the parent's.
+ env: []string,
+ // Fed to the child's stdin; "" closes stdin.
+ stdin: string,
+ shell: Shell,
+ // How long the child may run before it is killed; 0 is no limit. Only
+ // the capturing forms honour it.
+ timeout: time.Duration,
+}
+
+// capture runs cmd through the shell and returns everything it produced.
+capture :: proc(cmd: string, opts := Opts{}, allocator := context.allocator) -> Result {
+ argv := shell_argv(cmd, opts.shell, context.temp_allocator)
+ r := exec(argv, opts, allocator)
+ r.cmd = cmd
+ return r
+}
+
+// out runs cmd and returns its stdout with trailing whitespace removed.
+out :: proc(
+ cmd: string,
+ opts := Opts{},
+ allocator := context.allocator,
+) -> (
+ s: string,
+ success: bool,
+) {
+ r := capture(cmd, opts, allocator)
+ return strings.trim_right_space(r.stdout), r.ok
+}
+
+// lines runs cmd and returns its stdout split into lines, without a trailing
+// empty line.
+lines :: proc(
+ cmd: string,
+ opts := Opts{},
+ allocator := context.allocator,
+) -> (
+ result: []string,
+ success: bool,
+) {
+ r := capture(cmd, opts, allocator)
+ return split_lines(r.stdout, allocator), r.ok
+}
+
+// ok runs cmd and reports whether it exited with 0. Output is discarded.
+ok :: proc(cmd: string, opts := Opts{}) -> bool {
+ return capture(cmd, opts, context.temp_allocator).ok
+}
+
+// run runs cmd with the terminal attached, so the user sees its output live.
+run :: proc(cmd: string, opts := Opts{}) -> (code: int, success: bool) {
+ argv := shell_argv(cmd, opts.shell, context.temp_allocator)
+ return exec_run(argv, opts)
+}
+
+// exec runs argv directly and captures stdout and stderr.
+exec :: proc(argv: []string, opts := Opts{}, allocator := context.allocator) -> (r: Result) {
+ r.cmd = strings.join(argv, " ", allocator)
+ desc := os.Process_Desc {
+ working_dir = opts.dir,
+ command = argv,
+ env = opts.env,
+ }
+ stdin, stdin_path := stdin_file(opts.stdin)
+ defer cleanup_stdin(stdin, stdin_path)
+ desc.stdin = stdin
+
+ state, stdout, stderr, timed_out, err := capture_process(desc, opts.timeout, allocator)
+ if err != nil {
+ r.err = err
+ r.code = -1
+ r.stderr = os.error_string(err)
+ return
+ }
+ r.stdout = string(stdout)
+ r.stderr = string(stderr)
+ r.code = state.exit_code
+ r.timed_out = timed_out
+ r.ok = !timed_out && state.exited && state.success && state.exit_code == 0
+ return
+}
+
+// capture_process runs the process with both streams captured, as
+// os.process_exec does, and kills it when it runs past the timeout. What
+// the child wrote before then is returned with the state.
+capture_process :: proc(
+ desc: os.Process_Desc,
+ timeout: time.Duration,
+ allocator := context.allocator,
+) -> (
+ state: os.Process_State,
+ stdout, stderr: []byte,
+ timed_out: bool,
+ err: os.Error,
+) {
+ stdout_r, stdout_w := os.pipe() or_return
+ defer os.close(stdout_r)
+ stderr_r, stderr_w := os.pipe() or_return
+ defer os.close(stderr_r)
+
+ process: os.Process
+ {
+ // The write ends are closed on this side whatever happens, so the
+ // read ends see EOF once the child is done.
+ defer os.close(stdout_w)
+ defer os.close(stderr_w)
+ child := desc
+ child.stdout = stdout_w
+ child.stderr = stderr_w
+ process = os.process_start(child) or_return
+ }
+
+ out := make([dynamic]byte, allocator)
+ errs := make([dynamic]byte, allocator)
+ buf: [4096]u8 = ---
+ started := time.now()
+ stdout_done, stderr_done := false, false
+ for err == nil && (!stdout_done || !stderr_done) {
+ moved := false
+ if !stdout_done {
+ has_data, herr := os.pipe_has_data(stdout_r)
+ err = herr
+ n := 0
+ if err == nil && has_data {
+ n, err = os.read(stdout_r, buf[:])
+ moved = n > 0
+ }
+ switch err {
+ case nil:
+ append(&out, ..buf[:n])
+ case .EOF, .Broken_Pipe:
+ stdout_done = true
+ err = nil
+ }
+ }
+ if err == nil && !stderr_done {
+ has_data, herr := os.pipe_has_data(stderr_r)
+ err = herr
+ n := 0
+ if err == nil && has_data {
+ n, err = os.read(stderr_r, buf[:])
+ moved = moved || n > 0
+ }
+ switch err {
+ case nil:
+ append(&errs, ..buf[:n])
+ case .EOF, .Broken_Pipe:
+ stderr_done = true
+ err = nil
+ }
+ }
+ if timeout > 0 && time.since(started) > timeout {
+ _ = os.process_kill(process)
+ timed_out = true
+ break
+ }
+ if !moved {
+ // Nothing to read yet: yield rather than spin.
+ time.sleep(time.Millisecond)
+ }
+ }
+ stdout, stderr = out[:], errs[:]
+ if err != nil {
+ state, _ = os.process_wait(process, timeout = 0)
+ if !state.exited {
+ _ = os.process_kill(process)
+ state, _ = os.process_wait(process)
+ }
+ return
+ }
+ state, err = os.process_wait(process)
+ return
+}
+
+// exec_run runs argv with the terminal attached.
+exec_run :: proc(argv: []string, opts := Opts{}) -> (code: int, success: bool) {
+ desc := os.Process_Desc {
+ working_dir = opts.dir,
+ command = argv,
+ env = opts.env,
+ stdout = os.stdout,
+ stderr = os.stderr,
+ stdin = os.stdin,
+ }
+ if opts.stdin != "" {
+ stdin, stdin_path := stdin_file(opts.stdin)
+ defer cleanup_stdin(stdin, stdin_path)
+ desc.stdin = stdin
+ return wait(desc)
+ }
+ return wait(desc)
+}
+
+// which finds name on PATH the way the shell would, honouring PATHEXT on
+// Windows. A name containing a separator is checked as given.
+which :: proc(name: string, allocator := context.allocator) -> (path: string, found: bool) {
+ if strings.contains_any(name, filepath.SEPARATOR_CHARS) {
+ if os.is_file(name) {
+ return strings.clone(name, allocator), true
+ }
+ return "", false
+ }
+ path_env, has_path := os.lookup_env("PATH", context.temp_allocator)
+ if !has_path {
+ return "", false
+ }
+ for dir in strings.split_iterator(&path_env, LIST_SEPARATOR) {
+ if dir == "" {
+ continue
+ }
+ for ext in executable_extensions() {
+ file := strings.concatenate({name, ext}, context.temp_allocator)
+ candidate, _ := filepath.join({dir, file}, context.temp_allocator)
+ if os.is_file(candidate) {
+ return strings.clone(candidate, allocator), true
+ }
+ }
+ }
+ return "", false
+}
+
+// error renders a failed Result as one message: the command, the exit code,
+// and whatever it wrote to stderr.
+error :: proc(r: Result, allocator := context.allocator) -> string {
+ b := strings.builder_make(allocator)
+ if r.err != nil {
+ strings.write_string(&b, "cannot start: ")
+ strings.write_string(&b, r.cmd)
+ strings.write_string(&b, ": ")
+ strings.write_string(&b, os.error_string(r.err))
+ return strings.to_string(b)
+ }
+ strings.write_string(&b, "command failed (exit ")
+ strings.write_int(&b, r.code)
+ strings.write_string(&b, "): ")
+ strings.write_string(&b, r.cmd)
+ stderr := strings.trim_right_space(r.stderr)
+ if stderr != "" {
+ strings.write_byte(&b, '\n')
+ strings.write_string(&b, stderr)
+ }
+ return strings.to_string(b)
+}
+
+// ---- internals ----------------------------------------------------------
+
+LIST_SEPARATOR :: ";" when ODIN_OS == .Windows else ":"
+
+wait :: proc(desc: os.Process_Desc) -> (code: int, success: bool) {
+ p, err := os.process_start(desc)
+ if err != nil {
+ return -1, false
+ }
+ state, werr := os.process_wait(p)
+ if werr != nil {
+ return -1, false
+ }
+ return state.exit_code, state.exited && state.success && state.exit_code == 0
+}
+
+// stdin_file spools text into a temp file and returns it opened for reading,
+// so the child can consume more than a pipe buffer without deadlock.
+stdin_file :: proc(text: string) -> (f: ^os.File, path: string) {
+ if text == "" {
+ return nil, ""
+ }
+ tmp, err := os.create_temp_file("", "sh-stdin-*", {.Read})
+ if err != nil {
+ return nil, ""
+ }
+ if _, werr := os.write_string(tmp, text); werr != nil {
+ os.close(tmp)
+ return nil, ""
+ }
+ if _, serr := os.seek(tmp, 0, .Start); serr != nil {
+ os.close(tmp)
+ return nil, ""
+ }
+ return tmp, strings.clone(os.name(tmp), context.temp_allocator)
+}
+
+cleanup_stdin :: proc(f: ^os.File, path: string) {
+ if f == nil {
+ return
+ }
+ os.close(f)
+ if path != "" {
+ os.remove(path)
+ }
+}
+
+split_lines :: proc(s: string, allocator := context.allocator) -> []string {
+ trimmed := strings.trim_right(s, "\r\n")
+ if trimmed == "" {
+ return nil
+ }
+ parts, _ := strings.split_lines(trimmed, allocator)
+ for &p in parts {
+ p = strings.trim_right(p, "\r")
+ }
+ return parts
+}
diff --git a/sh/sh_test.odin b/sh/sh_test.odin
@@ -0,0 +1,56 @@
+package sh
+
+import "core:strings"
+import "core:testing"
+
+@(test)
+which_finds_shell :: proc(t: ^testing.T) {
+ name := "cmd" when ODIN_OS == .Windows else "sh"
+ p, found := which(name, context.temp_allocator)
+ testing.expect(t, found, "shell must be on PATH")
+ testing.expect(t, len(p) > 0)
+ _, found = which("definitely-not-a-program-3f9a", context.temp_allocator)
+ testing.expect(t, !found)
+}
+
+@(test)
+out_and_lines :: proc(t: ^testing.T) {
+ s, ok := out("echo hello", allocator = context.temp_allocator)
+ testing.expect(t, ok)
+ testing.expect_value(t, s, "hello")
+
+ ls, lok := lines("echo one&& echo two", allocator = context.temp_allocator)
+ testing.expect(t, lok)
+ testing.expect_value(t, len(ls), 2)
+ if len(ls) == 2 {
+ testing.expect_value(t, ls[0], "one")
+ testing.expect_value(t, ls[1], "two")
+ }
+}
+
+@(test)
+failure_is_reported :: proc(t: ^testing.T) {
+ r := capture("exit 3", allocator = context.temp_allocator)
+ testing.expect(t, !r.ok)
+ testing.expect_value(t, r.code, 3)
+ msg := error(r, context.temp_allocator)
+ testing.expect(t, strings.has_prefix(msg, "command failed (exit 3): exit 3"), msg)
+
+ r = exec({"definitely-not-a-program-3f9a"}, allocator = context.temp_allocator)
+ testing.expect(t, !r.ok)
+ testing.expect(t, r.err != nil, "starting a missing program must set err")
+}
+
+@(test)
+split_lines_strips_endings :: proc(t: ^testing.T) {
+ // Trailing empty lines are dropped; interior ones stay.
+ ls := split_lines("a\r\nb\n\n", context.temp_allocator)
+ testing.expect_value(t, len(ls), 2)
+ if len(ls) == 2 {
+ testing.expect_value(t, ls[0], "a")
+ testing.expect_value(t, ls[1], "b")
+ }
+ ls = split_lines("a\n\nb\n", context.temp_allocator)
+ testing.expect_value(t, len(ls), 3)
+ testing.expect_value(t, len(split_lines("", context.temp_allocator)), 0)
+}
diff --git a/sh/sh_unix.odin b/sh/sh_unix.odin
@@ -0,0 +1,37 @@
+#+build !windows
+package sh
+
+import "core:slice"
+import "core:strings"
+
+shell_argv :: proc(cmd: string, shell: Shell, allocator := context.allocator) -> []string {
+ switch shell {
+ case .Pwsh:
+ return slice.clone([]string{"pwsh", "-NoProfile", "-Command", cmd}, allocator)
+ case .Default, .Sh, .Cmd:
+ return slice.clone([]string{"/bin/sh", "-c", cmd}, allocator)
+ }
+ return slice.clone([]string{"/bin/sh", "-c", cmd}, allocator)
+}
+
+executable_extensions :: proc(allocator := context.temp_allocator) -> []string {
+ return slice.clone([]string{""}, allocator)
+}
+
+// quote makes s safe as one word in a sh command line.
+quote :: proc(s: string, allocator := context.allocator) -> string {
+ if s != "" && strings.index_any(s, " \t\n'\"\\$`!*?[]{}()<>|&;#~") < 0 {
+ return strings.clone(s, allocator)
+ }
+ b := strings.builder_make(allocator)
+ strings.write_byte(&b, '\'')
+ for c in s {
+ if c == '\'' {
+ strings.write_string(&b, `'\''`)
+ } else {
+ strings.write_rune(&b, c)
+ }
+ }
+ strings.write_byte(&b, '\'')
+ return strings.to_string(b)
+}
diff --git a/sh/sh_unix_test.odin b/sh/sh_unix_test.odin
@@ -0,0 +1,45 @@
+#+build !windows
+package sh
+
+import "core:testing"
+import "core:time"
+
+@(test)
+quote_posix :: proc(t: ^testing.T) {
+ testing.expect_value(t, quote("plain", context.temp_allocator), "plain")
+ testing.expect_value(t, quote("has space", context.temp_allocator), "'has space'")
+ testing.expect_value(t, quote("it's", context.temp_allocator), `'it'\''s'`)
+ testing.expect_value(t, quote("", context.temp_allocator), "''")
+}
+
+@(test)
+stdin_reaches_child :: proc(t: ^testing.T) {
+ s, ok := out("cat", {stdin = "from stdin"}, context.temp_allocator)
+ testing.expect(t, ok)
+ testing.expect_value(t, s, "from stdin")
+}
+
+@(test)
+dir_and_env :: proc(t: ^testing.T) {
+ s, ok := out("pwd", {dir = "/"}, context.temp_allocator)
+ testing.expect(t, ok)
+ testing.expect_value(t, s, "/")
+ s, ok = out(
+ "echo $JFM_TEST",
+ {env = {"JFM_TEST=set", "PATH=/usr/bin:/bin"}},
+ context.temp_allocator,
+ )
+ testing.expect(t, ok)
+ testing.expect_value(t, s, "set")
+}
+
+@(test)
+timeout_kills_a_slow_child :: proc(t: ^testing.T) {
+ r := exec({"sleep", "5"}, {timeout = 200 * time.Millisecond}, context.temp_allocator)
+ testing.expect(t, r.timed_out, "the child was not timed out")
+ testing.expect(t, !r.ok)
+ quick := exec({"echo", "fast"}, {timeout = 5 * time.Second}, context.temp_allocator)
+ testing.expect(t, quick.ok)
+ testing.expect(t, !quick.timed_out)
+ testing.expect_value(t, quick.stdout, "fast\n")
+}
diff --git a/sh/sh_windows.odin b/sh/sh_windows.odin
@@ -0,0 +1,74 @@
+#+build windows
+package sh
+
+import "core:os"
+import "core:slice"
+import "core:strings"
+
+shell_argv :: proc(cmd: string, shell: Shell, allocator := context.allocator) -> []string {
+ switch shell {
+ case .Pwsh:
+ exe := "pwsh"
+ if _, found := which("pwsh", allocator); !found {
+ exe = "powershell"
+ }
+ return slice.clone([]string{exe, "-NoProfile", "-Command", cmd}, allocator)
+ case .Sh:
+ return slice.clone([]string{"sh", "-c", cmd}, allocator)
+ case .Default, .Cmd:
+ comspec, found := os.lookup_env("COMSPEC", allocator)
+ if !found || comspec == "" {
+ comspec = "cmd.exe"
+ }
+ return slice.clone([]string{comspec, "/C", cmd}, allocator)
+ }
+ return slice.clone([]string{"cmd.exe", "/C", cmd}, allocator)
+}
+
+executable_extensions :: proc(allocator := context.temp_allocator) -> []string {
+ pathext, found := os.lookup_env("PATHEXT", allocator)
+ if !found || pathext == "" {
+ pathext = ".COM;.EXE;.BAT;.CMD"
+ }
+ exts := make([dynamic]string, allocator)
+ append(&exts, "")
+ for ext in strings.split_iterator(&pathext, ";") {
+ if ext != "" {
+ append(&exts, ext)
+ }
+ }
+ return exts[:]
+}
+
+// quote makes s safe as one argument for cmd.exe and the C runtime parser.
+quote :: proc(s: string, allocator := context.allocator) -> string {
+ if s != "" && strings.index_any(s, " \t\n\"&|<>^%()") < 0 {
+ return strings.clone(s, allocator)
+ }
+ b := strings.builder_make(allocator)
+ strings.write_byte(&b, '"')
+ backslashes := 0
+ for c in s {
+ switch c {
+ case '\\':
+ backslashes += 1
+ continue
+ case '"':
+ for _ in 0 ..< backslashes * 2 + 1 {
+ strings.write_byte(&b, '\\')
+ }
+ strings.write_byte(&b, '"')
+ case:
+ for _ in 0 ..< backslashes {
+ strings.write_byte(&b, '\\')
+ }
+ strings.write_rune(&b, c)
+ }
+ backslashes = 0
+ }
+ for _ in 0 ..< backslashes * 2 {
+ strings.write_byte(&b, '\\')
+ }
+ strings.write_byte(&b, '"')
+ return strings.to_string(b)
+}
diff --git a/sh/sh_windows_test.odin b/sh/sh_windows_test.odin
@@ -0,0 +1,13 @@
+#+build windows
+package sh
+
+import "core:testing"
+
+@(test)
+quote_cmd :: proc(t: ^testing.T) {
+ testing.expect_value(t, quote("plain", context.temp_allocator), "plain")
+ testing.expect_value(t, quote("has space", context.temp_allocator), `"has space"`)
+ testing.expect_value(t, quote(`say "hi"`, context.temp_allocator), `"say \"hi\""`)
+ testing.expect_value(t, quote(`C:\dir\`, context.temp_allocator), `C:\dir\`)
+ testing.expect_value(t, quote(`C:\my dir\`, context.temp_allocator), `"C:\my dir\\"`)
+}