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 }