@tishlang/tish-format 2.12.0 → 2.35.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.
Files changed (88) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish-format +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +12 -2
  4. package/crates/tish/tests/fixtures/fs_parity_callbacks.tish +22 -0
  5. package/crates/tish/tests/fixtures/fs_parity_promises.tish +21 -0
  6. package/crates/tish/tests/fixtures/fs_parity_sync.tish +20 -0
  7. package/crates/tish/tests/fs_parity.rs +103 -0
  8. package/crates/tish/tests/integration_test.rs +80 -0
  9. package/crates/tish/tests/shortcircuit.rs +3 -3
  10. package/crates/tish_ast/src/ast.rs +84 -0
  11. package/crates/tish_builtins/Cargo.toml +1 -0
  12. package/crates/tish_builtins/src/array.rs +724 -72
  13. package/crates/tish_builtins/src/collections.rs +149 -40
  14. package/crates/tish_builtins/src/globals.rs +215 -25
  15. package/crates/tish_builtins/src/math.rs +65 -7
  16. package/crates/tish_builtins/src/number.rs +173 -0
  17. package/crates/tish_builtins/src/string.rs +263 -23
  18. package/crates/tish_bytecode/src/chunk.rs +7 -0
  19. package/crates/tish_bytecode/src/compiler.rs +611 -63
  20. package/crates/tish_bytecode/src/lib.rs +1 -1
  21. package/crates/tish_bytecode/src/opcode.rs +156 -2
  22. package/crates/tish_bytecode/src/serialize.rs +2 -0
  23. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  24. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  25. package/crates/tish_compile/src/codegen.rs +18219 -5283
  26. package/crates/tish_compile/src/infer.rs +1562 -30
  27. package/crates/tish_compile/src/lib.rs +29 -4
  28. package/crates/tish_compile/src/resolve.rs +151 -13
  29. package/crates/tish_compile/src/types.rs +224 -28
  30. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  31. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  32. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  33. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  34. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  35. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  36. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  37. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  38. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  39. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  40. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  41. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  42. package/crates/tish_compile_js/src/codegen.rs +94 -3
  43. package/crates/tish_compile_js/src/tests_jsx.rs +27 -0
  44. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +115 -2
  45. package/crates/tish_core/Cargo.toml +4 -0
  46. package/crates/tish_core/src/json.rs +109 -6
  47. package/crates/tish_core/src/lib.rs +222 -2
  48. package/crates/tish_core/src/shape.rs +4 -2
  49. package/crates/tish_core/src/uri.rs +45 -0
  50. package/crates/tish_core/src/value.rs +571 -35
  51. package/crates/tish_core/src/vmref.rs +14 -0
  52. package/crates/tish_eval/Cargo.toml +2 -1
  53. package/crates/tish_eval/src/eval.rs +1328 -101
  54. package/crates/tish_eval/src/natives.rs +283 -18
  55. package/crates/tish_eval/src/regex.rs +47 -1
  56. package/crates/tish_eval/src/value.rs +76 -21
  57. package/crates/tish_ffi/src/lib.rs +11 -1
  58. package/crates/tish_ffi/tests/double_free.rs +35 -0
  59. package/crates/tish_fmt/Cargo.toml +1 -1
  60. package/crates/tish_fmt/src/lib.rs +94 -1
  61. package/crates/tish_lexer/src/lib.rs +76 -0
  62. package/crates/tish_lexer/src/token.rs +4 -0
  63. package/crates/tish_lint/src/lib.rs +126 -0
  64. package/crates/tish_lsp/README.md +2 -1
  65. package/crates/tish_lsp/src/main.rs +378 -28
  66. package/crates/tish_native/src/build.rs +41 -0
  67. package/crates/tish_opt/src/lib.rs +20 -1
  68. package/crates/tish_parser/Cargo.toml +4 -0
  69. package/crates/tish_parser/src/lib.rs +68 -0
  70. package/crates/tish_parser/src/parser.rs +479 -28
  71. package/crates/tish_resolve/src/lib.rs +75 -5
  72. package/crates/tish_runtime/Cargo.toml +10 -1
  73. package/crates/tish_runtime/src/fs_ext.rs +359 -0
  74. package/crates/tish_runtime/src/http.rs +28 -10
  75. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  76. package/crates/tish_runtime/src/http_hyper.rs +41 -18
  77. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  78. package/crates/tish_runtime/src/lib.rs +651 -51
  79. package/crates/tish_runtime/src/timers.rs +49 -2
  80. package/crates/tish_ui/src/jsx.rs +21 -3
  81. package/crates/tish_vm/src/jit.rs +2514 -117
  82. package/crates/tish_vm/src/vm.rs +1479 -224
  83. package/package.json +1 -1
  84. package/platform/darwin-arm64/tish-fmt +0 -0
  85. package/platform/darwin-x64/tish-fmt +0 -0
  86. package/platform/linux-arm64/tish-fmt +0 -0
  87. package/platform/linux-x64/tish-fmt +0 -0
  88. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -613,6 +613,20 @@ impl Printer {
613
613
  self.buf.push_str(") ");
614
614
  self.stmt_inline_or_block(body, level);
615
615
  }
616
+ Statement::ForIn {
617
+ name,
618
+ object,
619
+ body,
620
+ ..
621
+ } => {
622
+ self.indent(level);
623
+ self.buf.push_str("for (let ");
624
+ self.buf.push_str(name);
625
+ self.buf.push_str(" in ");
626
+ self.expr(object);
627
+ self.buf.push_str(") ");
628
+ self.stmt_inline_or_block(body, level);
629
+ }
616
630
  Statement::Return { value, .. } => {
617
631
  self.indent(level);
618
632
  self.buf.push_str("return");
@@ -817,6 +831,43 @@ impl Printer {
817
831
  self.buf.push_str("default ");
818
832
  self.expr(e);
819
833
  }
834
+ // #305: re-export round-trip — `export { a, b as c } from "m"` / `export * from "m"`
835
+ ExportDeclaration::ReExport {
836
+ specifiers,
837
+ all,
838
+ from,
839
+ ..
840
+ } => {
841
+ if *all {
842
+ // `export *` always has a source.
843
+ self.buf.push_str("* from \"");
844
+ self.buf.push_str(from.as_deref().unwrap_or(""));
845
+ self.buf.push('"');
846
+ } else {
847
+ self.buf.push_str("{ ");
848
+ for (i, spec) in specifiers.iter().enumerate() {
849
+ if i > 0 {
850
+ self.buf.push_str(", ");
851
+ }
852
+ if let tishlang_ast::ImportSpecifier::Named { name, alias, .. } =
853
+ spec
854
+ {
855
+ self.buf.push_str(name);
856
+ if let Some(a) = alias {
857
+ self.buf.push_str(" as ");
858
+ self.buf.push_str(a);
859
+ }
860
+ }
861
+ }
862
+ self.buf.push_str(" }");
863
+ // #415: a local named export (`export { a }`) has no `from` clause.
864
+ if let Some(from) = from {
865
+ self.buf.push_str(" from \"");
866
+ self.buf.push_str(from);
867
+ self.buf.push('"');
868
+ }
869
+ }
870
+ }
820
871
  }
821
872
  }
822
873
  }
@@ -1127,6 +1178,10 @@ impl Printer {
1127
1178
  /// Print JSX children inline and verbatim. JSX whitespace is significant in tish, so text is
1128
1179
  /// emitted exactly as written and there is no reflow/indentation. A nested element/fragment is
1129
1180
  /// printed bare (as a child element); any other expression child gets `{ }`.
1181
+ ///
1182
+ /// Do NOT re-indent children by structural depth here: any injected newline/space becomes a
1183
+ /// real `JsxChild::Text` node and changes the rendered output (and breaks idempotency). The
1184
+ /// source's own layout inside the children is preserved as-is via the verbatim text nodes.
1130
1185
  fn jsx_children(&mut self, children: &[JsxChild]) {
1131
1186
  for ch in children {
1132
1187
  match ch {
@@ -1402,9 +1457,12 @@ impl Printer {
1402
1457
  }
1403
1458
  }
1404
1459
  JsxProp::Spread(e) => {
1460
+ // A leading space separates this prop from the tag/prior prop; the
1461
+ // closing brace must NOT carry a trailing space, or a following attr
1462
+ // (which prepends its own space) would render as a double space.
1405
1463
  self.buf.push_str(" {...");
1406
1464
  self.expr(e);
1407
- self.buf.push_str("} ");
1465
+ self.buf.push('}');
1408
1466
  }
1409
1467
  }
1410
1468
  }
@@ -1885,6 +1943,41 @@ mod tests {
1885
1943
  }
1886
1944
  }
1887
1945
 
1946
+ #[test]
1947
+ fn multiline_jsx_children_are_bare_and_verbatim() {
1948
+ // #157: multi-line JSX nested inside an arrow body must print element children BARE (not
1949
+ // `{<child>}`-wrapped), must NOT emit stray blank ` ` lines, and must NOT re-indent to a
1950
+ // hardcoded 2-space depth — JSX whitespace is significant, so the source layout is kept
1951
+ // verbatim. This golden string locks the corrected output.
1952
+ let src = "const x = () => {\n return <div>\n <span>hello</span>\n <span>world</span>\n </div>\n}\n";
1953
+ let out = format_source(src).unwrap();
1954
+ assert_eq!(
1955
+ out,
1956
+ "const x = () => {\n return <div>\n <span>hello</span>\n <span>world</span>\n </div>\n}\n",
1957
+ "multi-line JSX corrupted: {out:?}"
1958
+ );
1959
+ // No curly-wrapped element children, and no blank two-space line.
1960
+ assert!(!out.contains("{<"), "element child was brace-wrapped: {out:?}");
1961
+ assert!(!out.contains("\n \n"), "stray blank two-space line: {out:?}");
1962
+ // Idempotent.
1963
+ assert_eq!(format_source(&out).unwrap(), out, "not idempotent: {out:?}");
1964
+ }
1965
+
1966
+ #[test]
1967
+ fn multiline_jsx_fragment_children_are_bare_and_verbatim() {
1968
+ // #157: the JsxFragment path shares the same layout logic as JsxElement; verify a
1969
+ // multi-line fragment keeps its children bare and its source layout verbatim.
1970
+ let src = "const y = () => {\n return <>\n <span>a</span>\n <span>b</span>\n </>\n}\n";
1971
+ let out = format_source(src).unwrap();
1972
+ assert_eq!(
1973
+ out,
1974
+ "const y = () => {\n return <>\n <span>a</span>\n <span>b</span>\n </>\n}\n",
1975
+ "multi-line JSX fragment corrupted: {out:?}"
1976
+ );
1977
+ assert!(!out.contains("{<"), "fragment element child was brace-wrapped: {out:?}");
1978
+ assert_eq!(format_source(&out).unwrap(), out, "not idempotent: {out:?}");
1979
+ }
1980
+
1888
1981
  #[test]
1889
1982
  fn non_finite_number_literal_emits_valid_token() {
1890
1983
  // 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