jm

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

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:
Ash/sh.odin | 357+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ash/sh_test.odin | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ash/sh_unix.odin | 37+++++++++++++++++++++++++++++++++++++
Ash/sh_unix_test.odin | 45+++++++++++++++++++++++++++++++++++++++++++++
Ash/sh_windows.odin | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ash/sh_windows_test.odin | 13+++++++++++++
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\\"`) +}