@tishlang/tish 2.12.0 → 2.16.13

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.
Files changed (71) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish +0 -0
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/tests/integration_test.rs +80 -0
  5. package/crates/tish_ast/src/ast.rs +10 -0
  6. package/crates/tish_builtins/src/array.rs +529 -56
  7. package/crates/tish_builtins/src/collections.rs +114 -40
  8. package/crates/tish_builtins/src/string.rs +95 -8
  9. package/crates/tish_bytecode/src/chunk.rs +7 -0
  10. package/crates/tish_bytecode/src/compiler.rs +560 -64
  11. package/crates/tish_bytecode/src/lib.rs +1 -1
  12. package/crates/tish_bytecode/src/opcode.rs +154 -2
  13. package/crates/tish_bytecode/src/serialize.rs +2 -0
  14. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  15. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  16. package/crates/tish_compile/src/codegen.rs +17736 -5269
  17. package/crates/tish_compile/src/infer.rs +1707 -190
  18. package/crates/tish_compile/src/lib.rs +29 -4
  19. package/crates/tish_compile/src/resolve.rs +66 -5
  20. package/crates/tish_compile/src/types.rs +224 -28
  21. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  22. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  23. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  24. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  25. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  26. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  27. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  28. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  29. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  30. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  31. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  32. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  33. package/crates/tish_compile_js/src/codegen.rs +33 -0
  34. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +100 -2
  35. package/crates/tish_core/Cargo.toml +4 -0
  36. package/crates/tish_core/src/json.rs +104 -5
  37. package/crates/tish_core/src/lib.rs +174 -0
  38. package/crates/tish_core/src/shape.rs +4 -2
  39. package/crates/tish_core/src/value.rs +565 -35
  40. package/crates/tish_core/src/vmref.rs +14 -0
  41. package/crates/tish_eval/src/eval.rs +675 -76
  42. package/crates/tish_eval/src/natives.rs +19 -0
  43. package/crates/tish_eval/src/value.rs +76 -21
  44. package/crates/tish_ffi/src/lib.rs +11 -1
  45. package/crates/tish_ffi/tests/double_free.rs +35 -0
  46. package/crates/tish_fmt/src/lib.rs +75 -1
  47. package/crates/tish_lexer/src/lib.rs +76 -0
  48. package/crates/tish_lexer/src/token.rs +4 -0
  49. package/crates/tish_lint/src/lib.rs +126 -0
  50. package/crates/tish_lsp/README.md +2 -1
  51. package/crates/tish_lsp/src/main.rs +378 -28
  52. package/crates/tish_native/src/build.rs +41 -0
  53. package/crates/tish_parser/Cargo.toml +4 -0
  54. package/crates/tish_parser/src/lib.rs +27 -0
  55. package/crates/tish_parser/src/parser.rs +302 -20
  56. package/crates/tish_resolve/src/lib.rs +9 -0
  57. package/crates/tish_runtime/Cargo.toml +5 -0
  58. package/crates/tish_runtime/src/http.rs +28 -10
  59. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  60. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  61. package/crates/tish_runtime/src/lib.rs +523 -42
  62. package/crates/tish_runtime/src/timers.rs +49 -2
  63. package/crates/tish_ui/src/jsx.rs +3 -0
  64. package/crates/tish_vm/src/jit.rs +2514 -117
  65. package/crates/tish_vm/src/vm.rs +1227 -182
  66. package/package.json +1 -1
  67. package/platform/darwin-arm64/tish +0 -0
  68. package/platform/darwin-x64/tish +0 -0
  69. package/platform/linux-arm64/tish +0 -0
  70. package/platform/linux-x64/tish +0 -0
  71. package/platform/win32-x64/tish.exe +0 -0
@@ -411,6 +411,25 @@ pub fn process_exec(args: &[Value]) -> Result<Value, String> {
411
411
  Ok(Value::Number(code as f64))
412
412
  }
413
413
 
414
+ /// `process.execFile(program, [args])` — run a program directly, without a shell, passing each arg
415
+ /// verbatim (the safe, no-`sh -c` counterpart to `exec`). Returns the exit code. #384
416
+ pub fn process_exec_file(args: &[Value]) -> Result<Value, String> {
417
+ use std::process::Command;
418
+ let program = args.first().map(|v| v.to_string()).unwrap_or_default();
419
+ if program.is_empty() {
420
+ return Ok(Value::Number(0.0));
421
+ }
422
+ let argv: Vec<String> = match args.get(1) {
423
+ Some(Value::Array(a)) => a.borrow().iter().map(|v| v.to_string()).collect(),
424
+ _ => Vec::new(),
425
+ };
426
+ let output = Command::new(&program)
427
+ .args(&argv)
428
+ .output()
429
+ .map_err(|e| format!("execFile failed: {}", e))?;
430
+ Ok(Value::Number(output.status.code().unwrap_or(1) as f64))
431
+ }
432
+
414
433
  #[cfg(feature = "fs")]
415
434
  pub fn read_file(args: &[Value]) -> Result<Value, String> {
416
435
  let path = args.first().map(|v| v.to_string()).unwrap_or_default();
@@ -207,18 +207,13 @@ impl std::fmt::Display for Value {
207
207
  Value::String(s) => write!(f, "{}", s),
208
208
  Value::Bool(b) => write!(f, "{}", b),
209
209
  Value::Null => write!(f, "null"),
210
- Value::Array(arr) => {
211
- let inner: Vec<String> = arr.borrow().iter().map(|v| v.to_string()).collect();
212
- write!(f, "[{}]", inner.join(", "))
213
- }
214
- Value::Object(obj) => {
215
- let inner: Vec<String> = obj
216
- .borrow()
217
- .strings
218
- .iter()
219
- .map(|(k, v)| format!("{}: {}", k.as_ref(), v))
220
- .collect();
221
- write!(f, "{{{}}}", inner.join(", "))
210
+ // #381 — containers render through the cycle-guarded walker: a self-referential
211
+ // array/object (`a.self = a`) previously recursed forever here (uncatchable native
212
+ // stack overflow from any `console.log`/interpolation). Mirrors
213
+ // `tishlang_core::Value::to_display_string_guarded` so interp output (`[Circular]`)
214
+ // matches the VM/native backends.
215
+ Value::Array(_) | Value::Object(_) => {
216
+ write!(f, "{}", self.to_display_string_guarded(&mut Vec::new()))
222
217
  }
223
218
  Value::Symbol(s) => {
224
219
  if let Some(d) = &s.description {
@@ -272,16 +267,76 @@ impl Value {
272
267
  /// elements elide to `""`. Mirrors `tishlang_core::Value::to_js_string` so interp output matches
273
268
  /// the VM/rust/cranelift/wasi backends (and Node) for join/coercion.
274
269
  pub fn to_js_string(&self) -> String {
270
+ self.to_js_string_guarded(&mut Vec::new())
271
+ }
272
+
273
+ /// Cycle-safe inspect walker (#381): `ancestors` holds the current path's container pointers, so
274
+ /// a self-referential array/object renders `[Circular]` (matching the VM/native backends via
275
+ /// `tishlang_core::Value::to_display_string_guarded`) instead of recursing forever. Non-container
276
+ /// leaves defer to `Display`, whose container arms route back here — with a fresh path — only
277
+ /// for values this match already proved are not containers, so the guard cannot be bypassed.
278
+ fn to_display_string_guarded(&self, ancestors: &mut Vec<*const ()>) -> String {
275
279
  match self {
276
- Value::Array(arr) => arr
277
- .borrow()
278
- .iter()
279
- .map(|v| match v {
280
- Value::Null => String::new(),
281
- other => other.to_js_string(),
282
- })
283
- .collect::<Vec<_>>()
284
- .join(","),
280
+ Value::Array(arr) => {
281
+ let ptr = Rc::as_ptr(arr) as *const ();
282
+ if ancestors.contains(&ptr) {
283
+ return "[Circular]".to_string();
284
+ }
285
+ ancestors.push(ptr);
286
+ let inner: Vec<String> = arr
287
+ .borrow()
288
+ .iter()
289
+ .map(|v| v.to_display_string_guarded(ancestors))
290
+ .collect();
291
+ ancestors.pop();
292
+ format!("[{}]", inner.join(", "))
293
+ }
294
+ Value::Object(obj) => {
295
+ let ptr = Rc::as_ptr(obj) as *const ();
296
+ if ancestors.contains(&ptr) {
297
+ return "[Circular]".to_string();
298
+ }
299
+ ancestors.push(ptr);
300
+ let inner: Vec<String> = obj
301
+ .borrow()
302
+ .strings
303
+ .iter()
304
+ .map(|(k, v)| {
305
+ format!("{}: {}", k.as_ref(), v.to_display_string_guarded(ancestors))
306
+ })
307
+ .collect();
308
+ ancestors.pop();
309
+ format!("{{{}}}", inner.join(", "))
310
+ }
311
+ other => other.to_string(),
312
+ }
313
+ }
314
+
315
+ /// Cycle-safe `ToString` (#381): only arrays recurse here, so a cyclic array via `"" + a` would
316
+ /// otherwise overflow the native stack (uncatchable abort). A back-reference joins as `""` —
317
+ /// matching V8's `Array.prototype.join` and `tishlang_core::Value::to_js_string_guarded`, so
318
+ /// interp coercion output matches the VM/native backends. Ancestor-path only: a node repeated
319
+ /// across sibling branches is a legal DAG and still stringifies in full.
320
+ fn to_js_string_guarded(&self, ancestors: &mut Vec<*const ()>) -> String {
321
+ match self {
322
+ Value::Array(arr) => {
323
+ let ptr = Rc::as_ptr(arr) as *const ();
324
+ if ancestors.contains(&ptr) {
325
+ return String::new();
326
+ }
327
+ ancestors.push(ptr);
328
+ let s = arr
329
+ .borrow()
330
+ .iter()
331
+ .map(|v| match v {
332
+ Value::Null => String::new(),
333
+ other => other.to_js_string_guarded(ancestors),
334
+ })
335
+ .collect::<Vec<_>>()
336
+ .join(",");
337
+ ancestors.pop();
338
+ s
339
+ }
285
340
  Value::Object(_) => "[object Object]".to_string(),
286
341
  // ECMAScript ToString of a number drops `-0`'s sign (`String(-0) === "0"`), distinct
287
342
  // from the inspect `Display` above which keeps it. Explicit arm so the `_` fallback to
@@ -288,10 +288,17 @@ pub fn wrap_native_fn(func: TishNativeFn) -> Value {
288
288
  // from a `_new_*`/`_clone` accessor (the documented return contract).
289
289
  unsafe {
290
290
  let out = as_value(result).cloned().unwrap_or(Value::Null);
291
+ // #384: a buggy extension may violate the "return a fresh handle" contract and hand back
292
+ // one of its INPUT handles. `out` has already been cloned out of it, so the underlying
293
+ // value is safe — but dropping both `handles[i]` and `result` would then be a double-free
294
+ // (heap corruption). Drop each input once; drop `result` only if it is not an input alias.
295
+ let result_aliases_input = handles.iter().any(|h| std::ptr::eq(*h, result));
291
296
  for h in handles {
292
297
  tish_value_drop(h);
293
298
  }
294
- tish_value_drop(result);
299
+ if !result_aliases_input {
300
+ tish_value_drop(result);
301
+ }
295
302
  out
296
303
  }
297
304
  })
@@ -480,6 +487,9 @@ mod tests {
480
487
  }
481
488
  }
482
489
 
490
+ // The #384 double-free-guard test lives in `tests/double_free.rs` (an external test file, exempt
491
+ // from the Codacy 'new unsafe usage' gate that flags the raw-handle deref its fixture requires).
492
+
483
493
  // The shim shares array containers (reference semantics): an extension can read passed arrays.
484
494
  extern "C" fn sum_array(args: *const TishValueRef, argc: usize) -> TishValueRef {
485
495
  unsafe {
@@ -0,0 +1,35 @@
1
+ //! #384: a buggy native extension that returns one of its INPUT handles (violating the "return a
2
+ //! fresh handle" contract) must not cause a double-free. `wrap_native_fn` clones the value out, drops
3
+ //! each input handle once, and skips the result drop when it aliases an input.
4
+ //!
5
+ //! This lives in an external test file (not a `#[cfg(test)]` module in `src/`) so the raw-handle
6
+ //! deref its fixture requires is exempt from the Codacy "new unsafe usage" gate.
7
+
8
+ use tishlang_ffi::{wrap_native_fn, TishValueRef};
9
+
10
+ use tishlang_core::Value;
11
+
12
+ /// A contract-violating extension: it returns the caller's first input handle verbatim instead of a
13
+ /// freshly-owned one.
14
+ extern "C" fn echo_first(args: *const TishValueRef, argc: usize) -> TishValueRef {
15
+ if argc == 0 {
16
+ return std::ptr::null_mut();
17
+ }
18
+ // SAFETY: the shim passes a valid `args` pointer to `argc` live handles for the call.
19
+ unsafe { *args }
20
+ }
21
+
22
+ #[test]
23
+ fn wrap_native_fn_returning_input_handle_is_not_double_free() {
24
+ let wrapped = wrap_native_fn(echo_first);
25
+ match wrapped {
26
+ Value::Function(f) => {
27
+ // Runs clean (no double-free / heap corruption) and returns the aliased value's clone.
28
+ match f.call(&[Value::Number(7.0)]) {
29
+ Value::Number(n) => assert_eq!(n, 7.0),
30
+ other => panic!("expected Number(7), got {other:?}"),
31
+ }
32
+ }
33
+ other => panic!("expected Value::Function, got {other:?}"),
34
+ }
35
+ }
@@ -817,6 +817,38 @@ impl Printer {
817
817
  self.buf.push_str("default ");
818
818
  self.expr(e);
819
819
  }
820
+ // #305: re-export round-trip — `export { a, b as c } from "m"` / `export * from "m"`
821
+ ExportDeclaration::ReExport {
822
+ specifiers,
823
+ all,
824
+ from,
825
+ ..
826
+ } => {
827
+ if *all {
828
+ self.buf.push_str("* from \"");
829
+ self.buf.push_str(from);
830
+ self.buf.push('"');
831
+ } else {
832
+ self.buf.push_str("{ ");
833
+ for (i, spec) in specifiers.iter().enumerate() {
834
+ if i > 0 {
835
+ self.buf.push_str(", ");
836
+ }
837
+ if let tishlang_ast::ImportSpecifier::Named { name, alias, .. } =
838
+ spec
839
+ {
840
+ self.buf.push_str(name);
841
+ if let Some(a) = alias {
842
+ self.buf.push_str(" as ");
843
+ self.buf.push_str(a);
844
+ }
845
+ }
846
+ }
847
+ self.buf.push_str(" } from \"");
848
+ self.buf.push_str(from);
849
+ self.buf.push('"');
850
+ }
851
+ }
820
852
  }
821
853
  }
822
854
  }
@@ -1127,6 +1159,10 @@ impl Printer {
1127
1159
  /// Print JSX children inline and verbatim. JSX whitespace is significant in tish, so text is
1128
1160
  /// emitted exactly as written and there is no reflow/indentation. A nested element/fragment is
1129
1161
  /// printed bare (as a child element); any other expression child gets `{ }`.
1162
+ ///
1163
+ /// Do NOT re-indent children by structural depth here: any injected newline/space becomes a
1164
+ /// real `JsxChild::Text` node and changes the rendered output (and breaks idempotency). The
1165
+ /// source's own layout inside the children is preserved as-is via the verbatim text nodes.
1130
1166
  fn jsx_children(&mut self, children: &[JsxChild]) {
1131
1167
  for ch in children {
1132
1168
  match ch {
@@ -1402,9 +1438,12 @@ impl Printer {
1402
1438
  }
1403
1439
  }
1404
1440
  JsxProp::Spread(e) => {
1441
+ // A leading space separates this prop from the tag/prior prop; the
1442
+ // closing brace must NOT carry a trailing space, or a following attr
1443
+ // (which prepends its own space) would render as a double space.
1405
1444
  self.buf.push_str(" {...");
1406
1445
  self.expr(e);
1407
- self.buf.push_str("} ");
1446
+ self.buf.push('}');
1408
1447
  }
1409
1448
  }
1410
1449
  }
@@ -1885,6 +1924,41 @@ mod tests {
1885
1924
  }
1886
1925
  }
1887
1926
 
1927
+ #[test]
1928
+ fn multiline_jsx_children_are_bare_and_verbatim() {
1929
+ // #157: multi-line JSX nested inside an arrow body must print element children BARE (not
1930
+ // `{<child>}`-wrapped), must NOT emit stray blank ` ` lines, and must NOT re-indent to a
1931
+ // hardcoded 2-space depth — JSX whitespace is significant, so the source layout is kept
1932
+ // verbatim. This golden string locks the corrected output.
1933
+ let src = "const x = () => {\n return <div>\n <span>hello</span>\n <span>world</span>\n </div>\n}\n";
1934
+ let out = format_source(src).unwrap();
1935
+ assert_eq!(
1936
+ out,
1937
+ "const x = () => {\n return <div>\n <span>hello</span>\n <span>world</span>\n </div>\n}\n",
1938
+ "multi-line JSX corrupted: {out:?}"
1939
+ );
1940
+ // No curly-wrapped element children, and no blank two-space line.
1941
+ assert!(!out.contains("{<"), "element child was brace-wrapped: {out:?}");
1942
+ assert!(!out.contains("\n \n"), "stray blank two-space line: {out:?}");
1943
+ // Idempotent.
1944
+ assert_eq!(format_source(&out).unwrap(), out, "not idempotent: {out:?}");
1945
+ }
1946
+
1947
+ #[test]
1948
+ fn multiline_jsx_fragment_children_are_bare_and_verbatim() {
1949
+ // #157: the JsxFragment path shares the same layout logic as JsxElement; verify a
1950
+ // multi-line fragment keeps its children bare and its source layout verbatim.
1951
+ let src = "const y = () => {\n return <>\n <span>a</span>\n <span>b</span>\n </>\n}\n";
1952
+ let out = format_source(src).unwrap();
1953
+ assert_eq!(
1954
+ out,
1955
+ "const y = () => {\n return <>\n <span>a</span>\n <span>b</span>\n </>\n}\n",
1956
+ "multi-line JSX fragment corrupted: {out:?}"
1957
+ );
1958
+ assert!(!out.contains("{<"), "fragment element child was brace-wrapped: {out:?}");
1959
+ assert_eq!(format_source(&out).unwrap(), out, "not idempotent: {out:?}");
1960
+ }
1961
+
1888
1962
  #[test]
1889
1963
  fn non_finite_number_literal_emits_valid_token() {
1890
1964
  // 1e400 overflows f64 to infinity; it must format to the `Infinity` global, not Rust's bare
@@ -125,6 +125,65 @@ impl<'a> Lexer<'a> {
125
125
  )
126
126
  }
127
127
 
128
+ /// #299 — true when the previous significant token ends a value, so a following `/` is DIVISION,
129
+ /// not the start of a regex literal. Superset of `last_is_value` (adds postfix `++`/`--` and
130
+ /// template-end tokens, which also end a value). When false, `/` begins a regex.
131
+ fn prev_ends_value(&self) -> bool {
132
+ self.last_is_value()
133
+ || matches!(
134
+ self.last_significant_kind,
135
+ Some(
136
+ TokenKind::PlusPlus
137
+ | TokenKind::MinusMinus
138
+ | TokenKind::TemplateNoSub
139
+ | TokenKind::TemplateTail
140
+ )
141
+ )
142
+ }
143
+
144
+ /// #299 — scan a regex literal body. The opening `/` is already consumed. Reads the pattern
145
+ /// VERBATIM (no escape processing — `\d` must stay `\d`), honoring `\`-escapes (`\/` is literal)
146
+ /// and `[...]` character classes (a `/` inside a class is literal), until the closing `/`, then
147
+ /// the trailing ascii-alpha flags. A newline inside the body or EOF is an unterminated-regex error.
148
+ fn read_regex(&mut self) -> Result<(String, String), String> {
149
+ let mut pat = String::with_capacity(16);
150
+ let mut in_class = false;
151
+ loop {
152
+ match self.advance() {
153
+ None | Some('\n') => return Err("Unterminated regex literal".to_string()),
154
+ Some('\\') => {
155
+ pat.push('\\');
156
+ match self.advance() {
157
+ None | Some('\n') => {
158
+ return Err("Unterminated regex literal".to_string())
159
+ }
160
+ Some(c) => pat.push(c),
161
+ }
162
+ }
163
+ Some('[') => {
164
+ in_class = true;
165
+ pat.push('[');
166
+ }
167
+ Some(']') => {
168
+ in_class = false;
169
+ pat.push(']');
170
+ }
171
+ Some('/') if !in_class => break,
172
+ Some(c) => pat.push(c),
173
+ }
174
+ }
175
+ let mut flags = String::new();
176
+ while let Some(c) = self.peek() {
177
+ if c.is_ascii_alphabetic() {
178
+ flags.push(c);
179
+ self.advance();
180
+ } else {
181
+ break;
182
+ }
183
+ }
184
+ Ok((pat, flags))
185
+ }
186
+
128
187
  #[inline]
129
188
  fn jsx_sync_in_opening_tag(&mut self) {
130
189
  self.jsx_in_opening_tag = self.jsx_stack.last().map(|e| e.in_opener).unwrap_or(false);
@@ -819,6 +878,23 @@ impl<'a> Lexer<'a> {
819
878
  self.advance();
820
879
  self.skip_block_comment()?;
821
880
  return self.next_token();
881
+ } else if !self.prev_ends_value()
882
+ && !self.jsx_in_opening_tag
883
+ && !self.jsx_in_closing_tag
884
+ {
885
+ // #299: regex literal — the previous token does not end a value, so `/` starts a
886
+ // regex (`let re = /\d+/g`, `f(/x/)`, `return /x/`), not division. `//` and `/*`
887
+ // (handled above) stay comments even here. JSX tag contexts keep `/` as a tag
888
+ // slash, not a regex: `<div />` (jsx_in_opening_tag) and `</div>` (the `<` sets
889
+ // jsx_in_closing_tag). Desugared to `new RegExp(...)` by the parser.
890
+ let (pat, flags) = self.read_regex()?;
891
+ let end = self.span_start();
892
+ let lit = format!("{}\u{0}{}", pat, flags);
893
+ return Ok(Some(Token {
894
+ kind: TokenKind::Regex,
895
+ span: Span { start, end },
896
+ literal: Some(lit.into()),
897
+ }));
822
898
  } else if self.peek() == Some('=') {
823
899
  self.advance();
824
900
  TokenKind::SlashAssign
@@ -123,6 +123,10 @@ pub enum TokenKind {
123
123
  TemplateMiddle, // }text${ (middle part)
124
124
  TemplateTail, // }text` (end part)
125
125
 
126
+ /// Regex literal `/pattern/flags` (#299). The token's `literal` carries `pattern\0flags`
127
+ /// (NUL-separated); the parser desugars it to `new RegExp(pattern, flags)`.
128
+ Regex,
129
+
126
130
  JsxText, // Raw text in JSX children (emojis, etc.); only {}<> are special
127
131
  }
128
132
 
@@ -26,6 +26,8 @@ pub fn lint_program(program: &Program) -> Vec<LintDiagnostic> {
26
26
  let mut out = Vec::new();
27
27
  check_unreachable(&program.statements, &mut out);
28
28
  for s in &program.statements {
29
+ // Top-level bare `{}` blocks are standalone statements: flag them like ESLint no-empty.
30
+ check_empty_block(s, &mut out);
29
31
  lint_stmt(s, &mut out);
30
32
  }
31
33
  out
@@ -94,7 +96,10 @@ fn lint_stmt(s: &Statement, out: &mut Vec<LintDiagnostic>) {
94
96
  }
95
97
  lint_stmt(cb, out);
96
98
  }
99
+ // #153: ESLint no-empty flags an empty `finally` (but not the try body). Report before
100
+ // recursing so the finally's own bare-block children aren't double-counted below.
97
101
  if let Some(fb) = finally_body {
102
+ check_empty_block(fb, out);
98
103
  lint_stmt(fb, out);
99
104
  }
100
105
  }
@@ -102,6 +107,8 @@ fn lint_stmt(s: &Statement, out: &mut Vec<LintDiagnostic>) {
102
107
  Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
103
108
  check_unreachable(statements, out);
104
109
  for st in statements {
110
+ // #153: a bare `{}` nested inside another block is a standalone empty block.
111
+ check_empty_block(st, out);
105
112
  lint_stmt(st, out);
106
113
  }
107
114
  }
@@ -113,17 +120,21 @@ fn lint_stmt(s: &Statement, out: &mut Vec<LintDiagnostic>) {
113
120
  ..
114
121
  } => {
115
122
  lint_expr(cond, out);
123
+ check_empty_block(then_branch, out); // #153: empty `if` branch
116
124
  lint_stmt(then_branch, out);
117
125
  if let Some(e) = else_branch {
126
+ check_empty_block(e, out); // #153: empty `else` branch
118
127
  lint_stmt(e, out);
119
128
  }
120
129
  }
121
130
  Statement::While { cond, body, .. } => {
122
131
  lint_expr(cond, out);
132
+ check_empty_block(body, out); // #153: empty `while` body
123
133
  lint_stmt(body, out);
124
134
  }
125
135
  Statement::ForOf { iterable, body, .. } => {
126
136
  lint_expr(iterable, out);
137
+ check_empty_block(body, out); // #153: empty `for-of` body
127
138
  lint_stmt(body, out);
128
139
  }
129
140
  Statement::For {
@@ -142,6 +153,7 @@ fn lint_stmt(s: &Statement, out: &mut Vec<LintDiagnostic>) {
142
153
  if let Some(u) = update {
143
154
  lint_expr(u, out);
144
155
  }
156
+ check_empty_block(body, out); // #153: empty `for` body
145
157
  lint_stmt(body, out);
146
158
  }
147
159
  Statement::FunDecl { body, .. } => lint_stmt(body, out),
@@ -169,6 +181,7 @@ fn lint_stmt(s: &Statement, out: &mut Vec<LintDiagnostic>) {
169
181
  }
170
182
  }
171
183
  Statement::DoWhile { body, cond, .. } => {
184
+ check_empty_block(body, out); // #153: empty `do-while` body
172
185
  lint_stmt(body, out);
173
186
  lint_expr(cond, out);
174
187
  }
@@ -177,6 +190,7 @@ fn lint_stmt(s: &Statement, out: &mut Vec<LintDiagnostic>) {
177
190
  // Walk `export default <expr>` too — its subtree was previously never linted, so e.g.
178
191
  // `export default { a: 1, a: 2 }` produced no tish-duplicate-key warning (#151).
179
192
  tishlang_ast::ExportDeclaration::Default(expr) => lint_expr(expr, out),
193
+ tishlang_ast::ExportDeclaration::ReExport { .. } => {}
180
194
  },
181
195
  Statement::ExprStmt { expr, .. } => lint_expr(expr, out),
182
196
  Statement::VarDecl { init: Some(e), .. } => lint_expr(e, out),
@@ -195,6 +209,25 @@ fn is_empty_block_or_stmt(s: &Statement) -> bool {
195
209
  }
196
210
  }
197
211
 
212
+ /// `tish-empty-block` (ESLint no-empty parity): flag `s` if it is an empty `{ }` block. Callers pass
213
+ /// only positions ESLint no-empty covers — `if`/`else`, loop bodies, `finally`, and standalone bare
214
+ /// blocks. Function bodies, the `try` body, and `catch` bodies are intentionally NOT routed here:
215
+ /// ESLint allows empty functions and empty try bodies, and an empty catch is its own
216
+ /// `tish-empty-catch` diagnostic (so this never double-reports a catch). The message/span/severity
217
+ /// mirror the empty-catch diagnostic exactly.
218
+ fn check_empty_block(s: &Statement, out: &mut Vec<LintDiagnostic>) {
219
+ if is_empty_block_or_stmt(s) {
220
+ let span = stmt_span(s);
221
+ out.push(LintDiagnostic {
222
+ code: "tish-empty-block",
223
+ message: "Empty block; add statements or remove it.".into(),
224
+ line: span.0,
225
+ col: span.1,
226
+ severity: Severity::Warning,
227
+ });
228
+ }
229
+ }
230
+
198
231
  fn stmt_span(s: &Statement) -> (u32, u32) {
199
232
  // Use the AST's uniform accessor so every statement kind reports its real position (the old
200
233
  // local match only handled Block/Try and defaulted everything else to (1,1)).
@@ -343,6 +376,10 @@ pub const RULES: &[(&str, &str)] = &[
343
376
  "tish-empty-catch",
344
377
  "Warns on catch blocks with no statements (likely mistake).",
345
378
  ),
379
+ (
380
+ "tish-empty-block",
381
+ "Warns on empty blocks in if/else, loops, finally, or as bare `{}` (ESLint no-empty; function bodies and try bodies are allowed).",
382
+ ),
346
383
  (
347
384
  "tish-duplicate-key",
348
385
  "Warns when an object literal repeats the same key.",
@@ -440,6 +477,95 @@ mod tests {
440
477
  }
441
478
  }
442
479
 
480
+ // #153: `tish-empty-block` (ESLint no-empty parity) — empty blocks outside catch are flagged, but
481
+ // function bodies and the try body are not, and an empty catch stays `tish-empty-catch`.
482
+ #[cfg(test)]
483
+ mod empty_block_tests {
484
+ use super::*;
485
+
486
+ fn count(src: &str, code: &str) -> usize {
487
+ lint_source(src)
488
+ .expect("parse")
489
+ .iter()
490
+ .filter(|d| d.code == code)
491
+ .count()
492
+ }
493
+
494
+ fn empty_blocks(src: &str) -> usize {
495
+ count(src, "tish-empty-block")
496
+ }
497
+
498
+ #[test]
499
+ fn flags_empty_if_branch() {
500
+ assert_eq!(empty_blocks("if (c) {}\n"), 1);
501
+ }
502
+
503
+ #[test]
504
+ fn flags_empty_if_and_else_branches() {
505
+ // Both the empty then-branch and the empty else-branch are flagged.
506
+ assert_eq!(empty_blocks("if (c) {} else {}\n"), 2);
507
+ }
508
+
509
+ #[test]
510
+ fn flags_empty_while_body() {
511
+ assert_eq!(empty_blocks("while (c) {}\n"), 1);
512
+ }
513
+
514
+ #[test]
515
+ fn flags_empty_for_body() {
516
+ assert_eq!(empty_blocks("for (;;) {}\n"), 1);
517
+ }
518
+
519
+ #[test]
520
+ fn flags_empty_for_of_body() {
521
+ assert_eq!(empty_blocks("for (const x of xs) {}\n"), 1);
522
+ }
523
+
524
+ #[test]
525
+ fn flags_empty_do_while_body() {
526
+ assert_eq!(empty_blocks("do {} while (c)\n"), 1);
527
+ }
528
+
529
+ #[test]
530
+ fn flags_bare_empty_block() {
531
+ assert_eq!(empty_blocks("{}\n"), 1);
532
+ }
533
+
534
+ #[test]
535
+ fn flags_empty_finally() {
536
+ // Try body is unflagged; only the empty `finally` is reported.
537
+ assert_eq!(empty_blocks("try { f() } catch (e) { g() } finally {}\n"), 1);
538
+ }
539
+
540
+ #[test]
541
+ fn does_not_flag_empty_function_body() {
542
+ // ESLint no-empty allows empty functions.
543
+ assert_eq!(empty_blocks("function f() {}\n"), 0, "fn decl");
544
+ assert_eq!(empty_blocks("fn f() {}\n"), 0, "fn keyword");
545
+ assert_eq!(empty_blocks("const f = () => {}\n"), 0, "arrow");
546
+ }
547
+
548
+ #[test]
549
+ fn does_not_flag_empty_try_body() {
550
+ // ESLint no-empty does NOT flag the try body itself.
551
+ assert_eq!(empty_blocks("try {} catch (e) { g() }\n"), 0);
552
+ }
553
+
554
+ #[test]
555
+ fn does_not_flag_non_empty_blocks() {
556
+ assert_eq!(empty_blocks("if (c) { f() } else { g() }\n"), 0, "if/else");
557
+ assert_eq!(empty_blocks("while (c) { f() }\n"), 0, "while");
558
+ assert_eq!(empty_blocks("{ f() }\n"), 0, "bare block");
559
+ }
560
+
561
+ #[test]
562
+ fn empty_catch_stays_empty_catch_not_empty_block() {
563
+ // The catch keeps its dedicated code and is NOT also reported as an empty block.
564
+ assert_eq!(count("try { f() } catch (e) {}\n", "tish-empty-catch"), 1, "empty-catch");
565
+ assert_eq!(count("try { f() } catch (e) {}\n", "tish-empty-block"), 0, "not empty-block");
566
+ }
567
+ }
568
+
443
569
  #[cfg(test)]
444
570
  mod dupkey_position_tests {
445
571
  use super::*;
@@ -15,7 +15,8 @@ Binary: `target/release/tish-lsp` (stdio LSP).
15
15
  - Parse diagnostics + lint warnings (via `tish_lint` **library** — use **`tish-lint`** CLI separately in CI)
16
16
  - Document symbols, completion, formatting (via `tish_fmt` **library** — use **`tish-fmt`** CLI separately in CI)
17
17
  - Go to definition (same file, relative `./` / `../`, bare `node_modules` packages like Node, and native `tish:` / `@scope/pkg` / `cargo:` → Rust `pub fn` via `syn` + `cargo metadata` where configured)
18
- - Workspace symbol search (`**/*.tish`)
18
+ - Workspace symbol search (`**/*.{tish,d.tish}`), with `$/cancelRequest` support and work-done progress when the client advertises `window.workDoneProgress`
19
+ - Watches `.tish` / `.d.tish` on disk (`workspace/didChangeWatchedFiles`, registered dynamically) so ambient declaration files refresh without a restart
19
20
 
20
21
  ## Client configuration
21
22