@tishlang/tish-format 2.2.7 → 2.9.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 (56) hide show
  1. package/bin/tish-format +0 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +5 -1
  3. package/crates/tish/Cargo.toml +1 -0
  4. package/crates/tish/src/cli_help.rs +15 -0
  5. package/crates/tish/src/main.rs +139 -12
  6. package/crates/tish/tests/integration_test.rs +65 -0
  7. package/crates/tish_ast/src/ast.rs +6 -2
  8. package/crates/tish_builtins/src/array.rs +82 -1
  9. package/crates/tish_builtins/src/globals.rs +50 -16
  10. package/crates/tish_builtins/src/math.rs +63 -9
  11. package/crates/tish_builtins/src/number.rs +20 -1
  12. package/crates/tish_builtins/src/string.rs +68 -4
  13. package/crates/tish_builtins/src/typedarrays.rs +23 -16
  14. package/crates/tish_bytecode/src/compiler.rs +94 -28
  15. package/crates/tish_bytecode/tests/break_continue_bytecode.rs +19 -0
  16. package/crates/tish_compile/src/check.rs +1 -1
  17. package/crates/tish_compile/src/codegen.rs +3133 -582
  18. package/crates/tish_compile/src/infer.rs +1950 -56
  19. package/crates/tish_compile/src/lib.rs +38 -0
  20. package/crates/tish_compile/src/resolve.rs +3 -2
  21. package/crates/tish_compile/src/types.rs +17 -0
  22. package/crates/tish_compile_js/Cargo.toml +3 -0
  23. package/crates/tish_compile_js/src/codegen.rs +365 -9
  24. package/crates/tish_compile_js/src/lib.rs +2 -1
  25. package/crates/tish_compile_js/src/tests_jsx.rs +229 -1
  26. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  27. package/crates/tish_core/Cargo.toml +2 -0
  28. package/crates/tish_core/src/json.rs +135 -34
  29. package/crates/tish_core/src/lib.rs +23 -1
  30. package/crates/tish_core/src/value.rs +64 -11
  31. package/crates/tish_eval/src/eval.rs +144 -197
  32. package/crates/tish_eval/src/natives.rs +45 -45
  33. package/crates/tish_eval/src/regex.rs +35 -27
  34. package/crates/tish_eval/src/value.rs +8 -0
  35. package/crates/tish_fmt/Cargo.toml +1 -1
  36. package/crates/tish_fmt/src/lib.rs +45 -1
  37. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  38. package/crates/tish_lint/src/lib.rs +197 -21
  39. package/crates/tish_lsp/Cargo.toml +6 -0
  40. package/crates/tish_lsp/src/import_goto.rs +52 -7
  41. package/crates/tish_lsp/src/main.rs +856 -140
  42. package/crates/tish_opt/src/lib.rs +5 -3
  43. package/crates/tish_parser/src/lib.rs +23 -5
  44. package/crates/tish_parser/src/parser.rs +15 -26
  45. package/crates/tish_resolve/src/lib.rs +188 -18
  46. package/crates/tish_runtime/src/lib.rs +58 -18
  47. package/crates/tish_ui/src/jsx.rs +2 -2
  48. package/crates/tish_vm/src/jit.rs +212 -10
  49. package/crates/tish_vm/src/vm.rs +39 -18
  50. package/crates/tish_wasm/src/lib.rs +116 -22
  51. package/package.json +1 -1
  52. package/platform/darwin-arm64/tish-fmt +0 -0
  53. package/platform/darwin-x64/tish-fmt +0 -0
  54. package/platform/linux-arm64/tish-fmt +0 -0
  55. package/platform/linux-x64/tish-fmt +0 -0
  56. package/platform/win32-x64/tish-fmt.exe +0 -0
@@ -254,46 +254,54 @@ pub fn string_split(input: &str, separator: &Value, limit: Option<usize>) -> Val
254
254
  return Value::Array(Rc::new(RefCell::new(Vec::new())));
255
255
  }
256
256
 
257
- match separator {
257
+ // JS semantics in every branch: split the string fully, then truncate the result to `limit`.
258
+ // (Rust's `splitn` keeps the unsplit remainder in the last slot — that's Python-style maxsplit,
259
+ // not JS — so we must split fully and `truncate`. `truncate(usize::MAX)` is a no-op for the
260
+ // no-limit case.) This mirrors `tish_builtins::string::split_limit` /
261
+ // `tishlang_runtime::string_split_regex` so interp, vm, and the native backends agree.
262
+ let mut result: Vec<Value> = match separator {
258
263
  Value::RegExp(re) => {
259
264
  let re = re.borrow();
260
- let mut result = Vec::new();
265
+ let mut parts = Vec::new();
261
266
  let mut last_end = 0;
262
-
263
267
  for mat in re.regex.find_iter(input) {
264
268
  match mat {
265
269
  Ok(m) => {
266
- if result.len() >= max - 1 {
267
- break;
268
- }
269
- result.push(Value::String(input[last_end..m.start()].into()));
270
+ parts.push(Value::String(input[last_end..m.start()].into()));
270
271
  last_end = m.end();
271
272
  }
272
273
  Err(_) => break,
273
274
  }
274
275
  }
275
-
276
- if result.len() < max {
277
- result.push(Value::String(input[last_end..].into()));
278
- }
279
-
280
- Value::Array(Rc::new(RefCell::new(result)))
281
- }
282
- Value::String(sep) => {
283
- let parts: Vec<Value> = input
284
- .splitn(max, sep.as_ref())
285
- .map(|s| Value::String(s.into()))
286
- .collect();
287
- Value::Array(Rc::new(RefCell::new(parts)))
276
+ parts.push(Value::String(input[last_end..].into()));
277
+ parts
288
278
  }
289
- Value::Null => Value::Array(Rc::new(RefCell::new(vec![Value::String(input.into())]))),
279
+ // JS `split("")` → the string's chars with no surrounding empties (`"".split("")` → `[]`),
280
+ // unlike Rust's `str::split("")`. Mirrors `tish_builtins::string::split_limit`. #247
281
+ Value::String(sep) if sep.is_empty() => input
282
+ .chars()
283
+ .map(|c| Value::String(c.to_string().into()))
284
+ .collect(),
285
+ Value::String(sep) => input
286
+ .split(sep.as_ref())
287
+ .map(|s| Value::String(s.into()))
288
+ .collect(),
289
+ Value::Null => vec![Value::String(input.into())],
290
290
  _ => {
291
291
  let sep_str = separator.to_string();
292
- let parts: Vec<Value> = input
293
- .splitn(max, &sep_str)
294
- .map(|s| Value::String(s.into()))
295
- .collect();
296
- Value::Array(Rc::new(RefCell::new(parts)))
292
+ if sep_str.is_empty() {
293
+ input
294
+ .chars()
295
+ .map(|c| Value::String(c.to_string().into()))
296
+ .collect()
297
+ } else {
298
+ input
299
+ .split(&sep_str)
300
+ .map(|s| Value::String(s.into()))
301
+ .collect()
302
+ }
297
303
  }
298
- }
304
+ };
305
+ result.truncate(max);
306
+ Value::Array(Rc::new(RefCell::new(result)))
299
307
  }
@@ -197,6 +197,10 @@ impl std::fmt::Debug for Value {
197
197
  impl std::fmt::Display for Value {
198
198
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199
199
  match self {
200
+ // Inspect form (`console.log`): keeps the sign of negative zero, unlike the ECMAScript
201
+ // ToString used by `to_js_string`. Matches Node's console output (`console.log(-0)` →
202
+ // `-0`). `to_js_string` has an explicit `Number` arm so ToString still drops it. (#247)
203
+ Value::Number(n) if *n == 0.0 && n.is_sign_negative() => write!(f, "-0"),
200
204
  // Match JS `Number.prototype.toString` (exponential past digit 21 / before −6),
201
205
  // shared with the VM/native path via `tishlang_core`.
202
206
  Value::Number(n) => write!(f, "{}", tishlang_core::js_number_to_string(*n)),
@@ -279,6 +283,10 @@ impl Value {
279
283
  .collect::<Vec<_>>()
280
284
  .join(","),
281
285
  Value::Object(_) => "[object Object]".to_string(),
286
+ // ECMAScript ToString of a number drops `-0`'s sign (`String(-0) === "0"`), distinct
287
+ // from the inspect `Display` above which keeps it. Explicit arm so the `_` fallback to
288
+ // `to_string()` (inspect) is not used for numbers. (#247)
289
+ Value::Number(n) => tishlang_core::js_number_to_string(*n),
282
290
  _ => self.to_string(),
283
291
  }
284
292
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_fmt"
3
- version = "2.2.7"
3
+ version = "2.9.0"
4
4
  edition = "2021"
5
5
  description = "Opinionated formatter for Tish source (parse → pretty-print)"
6
6
  license-file = { workspace = true }
@@ -248,7 +248,7 @@ impl Printer {
248
248
 
249
249
  fn object_prop(&mut self, pr: &ObjectProp) {
250
250
  match pr {
251
- ObjectProp::KeyValue(k, v) => {
251
+ ObjectProp::KeyValue(k, v, _) => {
252
252
  self.buf.push_str(k.as_ref());
253
253
  self.buf.push_str(": ");
254
254
  self.expr(v);
@@ -2235,4 +2235,48 @@ let x = add(1, 2)
2235
2235
  tishlang_parser::parse(&out).unwrap();
2236
2236
  assert_eq!(format_source(&out).unwrap(), out, "not idempotent:\n{out}");
2237
2237
  }
2238
+
2239
+ /// Broad idempotence guard (#163): the existing tests targeted specific constructs (JSX, new-
2240
+ /// expr) that *had* regressed, leaving structural blind spots. This sweeps a diverse corpus and
2241
+ /// asserts, for every snippet, that (a) the formatted output re-parses and (b) re-formatting is
2242
+ /// a fixed point — so any future construct that formats non-idempotently is caught here.
2243
+ #[test]
2244
+ fn format_is_idempotent_over_a_corpus() {
2245
+ let corpus = [
2246
+ "let a = { x: 1, y: { z: [1, 2, 3] } }\n",
2247
+ "let f = (a, b) => a + b\n",
2248
+ "let g = (x) => { return x * 2 }\n",
2249
+ "const e = <div class=\"x\">{a}<span>b</span></div>\n",
2250
+ "let frag = <><a>1</a><b>2</b></>\n",
2251
+ "let n = new Foo(1).bar().baz\n",
2252
+ "let n2 = new (getCtor())(arg)\n",
2253
+ "let t = `a${b}c${d}e`\n",
2254
+ "let lit = \"has a literal $ and a ${notInterp}\"\n",
2255
+ "if (x) { return 1 } else if (y) { return 2 } else { return 3 }\n",
2256
+ "for (let i = 0; i < n; i = i + 1) { f(i) }\n",
2257
+ "for (const k of items) { use(k) }\n",
2258
+ "while (cond) { step() }\n",
2259
+ "do { once() } while (again)\n",
2260
+ "switch (x) { case 1: f(); break; default: g() }\n",
2261
+ "let u: number | string = 1\n",
2262
+ "type T = { a: number, b: string[] }\n",
2263
+ "export fn h(x: T): T { return x }\n",
2264
+ "let v = a ? b : c\n",
2265
+ "let w = a ?? b\n",
2266
+ "let arr = [{ k: 1 }, { k: 2 }, ...rest]\n",
2267
+ "let obj = { ...base, x: 1, y: 2 }\n",
2268
+ "let chain = a?.b?.c\n",
2269
+ "let big = 1e400\n",
2270
+ "try { risky() } catch (e) { handle(e) } finally { cleanup() }\n",
2271
+ "async fn af() { return await thing() }\n",
2272
+ "import { a, b as c } from \"./m\"\nexport default a\n",
2273
+ ];
2274
+ for src in corpus {
2275
+ let once = format_source(src).expect("format");
2276
+ tishlang_parser::parse(&once)
2277
+ .unwrap_or_else(|e| panic!("formatted output must re-parse for {src:?}: {e}\n{once}"));
2278
+ let twice = format_source(&once).expect("re-format");
2279
+ assert_eq!(once, twice, "non-idempotent for {src:?}:\n{once:?}\n ->\n{twice:?}");
2280
+ }
2281
+ }
2238
2282
  }
@@ -20,6 +20,11 @@ struct Cli {
20
20
  #[arg(long = "format", value_enum, default_value_t = OutputFormat::Text)]
21
21
  output_format: OutputFormat,
22
22
 
23
+ /// Exit non-zero if any WARNING is found (not just errors), so CI can gate on lint findings.
24
+ /// Without it, lint warnings print but the process exits 0 (only parse errors fail). (#154)
25
+ #[arg(long = "deny-warnings")]
26
+ deny_warnings: bool,
27
+
23
28
  #[arg(required = true)]
24
29
  paths: Vec<String>,
25
30
  }
@@ -36,7 +41,7 @@ struct Issue {
36
41
 
37
42
  fn main() {
38
43
  let cli = Cli::parse();
39
- if let Err(e) = run(&cli.paths, cli.output_format) {
44
+ if let Err(e) = run(&cli.paths, cli.output_format, cli.deny_warnings) {
40
45
  eprintln!("{}", e);
41
46
  std::process::exit(1);
42
47
  }
@@ -67,7 +72,7 @@ fn collect_files(paths: &[String]) -> Result<Vec<PathBuf>, String> {
67
72
  Ok(files)
68
73
  }
69
74
 
70
- fn run(paths: &[String], format: OutputFormat) -> Result<(), String> {
75
+ fn run(paths: &[String], format: OutputFormat, deny_warnings: bool) -> Result<(), String> {
71
76
  let files = collect_files(paths)?;
72
77
  let mut issues: Vec<Issue> = Vec::new();
73
78
  for f in files {
@@ -103,6 +108,13 @@ fn run(paths: &[String], format: OutputFormat) -> Result<(), String> {
103
108
  }
104
109
 
105
110
  let error_count = issues.iter().filter(|i| i.level == "error").count();
111
+ let warning_count = issues.iter().filter(|i| i.level == "warning").count();
112
+ // Always fail on errors (parse errors); with --deny-warnings, lint warnings fail too.
113
+ let fail_count = if deny_warnings {
114
+ error_count + warning_count
115
+ } else {
116
+ error_count
117
+ };
106
118
 
107
119
  match format {
108
120
  OutputFormat::Text => {
@@ -117,18 +129,15 @@ fn run(paths: &[String], format: OutputFormat) -> Result<(), String> {
117
129
  i.message
118
130
  );
119
131
  }
120
- if error_count > 0 {
121
- return Err(format!("{} issue(s)", error_count));
122
- }
123
132
  }
124
133
  OutputFormat::Sarif => {
125
134
  print_sarif(&issues)?;
126
- if error_count > 0 {
127
- return Err(format!("{} issue(s)", error_count));
128
- }
129
135
  }
130
136
  }
131
137
 
138
+ if fail_count > 0 {
139
+ return Err(format!("{} issue(s)", fail_count));
140
+ }
132
141
  Ok(())
133
142
  }
134
143
 
@@ -24,12 +24,47 @@ pub struct LintDiagnostic {
24
24
  /// Run all default rules on parsed program.
25
25
  pub fn lint_program(program: &Program) -> Vec<LintDiagnostic> {
26
26
  let mut out = Vec::new();
27
+ check_unreachable(&program.statements, &mut out);
27
28
  for s in &program.statements {
28
29
  lint_stmt(s, &mut out);
29
30
  }
30
31
  out
31
32
  }
32
33
 
34
+ /// `tish-unreachable-code` (no-unreachable): in a statement sequence, the first statement after an
35
+ /// unconditional `return`/`throw`/`break`/`continue` sibling can never run. Flags only that first
36
+ /// statement (not the whole tail — less noise) and skips hoisted `fn` declarations, which are valid
37
+ /// after a terminator. Only a DIRECT-sibling terminator triggers it, so a conditional
38
+ /// `if (c) return` followed by more statements is correctly NOT flagged.
39
+ fn check_unreachable(stmts: &[Statement], out: &mut Vec<LintDiagnostic>) {
40
+ let mut terminated = false;
41
+ for st in stmts {
42
+ if terminated {
43
+ if matches!(st, Statement::FunDecl { .. }) {
44
+ continue; // function declarations hoist — reachable regardless of position
45
+ }
46
+ let (line, col) = stmt_span(st);
47
+ out.push(LintDiagnostic {
48
+ code: "tish-unreachable-code",
49
+ message: "Unreachable code after return/throw/break/continue.".into(),
50
+ line,
51
+ col,
52
+ severity: Severity::Warning,
53
+ });
54
+ return;
55
+ }
56
+ if matches!(
57
+ st,
58
+ Statement::Return { .. }
59
+ | Statement::Throw { .. }
60
+ | Statement::Break { .. }
61
+ | Statement::Continue { .. }
62
+ ) {
63
+ terminated = true;
64
+ }
65
+ }
66
+ }
67
+
33
68
  /// Lint source: parse then lint. Parse errors are not reported here.
34
69
  pub fn lint_source(source: &str) -> Result<Vec<LintDiagnostic>, String> {
35
70
  let program = tishlang_parser::parse(source)?;
@@ -63,51 +98,86 @@ fn lint_stmt(s: &Statement, out: &mut Vec<LintDiagnostic>) {
63
98
  lint_stmt(fb, out);
64
99
  }
65
100
  }
66
- Statement::Block { statements, .. } => {
101
+ // Block and the transparent comma-declarator group (#141: Multi was previously skipped).
102
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
103
+ check_unreachable(statements, out);
67
104
  for st in statements {
68
105
  lint_stmt(st, out);
69
106
  }
70
107
  }
108
+ // #140: control-flow CONDITION expressions are linted too, not just bodies.
71
109
  Statement::If {
110
+ cond,
72
111
  then_branch,
73
112
  else_branch,
74
113
  ..
75
114
  } => {
115
+ lint_expr(cond, out);
76
116
  lint_stmt(then_branch, out);
77
117
  if let Some(e) = else_branch {
78
118
  lint_stmt(e, out);
79
119
  }
80
120
  }
81
- Statement::While { body, .. } | Statement::ForOf { body, .. } => lint_stmt(body, out),
82
- Statement::For { init, body, .. } => {
121
+ Statement::While { cond, body, .. } => {
122
+ lint_expr(cond, out);
123
+ lint_stmt(body, out);
124
+ }
125
+ Statement::ForOf { iterable, body, .. } => {
126
+ lint_expr(iterable, out);
127
+ lint_stmt(body, out);
128
+ }
129
+ Statement::For {
130
+ init,
131
+ cond,
132
+ update,
133
+ body,
134
+ ..
135
+ } => {
83
136
  if let Some(i) = init {
84
137
  lint_stmt(i, out);
85
138
  }
139
+ if let Some(c) = cond {
140
+ lint_expr(c, out);
141
+ }
142
+ if let Some(u) = update {
143
+ lint_expr(u, out);
144
+ }
86
145
  lint_stmt(body, out);
87
146
  }
88
147
  Statement::FunDecl { body, .. } => lint_stmt(body, out),
89
148
  Statement::Switch {
149
+ expr,
90
150
  cases,
91
151
  default_body,
92
152
  ..
93
153
  } => {
94
- for (_, stmts) in cases {
154
+ lint_expr(expr, out);
155
+ for (case_expr, stmts) in cases {
156
+ if let Some(ce) = case_expr {
157
+ lint_expr(ce, out);
158
+ }
159
+ check_unreachable(stmts, out);
95
160
  for st in stmts {
96
161
  lint_stmt(st, out);
97
162
  }
98
163
  }
99
164
  if let Some(def) = default_body {
165
+ check_unreachable(def, out);
100
166
  for st in def {
101
167
  lint_stmt(st, out);
102
168
  }
103
169
  }
104
170
  }
105
- Statement::DoWhile { body, .. } => lint_stmt(body, out),
106
- Statement::Export { declaration, .. } => {
107
- if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
108
- lint_stmt(inner, out);
109
- }
171
+ Statement::DoWhile { body, cond, .. } => {
172
+ lint_stmt(body, out);
173
+ lint_expr(cond, out);
110
174
  }
175
+ Statement::Export { declaration, .. } => match declaration.as_ref() {
176
+ tishlang_ast::ExportDeclaration::Named(inner) => lint_stmt(inner, out),
177
+ // Walk `export default <expr>` too — its subtree was previously never linted, so e.g.
178
+ // `export default { a: 1, a: 2 }` produced no tish-duplicate-key warning (#151).
179
+ tishlang_ast::ExportDeclaration::Default(expr) => lint_expr(expr, out),
180
+ },
111
181
  Statement::ExprStmt { expr, .. } => lint_expr(expr, out),
112
182
  Statement::VarDecl { init: Some(e), .. } => lint_expr(e, out),
113
183
  Statement::VarDeclDestructure { init, .. } => lint_expr(init, out),
@@ -126,29 +196,26 @@ fn is_empty_block_or_stmt(s: &Statement) -> bool {
126
196
  }
127
197
 
128
198
  fn stmt_span(s: &Statement) -> (u32, u32) {
129
- let sp = match s {
130
- Statement::Block { span, .. } => *span,
131
- Statement::Try { span, .. } => *span,
132
- _ => tishlang_ast::Span {
133
- start: (1, 1),
134
- end: (1, 1),
135
- },
136
- };
199
+ // Use the AST's uniform accessor so every statement kind reports its real position (the old
200
+ // local match only handled Block/Try and defaulted everything else to (1,1)).
201
+ let sp = s.span();
137
202
  (sp.start.0 as u32, sp.start.1 as u32)
138
203
  }
139
204
 
140
205
  fn lint_expr(e: &Expr, out: &mut Vec<LintDiagnostic>) {
141
206
  match e {
142
- Expr::Object { props, span, .. } => {
207
+ Expr::Object { props, .. } => {
143
208
  let mut seen: HashSet<&str> = HashSet::new();
144
209
  for p in props {
145
- if let ObjectProp::KeyValue(k, v) = p {
210
+ if let ObjectProp::KeyValue(k, v, kspan) = p {
146
211
  if !seen.insert(k.as_ref()) {
212
+ // Point at the duplicated KEY, not the enclosing `{` — so distinct dupes get
213
+ // distinct positions (#143).
147
214
  out.push(LintDiagnostic {
148
215
  code: "tish-duplicate-key",
149
216
  message: format!("Duplicate object key `{}`", k),
150
- line: span.start.0 as u32,
151
- col: span.start.1 as u32,
217
+ line: kspan.start.0 as u32,
218
+ col: kspan.start.1 as u32,
152
219
  severity: Severity::Warning,
153
220
  });
154
221
  }
@@ -238,6 +305,8 @@ fn lint_expr(e: &Expr, out: &mut Vec<LintDiagnostic>) {
238
305
  }
239
306
  Expr::Await { operand, .. } => lint_expr(operand, out),
240
307
  Expr::TypeOf { operand, .. } => lint_expr(operand, out),
308
+ // #142: descend into a delete target (`delete obj[expr]` / `delete (expr).prop`).
309
+ Expr::Delete { target, .. } => lint_expr(target, out),
241
310
  Expr::JsxElement {
242
311
  props, children, ..
243
312
  } => {
@@ -279,3 +348,110 @@ pub const RULES: &[(&str, &str)] = &[
279
348
  "Warns when an object literal repeats the same key.",
280
349
  ),
281
350
  ];
351
+
352
+ #[cfg(test)]
353
+ mod tests {
354
+ use super::*;
355
+
356
+ /// Count `tish-duplicate-key` diagnostics the linter emits for `src` — a probe for whether the
357
+ /// linter actually descends into a given position (the dup-key rule fires on any object literal
358
+ /// the walk reaches).
359
+ fn dup_keys(src: &str) -> usize {
360
+ lint_source(src)
361
+ .expect("parse")
362
+ .iter()
363
+ .filter(|d| d.code == "tish-duplicate-key")
364
+ .count()
365
+ }
366
+
367
+ #[test]
368
+ fn baseline_dup_key_in_plain_var_is_linted() {
369
+ assert_eq!(dup_keys("let x = { a: 1, a: 2 }\n"), 1);
370
+ }
371
+
372
+ // #141: comma-separated declarators lower to Statement::Multi, which the linter skipped.
373
+ #[test]
374
+ fn lints_inside_comma_declarators() {
375
+ assert!(
376
+ dup_keys("let x = { a: 1, a: 2 }, y = 3\n") >= 1,
377
+ "dup key in a comma-declarator (Statement::Multi) must be linted"
378
+ );
379
+ }
380
+
381
+ // #140: control-flow CONDITION expressions were never linted (only bodies were walked).
382
+ #[test]
383
+ fn lints_inside_control_flow_conditions() {
384
+ assert!(dup_keys("if ({ a: 1, a: 2 }) {}\n") >= 1, "if condition");
385
+ assert!(dup_keys("while ({ a: 1, a: 2 }) { break }\n") >= 1, "while condition");
386
+ assert!(
387
+ dup_keys("for (let i = 0; ({ a: 1, a: 2 }); i = i + 1) { break }\n") >= 1,
388
+ "for condition"
389
+ );
390
+ assert!(dup_keys("do { break } while ({ a: 1, a: 2 })\n") >= 1, "do-while condition");
391
+ assert!(
392
+ dup_keys("switch ({ a: 1, a: 2 }) { case 1: break }\n") >= 1,
393
+ "switch discriminant"
394
+ );
395
+ }
396
+
397
+ // #142: the linter never descended into a delete target.
398
+ #[test]
399
+ fn lints_inside_delete_target() {
400
+ assert!(dup_keys("delete ({ a: 1, a: 2 }).a\n") >= 1, "delete target");
401
+ }
402
+
403
+ // #151: the `export default <expr>` subtree was never walked (only `export <named>` was).
404
+ #[test]
405
+ fn lints_inside_export_default() {
406
+ assert!(
407
+ dup_keys("export default { a: 1, a: 2 }\n") >= 1,
408
+ "dup key inside `export default` must be linted"
409
+ );
410
+ }
411
+
412
+ // #152: no-unreachable.
413
+ fn unreachable(src: &str) -> usize {
414
+ lint_source(src)
415
+ .expect("parse")
416
+ .iter()
417
+ .filter(|d| d.code == "tish-unreachable-code")
418
+ .count()
419
+ }
420
+
421
+ #[test]
422
+ fn flags_code_after_terminator() {
423
+ assert_eq!(unreachable("fn f() {\n return 1\n let x = 2\n}\n"), 1, "after return");
424
+ assert_eq!(unreachable("fn g() {\n throw 1\n g()\n}\n"), 1, "after throw");
425
+ assert_eq!(
426
+ unreachable("fn h() {\n while (true) {\n break\n h()\n }\n}\n"),
427
+ 1,
428
+ "after break in loop body"
429
+ );
430
+ }
431
+
432
+ #[test]
433
+ fn does_not_flag_reachable_or_hoisted() {
434
+ // Conditional return is NOT a direct-sibling terminator → following code is reachable.
435
+ assert_eq!(unreachable("fn f(c) {\n if (c) { return 1 }\n let x = 2\n}\n"), 0, "conditional");
436
+ // Function declarations hoist — valid after a return.
437
+ assert_eq!(unreachable("fn g() {\n return 1\n fn helper() { return 2 }\n}\n"), 0, "hoisted fn");
438
+ // Switch fall-through (no terminator) is intentional.
439
+ assert_eq!(unreachable("switch (x) {\n case 1:\n a()\n case 2:\n b()\n}\n"), 0, "fallthrough");
440
+ }
441
+ }
442
+
443
+ #[cfg(test)]
444
+ mod dupkey_position_tests {
445
+ use super::*;
446
+ #[test]
447
+ fn dup_key_points_at_the_duplicated_key() {
448
+ // `let x = { a: 1, a: 2 }` — the duplicate `a` is at 1-indexed col 17; the object `{` is col 9.
449
+ let d: Vec<_> = lint_source("let x = { a: 1, a: 2 }\n")
450
+ .expect("parse")
451
+ .into_iter()
452
+ .filter(|d| d.code == "tish-duplicate-key")
453
+ .collect();
454
+ assert_eq!(d.len(), 1, "exactly one dup-key");
455
+ assert_eq!((d[0].line, d[0].col), (1, 17), "#143: must point at the duplicated key, not the `{{`");
456
+ }
457
+ }
@@ -23,3 +23,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "tim
23
23
  serde_json = "1"
24
24
  regex = "1"
25
25
  walkdir = "2"
26
+
27
+ [dev-dependencies]
28
+ # Drive `LspService` over JSON-RPC in tests (#163). Must match the `tower` major that tower-lsp 0.20
29
+ # implements `Service` against (0.4), or the trait bound on `.call()` won't be satisfied. `util`
30
+ # brings in `ServiceExt::ready`.
31
+ tower = { version = "0.4", features = ["util"] }