@tishlang/tish 1.3.4 → 1.3.8

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  Pay It Forward License (PIF)
2
2
 
3
- Copyright (c) 2025 The Tish Project Authors
3
+ Copyright (c) 2025-present The Tish Project Authors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
6
 
package/bin/tish CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang"
3
- version = "1.3.4"
3
+ version = "1.3.8"
4
4
  edition = "2021"
5
5
  description = "Tish CLI - run, REPL, compile to native"
6
6
  license-file = { workspace = true }
@@ -0,0 +1,54 @@
1
+ //! Ensure `tish run` and `tish run --no-optimize` agree on stdout for the same program.
2
+
3
+ use std::path::PathBuf;
4
+ use std::process::Command;
5
+
6
+ #[test]
7
+ fn string_or_fixture_stdout_matches_with_and_without_optimize() {
8
+ let tish = PathBuf::from(env!("CARGO_BIN_EXE_tish"));
9
+ let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
10
+ .join("..")
11
+ .join("tish_vm")
12
+ .join("tests")
13
+ .join("fixtures")
14
+ .join("or_string_cmd.tish");
15
+ assert!(fixture.is_file(), "missing fixture {}", fixture.display());
16
+
17
+ let out_default = Command::new(&tish)
18
+ .args([
19
+ "run",
20
+ "--feature",
21
+ "process",
22
+ fixture.to_str().unwrap(),
23
+ ])
24
+ .output()
25
+ .expect("spawn tish run");
26
+ assert!(
27
+ out_default.status.success(),
28
+ "stderr: {}",
29
+ String::from_utf8_lossy(&out_default.stderr)
30
+ );
31
+
32
+ let out_noopt = Command::new(&tish)
33
+ .args([
34
+ "run",
35
+ "--no-optimize",
36
+ "--feature",
37
+ "process",
38
+ fixture.to_str().unwrap(),
39
+ ])
40
+ .output()
41
+ .expect("spawn tish run --no-optimize");
42
+ assert!(
43
+ out_noopt.status.success(),
44
+ "stderr: {}",
45
+ String::from_utf8_lossy(&out_noopt.stderr)
46
+ );
47
+
48
+ assert_eq!(
49
+ out_default.stdout, out_noopt.stdout,
50
+ "stdout differs:\n default: {:?}\n noopt: {:?}",
51
+ String::from_utf8_lossy(&out_default.stdout),
52
+ String::from_utf8_lossy(&out_noopt.stdout)
53
+ );
54
+ }
@@ -34,10 +34,22 @@ fn instruction_size(code: &[u8], ip: usize) -> Option<usize> {
34
34
  opcode.instruction_size(code, ip)
35
35
  }
36
36
 
37
- /// For a Jump or JumpIfFalse at `ip`, return the final target IP after following
38
- /// a chain of jumps (Jump -> Jump -> ... -> non-jump).
39
- fn final_jump_target(code: &[u8], jump_ip: usize) -> Option<usize> {
40
- let mut ip = jump_ip;
37
+ /// Advance past `Nop` bytes left by other peepholes (`Dup`+`Pop` `Nop`+`Nop`, etc.).
38
+ /// Jump resolution must not treat a `Nop` run as the end of a chain, or we leave a jump
39
+ /// targeting the middle of padding while `chain_jumps` redirects another jump past it —
40
+ /// that misaligns `||` short-circuit when nested in an outer `if`.
41
+ fn skip_leading_nops(code: &[u8], mut ip: usize) -> usize {
42
+ while ip < code.len() && Opcode::from_u8(code[ip]) == Some(Opcode::Nop) {
43
+ ip += 1;
44
+ }
45
+ ip
46
+ }
47
+
48
+ /// After a branch lands at `ip`, follow only **unconditional** `Jump` instructions.
49
+ /// Must not follow `JumpIfFalse`: that opcode is conditional; treating it like `Jump`
50
+ /// breaks short-circuit codegen (e.g. `a === 1 || b === 2` inside `if (...)`).
51
+ fn skip_unconditional_jump_chain(code: &[u8], mut ip: usize) -> Option<usize> {
52
+ ip = skip_leading_nops(code, ip);
41
53
  let mut visited = 0u32;
42
54
  const MAX_CHAIN: u32 = 1000;
43
55
  loop {
@@ -45,22 +57,39 @@ fn final_jump_target(code: &[u8], jump_ip: usize) -> Option<usize> {
45
57
  return None;
46
58
  }
47
59
  visited += 1;
60
+ if ip > code.len() {
61
+ return None;
62
+ }
63
+ if ip == code.len() {
64
+ return Some(ip);
65
+ }
48
66
  let _ = instruction_size(code, ip)?;
49
67
  let op = Opcode::from_u8(code[ip])?;
50
- match op {
51
- Opcode::Jump => {
52
- let offset = read_i16(code, ip + 1) as isize;
53
- ip = (ip as isize + 3 + offset).max(0) as usize;
54
- }
55
- Opcode::JumpIfFalse => {
56
- let offset = read_i16(code, ip + 1) as isize;
57
- ip = (ip as isize + 3 + offset).max(0) as usize;
58
- }
59
- _ => return Some(ip),
68
+ if op != Opcode::Jump {
69
+ return Some(ip);
60
70
  }
71
+ let offset = read_i16(code, ip + 1) as isize;
72
+ ip = (ip as isize + 3 + offset).max(0) as usize;
73
+ ip = skip_leading_nops(code, ip);
61
74
  }
62
75
  }
63
76
 
77
+ /// For a `Jump` or `JumpIfFalse` at `jump_ip`, return the final IP after resolving the
78
+ /// taken branch and then skipping through any **unconditional** `Jump` chain only.
79
+ fn final_jump_target(code: &[u8], jump_ip: usize) -> Option<usize> {
80
+ let _ = instruction_size(code, jump_ip)?;
81
+ let op = Opcode::from_u8(code[jump_ip])?;
82
+ let first_target = match op {
83
+ Opcode::Jump | Opcode::JumpIfFalse => {
84
+ let offset = read_i16(code, jump_ip + 1) as isize;
85
+ (jump_ip as isize + 3 + offset).max(0) as usize
86
+ }
87
+ _ => return Some(jump_ip),
88
+ };
89
+ let first_target = skip_leading_nops(code, first_target);
90
+ skip_unconditional_jump_chain(code, first_target)
91
+ }
92
+
64
93
  /// Replace instruction at [ip..ip+len) with Nops (preserves length, no offset updates).
65
94
  fn nop_out(code: &mut [u8], ip: usize, len: usize) {
66
95
  for i in 0..len {
@@ -2,6 +2,9 @@
2
2
  //!
3
3
  //! Use for REPL, console.log, and any terminal output so numbers, strings,
4
4
  //! booleans, null, and object structure are easier to scan.
5
+ //!
6
+ //! `console.log` prints string arguments without surrounding quotes (like Node); nested
7
+ //! strings inside arrays/objects stay quoted. The REPL still quotes string results for clarity.
5
8
 
6
9
  use std::io::IsTerminal;
7
10
  use std::sync::OnceLock;
@@ -40,10 +43,12 @@ pub fn format_value_styled(value: &Value, colors: bool) -> String {
40
43
  if !colors {
41
44
  return value.to_display_string();
42
45
  }
43
- format_value_styled_inner(value, colors)
46
+ format_value_styled_inner(value, colors, true)
44
47
  }
45
48
 
46
- fn format_value_styled_inner(value: &Value, colors: bool) -> String {
49
+ /// `quote_strings`: when true (REPL / inspect), strings render as quoted literals. When false
50
+ /// (top-level `console.log` arguments), strings render raw like Node.
51
+ fn format_value_styled_inner(value: &Value, colors: bool, quote_strings: bool) -> String {
47
52
  match value {
48
53
  Value::Number(n) => {
49
54
  let s = if n.is_nan() {
@@ -58,8 +63,12 @@ fn format_value_styled_inner(value: &Value, colors: bool) -> String {
58
63
  format!("{NUMBER}{s}{RESET}")
59
64
  }
60
65
  Value::String(s) => {
61
- let escaped = escape_string_for_display(s);
62
- format!("{STRING}\"{escaped}\"{RESET}")
66
+ if quote_strings {
67
+ let escaped = escape_string_for_display(s);
68
+ format!("{STRING}\"{escaped}\"{RESET}")
69
+ } else {
70
+ format!("{STRING}{}{RESET}", s.as_ref())
71
+ }
63
72
  }
64
73
  Value::Bool(b) => format!("{BOOLEAN}{b}{RESET}"),
65
74
  Value::Null => format!("{NULL}null{RESET}"),
@@ -67,7 +76,7 @@ fn format_value_styled_inner(value: &Value, colors: bool) -> String {
67
76
  let inner: Vec<String> = arr
68
77
  .borrow()
69
78
  .iter()
70
- .map(|v| format_value_styled_inner(v, colors))
79
+ .map(|v| format_value_styled_inner(v, colors, true))
71
80
  .collect();
72
81
  let sep = format!("{PUNCT}, {RESET}");
73
82
  format!("{PUNCT}[{RESET}{}{PUNCT}]{RESET}", inner.join(&sep))
@@ -80,7 +89,7 @@ fn format_value_styled_inner(value: &Value, colors: bool) -> String {
80
89
  format!(
81
90
  "{KEY}{}{RESET}{PUNCT}: {RESET}{}",
82
91
  k.as_ref(),
83
- format_value_styled_inner(v, colors)
92
+ format_value_styled_inner(v, colors, true)
84
93
  )
85
94
  })
86
95
  .collect();
@@ -123,10 +132,18 @@ pub fn format_values_for_console(values: &[Value], colors: bool) -> String {
123
132
  match iter.next() {
124
133
  None => String::new(),
125
134
  Some(first) => {
126
- let mut result = format_value_styled(first, colors);
135
+ let mut result = if colors {
136
+ format_value_styled_inner(first, colors, false)
137
+ } else {
138
+ first.to_display_string()
139
+ };
127
140
  for v in iter {
128
141
  result.push(' ');
129
- result.push_str(&format_value_styled(v, colors));
142
+ if colors {
143
+ result.push_str(&format_value_styled_inner(v, colors, false));
144
+ } else {
145
+ result.push_str(&v.to_display_string());
146
+ }
130
147
  }
131
148
  result
132
149
  }
@@ -27,7 +27,9 @@ rand = "0.10"
27
27
  wasm-bindgen = { version = "0.2", optional = true }
28
28
 
29
29
  [dev-dependencies]
30
+ tishlang_compile = { path = "../tish_compile", version = ">=0.1" }
30
31
  tishlang_parser = { path = "../tish_parser", version = ">=0.1" }
32
+ tishlang_opt = { path = "../tish_opt", version = ">=0.1" }
31
33
 
32
34
  [target.'cfg(target_arch = "wasm32")'.dependencies]
33
35
  getrandom = { version = "0.4", features = ["wasm_js"] }
@@ -37,6 +37,10 @@ fn get_builtin_export(spec: &str, export_name: &str) -> Option<Value> {
37
37
  #[cfg(feature = "http")]
38
38
  if spec == "tish:http" {
39
39
  return match export_name {
40
+ // Bytecode compiler lowers `await expr` to `tish:http.await(promise)` (see tish_bytecode compiler).
41
+ "await" => Some(Value::Function(Rc::new(|args: &[Value]| {
42
+ tishlang_runtime::await_promise(args.first().cloned().unwrap_or(Value::Null))
43
+ }))),
40
44
  "fetch" => Some(Value::Function(Rc::new(|args: &[Value]| {
41
45
  tishlang_runtime::fetch_promise(args.to_vec())
42
46
  }))),
@@ -0,0 +1,2 @@
1
+ let cmd = "a"
2
+ if (cmd === "a" || cmd === "b") { console.log("hit") } else { console.log("miss") }
@@ -0,0 +1,143 @@
1
+ //! Regression: bytecode peephole `chain_jumps` must not follow `JumpIfFalse` as if it were an
2
+ //! unconditional `Jump`. Doing so broke `===` + `||` when nested as the condition of an outer `if`
3
+ //! (default VM differed from `--backend interp` / `--no-optimize`).
4
+ //!
5
+ //! CLI parity for the same source is covered in `crates/tish/tests/run_optimize_stdout_parity.rs`.
6
+
7
+ use std::path::PathBuf;
8
+
9
+ use tishlang_bytecode::{
10
+ compile, compile_for_repl, compile_for_repl_unoptimized, compile_unoptimized,
11
+ };
12
+ use tishlang_core::Value;
13
+
14
+ fn run_chunk(chunk: &tishlang_bytecode::Chunk) -> Value {
15
+ tishlang_vm::run(chunk).expect("vm run")
16
+ }
17
+
18
+ /// `tish run` ends with trailing `null` when the last statement is not a REPL-style expr; use
19
+ /// `compile_for_repl` so the VM return value reflects the `||` result (catches peephole/AST bugs).
20
+ #[test]
21
+ fn string_strict_eq_logical_or_repl_last_expr_is_true() {
22
+ let src = "let cmd = \"a\"\ncmd === \"a\" || cmd === \"b\"";
23
+ let opt = tishlang_opt::optimize(&tishlang_parser::parse(src).expect("parse"));
24
+ let v_peep = run_chunk(&compile_for_repl(&opt).expect("compile repl"));
25
+ let v_unopt = run_chunk(&compile_for_repl_unoptimized(&opt).expect("compile repl unopt"));
26
+ assert!(
27
+ v_peep.strict_eq(&v_unopt),
28
+ "peephole vs unopt repl: peep={v_peep:?} unopt={v_unopt:?}"
29
+ );
30
+ assert!(
31
+ matches!(&v_peep, Value::Bool(true)),
32
+ "expected true for cmd===a||cmd===b with cmd=a, got {v_peep:?}"
33
+ );
34
+ }
35
+
36
+ /// `?:` uses different codegen than `if`; both must agree with unoptimized bytecode.
37
+ #[test]
38
+ fn string_strict_eq_logical_or_inside_ternary_repl_last_expr() {
39
+ // Statement boundary: without `;` or `;`-like ASI, the parser can tie the `(` line to `let`.
40
+ let src = "let cmd = \"a\"\n;(cmd === \"a\" || cmd === \"b\") ? 1 : 0";
41
+ let opt = tishlang_opt::optimize(&tishlang_parser::parse(src).expect("parse"));
42
+ let v_peep = run_chunk(&compile_for_repl(&opt).expect("compile repl"));
43
+ let v_unopt = run_chunk(&compile_for_repl_unoptimized(&opt).expect("compile repl unopt"));
44
+ assert!(v_peep.strict_eq(&v_unopt), "peep={v_peep:?} unopt={v_unopt:?}");
45
+ assert!(
46
+ matches!(&v_peep, Value::Number(n) if *n == 1.0),
47
+ "expected 1, got {v_peep:?}"
48
+ );
49
+ }
50
+
51
+ #[test]
52
+ fn logical_or_strict_eq_peephole_matches_unoptimized() {
53
+ let src = "let a = 1\nlet b = 2\na === 1 || b === 2";
54
+ let program = tishlang_parser::parse(src).expect("parse");
55
+ let program = tishlang_opt::optimize(&program);
56
+
57
+ let v_peep = run_chunk(&compile(&program).expect("compile"));
58
+ let v_raw = run_chunk(&compile_unoptimized(&program).expect("compile unopt"));
59
+ assert!(
60
+ v_peep.strict_eq(&v_raw),
61
+ "peephole changed semantics: peep={v_peep:?} raw={v_raw:?}"
62
+ );
63
+
64
+ let v_peep_repl = run_chunk(&compile_for_repl(&program).expect("compile repl"));
65
+ let v_raw_repl = run_chunk(&compile_for_repl_unoptimized(&program).expect("compile repl unopt"));
66
+ assert!(
67
+ v_peep_repl.strict_eq(&v_raw_repl),
68
+ "repl: peep={v_peep_repl:?} raw={v_raw_repl:?}"
69
+ );
70
+ }
71
+
72
+ #[test]
73
+ fn logical_or_inside_if_condition_peephole_matches_unoptimized() {
74
+ let src = "let a = 1\nlet b = 2\nif (a === 1 || b === 2) { 1 } else { 0 }";
75
+ let program = tishlang_parser::parse(src).expect("parse");
76
+ let program = tishlang_opt::optimize(&program);
77
+
78
+ let v_peep = run_chunk(&compile(&program).expect("compile"));
79
+ let v_raw = run_chunk(&compile_unoptimized(&program).expect("compile unopt"));
80
+ assert!(
81
+ v_peep.strict_eq(&v_raw),
82
+ "if + || : peep={v_peep:?} raw={v_raw:?}"
83
+ );
84
+ }
85
+
86
+ #[test]
87
+ fn string_strict_eq_logical_or_ast_opt_matches_unoptimized_bytecode() {
88
+ let src = "let cmd = \"a\"\nif (cmd === \"a\" || cmd === \"b\") { 1 } else { 0 }";
89
+ let raw = tishlang_parser::parse(src).expect("parse");
90
+ let opt = tishlang_opt::optimize(&raw);
91
+ let v_raw = run_chunk(&compile_unoptimized(&raw).expect("raw"));
92
+ let v_opt = run_chunk(&compile_unoptimized(&opt).expect("opt"));
93
+ assert!(
94
+ v_raw.strict_eq(&v_opt),
95
+ "AST optimizer changed semantics: raw={v_raw:?} opt={v_opt:?}"
96
+ );
97
+ }
98
+
99
+ #[test]
100
+ fn string_strict_eq_logical_or_peephole_matches_unoptimized() {
101
+ let src = "let cmd = \"a\"\nif (cmd === \"a\" || cmd === \"b\") { 1 } else { 0 }";
102
+ let program = tishlang_opt::optimize(&tishlang_parser::parse(src).expect("parse"));
103
+ let v_peep = run_chunk(&compile(&program).expect("compile"));
104
+ let v_raw = run_chunk(&compile_unoptimized(&program).expect("unopt"));
105
+ assert!(
106
+ v_peep.strict_eq(&v_raw),
107
+ "peephole + strings: peep={v_peep:?} raw={v_raw:?}"
108
+ );
109
+ }
110
+
111
+ /// `tish run path/to/file.tish` uses merge_modules; ensure that matches plain parse for the fixture.
112
+ #[test]
113
+ fn merged_module_program_bytecode_matches_parse_for_string_or_fixture() {
114
+ let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/or_string_cmd.tish");
115
+ let src = std::fs::read_to_string(&fixture).expect("read fixture");
116
+ let modules = tishlang_compile::resolve_project(&fixture, Some(fixture.parent().unwrap()))
117
+ .expect("resolve");
118
+ let merged = tishlang_compile::merge_modules(modules).expect("merge");
119
+ let flat = tishlang_parser::parse(&src).expect("parse");
120
+ let m_opt = tishlang_opt::optimize(&merged);
121
+ let f_opt = tishlang_opt::optimize(&flat);
122
+ let c_m = compile(&m_opt).expect("compile merged");
123
+ let c_f = compile(&f_opt).expect("compile flat");
124
+ assert_eq!(
125
+ c_m.code, c_f.code,
126
+ "merge_modules vs parse produced different bytecode"
127
+ );
128
+ }
129
+
130
+ /// `if (cmd === "a" || cmd === "b")` must match unoptimized VM semantics (Nop padding from other
131
+ /// peepholes must not confuse `chain_jumps`).
132
+ #[test]
133
+ fn string_eq_or_in_if_stmt_matches_unoptimized_repl() {
134
+ let src = "let cmd = \"a\"\nlet ok = false\nif (cmd === \"a\" || cmd === \"b\") { ok = true } else { ok = false }\nok";
135
+ let program = tishlang_opt::optimize(&tishlang_parser::parse(src).expect("parse"));
136
+ let v_peep = run_chunk(&compile_for_repl(&program).expect("compile repl"));
137
+ let v_raw = run_chunk(&compile_for_repl_unoptimized(&program).expect("compile repl unopt"));
138
+ assert!(v_peep.strict_eq(&v_raw), "peep={v_peep:?} raw={v_raw:?}");
139
+ assert!(
140
+ matches!(&v_peep, Value::Bool(true)),
141
+ "expected ok=true, got {v_peep:?}"
142
+ );
143
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish",
3
- "version": "1.3.4",
3
+ "version": "1.3.8",
4
4
  "description": "Tish - minimal TS/JS-compatible language. Run, REPL, build to native or other targets.",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file