@tishlang/tish 2.2.5 → 2.8.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 (53) 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 +1 -1
  4. package/crates/tish/src/cli_help.rs +12 -0
  5. package/crates/tish/src/main.rs +69 -11
  6. package/crates/tish_ast/src/ast.rs +12 -5
  7. package/crates/tish_build_utils/src/lib.rs +37 -0
  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 +11 -10
  17. package/crates/tish_compile/src/codegen.rs +1386 -42
  18. package/crates/tish_compile/src/infer.rs +16 -16
  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 +26 -9
  22. package/crates/tish_compile_js/src/codegen.rs +55 -4
  23. package/crates/tish_compile_js/src/tests_jsx.rs +44 -0
  24. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +1 -0
  25. package/crates/tish_core/Cargo.toml +2 -0
  26. package/crates/tish_core/src/json.rs +135 -34
  27. package/crates/tish_core/src/lib.rs +23 -1
  28. package/crates/tish_core/src/value.rs +64 -11
  29. package/crates/tish_eval/src/eval.rs +144 -197
  30. package/crates/tish_eval/src/natives.rs +45 -45
  31. package/crates/tish_eval/src/regex.rs +35 -27
  32. package/crates/tish_eval/src/value.rs +8 -0
  33. package/crates/tish_fmt/src/lib.rs +46 -2
  34. package/crates/tish_lint/src/bin/tish-lint.rs +17 -8
  35. package/crates/tish_lint/src/lib.rs +197 -21
  36. package/crates/tish_lsp/Cargo.toml +7 -1
  37. package/crates/tish_lsp/src/import_goto.rs +52 -7
  38. package/crates/tish_lsp/src/main.rs +913 -145
  39. package/crates/tish_opt/src/lib.rs +5 -3
  40. package/crates/tish_parser/src/lib.rs +23 -5
  41. package/crates/tish_parser/src/parser.rs +35 -35
  42. package/crates/tish_resolve/src/lib.rs +567 -17
  43. package/crates/tish_runtime/src/lib.rs +58 -18
  44. package/crates/tish_ui/src/jsx.rs +2 -2
  45. package/crates/tish_vm/src/jit.rs +212 -10
  46. package/crates/tish_vm/src/vm.rs +39 -18
  47. package/crates/tish_wasm/src/lib.rs +116 -22
  48. package/package.json +1 -1
  49. package/platform/darwin-arm64/tish +0 -0
  50. package/platform/darwin-x64/tish +0 -0
  51. package/platform/linux-arm64/tish +0 -0
  52. package/platform/linux-x64/tish +0 -0
  53. package/platform/win32-x64/tish.exe +0 -0
@@ -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);
@@ -1046,7 +1046,7 @@ impl Printer {
1046
1046
 
1047
1047
  fn type_ann(&mut self, t: &TypeAnnotation) {
1048
1048
  match t {
1049
- TypeAnnotation::Simple(s) => self.buf.push_str(s.as_ref()),
1049
+ TypeAnnotation::Simple(s, _) => self.buf.push_str(s.as_ref()),
1050
1050
  TypeAnnotation::Array(inner) => {
1051
1051
  self.type_ann(inner);
1052
1052
  self.buf.push_str("[]");
@@ -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
+ }
@@ -19,7 +19,13 @@ tishlang_fmt = { path = "../tish_fmt", version = ">=0.1" }
19
19
  tishlang_lint = { path = "../tish_lint", version = ">=0.1" }
20
20
  tishlang_resolve = { path = "../tish_resolve", version = ">=0.1" }
21
21
  tower-lsp = "0.20"
22
- tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std"] }
22
+ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "time"] }
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.