@tishlang/tish-format 2.35.0 → 2.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/tish-format CHANGED
Binary file
@@ -0,0 +1,16 @@
1
+ // Issue #475: process.execCapture / execFileCapture return { code, stdout, stderr }.
2
+ import { execFileCapture, execCapture } from "tish:process"
3
+
4
+ let ok = execFileCapture("echo", ["hi"])
5
+ console.log("ok=" + ok.code + "," + ok.stdout.trim() + "," + (ok.stderr === ""))
6
+
7
+ let bad = execFileCapture("false", [])
8
+ console.log("bad=" + bad.code)
9
+
10
+ let miss = execFileCapture("/no/such/prog/xyz", [])
11
+ console.log("miss=" + miss.code + "," + (miss.stderr !== ""))
12
+
13
+ let shell = execCapture("echo a; echo b 1>&2")
14
+ console.log("shell=" + shell.code + "," + shell.stdout.trim() + "," + shell.stderr.trim())
15
+
16
+ console.log("done")
@@ -0,0 +1,40 @@
1
+ //! Issue #475: `process.execCapture` / `execFileCapture` return `{ code, stdout, stderr }` identically
2
+ //! on every backend (interpreter, bytecode VM, native AOT).
3
+
4
+ use std::path::PathBuf;
5
+ use std::process::Command;
6
+
7
+ fn run_on(backend: &str) -> String {
8
+ let tish = PathBuf::from(env!("CARGO_BIN_EXE_tish"));
9
+ let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
10
+ .join("tests")
11
+ .join("fixtures")
12
+ .join("process_capture.tish");
13
+ let out = Command::new(&tish)
14
+ .args([
15
+ "run",
16
+ "--backend",
17
+ backend,
18
+ "--feature",
19
+ "process",
20
+ path.to_str().unwrap(),
21
+ ])
22
+ .output()
23
+ .expect("spawn tish run");
24
+ assert!(
25
+ out.status.success(),
26
+ "tish run --backend {backend} process_capture.tish failed: {}",
27
+ String::from_utf8_lossy(&out.stderr)
28
+ );
29
+ String::from_utf8_lossy(&out.stdout).into_owned()
30
+ }
31
+
32
+ const EXPECTED: &str = "ok=0,hi,true\nbad=1\nmiss=-1,true\nshell=0,a,b\ndone\n";
33
+
34
+ #[test]
35
+ #[cfg(unix)]
36
+ fn exec_capture_on_every_backend() {
37
+ for backend in ["vm", "interp", "native"] {
38
+ assert_eq!(run_on(backend), EXPECTED, "exec-capture mismatch on {backend}");
39
+ }
40
+ }
@@ -1690,9 +1690,11 @@ impl Codegen {
1690
1690
  "cwd" => Some("Value::native(|args: &[Value]| tish_process_cwd(args))"),
1691
1691
  "exec" => Some("Value::native(|args: &[Value]| tish_process_exec(args))"),
1692
1692
  "execFile" => Some("Value::native(|args: &[Value]| tish_process_exec_file(args))"),
1693
+ "execCapture" => Some("Value::native(|args: &[Value]| tish_process_exec_capture(args))"),
1694
+ "execFileCapture" => Some("Value::native(|args: &[Value]| tish_process_exec_file_capture(args))"),
1693
1695
  "argv" => Some("Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))"),
1694
1696
  "env" => Some("Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect())"),
1695
- "process" => Some("{ let mut m = ObjectMap::default(); m.insert(Arc::from(\"exit\"), Value::native(|args: &[Value]| tish_process_exit(args))); m.insert(Arc::from(\"cwd\"), Value::native(|args: &[Value]| tish_process_cwd(args))); m.insert(Arc::from(\"exec\"), Value::native(|args: &[Value]| tish_process_exec(args))); m.insert(Arc::from(\"execFile\"), Value::native(|args: &[Value]| tish_process_exec_file(args))); m.insert(Arc::from(\"argv\"), Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))); m.insert(Arc::from(\"env\"), Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect::<ObjectMap>())); Value::object(m) }"),
1697
+ "process" => Some("{ let mut m = ObjectMap::default(); m.insert(Arc::from(\"exit\"), Value::native(|args: &[Value]| tish_process_exit(args))); m.insert(Arc::from(\"cwd\"), Value::native(|args: &[Value]| tish_process_cwd(args))); m.insert(Arc::from(\"exec\"), Value::native(|args: &[Value]| tish_process_exec(args))); m.insert(Arc::from(\"execFile\"), Value::native(|args: &[Value]| tish_process_exec_file(args))); m.insert(Arc::from(\"execCapture\"), Value::native(|args: &[Value]| tish_process_exec_capture(args))); m.insert(Arc::from(\"execFileCapture\"), Value::native(|args: &[Value]| tish_process_exec_file_capture(args))); m.insert(Arc::from(\"argv\"), Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))); m.insert(Arc::from(\"env\"), Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect::<ObjectMap>())); Value::object(m) }"),
1696
1698
  _ => None,
1697
1699
  },
1698
1700
  "tish:ws" if self.has_feature("ws") => match export_name {
@@ -2246,7 +2248,7 @@ impl Codegen {
2246
2248
  self.write("use tishlang_ui::{fragment_value, install_thread_local_host, native_create_root, native_use_state, ui_h, ui_text, HeadlessHost};\n");
2247
2249
  }
2248
2250
  if self.has_feature("process") {
2249
- self.write("use tishlang_runtime::{process_exit as tish_process_exit, process_cwd as tish_process_cwd, process_exec as tish_process_exec, process_exec_file as tish_process_exec_file};\n");
2251
+ self.write("use tishlang_runtime::{process_exit as tish_process_exit, process_cwd as tish_process_cwd, process_exec as tish_process_exec, process_exec_file as tish_process_exec_file, process_exec_capture as tish_process_exec_capture, process_exec_file_capture as tish_process_exec_file_capture};\n");
2250
2252
  }
2251
2253
  if self.has_feature("timers") {
2252
2254
  self.write("use tishlang_runtime::{timer_set_timeout as tish_timer_set_timeout, timer_clear_timeout as tish_timer_clear_timeout, timer_set_interval as tish_timer_set_interval, timer_clear_interval as tish_timer_clear_interval};\n");
@@ -2651,6 +2653,8 @@ impl Codegen {
2651
2653
  self.writeln("p.insert(Arc::from(\"cwd\"), Value::native(|args: &[Value]| tish_process_cwd(args)));");
2652
2654
  self.writeln("p.insert(Arc::from(\"exec\"), Value::native(|args: &[Value]| tish_process_exec(args)));");
2653
2655
  self.writeln("p.insert(Arc::from(\"execFile\"), Value::native(|args: &[Value]| tish_process_exec_file(args)));");
2656
+ self.writeln("p.insert(Arc::from(\"execCapture\"), Value::native(|args: &[Value]| tish_process_exec_capture(args)));");
2657
+ self.writeln("p.insert(Arc::from(\"execFileCapture\"), Value::native(|args: &[Value]| tish_process_exec_file_capture(args)));");
2654
2658
  self.writeln("let argv: Vec<Value> = std::env::args().map(|s| Value::String(s.into())).collect();");
2655
2659
  self.writeln("p.insert(Arc::from(\"argv\"), Value::Array(VmRef::new(argv)));");
2656
2660
  self.writeln("let mut env_obj = ObjectMap::default();");
@@ -320,6 +320,8 @@ impl Evaluator {
320
320
  process_obj.insert("cwd".into(), Value::Native(natives::process_cwd));
321
321
  process_obj.insert("exec".into(), Value::Native(natives::process_exec));
322
322
  process_obj.insert("execFile".into(), Value::Native(natives::process_exec_file));
323
+ process_obj.insert("execCapture".into(), Value::Native(natives::process_exec_capture));
324
+ process_obj.insert("execFileCapture".into(), Value::Native(natives::process_exec_file_capture));
323
325
  let argv: Vec<Value> = tishlang_core::process_argv()
324
326
  .into_iter()
325
327
  .map(|s| Value::String(s.into()))
@@ -1412,6 +1414,8 @@ impl Evaluator {
1412
1414
  exports.insert("cwd".into(), Value::Native(natives::process_cwd));
1413
1415
  exports.insert("exec".into(), Value::Native(natives::process_exec));
1414
1416
  exports.insert("execFile".into(), Value::Native(natives::process_exec_file));
1417
+ exports.insert("execCapture".into(), Value::Native(natives::process_exec_capture));
1418
+ exports.insert("execFileCapture".into(), Value::Native(natives::process_exec_file_capture));
1415
1419
  let argv: Vec<Value> = tishlang_core::process_argv()
1416
1420
  .into_iter()
1417
1421
  .map(|s| Value::String(s.into()))
@@ -1432,6 +1436,8 @@ impl Evaluator {
1432
1436
  process_obj.insert("cwd".into(), Value::Native(natives::process_cwd));
1433
1437
  process_obj.insert("exec".into(), Value::Native(natives::process_exec));
1434
1438
  process_obj.insert("execFile".into(), Value::Native(natives::process_exec_file));
1439
+ process_obj.insert("execCapture".into(), Value::Native(natives::process_exec_capture));
1440
+ process_obj.insert("execFileCapture".into(), Value::Native(natives::process_exec_file_capture));
1435
1441
  process_obj.insert("argv".into(), Value::Array(Rc::new(RefCell::new(argv))));
1436
1442
  process_obj.insert("env".into(), Value::object(env_obj));
1437
1443
  exports.insert(
@@ -523,6 +523,60 @@ pub fn process_exec_file(args: &[Value]) -> Result<Value, String> {
523
523
  Ok(Value::Number(output.status.code().unwrap_or(1) as f64))
524
524
  }
525
525
 
526
+ /// `{ code, stdout, stderr }` capture object (#475) — lossy-UTF-8 streams (fs convention). Insertion
527
+ /// order (code, stdout, stderr) matches the vm/native builders so JSON.stringify agrees cross-backend.
528
+ #[cfg(feature = "process")]
529
+ fn process_capture_obj(code: i32, stdout: String, stderr: String) -> Value {
530
+ let mut m = crate::value::PropMap::default();
531
+ m.insert("code".into(), Value::Number(code as f64));
532
+ m.insert("stdout".into(), Value::String(stdout.into()));
533
+ m.insert("stderr".into(), Value::String(stderr.into()));
534
+ Value::object(m)
535
+ }
536
+
537
+ #[cfg(feature = "process")]
538
+ fn process_capture_result(out: std::io::Result<std::process::Output>) -> Value {
539
+ match out {
540
+ Ok(o) => process_capture_obj(
541
+ o.status.code().unwrap_or(-1),
542
+ String::from_utf8_lossy(&o.stdout).into_owned(),
543
+ String::from_utf8_lossy(&o.stderr).into_owned(),
544
+ ),
545
+ Err(e) => process_capture_obj(-1, String::new(), e.to_string()),
546
+ }
547
+ }
548
+
549
+ /// `process.execCapture(cmd)` — `sh -c cmd`, capturing `{ code, stdout, stderr }` (#475).
550
+ #[cfg(feature = "process")]
551
+ pub fn process_exec_capture(args: &[Value]) -> Result<Value, String> {
552
+ use std::process::Command;
553
+ let cmd = args.first().map(|v| v.to_string()).unwrap_or_default();
554
+ if cmd.is_empty() {
555
+ return Ok(process_capture_obj(0, String::new(), String::new()));
556
+ }
557
+ Ok(process_capture_result(
558
+ Command::new("sh").arg("-c").arg(&cmd).output(),
559
+ ))
560
+ }
561
+
562
+ /// `process.execFileCapture(program, [args])` — no-shell run with output capture (safe for untrusted
563
+ /// args, #384/#475). Returns `{ code, stdout, stderr }`.
564
+ #[cfg(feature = "process")]
565
+ pub fn process_exec_file_capture(args: &[Value]) -> Result<Value, String> {
566
+ use std::process::Command;
567
+ let program = args.first().map(|v| v.to_string()).unwrap_or_default();
568
+ if program.is_empty() {
569
+ return Ok(process_capture_obj(0, String::new(), String::new()));
570
+ }
571
+ let argv: Vec<String> = match args.get(1) {
572
+ Some(Value::Array(a)) => a.borrow().iter().map(|v| v.to_string()).collect(),
573
+ _ => Vec::new(),
574
+ };
575
+ Ok(process_capture_result(
576
+ Command::new(&program).args(&argv).output(),
577
+ ))
578
+ }
579
+
526
580
  #[cfg(feature = "fs")]
527
581
  pub fn read_file(args: &[Value]) -> Result<Value, String> {
528
582
  let path = args.first().map(|v| v.to_string()).unwrap_or_default();
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_fmt"
3
- version = "2.35.0"
3
+ version = "2.36.0"
4
4
  edition = "2021"
5
5
  description = "Opinionated formatter for Tish source (parse → pretty-print)"
6
6
  license-file = { workspace = true }
@@ -1049,9 +1049,75 @@ pub fn process_exec_file(args: &[Value]) -> Value {
1049
1049
  }
1050
1050
  }
1051
1051
 
1052
+ /// Build the `{ code: number, stdout: string, stderr: string }` capture object (#475). stdout/stderr
1053
+ /// are lossy-UTF-8 decoded, matching the `fs` string convention (invalid bytes → replacement chars,
1054
+ /// never dropped output).
1055
+ #[cfg(feature = "process")]
1056
+ fn process_capture_obj(code: i32, stdout: String, stderr: String) -> Value {
1057
+ let mut m = tishlang_core::ObjectMap::default();
1058
+ m.insert("code".into(), Value::Number(code as f64));
1059
+ m.insert("stdout".into(), Value::String(stdout.into()));
1060
+ m.insert("stderr".into(), Value::String(stderr.into()));
1061
+ Value::object(m)
1062
+ }
1063
+
1064
+ /// Build the capture object from a `Command::output()` result. `code` is the exit status, or `-1`
1065
+ /// when the process was killed by a signal or failed to spawn (spawn failure puts the OS error in
1066
+ /// `stderr`).
1067
+ #[cfg(feature = "process")]
1068
+ fn process_capture_result(out: std::io::Result<std::process::Output>) -> Value {
1069
+ match out {
1070
+ Ok(o) => process_capture_obj(
1071
+ o.status.code().unwrap_or(-1),
1072
+ String::from_utf8_lossy(&o.stdout).into_owned(),
1073
+ String::from_utf8_lossy(&o.stderr).into_owned(),
1074
+ ),
1075
+ Err(e) => process_capture_obj(-1, String::new(), e.to_string()),
1076
+ }
1077
+ }
1078
+
1079
+ /// `process.execCapture(cmd)` — run `sh -c cmd` and CAPTURE its output as
1080
+ /// `{ code: number, stdout: string, stderr: string }` (Rust `Command::output`; Node
1081
+ /// `execSync`/`spawnSync({ encoding })`). Use `execFileCapture` for untrusted arguments — this shell
1082
+ /// form interprets metacharacters.
1083
+ #[cfg(feature = "process")]
1084
+ pub fn process_exec_capture(args: &[Value]) -> Value {
1085
+ use std::process::Command;
1086
+ let cmd = args
1087
+ .first()
1088
+ .map(|v| v.to_display_string())
1089
+ .unwrap_or_default();
1090
+ if cmd.is_empty() {
1091
+ return process_capture_obj(0, String::new(), String::new());
1092
+ }
1093
+ process_capture_result(Command::new("sh").arg("-c").arg(&cmd).output())
1094
+ }
1095
+
1096
+ /// `process.execFileCapture(program, [args])` — run a program directly, WITHOUT a shell, capturing
1097
+ /// output as `{ code, stdout, stderr }`. Arguments are passed verbatim, so shell metacharacters in
1098
+ /// untrusted data are never interpreted — the safe capturing counterpart for input-derived args
1099
+ /// (#384, #475). This is the primitive for shelling out to a CLI and reading its output (e.g.
1100
+ /// `git status --porcelain`).
1101
+ #[cfg(feature = "process")]
1102
+ pub fn process_exec_file_capture(args: &[Value]) -> Value {
1103
+ use std::process::Command;
1104
+ let program = args
1105
+ .first()
1106
+ .map(|v| v.to_display_string())
1107
+ .unwrap_or_default();
1108
+ if program.is_empty() {
1109
+ return process_capture_obj(0, String::new(), String::new());
1110
+ }
1111
+ let argv: Vec<String> = match args.get(1) {
1112
+ Some(Value::Array(a)) => a.borrow().iter().map(|v| v.to_display_string()).collect(),
1113
+ _ => Vec::new(),
1114
+ };
1115
+ process_capture_result(Command::new(&program).args(&argv).output())
1116
+ }
1117
+
1052
1118
  #[cfg(all(test, feature = "process", unix))]
1053
1119
  mod execfile_tests_384 {
1054
- use super::process_exec_file;
1120
+ use super::{process_exec_file, process_exec_file_capture};
1055
1121
  use tishlang_core::{Value, VmRef};
1056
1122
 
1057
1123
  #[test]
@@ -1065,6 +1131,47 @@ mod execfile_tests_384 {
1065
1131
  // empty program is a no-op returning 0, matching `exec`.
1066
1132
  assert!(matches!(process_exec_file(&[]), Value::Number(n) if n == 0.0));
1067
1133
  }
1134
+
1135
+ // #475: `{ code, stdout, stderr }` capture.
1136
+ fn fields(v: &Value) -> (f64, String, String) {
1137
+ let Value::Object(o) = v else {
1138
+ panic!("execFileCapture must return an object, got {v:?}")
1139
+ };
1140
+ let b = o.borrow();
1141
+ let num = |k: &str| match b.strings.get(k) {
1142
+ Some(Value::Number(n)) => *n,
1143
+ _ => f64::NAN,
1144
+ };
1145
+ let string = |k: &str| match b.strings.get(k) {
1146
+ Some(Value::String(s)) => s.to_string(),
1147
+ _ => String::new(),
1148
+ };
1149
+ (num("code"), string("stdout"), string("stderr"))
1150
+ }
1151
+
1152
+ #[test]
1153
+ fn execfile_capture_returns_code_stdout_stderr() {
1154
+ // Success: stdout captured, code 0, stderr empty.
1155
+ let args = Value::Array(VmRef::new(vec![Value::String("captured".into())]));
1156
+ let (code, out, err) =
1157
+ fields(&process_exec_file_capture(&[Value::String("echo".into()), args]));
1158
+ assert_eq!(code, 0.0);
1159
+ assert_eq!(out.trim(), "captured");
1160
+ assert_eq!(err, "");
1161
+ // Non-zero exit is reported, not an error.
1162
+ let (code, _, _) = fields(&process_exec_file_capture(&[Value::String("false".into())]));
1163
+ assert_eq!(code, 1.0);
1164
+ // Spawn failure → code -1 with the OS error in stderr (never panics).
1165
+ let (code, out, err) =
1166
+ fields(&process_exec_file_capture(&[Value::String("/no/such/prog/xyz".into())]));
1167
+ assert_eq!(code, -1.0);
1168
+ assert!(out.is_empty());
1169
+ assert!(!err.is_empty());
1170
+ // Empty program → 0 / empty (mirrors execFile).
1171
+ let (code, out, err) = fields(&process_exec_file_capture(&[]));
1172
+ assert_eq!(code, 0.0);
1173
+ assert!(out.is_empty() && err.is_empty());
1174
+ }
1068
1175
  }
1069
1176
 
1070
1177
  #[cfg(feature = "fs")]
@@ -337,6 +337,12 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
337
337
  "execFile" => Some(Value::native(|args: &[Value]| {
338
338
  tishlang_runtime::process_exec_file(args)
339
339
  })),
340
+ "execCapture" => Some(Value::native(|args: &[Value]| {
341
+ tishlang_runtime::process_exec_capture(args)
342
+ })),
343
+ "execFileCapture" => Some(Value::native(|args: &[Value]| {
344
+ tishlang_runtime::process_exec_file_capture(args)
345
+ })),
340
346
  "argv" => Some(Value::Array(VmRef::new(
341
347
  tishlang_core::process_argv()
342
348
  .into_iter()
@@ -366,6 +372,16 @@ fn get_builtin_export(enabled: &HashSet<String>, spec: &str, export_name: &str)
366
372
  "execFile".into(),
367
373
  Value::native(|args: &[Value]| tishlang_runtime::process_exec_file(args)),
368
374
  );
375
+ m.insert(
376
+ "execCapture".into(),
377
+ Value::native(|args: &[Value]| tishlang_runtime::process_exec_capture(args)),
378
+ );
379
+ m.insert(
380
+ "execFileCapture".into(),
381
+ Value::native(|args: &[Value]| {
382
+ tishlang_runtime::process_exec_file_capture(args)
383
+ }),
384
+ );
369
385
  m.insert(
370
386
  "argv".into(),
371
387
  Value::Array(VmRef::new(
@@ -959,6 +975,14 @@ fn init_globals(enabled: &HashSet<String>) -> ObjectMap {
959
975
  "execFile".into(),
960
976
  Value::native(|args: &[Value]| tishlang_runtime::process_exec_file(args)),
961
977
  );
978
+ process_obj.insert(
979
+ "execCapture".into(),
980
+ Value::native(|args: &[Value]| tishlang_runtime::process_exec_capture(args)),
981
+ );
982
+ process_obj.insert(
983
+ "execFileCapture".into(),
984
+ Value::native(|args: &[Value]| tishlang_runtime::process_exec_file_capture(args)),
985
+ );
962
986
  process_obj.insert(
963
987
  "argv".into(),
964
988
  Value::Array(VmRef::new(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-format",
3
- "version": "2.35.0",
3
+ "version": "2.36.0",
4
4
  "description": "Tish code formatter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file