@tishlang/tish 2.2.0 → 2.2.4

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.
@@ -72,6 +72,35 @@ fn synthetic_name_span(start: (usize, usize), name: &str) -> tishlang_ast::Span
72
72
  }
73
73
  }
74
74
 
75
+ thread_local! {
76
+ /// When active, accumulates the definition spans that identifier uses resolve to during one
77
+ /// `collect_unresolved_identifiers` scope-aware walk (`record_unresolved` already resolves every
78
+ /// use). `collect_unused_bindings` activates it to learn which declarations are referenced in a
79
+ /// single O(N) pass, instead of re-scanning the whole program per binding (which was ~O(N³) and
80
+ /// froze large files). Inactive for normal callers (the unresolved-name diagnostic), which see
81
+ /// no behavior change.
82
+ static REFERENCED_DEFS: std::cell::RefCell<Option<std::collections::HashSet<(usize, usize)>>> =
83
+ const { std::cell::RefCell::new(None) };
84
+ }
85
+
86
+ /// One scope-aware resolution pass: the set of definition span *starts* that at least one identifier
87
+ /// use resolves to (a name's declaration position is unique, so the start identifies it). Reuses the
88
+ /// exact walk and scope rules of [`collect_unresolved_identifiers`], so it stays consistent with the
89
+ /// unresolved-name diagnostic. O(N) (single walk; scope lookups O(1)).
90
+ fn collect_referenced_def_spans(program: &Program) -> std::collections::HashSet<(usize, usize)> {
91
+ struct Guard;
92
+ impl Drop for Guard {
93
+ fn drop(&mut self) {
94
+ REFERENCED_DEFS.with(|r| *r.borrow_mut() = None);
95
+ }
96
+ }
97
+ let _g = Guard;
98
+ REFERENCED_DEFS.with(|r| *r.borrow_mut() = Some(std::collections::HashSet::new()));
99
+ // Side effect: record_unresolved inserts each resolved def span into REFERENCED_DEFS.
100
+ let _ = collect_unresolved_identifiers(program);
101
+ REFERENCED_DEFS.with(|r| r.borrow_mut().take().unwrap_or_default())
102
+ }
103
+
75
104
  fn collect_stmt(
76
105
  stmt: &Statement,
77
106
  source: &str,
@@ -160,6 +189,7 @@ fn collect_stmt(
160
189
  name,
161
190
  name_span,
162
191
  params,
192
+ rest_param,
163
193
  body,
164
194
  ..
165
195
  } => {
@@ -167,6 +197,9 @@ fn collect_stmt(
167
197
  for p in params {
168
198
  collect_fun_param(p, source, lsp_line, lsp_char, best);
169
199
  }
200
+ if let Some(rp) = rest_param {
201
+ consider(source, lsp_line, lsp_char, &rp.name_span, rp.name.clone(), best);
202
+ }
170
203
  collect_stmt(body, source, lsp_line, lsp_char, best);
171
204
  }
172
205
  Statement::Switch {
@@ -256,12 +289,16 @@ fn collect_stmt(
256
289
  name,
257
290
  name_span,
258
291
  params,
292
+ rest_param,
259
293
  ..
260
294
  } => {
261
295
  consider(source, lsp_line, lsp_char, name_span, name.clone(), best);
262
296
  for p in params {
263
297
  collect_fun_param(p, source, lsp_line, lsp_char, best);
264
298
  }
299
+ if let Some(rp) = rest_param {
300
+ consider(source, lsp_line, lsp_char, &rp.name_span, rp.name.clone(), best);
301
+ }
265
302
  }
266
303
  Statement::Break { .. } | Statement::Continue { .. } => {}
267
304
  }
@@ -484,8 +521,21 @@ fn collect_expr(
484
521
  }
485
522
  Expr::Await { operand, .. } => collect_expr(operand, source, lsp_line, lsp_char, best),
486
523
  Expr::JsxElement {
487
- props, children, ..
524
+ tag,
525
+ tag_span,
526
+ close_tag_span,
527
+ props,
528
+ children,
529
+ ..
488
530
  } => {
531
+ // A PascalCase tag references a component binding (`<Foo/>` lowers to `h(Foo, …)`); let
532
+ // the cursor land on it (opening or closing tag) for hover / go-to-def / rename.
533
+ if tag.chars().next().is_some_and(|c| c.is_uppercase()) {
534
+ consider(source, lsp_line, lsp_char, tag_span, tag.clone(), best);
535
+ if let Some(cs) = close_tag_span {
536
+ consider(source, lsp_line, lsp_char, cs, tag.clone(), best);
537
+ }
538
+ }
489
539
  for p in props {
490
540
  match p {
491
541
  tishlang_ast::JsxProp::Attr { value, .. } => if let tishlang_ast::JsxAttrValue::Expr(e) = value {
@@ -1021,6 +1071,48 @@ fn define_pattern_stack(pattern: &DestructPattern, scopes: &mut ScopeStack) {
1021
1071
  }
1022
1072
  }
1023
1073
 
1074
+ /// Pre-define hoisted function-declaration names (incl. `export fn` and ambient `declare fn`) for a
1075
+ /// statement list, so a reference to a function declared later in the same scope resolves. Function
1076
+ /// values capture the live scope, so calling a sibling function declared further down succeeds at
1077
+ /// runtime (e.g. mutual recursion, or a helper defined below its caller); the resolver mirrors that
1078
+ /// by defining the names before walking any sibling body. Only function names are hoisted — `let`
1079
+ /// is not, matching the interpreter.
1080
+ fn hoist_fn_names(stmts: &[Statement], scopes: &mut ScopeStack) {
1081
+ for s in stmts {
1082
+ match s {
1083
+ Statement::FunDecl {
1084
+ name, name_span, ..
1085
+ }
1086
+ | Statement::DeclareFun {
1087
+ name, name_span, ..
1088
+ } => {
1089
+ scopes.define(name.as_ref(), *name_span);
1090
+ }
1091
+ Statement::Export { declaration, .. } => {
1092
+ if let ExportDeclaration::Named(inner) = declaration.as_ref() {
1093
+ if let Statement::FunDecl {
1094
+ name, name_span, ..
1095
+ } = inner.as_ref()
1096
+ {
1097
+ scopes.define(name.as_ref(), *name_span);
1098
+ }
1099
+ }
1100
+ }
1101
+ Statement::Multi { statements, .. } => hoist_fn_names(statements, scopes),
1102
+ _ => {}
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ /// The default-value expression of a parameter, if any (`fn f(a = EXPR)`). Both simple and
1108
+ /// destructuring parameters can carry a default; rest parameters never do (no default syntax).
1109
+ fn param_default(p: &FunParam) -> Option<&Expr> {
1110
+ match p {
1111
+ FunParam::Simple(tp) => tp.default.as_ref(),
1112
+ FunParam::Destructure { default, .. } => default.as_ref(),
1113
+ }
1114
+ }
1115
+
1024
1116
  fn walk_expr_resolve(
1025
1117
  expr: &Expr,
1026
1118
  scopes: &ScopeStack,
@@ -1148,6 +1240,13 @@ fn walk_expr_resolve(
1148
1240
  for p in params {
1149
1241
  define_fun_param_stack(p, &mut inner);
1150
1242
  }
1243
+ for p in params {
1244
+ if let Some(e) = param_default(p) {
1245
+ if let Some(s) = walk_expr_resolve(e, &inner, target) {
1246
+ return Some(s);
1247
+ }
1248
+ }
1249
+ }
1151
1250
  match body {
1152
1251
  ArrowBody::Expr(e) => walk_expr_resolve(e, &inner, target),
1153
1252
  ArrowBody::Block(b) => walk_stmt_resolve(b, &mut inner, target),
@@ -1163,8 +1262,20 @@ fn walk_expr_resolve(
1163
1262
  }
1164
1263
  Expr::Await { operand, .. } => walk_expr_resolve(operand, scopes, target),
1165
1264
  Expr::JsxElement {
1166
- props, children, ..
1265
+ tag,
1266
+ tag_span,
1267
+ close_tag_span,
1268
+ props,
1269
+ children,
1270
+ ..
1167
1271
  } => {
1272
+ // Go-to-def on a PascalCase component tag (opening or closing) resolves to its binding.
1273
+ if tag.chars().next().is_some_and(|c| c.is_uppercase())
1274
+ && (*tag_span == tgt || *close_tag_span == Some(tgt))
1275
+ && tag.as_ref() == target.name.as_ref()
1276
+ {
1277
+ return scopes.resolve(tag.as_ref());
1278
+ }
1168
1279
  for p in props {
1169
1280
  match p {
1170
1281
  tishlang_ast::JsxProp::Attr { value, .. } => {
@@ -1307,6 +1418,7 @@ fn walk_stmt_resolve(
1307
1418
  .and_then(|e| walk_expr_resolve(e, scopes, target)),
1308
1419
  Statement::Block { statements, .. } => {
1309
1420
  scopes.push();
1421
+ hoist_fn_names(statements, scopes);
1310
1422
  let mut out = None;
1311
1423
  for s in statements {
1312
1424
  if let Some(x) = walk_stmt_resolve(s, scopes, target) {
@@ -1332,6 +1444,7 @@ fn walk_stmt_resolve(
1332
1444
  name,
1333
1445
  name_span,
1334
1446
  params,
1447
+ rest_param,
1335
1448
  body,
1336
1449
  ..
1337
1450
  } => {
@@ -1343,7 +1456,21 @@ fn walk_stmt_resolve(
1343
1456
  for p in params {
1344
1457
  define_fun_param_stack(p, scopes);
1345
1458
  }
1346
- let r = walk_stmt_resolve(body, scopes, target);
1459
+ if let Some(rp) = rest_param {
1460
+ scopes.define(rp.name.as_ref(), rp.name_span);
1461
+ }
1462
+ let mut r = None;
1463
+ for p in params {
1464
+ if let Some(e) = param_default(p) {
1465
+ if let Some(s) = walk_expr_resolve(e, scopes, target) {
1466
+ r = Some(s);
1467
+ break;
1468
+ }
1469
+ }
1470
+ }
1471
+ if r.is_none() {
1472
+ r = walk_stmt_resolve(body, scopes, target);
1473
+ }
1347
1474
  scopes.pop();
1348
1475
  if r.is_some() {
1349
1476
  return r;
@@ -1482,6 +1609,7 @@ fn walk_stmt_resolve(
1482
1609
  name,
1483
1610
  name_span,
1484
1611
  params,
1612
+ rest_param,
1485
1613
  ..
1486
1614
  } => {
1487
1615
  if *name_span == tgt_span && name.as_ref() == target.name.as_ref() {
@@ -1492,8 +1620,20 @@ fn walk_stmt_resolve(
1492
1620
  for p in params {
1493
1621
  define_fun_param_stack(p, scopes);
1494
1622
  }
1623
+ if let Some(rp) = rest_param {
1624
+ scopes.define(rp.name.as_ref(), rp.name_span);
1625
+ }
1626
+ let mut r = None;
1627
+ for p in params {
1628
+ if let Some(e) = param_default(p) {
1629
+ if let Some(s) = walk_expr_resolve(e, scopes, target) {
1630
+ r = Some(s);
1631
+ break;
1632
+ }
1633
+ }
1634
+ }
1495
1635
  scopes.pop();
1496
- None
1636
+ r
1497
1637
  }
1498
1638
  Statement::Break { .. } | Statement::Continue { .. } => None,
1499
1639
  }
@@ -1524,6 +1664,11 @@ pub struct UnresolvedIdentifier {
1524
1664
  /// Names always present on the interpreter root scope (see `tishlang_eval`) and listed in
1525
1665
  /// `stdlib/builtins.d.tish`. They must not produce "unresolved identifier" diagnostics when
1526
1666
  /// used without a `let`/`import`.
1667
+ ///
1668
+ /// NOTE: maintained by hand to track the globals the interpreter registers on its root scope
1669
+ /// (`tish_eval`). It is the only suppression the LSP has — the resolver does not load
1670
+ /// `builtins.d.tish` — so any global missing here surfaces as a false `tish-unresolved-name`
1671
+ /// error in the editor. Keep it in sync (see the `runtime_globals_not_unresolved` test).
1527
1672
  pub fn is_runtime_global_ident(name: &str) -> bool {
1528
1673
  matches!(
1529
1674
  name,
@@ -1533,6 +1678,7 @@ pub fn is_runtime_global_ident(name: &str) -> bool {
1533
1678
  | "decodeURI"
1534
1679
  | "encodeURI"
1535
1680
  | "Boolean"
1681
+ | "Number"
1536
1682
  | "isFinite"
1537
1683
  | "isNaN"
1538
1684
  | "Infinity"
@@ -1542,6 +1688,7 @@ pub fn is_runtime_global_ident(name: &str) -> bool {
1542
1688
  | "Object"
1543
1689
  | "Array"
1544
1690
  | "String"
1691
+ | "Symbol"
1545
1692
  | "Date"
1546
1693
  | "Set"
1547
1694
  | "Map"
@@ -1556,6 +1703,15 @@ pub fn is_runtime_global_ident(name: &str) -> bool {
1556
1703
  | "Uint32Array"
1557
1704
  | "AudioContext"
1558
1705
  | "RegExp"
1706
+ | "Error"
1707
+ | "TypeError"
1708
+ | "RangeError"
1709
+ | "SyntaxError"
1710
+ | "Promise"
1711
+ | "fetch"
1712
+ | "fetchAll"
1713
+ | "serve"
1714
+ | "htmlEscape"
1559
1715
  | "setTimeout"
1560
1716
  | "setInterval"
1561
1717
  | "clearTimeout"
@@ -1569,14 +1725,25 @@ fn record_unresolved(
1569
1725
  span: tishlang_ast::Span,
1570
1726
  out: &mut Vec<UnresolvedIdentifier>,
1571
1727
  ) {
1572
- if is_runtime_global_ident(name.as_ref()) {
1573
- return;
1574
- }
1575
- if scopes.resolve(name.as_ref()).is_none() {
1576
- out.push(UnresolvedIdentifier {
1577
- name: name.clone(),
1578
- span,
1579
- });
1728
+ match scopes.resolve(name.as_ref()) {
1729
+ // Resolved to an in-scope declaration. Record it (when collection is active) so
1730
+ // collect_unused_bindings learns this def is referenced — done before the global check so a
1731
+ // local that shadows a global name still counts as a use. Unresolved-name output unchanged.
1732
+ Some(def) => {
1733
+ REFERENCED_DEFS.with(|r| {
1734
+ if let Some(set) = r.borrow_mut().as_mut() {
1735
+ set.insert(def.start);
1736
+ }
1737
+ });
1738
+ }
1739
+ None => {
1740
+ if !is_runtime_global_ident(name.as_ref()) {
1741
+ out.push(UnresolvedIdentifier {
1742
+ name: name.clone(),
1743
+ span,
1744
+ });
1745
+ }
1746
+ }
1580
1747
  }
1581
1748
  }
1582
1749
 
@@ -1695,6 +1862,11 @@ fn check_unresolved_expr(expr: &Expr, scopes: &ScopeStack, out: &mut Vec<Unresol
1695
1862
  for p in params {
1696
1863
  define_fun_param_stack(p, &mut inner);
1697
1864
  }
1865
+ for p in params {
1866
+ if let Some(e) = param_default(p) {
1867
+ check_unresolved_expr(e, &inner, out);
1868
+ }
1869
+ }
1698
1870
  match body {
1699
1871
  ArrowBody::Expr(e) => check_unresolved_expr(e, &inner, out),
1700
1872
  ArrowBody::Block(b) => check_unresolved_stmt(b, &mut inner, out),
@@ -1828,6 +2000,7 @@ fn check_unresolved_stmt(
1828
2000
  }
1829
2001
  Statement::Block { statements, .. } => {
1830
2002
  scopes.push();
2003
+ hoist_fn_names(statements, scopes);
1831
2004
  for s in statements {
1832
2005
  check_unresolved_stmt(s, scopes, out);
1833
2006
  }
@@ -1843,6 +2016,7 @@ fn check_unresolved_stmt(
1843
2016
  name,
1844
2017
  name_span,
1845
2018
  params,
2019
+ rest_param,
1846
2020
  body,
1847
2021
  ..
1848
2022
  } => {
@@ -1851,6 +2025,14 @@ fn check_unresolved_stmt(
1851
2025
  for p in params {
1852
2026
  define_fun_param_stack(p, scopes);
1853
2027
  }
2028
+ if let Some(rp) = rest_param {
2029
+ scopes.define(rp.name.as_ref(), rp.name_span);
2030
+ }
2031
+ for p in params {
2032
+ if let Some(e) = param_default(p) {
2033
+ check_unresolved_expr(e, scopes, out);
2034
+ }
2035
+ }
1854
2036
  check_unresolved_stmt(body, scopes, out);
1855
2037
  scopes.pop();
1856
2038
  scopes.define(name.as_ref(), *name_span);
@@ -1944,6 +2126,7 @@ fn check_unresolved_stmt(
1944
2126
  name,
1945
2127
  name_span,
1946
2128
  params,
2129
+ rest_param,
1947
2130
  ..
1948
2131
  } => {
1949
2132
  scopes.push();
@@ -1951,6 +2134,14 @@ fn check_unresolved_stmt(
1951
2134
  for p in params {
1952
2135
  define_fun_param_stack(p, scopes);
1953
2136
  }
2137
+ if let Some(rp) = rest_param {
2138
+ scopes.define(rp.name.as_ref(), rp.name_span);
2139
+ }
2140
+ for p in params {
2141
+ if let Some(e) = param_default(p) {
2142
+ check_unresolved_expr(e, scopes, out);
2143
+ }
2144
+ }
1954
2145
  scopes.pop();
1955
2146
  scopes.define(name.as_ref(), *name_span);
1956
2147
  }
@@ -1962,6 +2153,7 @@ fn check_unresolved_stmt(
1962
2153
  pub fn collect_unresolved_identifiers(program: &Program) -> Vec<UnresolvedIdentifier> {
1963
2154
  let mut out = Vec::new();
1964
2155
  let mut scopes = ScopeStack::new();
2156
+ hoist_fn_names(&program.statements, &mut scopes);
1965
2157
  for stmt in &program.statements {
1966
2158
  check_unresolved_stmt(stmt, &mut scopes, &mut out);
1967
2159
  }
@@ -2424,59 +2616,291 @@ fn enumerate_stmt(stmt: &Statement, exported: bool, out: &mut Vec<BindingSite>)
2424
2616
  exported,
2425
2617
  });
2426
2618
  }
2427
- Statement::DeclareVar {
2428
- name, name_span, ..
2619
+ // Ambient `declare` declarations describe symbols defined elsewhere and have no body, so
2620
+ // nothing here is ever "unused" within this file (params can't be read; the name is API
2621
+ // surface for other modules). They contribute no unused-binding candidates.
2622
+ Statement::DeclareVar { .. } | Statement::DeclareFun { .. } => {}
2623
+ Statement::Break { .. } | Statement::Continue { .. } => {}
2624
+ }
2625
+ }
2626
+
2627
+ /// Collect PascalCase JSX component tag names used anywhere in `program`. A `<Foo/>` tag lowers to
2628
+ /// `h(Foo, …)` — a real reference to the `Foo` binding — but the tag is not a span-validated
2629
+ /// identifier use, so the reference walker misses it. Used by [`collect_unused_bindings`] to avoid
2630
+ /// flagging a component import/binding used only as a tag. (Lowercase tags lower to string
2631
+ /// literals, so they are not references.) Precise rename/find-references for tags still needs a
2632
+ /// real tag span in the AST and is handled separately.
2633
+ fn collect_jsx_component_tags(program: &Program) -> std::collections::HashSet<Arc<str>> {
2634
+ let mut out = std::collections::HashSet::new();
2635
+ for s in &program.statements {
2636
+ jsx_tags_stmt(s, &mut out);
2637
+ }
2638
+ out
2639
+ }
2640
+
2641
+ fn jsx_tags_stmt(s: &Statement, out: &mut std::collections::HashSet<Arc<str>>) {
2642
+ match s {
2643
+ Statement::VarDecl { init, .. } => {
2644
+ if let Some(e) = init {
2645
+ jsx_tags_expr(e, out);
2646
+ }
2647
+ }
2648
+ Statement::VarDeclDestructure { init, .. } => jsx_tags_expr(init, out),
2649
+ Statement::ExprStmt { expr, .. } => jsx_tags_expr(expr, out),
2650
+ Statement::If {
2651
+ cond,
2652
+ then_branch,
2653
+ else_branch,
2654
+ ..
2429
2655
  } => {
2430
- out.push(BindingSite {
2431
- name: name.clone(),
2432
- span: *name_span,
2433
- kind: UnusedBindingKind::Variable,
2434
- exported,
2435
- });
2656
+ jsx_tags_expr(cond, out);
2657
+ jsx_tags_stmt(then_branch, out);
2658
+ if let Some(b) = else_branch {
2659
+ jsx_tags_stmt(b, out);
2660
+ }
2436
2661
  }
2437
- Statement::DeclareFun {
2438
- name,
2439
- name_span,
2440
- params,
2441
- rest_param,
2662
+ Statement::While { cond, body, .. } => {
2663
+ jsx_tags_expr(cond, out);
2664
+ jsx_tags_stmt(body, out);
2665
+ }
2666
+ Statement::For {
2667
+ init,
2668
+ cond,
2669
+ update,
2670
+ body,
2442
2671
  ..
2443
2672
  } => {
2444
- out.push(BindingSite {
2445
- name: name.clone(),
2446
- span: *name_span,
2447
- kind: UnusedBindingKind::Variable,
2448
- exported,
2449
- });
2673
+ if let Some(i) = init {
2674
+ jsx_tags_stmt(i, out);
2675
+ }
2676
+ if let Some(e) = cond {
2677
+ jsx_tags_expr(e, out);
2678
+ }
2679
+ if let Some(e) = update {
2680
+ jsx_tags_expr(e, out);
2681
+ }
2682
+ jsx_tags_stmt(body, out);
2683
+ }
2684
+ Statement::ForOf { iterable, body, .. } => {
2685
+ jsx_tags_expr(iterable, out);
2686
+ jsx_tags_stmt(body, out);
2687
+ }
2688
+ Statement::Return { value, .. } => {
2689
+ if let Some(e) = value {
2690
+ jsx_tags_expr(e, out);
2691
+ }
2692
+ }
2693
+ Statement::Block { statements, .. } | Statement::Multi { statements, .. } => {
2694
+ for s in statements {
2695
+ jsx_tags_stmt(s, out);
2696
+ }
2697
+ }
2698
+ Statement::FunDecl { params, body, .. } => {
2450
2699
  for p in params {
2451
- enumerate_fun_param(p, exported, out);
2700
+ if let Some(e) = param_default(p) {
2701
+ jsx_tags_expr(e, out);
2702
+ }
2452
2703
  }
2453
- if let Some(r) = rest_param {
2454
- out.push(BindingSite {
2455
- name: r.name.clone(),
2456
- span: r.name_span,
2457
- kind: UnusedBindingKind::Parameter,
2458
- exported,
2459
- });
2704
+ jsx_tags_stmt(body, out);
2705
+ }
2706
+ Statement::Switch {
2707
+ expr,
2708
+ cases,
2709
+ default_body,
2710
+ ..
2711
+ } => {
2712
+ jsx_tags_expr(expr, out);
2713
+ for (_, stmts) in cases {
2714
+ for s in stmts {
2715
+ jsx_tags_stmt(s, out);
2716
+ }
2717
+ }
2718
+ if let Some(stmts) = default_body {
2719
+ for s in stmts {
2720
+ jsx_tags_stmt(s, out);
2721
+ }
2460
2722
  }
2461
2723
  }
2462
- Statement::Break { .. } | Statement::Continue { .. } => {}
2724
+ Statement::DoWhile { body, cond, .. } => {
2725
+ jsx_tags_stmt(body, out);
2726
+ jsx_tags_expr(cond, out);
2727
+ }
2728
+ Statement::Throw { value, .. } => jsx_tags_expr(value, out),
2729
+ Statement::Try {
2730
+ body,
2731
+ catch_body,
2732
+ finally_body,
2733
+ ..
2734
+ } => {
2735
+ jsx_tags_stmt(body, out);
2736
+ if let Some(cb) = catch_body {
2737
+ jsx_tags_stmt(cb, out);
2738
+ }
2739
+ if let Some(fb) = finally_body {
2740
+ jsx_tags_stmt(fb, out);
2741
+ }
2742
+ }
2743
+ Statement::Export { declaration, .. } => match declaration.as_ref() {
2744
+ ExportDeclaration::Named(inner) => jsx_tags_stmt(inner, out),
2745
+ ExportDeclaration::Default(e) => jsx_tags_expr(e, out),
2746
+ },
2747
+ Statement::Import { .. }
2748
+ | Statement::Break { .. }
2749
+ | Statement::Continue { .. }
2750
+ | Statement::TypeAlias { .. }
2751
+ | Statement::DeclareVar { .. }
2752
+ | Statement::DeclareFun { .. } => {}
2753
+ }
2754
+ }
2755
+
2756
+ fn jsx_tags_expr(e: &Expr, out: &mut std::collections::HashSet<Arc<str>>) {
2757
+ match e {
2758
+ Expr::JsxElement {
2759
+ tag,
2760
+ props,
2761
+ children,
2762
+ ..
2763
+ } => {
2764
+ if tag.chars().next().is_some_and(|c| c.is_uppercase()) {
2765
+ out.insert(tag.clone());
2766
+ }
2767
+ for p in props {
2768
+ match p {
2769
+ tishlang_ast::JsxProp::Attr { value, .. } => {
2770
+ if let tishlang_ast::JsxAttrValue::Expr(e) = value {
2771
+ jsx_tags_expr(e, out);
2772
+ }
2773
+ }
2774
+ tishlang_ast::JsxProp::Spread(e) => jsx_tags_expr(e, out),
2775
+ }
2776
+ }
2777
+ for ch in children {
2778
+ if let tishlang_ast::JsxChild::Expr(e) = ch {
2779
+ jsx_tags_expr(e, out);
2780
+ }
2781
+ }
2782
+ }
2783
+ Expr::JsxFragment { children, .. } => {
2784
+ for ch in children {
2785
+ if let tishlang_ast::JsxChild::Expr(e) = ch {
2786
+ jsx_tags_expr(e, out);
2787
+ }
2788
+ }
2789
+ }
2790
+ Expr::Binary { left, right, .. } | Expr::NullishCoalesce { left, right, .. } => {
2791
+ jsx_tags_expr(left, out);
2792
+ jsx_tags_expr(right, out);
2793
+ }
2794
+ Expr::Unary { operand, .. }
2795
+ | Expr::TypeOf { operand, .. }
2796
+ | Expr::Await { operand, .. } => jsx_tags_expr(operand, out),
2797
+ Expr::Delete { target, .. } => jsx_tags_expr(target, out),
2798
+ Expr::Call { callee, args, .. } | Expr::New { callee, args, .. } => {
2799
+ jsx_tags_expr(callee, out);
2800
+ for a in args {
2801
+ match a {
2802
+ CallArg::Expr(e) | CallArg::Spread(e) => jsx_tags_expr(e, out),
2803
+ }
2804
+ }
2805
+ }
2806
+ Expr::Member { object, .. } => jsx_tags_expr(object, out),
2807
+ Expr::Index { object, index, .. } => {
2808
+ jsx_tags_expr(object, out);
2809
+ jsx_tags_expr(index, out);
2810
+ }
2811
+ Expr::Conditional {
2812
+ cond,
2813
+ then_branch,
2814
+ else_branch,
2815
+ ..
2816
+ } => {
2817
+ jsx_tags_expr(cond, out);
2818
+ jsx_tags_expr(then_branch, out);
2819
+ jsx_tags_expr(else_branch, out);
2820
+ }
2821
+ Expr::Array { elements, .. } => {
2822
+ for el in elements {
2823
+ match el {
2824
+ tishlang_ast::ArrayElement::Expr(e)
2825
+ | tishlang_ast::ArrayElement::Spread(e) => jsx_tags_expr(e, out),
2826
+ }
2827
+ }
2828
+ }
2829
+ Expr::Object { props, .. } => {
2830
+ for p in props {
2831
+ match p {
2832
+ tishlang_ast::ObjectProp::KeyValue(_, e)
2833
+ | tishlang_ast::ObjectProp::Spread(e) => jsx_tags_expr(e, out),
2834
+ }
2835
+ }
2836
+ }
2837
+ Expr::Assign { value, .. }
2838
+ | Expr::CompoundAssign { value, .. }
2839
+ | Expr::LogicalAssign { value, .. } => jsx_tags_expr(value, out),
2840
+ Expr::MemberAssign { object, value, .. } => {
2841
+ jsx_tags_expr(object, out);
2842
+ jsx_tags_expr(value, out);
2843
+ }
2844
+ Expr::IndexAssign {
2845
+ object,
2846
+ index,
2847
+ value,
2848
+ ..
2849
+ } => {
2850
+ jsx_tags_expr(object, out);
2851
+ jsx_tags_expr(index, out);
2852
+ jsx_tags_expr(value, out);
2853
+ }
2854
+ Expr::ArrowFunction { params, body, .. } => {
2855
+ for p in params {
2856
+ if let Some(e) = param_default(p) {
2857
+ jsx_tags_expr(e, out);
2858
+ }
2859
+ }
2860
+ match body {
2861
+ ArrowBody::Expr(e) => jsx_tags_expr(e, out),
2862
+ ArrowBody::Block(b) => jsx_tags_stmt(b, out),
2863
+ }
2864
+ }
2865
+ Expr::TemplateLiteral { exprs, .. } => {
2866
+ for e in exprs {
2867
+ jsx_tags_expr(e, out);
2868
+ }
2869
+ }
2870
+ Expr::Ident { .. }
2871
+ | Expr::Literal { .. }
2872
+ | Expr::NativeModuleLoad { .. }
2873
+ | Expr::PostfixInc { .. }
2874
+ | Expr::PostfixDec { .. }
2875
+ | Expr::PrefixInc { .. }
2876
+ | Expr::PrefixDec { .. } => {}
2463
2877
  }
2464
2878
  }
2465
2879
 
2466
2880
  /// Declarations whose values are never read (imports, locals, parameters). Skips `exported` module
2467
2881
  /// bindings and names starting with `_` (common intentional-unused convention).
2468
- pub fn collect_unused_bindings(program: &Program, source: &str) -> Vec<UnusedBinding> {
2882
+ pub fn collect_unused_bindings(program: &Program, _source: &str) -> Vec<UnusedBinding> {
2469
2883
  let mut sites = Vec::new();
2470
2884
  for s in &program.statements {
2471
2885
  enumerate_stmt(s, false, &mut sites);
2472
2886
  }
2887
+ // A binding used only as a PascalCase JSX component tag (`<Foo/>`) is genuinely used — the
2888
+ // resolution walk can't see the tag, so consult the tag set to avoid a false "unused" report
2889
+ // (deleting such an import would break the build).
2890
+ let jsx_tags = collect_jsx_component_tags(program);
2891
+ // Which declarations are referenced, learned in ONE scope-aware pass. The previous approach
2892
+ // re-scanned the whole program per binding (re-resolving each use), which was ~O(N³) and froze
2893
+ // large files; this is O(N). A binding is unused iff nothing resolves to its definition span.
2894
+ let referenced = collect_referenced_def_spans(program);
2473
2895
  let mut out = Vec::new();
2474
2896
  for site in sites {
2475
2897
  if site.exported || site.name.as_ref().starts_with('_') {
2476
2898
  continue;
2477
2899
  }
2478
- let refs = reference_spans_for_def(program, source, site.name.as_ref(), site.span);
2479
- if refs.len() == 1 {
2900
+ if jsx_tags.contains(&site.name) {
2901
+ continue;
2902
+ }
2903
+ if !referenced.contains(&site.span.start) {
2480
2904
  out.push(UnusedBinding {
2481
2905
  name: site.name,
2482
2906
  span: site.span,
@@ -2496,6 +2920,7 @@ pub fn definition_span(
2496
2920
  ) -> Option<tishlang_ast::Span> {
2497
2921
  let use_site = name_at_cursor(program, source, lsp_line, lsp_character)?;
2498
2922
  let mut scopes = ScopeStack::new();
2923
+ hoist_fn_names(&program.statements, &mut scopes);
2499
2924
  for stmt in &program.statements {
2500
2925
  if let Some(s) = walk_stmt_resolve(stmt, &mut scopes, &use_site) {
2501
2926
  return Some(s);
@@ -3233,19 +3658,20 @@ fn refs_stmt(
3233
3658
  }
3234
3659
  Statement::FunDecl { params, body, .. } => {
3235
3660
  for p in params {
3236
- if let FunParam::Simple(tp) = p {
3237
- if let Some(e) = &tp.default {
3238
- refs_expr(e, program, source, name, def_span, out);
3239
- }
3240
- } else if let FunParam::Destructure {
3241
- default: Some(e), ..
3242
- } = p
3243
- {
3661
+ if let Some(e) = param_default(p) {
3244
3662
  refs_expr(e, program, source, name, def_span, out);
3245
3663
  }
3246
3664
  }
3247
3665
  refs_stmt(body, program, source, name, def_span, out);
3248
3666
  }
3667
+ // Ambient `declare fn` params can carry value-level defaults too.
3668
+ Statement::DeclareFun { params, .. } => {
3669
+ for p in params {
3670
+ if let Some(e) = param_default(p) {
3671
+ refs_expr(e, program, source, name, def_span, out);
3672
+ }
3673
+ }
3674
+ }
3249
3675
  Statement::Switch {
3250
3676
  expr,
3251
3677
  cases,
@@ -3283,13 +3709,19 @@ fn refs_stmt(
3283
3709
  refs_stmt(fb, program, source, name, def_span, out);
3284
3710
  }
3285
3711
  }
3712
+ // Descend into exported declarations so references inside `export fn`/`export let`/
3713
+ // `export default` bodies are counted (otherwise a binding used only there looks unused).
3714
+ Statement::Export { declaration, .. } => match declaration.as_ref() {
3715
+ ExportDeclaration::Named(inner) => {
3716
+ refs_stmt(inner, program, source, name, def_span, out)
3717
+ }
3718
+ ExportDeclaration::Default(e) => refs_expr(e, program, source, name, def_span, out),
3719
+ },
3286
3720
  Statement::Import { .. }
3287
- | Statement::Export { .. }
3288
3721
  | Statement::Break { .. }
3289
3722
  | Statement::Continue { .. }
3290
3723
  | Statement::TypeAlias { .. }
3291
- | Statement::DeclareVar { .. }
3292
- | Statement::DeclareFun { .. } => {}
3724
+ | Statement::DeclareVar { .. } => {}
3293
3725
  }
3294
3726
  }
3295
3727
 
@@ -3458,8 +3890,22 @@ fn refs_expr(
3458
3890
  }
3459
3891
  Expr::Await { operand, .. } => refs_expr(operand, program, source, name, def_span, out),
3460
3892
  Expr::JsxElement {
3461
- props, children, ..
3893
+ tag,
3894
+ tag_span,
3895
+ close_tag_span,
3896
+ props,
3897
+ children,
3898
+ ..
3462
3899
  } => {
3900
+ // A PascalCase tag is a reference to its component binding. Count BOTH the opening and
3901
+ // closing tag so find-references lists them and rename rewrites both (renaming only the
3902
+ // open tag would leave `<Bar></Foo>`). maybe_push validates each via definition_span.
3903
+ if tag.chars().next().is_some_and(|c| c.is_uppercase()) {
3904
+ maybe_push(tag_span, tag, out);
3905
+ if let Some(cs) = close_tag_span {
3906
+ maybe_push(cs, tag, out);
3907
+ }
3908
+ }
3463
3909
  for p in props {
3464
3910
  match p {
3465
3911
  tishlang_ast::JsxProp::Attr { value, .. } => {
@@ -3554,6 +4000,280 @@ mod tests {
3554
4000
  assert_eq!(u[0].kind, UnusedBindingKind::Import);
3555
4001
  }
3556
4002
 
4003
+ #[test]
4004
+ fn import_used_in_exported_fn_body_not_unused() {
4005
+ // Regression: a binding used only inside an `export fn` body was wrongly flagged unused
4006
+ // because refs_stmt did not descend into Statement::Export.
4007
+ let src = "import { publicUser } from \"./m\"\nexport fn toUserPublic(store, u) {\n let pub = publicUser(u)\n return pub\n}\n";
4008
+ let program = parse(src).expect("parse");
4009
+ let u = collect_unused_bindings(&program, src);
4010
+ assert!(u.is_empty(), "publicUser is used in the exported fn; u={u:?}");
4011
+ }
4012
+
4013
+ #[test]
4014
+ fn import_used_in_export_default_not_unused() {
4015
+ let src = "import { p } from \"./m\"\nexport default p(1)\n";
4016
+ let program = parse(src).expect("parse");
4017
+ let u = collect_unused_bindings(&program, src);
4018
+ assert!(u.is_empty(), "p is used in export default; u={u:?}");
4019
+ }
4020
+
4021
+ #[test]
4022
+ fn import_used_in_exported_let_init_not_unused() {
4023
+ let src = "import { p } from \"./m\"\nexport let x = p(1)\nx\n";
4024
+ let program = parse(src).expect("parse");
4025
+ let u = collect_unused_bindings(&program, src);
4026
+ assert!(u.is_empty(), "p is used in the exported let init; u={u:?}");
4027
+ }
4028
+
4029
+ #[test]
4030
+ fn genuinely_unused_import_still_flagged_with_export_present() {
4031
+ // Guard against the fix over-counting: a truly-unused import is still reported even when an
4032
+ // exported declaration is present in the module.
4033
+ let src = "import { used, dead } from \"./m\"\nexport fn f() {\n return used()\n}\n";
4034
+ let program = parse(src).expect("parse");
4035
+ let u = collect_unused_bindings(&program, src);
4036
+ assert_eq!(u.len(), 1, "only `dead` is unused; u={u:?}");
4037
+ assert_eq!(u[0].name.as_ref(), "dead");
4038
+ assert_eq!(u[0].kind, UnusedBindingKind::Import);
4039
+ }
4040
+
4041
+ #[test]
4042
+ fn reference_spans_include_uses_in_exported_fn_body() {
4043
+ // Directly exercises the fixed walker (also backs find-references / rename).
4044
+ let src = "import { publicUser } from \"./m\"\nexport fn f(u) {\n return publicUser(u)\n}\n";
4045
+ let program = parse(src).expect("parse");
4046
+ let def = tishlang_ast::Span {
4047
+ start: (1, 10),
4048
+ end: (1, 20),
4049
+ };
4050
+ let refs = reference_spans_for_def(&program, src, "publicUser", def);
4051
+ assert_eq!(refs.len(), 2, "def + one use in exported body; refs={refs:?}");
4052
+ assert!(refs.contains(&def));
4053
+ }
4054
+
4055
+ #[test]
4056
+ fn import_used_only_in_fn_param_default_not_unused() {
4057
+ // A name referenced only inside a parameter default was wrongly flagged unused because the
4058
+ // resolve walkers never descended into `default` expressions.
4059
+ let src = "import { base } from \"./m\"\nexport fn f(a = base(1)) {\n return a\n}\n";
4060
+ let program = parse(src).expect("parse");
4061
+ let u = collect_unused_bindings(&program, src);
4062
+ assert!(u.is_empty(), "base is used in the param default; u={u:?}");
4063
+ }
4064
+
4065
+ #[test]
4066
+ fn import_used_only_in_arrow_param_default_not_unused() {
4067
+ let src = "import { fallback } from \"./m\"\nlet make = (x = fallback) => x\nmake()\n";
4068
+ let program = parse(src).expect("parse");
4069
+ let u = collect_unused_bindings(&program, src);
4070
+ assert!(u.is_empty(), "fallback is used in the arrow param default; u={u:?}");
4071
+ }
4072
+
4073
+ #[test]
4074
+ fn gotodef_resolves_name_used_in_param_default() {
4075
+ let src = "import { base } from \"./m\"\nfn f(a = base(1)) {\n return a\n}\n";
4076
+ let program = parse(src).expect("parse");
4077
+ // cursor on `base` inside the default (0-indexed line 1, char 9)
4078
+ let def = definition_span(&program, src, 1, 9);
4079
+ assert_eq!(def, Some(tishlang_ast::Span { start: (1, 10), end: (1, 14) }), "def={def:?}");
4080
+ }
4081
+
4082
+ #[test]
4083
+ fn gotodef_resolves_rest_param_body_use() {
4084
+ let src = "fn f(...rest) {\n return rest\n}\n";
4085
+ let program = parse(src).expect("parse");
4086
+ // cursor on `rest` in the body (0-indexed line 1, char 9) -> the rest-param binding
4087
+ let def = definition_span(&program, src, 1, 9);
4088
+ assert_eq!(def, Some(tishlang_ast::Span { start: (1, 9), end: (1, 13) }), "def={def:?}");
4089
+ }
4090
+
4091
+ #[test]
4092
+ fn name_at_cursor_finds_rest_param_decl() {
4093
+ let src = "fn f(...rest) {\n return rest\n}\n";
4094
+ let program = parse(src).expect("parse");
4095
+ // cursor on the `...rest` declaration name (0-indexed line 0, char 8)
4096
+ let nu = name_at_cursor(&program, src, 0, 8).expect("name under cursor");
4097
+ assert_eq!(nu.name.as_ref(), "rest");
4098
+ }
4099
+
4100
+ #[test]
4101
+ fn param_default_referencing_sibling_param_resolves_to_param_not_outer() {
4102
+ // `a` exists both as an outer binding (line 0) and a param (line 1). The default `b = a`
4103
+ // must resolve to the PARAM, not the outer — i.e. defaults are checked with params in scope.
4104
+ let src = "let a = 1\nfn f(a, b = a) {\n return b\n}\nf(2)\n";
4105
+ let program = parse(src).expect("parse");
4106
+ // cursor on `a` inside `b = a` (0-indexed line 1, char 12)
4107
+ let def = definition_span(&program, src, 1, 12).expect("resolved");
4108
+ assert_eq!(def.start.0, 2, "should resolve to the param on line 2 (1-indexed); def={def:?}");
4109
+ }
4110
+
4111
+ #[test]
4112
+ fn references_include_both_param_default_and_body_uses() {
4113
+ let src = "fn helper() { 1 }\nfn f(b = helper()) {\n return helper()\n}\nf()\n";
4114
+ let program = parse(src).expect("parse");
4115
+ let def = tishlang_ast::Span { start: (1, 4), end: (1, 10) };
4116
+ let refs = reference_spans_for_def(&program, src, "helper", def);
4117
+ // def + use in param default + use in body
4118
+ assert_eq!(refs.len(), 3, "decl + default-use + body-use; refs={refs:?}");
4119
+ }
4120
+
4121
+ #[test]
4122
+ fn unresolved_name_in_param_default_reported() {
4123
+ let src = "fn f(a = mispelt) {\n return a\n}\n";
4124
+ let program = parse(src).expect("parse");
4125
+ let u = collect_unresolved_identifiers(&program);
4126
+ assert_eq!(u.len(), 1, "u={u:?}");
4127
+ assert_eq!(u[0].name.as_ref(), "mispelt");
4128
+ }
4129
+
4130
+ #[test]
4131
+ fn import_used_only_in_declare_fn_default_not_unused() {
4132
+ let src = "import { DEF } from \"./m\"\ndeclare fn connect(port = DEF): void\nconnect()\n";
4133
+ let program = parse(src).expect("parse");
4134
+ let u = collect_unused_bindings(&program, src);
4135
+ // The import used in the declare-fn param default must not be reported unused.
4136
+ // (Unrelated pre-existing declare-fn quirks may flag `connect`/`port`; not asserted here.)
4137
+ assert!(
4138
+ !u.iter().any(|b| b.name.as_ref() == "DEF"),
4139
+ "DEF is used in the declare-fn param default; u={u:?}"
4140
+ );
4141
+ }
4142
+
4143
+ #[test]
4144
+ fn forward_reference_to_later_fn_not_unresolved() {
4145
+ let src = "fn caller() { return helper() }\nfn helper() { return 42 }\ncaller()\n";
4146
+ let program = parse(src).expect("parse");
4147
+ let u = collect_unresolved_identifiers(&program);
4148
+ assert!(u.is_empty(), "helper is declared later but referenced (hoisted); u={u:?}");
4149
+ }
4150
+
4151
+ #[test]
4152
+ fn mutual_recursion_not_unresolved_or_unused() {
4153
+ let src = "fn even(n) { if (n == 0) { return true } return odd(n - 1) }\nfn odd(n) { if (n == 0) { return false } return even(n - 1) }\neven(10)\n";
4154
+ let program = parse(src).expect("parse");
4155
+ assert!(
4156
+ collect_unresolved_identifiers(&program).is_empty(),
4157
+ "mutual recursion should resolve"
4158
+ );
4159
+ let unused = collect_unused_bindings(&program, src);
4160
+ assert!(unused.is_empty(), "both fns are used; unused={unused:?}");
4161
+ }
4162
+
4163
+ #[test]
4164
+ fn gotodef_resolves_forward_fn_reference() {
4165
+ let src = "fn caller() { return helper() }\nfn helper() { return 42 }\n";
4166
+ let program = parse(src).expect("parse");
4167
+ // cursor on `helper` inside caller's body (0-indexed line 0, char 21)
4168
+ let def = definition_span(&program, src, 0, 21).expect("resolves");
4169
+ assert_eq!(def.start.0, 2, "should resolve to helper decl on line 2; def={def:?}");
4170
+ }
4171
+
4172
+ #[test]
4173
+ fn genuinely_undefined_still_unresolved_with_hoisting() {
4174
+ let src = "fn f() { return nope() }\nf()\n";
4175
+ let program = parse(src).expect("parse");
4176
+ let u = collect_unresolved_identifiers(&program);
4177
+ assert_eq!(u.len(), 1, "nope is undefined; u={u:?}");
4178
+ assert_eq!(u[0].name.as_ref(), "nope");
4179
+ }
4180
+
4181
+ #[test]
4182
+ fn declare_fn_name_and_params_not_unused() {
4183
+ // Ambient declare-fn: the (called) name and its params must not be flagged unused.
4184
+ let src = "declare fn connect(port): void\nconnect()\n";
4185
+ let program = parse(src).expect("parse");
4186
+ let u = collect_unused_bindings(&program, src);
4187
+ assert!(u.is_empty(), "declare-fn name + ambient param must not be unused; u={u:?}");
4188
+ // and the call resolves (hoisted declare-fn name)
4189
+ assert!(collect_unresolved_identifiers(&program).is_empty(), "connect() should resolve");
4190
+ }
4191
+
4192
+ #[test]
4193
+ fn runtime_globals_not_unresolved() {
4194
+ // Core interpreter globals must never be flagged as unresolved-name in the editor.
4195
+ let src = "let _ = [Number, Symbol, Error, TypeError, RangeError, SyntaxError, Promise, fetch, fetchAll, serve, htmlEscape, console, Math, JSON, Object, Array, String, Boolean, Date, RegExp, setTimeout]\n";
4196
+ let program = parse(src).expect("parse");
4197
+ let u = collect_unresolved_identifiers(&program);
4198
+ assert!(u.is_empty(), "no runtime global should be unresolved; u={u:?}");
4199
+ }
4200
+
4201
+ #[test]
4202
+ fn jsx_component_tag_resolves_and_is_referenced() {
4203
+ // `fn Foo` used as a component. go-to-def on the tag, find-references over both tags.
4204
+ let src = "fn Foo() {}\nlet el = <Foo></Foo>\nel\n";
4205
+ let program = parse(src).expect("parse");
4206
+ // go-to-def on the opening `<Foo` (0-idx line 1, char 10) → the `fn Foo` decl (line 2, 1-idx).
4207
+ let open = definition_span(&program, src, 1, 10).expect("open tag resolves");
4208
+ assert_eq!(open.start, (1, 4), "resolves to fn Foo; got {open:?}");
4209
+ // and on the closing `</Foo` (char 16).
4210
+ let close = definition_span(&program, src, 1, 16).expect("close tag resolves");
4211
+ assert_eq!(close.start, (1, 4), "close tag resolves to fn Foo; got {close:?}");
4212
+ // find-references: the def + the opening tag + the closing tag.
4213
+ let def = tishlang_ast::Span { start: (1, 4), end: (1, 7) };
4214
+ let refs = reference_spans_for_def(&program, src, "Foo", def);
4215
+ assert_eq!(refs.len(), 3, "def + open tag + close tag; refs={refs:?}");
4216
+ }
4217
+
4218
+ #[test]
4219
+ fn jsx_component_import_used_as_tag_not_unused() {
4220
+ let src = "import { Foo } from \"./c\"\nlet el = <Foo/>\nel\n";
4221
+ let program = parse(src).expect("parse");
4222
+ let u = collect_unused_bindings(&program, src);
4223
+ assert!(
4224
+ !u.iter().any(|b| b.name.as_ref() == "Foo"),
4225
+ "Foo is used as a component tag; u={u:?}"
4226
+ );
4227
+ }
4228
+
4229
+ #[test]
4230
+ fn jsx_nested_component_tag_counted() {
4231
+ let src = "import { Row } from \"./c\"\nlet el = <div><Row/></div>\nel\n";
4232
+ let program = parse(src).expect("parse");
4233
+ let u = collect_unused_bindings(&program, src);
4234
+ assert!(
4235
+ !u.iter().any(|b| b.name.as_ref() == "Row"),
4236
+ "Row is used as a nested component; u={u:?}"
4237
+ );
4238
+ }
4239
+
4240
+ #[test]
4241
+ fn lowercase_jsx_tag_does_not_mark_binding_used() {
4242
+ // Lowercase tags lower to string literals, not references — a same-named binding stays unused.
4243
+ let src = "let div = 1\nlet el = <div></div>\nel\n";
4244
+ let program = parse(src).expect("parse");
4245
+ let u = collect_unused_bindings(&program, src);
4246
+ assert!(
4247
+ u.iter().any(|b| b.name.as_ref() == "div"),
4248
+ "lowercase div binding is genuinely unused; u={u:?}"
4249
+ );
4250
+ }
4251
+
4252
+ #[test]
4253
+ fn collect_unused_bindings_handles_large_files() {
4254
+ // A long chain of mutually-referencing functions exercises the per-binding reference scan at
4255
+ // scale. Under the old per-binding/per-identifier definition_span re-resolution this was
4256
+ // ~O(N^3) and froze (~1.4s at 300 lines); with the memo it is fast. Also a correctness check:
4257
+ // every function is reachable, so none is unused.
4258
+ let n = 300;
4259
+ let mut src = String::new();
4260
+ for i in 0..n {
4261
+ if i + 1 < n {
4262
+ src.push_str(&format!("fn f{i}() {{ return f{}() }}\n", i + 1));
4263
+ } else {
4264
+ src.push_str(&format!("fn f{i}() {{ return 0 }}\n"));
4265
+ }
4266
+ }
4267
+ src.push_str("f0()\n");
4268
+ let program = parse(&src).expect("parse");
4269
+ let u = collect_unused_bindings(&program, &src);
4270
+ assert!(
4271
+ u.is_empty(),
4272
+ "all chained fns are reachable; unused={:?}",
4273
+ u.iter().map(|b| b.name.as_ref()).collect::<Vec<_>>()
4274
+ );
4275
+ }
4276
+
3557
4277
  #[test]
3558
4278
  fn unused_let_underscore_ignored() {
3559
4279
  let src = "let _x = 1\n";