@tishlang/tish 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 (55) hide show
  1. package/bin/tish +0 -0
  2. package/crates/js_to_tish/src/transform/expr.rs +5 -1
  3. package/crates/tish/Cargo.toml +2 -1
  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/src/lib.rs +45 -1
  36. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  37. package/crates/tish_lint/src/lib.rs +197 -21
  38. package/crates/tish_lsp/Cargo.toml +6 -0
  39. package/crates/tish_lsp/src/import_goto.rs +52 -7
  40. package/crates/tish_lsp/src/main.rs +856 -140
  41. package/crates/tish_opt/src/lib.rs +5 -3
  42. package/crates/tish_parser/src/lib.rs +23 -5
  43. package/crates/tish_parser/src/parser.rs +15 -26
  44. package/crates/tish_resolve/src/lib.rs +188 -18
  45. package/crates/tish_runtime/src/lib.rs +58 -18
  46. package/crates/tish_ui/src/jsx.rs +2 -2
  47. package/crates/tish_vm/src/jit.rs +212 -10
  48. package/crates/tish_vm/src/vm.rs +39 -18
  49. package/crates/tish_wasm/src/lib.rs +116 -22
  50. package/package.json +1 -1
  51. package/platform/darwin-arm64/tish +0 -0
  52. package/platform/darwin-x64/tish +0 -0
  53. package/platform/linux-arm64/tish +0 -0
  54. package/platform/linux-x64/tish +0 -0
  55. package/platform/win32-x64/tish.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
  }
@@ -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"] }
@@ -314,6 +314,7 @@ pub fn native_member_definition(
314
314
  lsp_line: u32,
315
315
  lsp_character: u32,
316
316
  word: &str,
317
+ open_docs: &HashMap<Url, String>,
317
318
  ) -> Option<NativeMemberDefinition> {
318
319
  let project_root = infer_project_root(file_path, roots)?;
319
320
  let from_dir = file_path.parent()?;
@@ -357,7 +358,8 @@ pub fn native_member_definition(
357
358
 
358
359
  if from_s.starts_with("./") || from_s.starts_with("../") {
359
360
  if members.len() == 1 {
360
- let loc = resolve_relative_tish(from_dir, from_s, members[0].as_ref())?;
361
+ let loc =
362
+ resolve_relative_tish(from_dir, from_s, members[0].as_ref(), open_docs)?;
361
363
  return Some(NativeMemberDefinition {
362
364
  location: loc,
363
365
  doc: None,
@@ -422,6 +424,7 @@ pub fn definition_for_native_receiver_member(
422
424
  lsp_line: u32,
423
425
  lsp_character: u32,
424
426
  word: &str,
427
+ open_docs: &HashMap<Url, String>,
425
428
  ) -> Option<Location> {
426
429
  native_member_definition(
427
430
  program,
@@ -432,11 +435,28 @@ pub fn definition_for_native_receiver_member(
432
435
  lsp_line,
433
436
  lsp_character,
434
437
  word,
438
+ open_docs,
435
439
  )
436
440
  .map(|d| d.location)
437
441
  }
438
442
 
439
- fn resolve_relative_tish(from_dir: &Path, from_s: &str, imported: &str) -> Option<Location> {
443
+ /// Source text of a target module: the live editor buffer if the file is open with unsaved edits,
444
+ /// otherwise the on-disk contents. Reading the open buffer keeps cross-file goto-definition/hover in
445
+ /// sync with what the user sees — disk would give a stale line/column (or miss a not-yet-saved
446
+ /// export entirely). #148
447
+ fn module_source(can: &Path, u: &Url, open_docs: &HashMap<Url, String>) -> Option<String> {
448
+ match open_docs.get(u) {
449
+ Some(buf) => Some(buf.clone()),
450
+ None => std::fs::read_to_string(can).ok(),
451
+ }
452
+ }
453
+
454
+ fn resolve_relative_tish(
455
+ from_dir: &Path,
456
+ from_s: &str,
457
+ imported: &str,
458
+ open_docs: &HashMap<Url, String>,
459
+ ) -> Option<Location> {
440
460
  let target = from_dir.join(from_s.trim_start_matches("./"));
441
461
  let target = if target.extension().is_none() {
442
462
  target.with_extension("tish")
@@ -445,13 +465,17 @@ fn resolve_relative_tish(from_dir: &Path, from_s: &str, imported: &str) -> Optio
445
465
  };
446
466
  let can = target.canonicalize().ok()?;
447
467
  let u = Url::from_file_path(&can).ok()?;
448
- let src = std::fs::read_to_string(&can).ok()?;
468
+ let src = module_source(&can, &u, open_docs)?;
449
469
  let prog = tishlang_parser::parse(&src).ok()?;
450
470
  crate::find_export(&prog, imported, &u, &src)
451
471
  }
452
472
 
453
473
  /// Resolve a relative default import (`import X from "./m"`) to the `export default` in the source.
454
- fn resolve_relative_tish_default(from_dir: &Path, from_s: &str) -> Option<Location> {
474
+ fn resolve_relative_tish_default(
475
+ from_dir: &Path,
476
+ from_s: &str,
477
+ open_docs: &HashMap<Url, String>,
478
+ ) -> Option<Location> {
455
479
  let target = from_dir.join(from_s.trim_start_matches("./"));
456
480
  let target = if target.extension().is_none() {
457
481
  target.with_extension("tish")
@@ -460,7 +484,7 @@ fn resolve_relative_tish_default(from_dir: &Path, from_s: &str) -> Option<Locati
460
484
  };
461
485
  let can = target.canonicalize().ok()?;
462
486
  let u = Url::from_file_path(&can).ok()?;
463
- let src = std::fs::read_to_string(&can).ok()?;
487
+ let src = module_source(&can, &u, open_docs)?;
464
488
  let prog = tishlang_parser::parse(&src).ok()?;
465
489
  crate::find_default_export(&prog, &u, &src)
466
490
  }
@@ -473,6 +497,7 @@ pub fn definition_for_import(
473
497
  word: &str,
474
498
  roots: &[PathBuf],
475
499
  cargo_src_cache: &RwLock<HashMap<(PathBuf, String), PathBuf>>,
500
+ open_docs: &HashMap<Url, String>,
476
501
  ) -> Option<Location> {
477
502
  if word.is_empty() {
478
503
  return None;
@@ -506,9 +531,9 @@ pub fn definition_for_import(
506
531
  // A default import binds the source module's `export default`, which has no name to
507
532
  // match; resolve it directly. Named imports resolve by exported name.
508
533
  return if is_default {
509
- resolve_relative_tish_default(from_dir, from_s)
534
+ resolve_relative_tish_default(from_dir, from_s, open_docs)
510
535
  } else {
511
- resolve_relative_tish(from_dir, from_s, imported)
536
+ resolve_relative_tish(from_dir, from_s, imported, open_docs)
512
537
  };
513
538
  }
514
539
 
@@ -563,6 +588,26 @@ mod receiver_member_tests {
563
588
  assert_eq!(mem, "setTitle");
564
589
  }
565
590
 
591
+ // #148: cross-file goto-def must read the open editor buffer (unsaved edits), not disk.
592
+ #[test]
593
+ fn module_source_prefers_open_buffer_over_disk() {
594
+ use std::collections::HashMap;
595
+ use tower_lsp::lsp_types::Url;
596
+ // A path that does NOT exist on disk: the disk branch would fail, so a non-None result
597
+ // proves the open buffer was used.
598
+ let path = std::path::Path::new("/nonexistent/tish/module.tish");
599
+ let url = Url::parse("file:///nonexistent/tish/module.tish").unwrap();
600
+ let mut docs = HashMap::new();
601
+ docs.insert(url.clone(), "export fn live() {}".to_string());
602
+ assert_eq!(
603
+ super::module_source(path, &url, &docs).as_deref(),
604
+ Some("export fn live() {}"),
605
+ "open buffer must win over (here, absent) disk content"
606
+ );
607
+ // Not open + nonexistent on disk → None (disk read fails), no panic.
608
+ assert_eq!(super::module_source(path, &url, &HashMap::new()), None);
609
+ }
610
+
566
611
  #[test]
567
612
  fn native_pragma_parse_optional_doc() {
568
613
  let src = r"// @tish-source window.innerHeight src/appkit/window_api.rs 289 | Height in points.