ttsc 0.19.2 → 0.20.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 (178) hide show
  1. package/README.md +7 -5
  2. package/cmd/graphdump/main.go +2 -4
  3. package/cmd/ttsc/filter.go +12 -4
  4. package/cmd/ttsc/filter_resolves_flag_names_case_insensitively_test.go +53 -0
  5. package/cmd/ttsc/flags_gen.go +3 -3
  6. package/cmd/ttscgraph/dump.go +5 -7
  7. package/cmd/ttscgraph/run_dump_emits_only_checker_verified_member_relations_test.go +118 -0
  8. package/cmd/ttscgraph/serve.go +58 -308
  9. package/cmd/ttscgraph/serve_session_keeps_base_url_fallback_unchanged_test.go +49 -0
  10. package/cmd/ttscgraph/serve_session_keeps_esm_directory_candidate_unchanged_test.go +61 -0
  11. package/cmd/ttscgraph/serve_session_keeps_exports_blocked_file_candidates_unchanged_test.go +64 -0
  12. package/cmd/ttscgraph/serve_session_keeps_inactive_conditional_export_unchanged_test.go +66 -0
  13. package/cmd/ttscgraph/serve_session_keeps_lower_priority_module_candidate_unchanged_test.go +67 -0
  14. package/cmd/ttscgraph/serve_session_keeps_lower_priority_paths_pattern_unchanged_test.go +58 -0
  15. package/cmd/ttscgraph/serve_session_keeps_nonlongest_root_dirs_candidate_unchanged_test.go +54 -0
  16. package/cmd/ttscgraph/serve_session_reloads_commonjs_package_entry_for_esm_test.go +50 -0
  17. package/cmd/ttscgraph/serve_session_reloads_module_suffix_candidate_test.go +41 -0
  18. package/cmd/ttscgraph/serve_session_reloads_superseding_module_candidates_test.go +216 -0
  19. package/cmd/ttscgraph/serve_session_retries_failed_dump_test.go +103 -0
  20. package/driver/graph.go +12 -6
  21. package/driver/lsp.go +30 -0
  22. package/driver/program.go +37 -1
  23. package/driver/resolution_candidates.go +819 -0
  24. package/driver/windowsjunction/windowsjunction.go +40 -0
  25. package/go.mod +2 -0
  26. package/internal/graph/build.go +45 -11
  27. package/internal/graph/dump.go +312 -108
  28. package/internal/graph/dump_checkout_paths_are_stable_test.go +210 -0
  29. package/internal/graph/dump_diagnostics_ride_the_generation_that_built_it_test.go +9 -3
  30. package/internal/graph/dump_paths.go +172 -0
  31. package/internal/graph/dump_paths_are_portable_and_injective_test.go +93 -0
  32. package/internal/graph/dump_schema_version_matches_the_typescript_client_test.go +1 -1
  33. package/internal/graph/dump_uses_ecma_line_terminators_test.go +87 -0
  34. package/internal/graph/duplicate_variable_object_members_keep_first_declaration_test.go +61 -0
  35. package/internal/graph/edges.go +220 -5
  36. package/internal/graph/enum_members_carry_their_names_on_the_enum_node_test.go +131 -0
  37. package/internal/graph/graph.go +177 -5
  38. package/internal/graph/line_model.go +122 -0
  39. package/internal/graph/literals.go +175 -0
  40. package/internal/graph/literals_are_absent_when_the_type_admits_more_than_it_can_name_test.go +74 -0
  41. package/internal/graph/literals_cover_enum_members_the_graph_has_no_nodes_for_test.go +79 -0
  42. package/internal/graph/literals_do_not_depend_on_declaration_line_wrapping_test.go +84 -0
  43. package/internal/graph/literals_render_every_value_in_typescript_source_form_test.go +77 -0
  44. package/internal/graph/literals_resolve_through_alias_indirection_test.go +64 -0
  45. package/internal/graph/member_relation_edges_follow_checker_validity_test.go +85 -0
  46. package/internal/graph/member_relation_edges_preserve_checker_boundaries_test.go +191 -0
  47. package/internal/graph/member_relation_edges_reject_checker_invalid_pairs_test.go +119 -0
  48. package/internal/graph/node_ids_preserve_hash_bearing_components_test.go +41 -0
  49. package/internal/graph/node_modifiers_emit_union_strings_test.go +4 -1
  50. package/internal/graph/object_literal_members_ride_on_the_snapshot_test.go +192 -0
  51. package/internal/graph/object_member_signatures_preserve_literal_whitespace_test.go +67 -0
  52. package/internal/graph/object_members.go +229 -0
  53. package/internal/graph/provenance.go +88 -30
  54. package/internal/graph/source_texts_cover_every_resident_program_source_test.go +73 -0
  55. package/internal/graphsymbols/position_helpers_follow_ecma_line_terminators_test.go +66 -0
  56. package/internal/graphsymbols/provider.go +26 -91
  57. package/internal/lspserver/lsp_clean_didopen_refreshes_compiler_state_test.go +144 -0
  58. package/internal/lspserver/lsp_completion.go +109 -0
  59. package/internal/lspserver/lsp_completion_gate_never_refuses_a_matching_line_test.go +50 -0
  60. package/internal/lspserver/lsp_completion_hints_longest_trigger_wins_test.go +149 -0
  61. package/internal/lspserver/lsp_completion_hints_nearest_trigger_wins_test.go +37 -0
  62. package/internal/lspserver/lsp_completion_merges_into_upstream_response_test.go +149 -0
  63. package/internal/lspserver/lsp_completion_nearest_hint_replaces_nearest_filter_test.go +43 -0
  64. package/internal/lspserver/lsp_completion_preserves_completion_list_extensions_test.go +99 -0
  65. package/internal/lspserver/lsp_completion_refuses_hints_inside_a_string_literal_test.go +69 -0
  66. package/internal/lspserver/lsp_completion_replaces_full_filter_in_rank_order_test.go +85 -0
  67. package/internal/lspserver/lsp_completion_request.go +455 -0
  68. package/internal/lspserver/lsp_completion_scope.go +530 -0
  69. package/internal/lspserver/lsp_completion_scope_bench_test.go +41 -0
  70. package/internal/lspserver/lsp_completion_scope_classifies_regex_statement_and_operator_boundaries_test.go +39 -0
  71. package/internal/lspserver/lsp_completion_scope_ignores_jsdoc_bytes_in_literals_test.go +108 -0
  72. package/internal/lspserver/lsp_completion_scope_refuses_regex_hints_test.go +52 -0
  73. package/internal/lspserver/lsp_completion_trigger_uses_last_rune_test.go +24 -0
  74. package/internal/lspserver/lsp_deleted_watched_file_withdraws_plugin_diagnostics_test.go +110 -0
  75. package/internal/lspserver/lsp_didsave_keeps_the_incremental_changed_uri_path_test.go +78 -0
  76. package/internal/lspserver/lsp_hints_absence_is_not_an_error_test.go +46 -0
  77. package/internal/lspserver/lsp_hints_refresh_coalesces_concurrent_requests_test.go +82 -0
  78. package/internal/lspserver/lsp_hints_refresh_ignores_a_superseded_generation_test.go +40 -0
  79. package/internal/lspserver/lsp_hints_refresh_keeps_each_producer_isolated_test.go +64 -0
  80. package/internal/lspserver/lsp_hints_refresh_replaces_the_session_snapshot_test.go +66 -0
  81. package/internal/lspserver/lsp_hints_refresh_scheduled_by_editor_events_test.go +90 -0
  82. package/internal/lspserver/lsp_hints_refresh_serves_completion_while_running_test.go +91 -0
  83. package/internal/lspserver/lsp_index_byte_from_bounds_its_search_start_test.go +38 -0
  84. package/internal/lspserver/lsp_initialize_constrains_position_encoding_to_utf16_test.go +205 -0
  85. package/internal/lspserver/lsp_late_completion_trigger_is_reported_once_test.go +105 -0
  86. package/internal/lspserver/lsp_native_completion_hints_decode_flat_lint_wire_test.go +66 -0
  87. package/internal/lspserver/lsp_native_completion_hints_grouped_compatibility_boundary_test.go +60 -0
  88. package/internal/lspserver/lsp_native_plugin_source.go +315 -2
  89. package/internal/lspserver/lsp_native_resident.go +304 -0
  90. package/internal/lspserver/lsp_plugin.go +85 -8
  91. package/internal/lspserver/lsp_plugin_completion_resolve_stays_local_test.go +82 -0
  92. package/internal/lspserver/lsp_position_conversions_span_astral_and_combining_runes_test.go +119 -0
  93. package/internal/lspserver/lsp_proxy.go +601 -9
  94. package/internal/lspserver/lsp_refresh.go +70 -0
  95. package/internal/lspserver/lsp_resident_falls_back_when_serve_unsupported_test.go +43 -0
  96. package/internal/lspserver/lsp_resident_maps_verb_args_test.go +34 -0
  97. package/internal/lspserver/lsp_server.go +13 -1
  98. package/internal/lspserver/lsp_watched_file_changes_reach_compiler_caches_test.go +172 -0
  99. package/lib/TtscCompiler.d.ts +3 -1
  100. package/lib/TtscCompiler.js +5 -1
  101. package/lib/TtscCompiler.js.map +1 -1
  102. package/lib/TtscService.d.ts +13 -3
  103. package/lib/TtscService.js +6 -6
  104. package/lib/TtscService.js.map +1 -1
  105. package/lib/compiler/internal/project/readConfigJson.d.ts +30 -0
  106. package/lib/compiler/internal/project/readConfigJson.js +208 -0
  107. package/lib/compiler/internal/project/readConfigJson.js.map +1 -0
  108. package/lib/compiler/internal/project/readProjectConfig.js +53 -149
  109. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  110. package/lib/compiler/internal/residentTransformProcess.d.ts +15 -2
  111. package/lib/compiler/internal/residentTransformProcess.js +159 -39
  112. package/lib/compiler/internal/residentTransformProcess.js.map +1 -1
  113. package/lib/compiler/internal/runBuild.d.ts +6 -0
  114. package/lib/compiler/internal/runBuild.js +143 -44
  115. package/lib/compiler/internal/runBuild.js.map +1 -1
  116. package/lib/compiler/internal/startResidentTransform.d.ts +5 -1
  117. package/lib/compiler/internal/startResidentTransform.js +3 -1
  118. package/lib/compiler/internal/startResidentTransform.js.map +1 -1
  119. package/lib/compiler/internal/transformProjectInMemory.js +12 -2
  120. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  121. package/lib/flags/parser.d.ts +19 -0
  122. package/lib/flags/parser.js +78 -36
  123. package/lib/flags/parser.js.map +1 -1
  124. package/lib/flags/schema.d.ts +49 -11
  125. package/lib/flags/schema.js +94 -22
  126. package/lib/flags/schema.js.map +1 -1
  127. package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +5 -0
  128. package/lib/internal/assertSafeExplicitCacheDirectory.js +26 -0
  129. package/lib/internal/assertSafeExplicitCacheDirectory.js.map +1 -0
  130. package/lib/launcher/internal/runTtsc.js +107 -69
  131. package/lib/launcher/internal/runTtsc.js.map +1 -1
  132. package/lib/launcher/internal/runTtsx.js +28 -41
  133. package/lib/launcher/internal/runTtsx.js.map +1 -1
  134. package/lib/launcher/internal/runtimeHooks.d.ts +15 -0
  135. package/lib/launcher/internal/runtimeHooks.js +18 -1
  136. package/lib/launcher/internal/runtimeHooks.js.map +1 -1
  137. package/lib/launcher/internal/watchTopology.d.ts +43 -0
  138. package/lib/launcher/internal/watchTopology.js +360 -0
  139. package/lib/launcher/internal/watchTopology.js.map +1 -0
  140. package/lib/plugin/internal/buildSourcePlugin.d.ts +14 -3
  141. package/lib/plugin/internal/buildSourcePlugin.js +247 -61
  142. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  143. package/lib/plugin/internal/loadProjectPlugins.js +63 -2
  144. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  145. package/lib/structures/ITtscCompilerTransformation.d.ts +16 -0
  146. package/lib/structures/internal/ITtscParsedProjectConfig.d.ts +2 -0
  147. package/package.json +8 -8
  148. package/shim/ast/shim.go +48 -3
  149. package/shim/astnav/go.mod +14 -0
  150. package/shim/astnav/go.sum +20 -0
  151. package/shim/astnav/shim.go +15 -0
  152. package/shim/checker/shim.go +113 -0
  153. package/shim/core/shim.go +47 -0
  154. package/shim/diagnosticwriter/lint.go +50 -0
  155. package/src/TtscCompiler.ts +5 -1
  156. package/src/TtscService.ts +37 -10
  157. package/src/compiler/internal/project/readConfigJson.ts +209 -0
  158. package/src/compiler/internal/project/readProjectConfig.ts +62 -158
  159. package/src/compiler/internal/residentTransformProcess.ts +197 -39
  160. package/src/compiler/internal/runBuild.ts +169 -47
  161. package/src/compiler/internal/startResidentTransform.ts +12 -1
  162. package/src/compiler/internal/transformProjectInMemory.ts +12 -1
  163. package/src/flags/parser.ts +113 -42
  164. package/src/flags/schema.ts +113 -31
  165. package/src/internal/assertSafeExplicitCacheDirectory.ts +29 -0
  166. package/src/launcher/internal/runTtsc.ts +117 -68
  167. package/src/launcher/internal/runTtsx.ts +29 -44
  168. package/src/launcher/internal/runtimeHooks.ts +30 -2
  169. package/src/launcher/internal/watchTopology.ts +466 -0
  170. package/src/plugin/internal/buildSourcePlugin.ts +326 -64
  171. package/src/plugin/internal/loadProjectPlugins.ts +80 -2
  172. package/src/structures/ITtscCompilerTransformation.ts +17 -0
  173. package/src/structures/internal/ITtscParsedProjectConfig.ts +2 -0
  174. package/tools/shim_audit/baseline.json +0 -3
  175. package/tools/shim_audit/main.go +42 -14
  176. package/tools/shim_audit/shim_dir_coverage_test.go +25 -0
  177. package/utility/flags_gen.go +3 -3
  178. package/utility/host.go +9 -3
@@ -188,7 +188,7 @@ func (pr *Provider) References(uri string, pos lspserver.LSPPosition, includeDec
188
188
  if e.To != target.ID || isStructuralEdge(e.Kind) {
189
189
  continue
190
190
  }
191
- f := nodeFile(e.From)
191
+ f := graph.NodeFile(e.From)
192
192
  if f == "" {
193
193
  continue
194
194
  }
@@ -196,7 +196,7 @@ func (pr *Provider) References(uri string, pos lspserver.LSPPosition, includeDec
196
196
  }
197
197
  if includeDeclaration {
198
198
  declText := sources[target.File]
199
- add(target.File, firstCodeOffset(declText, target.Pos), target.End)
199
+ add(target.File, graph.FirstCodeOffset(declText, target.Pos), target.End)
200
200
  }
201
201
  return locations, nil
202
202
  }
@@ -241,7 +241,7 @@ func buildSymbol(n *graph.Node, childrenOf map[string][]*graph.Node, text string
241
241
  }
242
242
 
243
243
  func nodeToSymbol(n *graph.Node, text string) lspserver.LSPDocumentSymbol {
244
- start := firstCodeOffset(text, n.Pos)
244
+ start := graph.FirstCodeOffset(text, n.Pos)
245
245
  end := n.End
246
246
  if end < start {
247
247
  end = start
@@ -322,7 +322,7 @@ func targetNodeAt(g *graph.Graph, file string, offset int) *graph.Node {
322
322
  return best
323
323
  }
324
324
  for _, e := range g.Edges {
325
- if !pathsEqual(nodeFile(e.From), file) {
325
+ if !pathsEqual(graph.NodeFile(e.From), file) {
326
326
  continue
327
327
  }
328
328
  if offset >= e.Pos && offset < e.End {
@@ -359,15 +359,6 @@ func sortNodes(nodes []*graph.Node) {
359
359
  })
360
360
  }
361
361
 
362
- // nodeFile recovers the source file path embedded in a node id
363
- // ("path#name:kind"); "" for an id without a path.
364
- func nodeFile(id string) string {
365
- if hash := strings.Index(id, "#"); hash >= 0 {
366
- return id[:hash]
367
- }
368
- return ""
369
- }
370
-
371
362
  // fileFromURI maps a file:// uri onto the source map key for the same file,
372
363
  // returning that key so text lookups by it succeed. Paths are compared
373
364
  // case-insensitively so a Windows drive-letter case mismatch between the
@@ -423,42 +414,10 @@ func uriFromFile(path string) string {
423
414
  return (&url.URL{Scheme: "file", Path: slashed}).String()
424
415
  }
425
416
 
426
- // firstCodeOffset advances past leading trivia (whitespace, // and /* */
427
- // comments) so a declaration span starts at the keyword rather than its doc
428
- // comment or indentation. It mirrors the graph dump's own trivia skip.
429
- func firstCodeOffset(text string, pos int) int {
430
- if pos < 0 {
431
- return 0
432
- }
433
- i := pos
434
- for i < len(text) {
435
- switch {
436
- case text[i] == ' ' || text[i] == '\t' || text[i] == '\r' || text[i] == '\n':
437
- i++
438
- case text[i] == '/' && i+1 < len(text) && text[i+1] == '/':
439
- i += 2
440
- for i < len(text) && text[i] != '\n' {
441
- i++
442
- }
443
- case text[i] == '/' && i+1 < len(text) && text[i+1] == '*':
444
- i += 2
445
- for i+1 < len(text) && !(text[i] == '*' && text[i+1] == '/') {
446
- i++
447
- }
448
- if i+1 < len(text) {
449
- i += 2
450
- } else {
451
- i = len(text)
452
- }
453
- default:
454
- return i
455
- }
456
- }
457
- return i
458
- }
459
-
460
417
  // offsetToPosition converts a byte offset into an LSP Position (0-based line,
461
- // UTF-16 code-unit column).
418
+ // UTF-16 code-unit column). The column unit is the session's negotiated
419
+ // PositionEncodingKind, which the proxy's constrainInitializePositionEncoding
420
+ // pins to UTF-16 for every ttscserver session.
462
421
  func offsetToPosition(text string, offset int) lspserver.LSPPosition {
463
422
  if offset < 0 {
464
423
  offset = 0
@@ -466,19 +425,12 @@ func offsetToPosition(text string, offset int) lspserver.LSPPosition {
466
425
  if offset > len(text) {
467
426
  offset = len(text)
468
427
  }
469
- line := 0
470
- lineStart := 0
471
- for i := 0; i < offset; {
472
- r, size := utf8.DecodeRuneInString(text[i:])
473
- if size == 0 {
474
- break
475
- }
476
- if r == '\n' {
477
- line++
478
- lineStart = i + size
479
- }
480
- i += size
428
+ starts := graph.ECMALineStarts(text)
429
+ line := sort.Search(len(starts), func(i int) bool { return starts[i] > offset }) - 1
430
+ if line < 0 {
431
+ line = 0
481
432
  }
433
+ lineStart := starts[line]
482
434
  character := 0
483
435
  for i := lineStart; i < offset; {
484
436
  r, size := utf8.DecodeRuneInString(text[i:])
@@ -495,54 +447,37 @@ func offsetToPosition(text string, offset int) lspserver.LSPPosition {
495
447
  return lspserver.LSPPosition{Line: line, Character: character}
496
448
  }
497
449
 
498
- // lspPositionToOffset converts an LSP Position (0-based line, UTF-16 column)
499
- // into a byte offset. It returns (offset, false) when the position points past
500
- // the end of the text so the caller can treat it as "no symbol here".
450
+ // lspPositionToOffset converts an LSP Position (0-based line, UTF-16 column —
451
+ // the encoding the proxy's constrainInitializePositionEncoding pins for every
452
+ // ttscserver session) into a byte offset. It returns (offset, false) when the
453
+ // position points past the end of the text so the caller can treat it as "no
454
+ // symbol here".
501
455
  func lspPositionToOffset(text string, pos lspserver.LSPPosition) (int, bool) {
502
456
  if pos.Line < 0 || pos.Character < 0 {
503
457
  return 0, false
504
458
  }
505
- i := 0
506
- line := 0
507
- for line < pos.Line {
508
- if i >= len(text) {
509
- return len(text), false
510
- }
511
- r, size := utf8.DecodeRuneInString(text[i:])
512
- if size == 0 {
513
- return len(text), false
514
- }
515
- if r == '\r' {
516
- i += size
517
- if i < len(text) && text[i] == '\n' {
518
- i++
519
- }
520
- line++
521
- continue
522
- }
523
- if r == '\n' {
524
- i += size
525
- line++
526
- continue
527
- }
528
- i += size
459
+ starts := graph.ECMALineStarts(text)
460
+ if pos.Line >= len(starts) {
461
+ return len(text), false
529
462
  }
463
+ i := starts[pos.Line]
464
+ lineEnd := graph.LineEnd(text, starts, pos.Line)
530
465
  units := 0
531
466
  for units < pos.Character {
532
- if i >= len(text) {
467
+ if i >= lineEnd {
533
468
  return len(text), false
534
469
  }
535
470
  r, size := utf8.DecodeRuneInString(text[i:])
536
471
  if size == 0 {
537
472
  return len(text), false
538
473
  }
539
- if r == '\n' || r == '\r' {
540
- return i, false
541
- }
542
474
  n := utf16.RuneLen(r)
543
475
  if n <= 0 {
544
476
  n = 1
545
477
  }
478
+ if units+n > pos.Character {
479
+ return i, false
480
+ }
546
481
  units += n
547
482
  i += size
548
483
  }
@@ -0,0 +1,144 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "sync"
10
+ "testing"
11
+ )
12
+
13
+ // recordingResidentSource is a PluginSource that also implements the resident
14
+ // invalidator, recording every InvalidateResidentPrograms call so a test can
15
+ // assert both that an invalidation happened and how it was localized.
16
+ //
17
+ // A clean open schedules its diagnostics on a goroutine that holds this same
18
+ // source, so the recording is mutex-guarded: the assertions below read it while
19
+ // that goroutine may still be running.
20
+ type recordingResidentSource struct {
21
+ NullPluginSource
22
+ mu sync.Mutex
23
+ calls [][]string
24
+ }
25
+
26
+ func (s *recordingResidentSource) InvalidateResidentPrograms(uris ...string) {
27
+ s.mu.Lock()
28
+ defer s.mu.Unlock()
29
+ s.calls = append(s.calls, append([]string(nil), uris...))
30
+ }
31
+
32
+ func (s *recordingResidentSource) recorded() [][]string {
33
+ s.mu.Lock()
34
+ defer s.mu.Unlock()
35
+ return append([][]string(nil), s.calls...)
36
+ }
37
+
38
+ // recordingSymbolProvider counts Invalidate calls; the two answer methods are
39
+ // never reached by these notification tests. Invalidate is only ever called from
40
+ // the notification path, which is the test's own goroutine.
41
+ type recordingSymbolProvider struct{ invalidations int }
42
+
43
+ func (p *recordingSymbolProvider) DocumentSymbols(string) ([]LSPDocumentSymbol, error) {
44
+ return nil, nil
45
+ }
46
+
47
+ func (p *recordingSymbolProvider) References(string, LSPPosition, bool) ([]LSPLocation, error) {
48
+ return nil, nil
49
+ }
50
+
51
+ func (p *recordingSymbolProvider) Invalidate() { p.invalidations++ }
52
+
53
+ // testFileURI renders an absolute path as the file URI an editor would send.
54
+ func testFileURI(path string) string {
55
+ return "file:///" + strings.TrimPrefix(filepath.ToSlash(path), "/")
56
+ }
57
+
58
+ func didOpenEnvelope(uri string, text string) Envelope {
59
+ params, _ := json.Marshal(map[string]any{
60
+ "textDocument": map[string]any{
61
+ "uri": uri,
62
+ "languageId": "typescript",
63
+ "version": 1,
64
+ "text": text,
65
+ },
66
+ })
67
+ return Envelope{JSONRPC: "2.0", Method: methodDidOpen, Params: params}
68
+ }
69
+
70
+ // TestLSPCleanDidOpenRefreshesCompilerState verifies that opening a document
71
+ // whose buffer already matches disk refreshes both compiler-backed caches, while
72
+ // opening a dirty buffer still refreshes neither.
73
+ //
74
+ // The resident lint Program and the graph symbol provider stay warm for the
75
+ // whole editor session, and before this only didSave refreshed them. A document
76
+ // closed across a branch switch, a `git pull`, or an edit from a second editor
77
+ // therefore came back with diagnostics computed over the pre-change AST and
78
+ // published against the new buffer. Equality with today's disk is not evidence
79
+ // that the warm Program was built from today's disk, so the clean branch is
80
+ // exactly the branch that must invalidate — and the dirty branch, which reports
81
+ // nothing at all until the buffer reaches disk, must keep not invalidating.
82
+ //
83
+ // 1. Write a file, open it with the same text, and assert both caches were
84
+ // refreshed and the resident refresh named that document's URI.
85
+ // 2. Open a second file with text that differs from disk.
86
+ // 3. Assert neither cache was refreshed and the notification still forwards.
87
+ func TestLSPCleanDidOpenRefreshesCompilerState(t *testing.T) {
88
+ dir := t.TempDir()
89
+ clean := filepath.Join(dir, "clean.ts")
90
+ if err := os.WriteFile(clean, []byte("export const value = 1;\n"), 0o600); err != nil {
91
+ t.Fatalf("write clean source: %v", err)
92
+ }
93
+ cleanURI := testFileURI(clean)
94
+
95
+ plugins := &recordingResidentSource{}
96
+ symbols := &recordingSymbolProvider{}
97
+ proxy := NewProxy(ProxyOptions{
98
+ EditorOut: io.Discard,
99
+ UpstreamIn: io.Discard,
100
+ Source: plugins,
101
+ SymbolProvider: symbols,
102
+ })
103
+
104
+ handled, err := proxy.handleEditorEnvelope(
105
+ didOpenEnvelope(cleanURI, "export const value = 1;\n"),
106
+ nil,
107
+ )
108
+ if err != nil {
109
+ t.Fatalf("clean didOpen: %v", err)
110
+ }
111
+ if handled {
112
+ t.Fatal("clean didOpen was answered locally instead of forwarded to tsgo")
113
+ }
114
+ if symbols.invalidations != 1 {
115
+ t.Errorf("clean didOpen symbol invalidations = %d, want 1", symbols.invalidations)
116
+ }
117
+ if len(plugins.recorded()) != 1 {
118
+ t.Fatalf("clean didOpen resident invalidations = %d, want 1", len(plugins.recorded()))
119
+ }
120
+ if len(plugins.recorded()[0]) != 1 || plugins.recorded()[0][0] != cleanURI {
121
+ t.Errorf("clean didOpen resident invalidation = %v, want the opened uri %q", plugins.recorded()[0], cleanURI)
122
+ }
123
+
124
+ dirty := filepath.Join(dir, "dirty.ts")
125
+ if err := os.WriteFile(dirty, []byte("export const value = 1;\n"), 0o600); err != nil {
126
+ t.Fatalf("write dirty source: %v", err)
127
+ }
128
+ handled, err = proxy.handleEditorEnvelope(
129
+ didOpenEnvelope(testFileURI(dirty), "export const value = 2;\n"),
130
+ nil,
131
+ )
132
+ if err != nil {
133
+ t.Fatalf("dirty didOpen: %v", err)
134
+ }
135
+ if handled {
136
+ t.Fatal("dirty didOpen was answered locally instead of forwarded to tsgo")
137
+ }
138
+ if symbols.invalidations != 1 {
139
+ t.Errorf("dirty didOpen refreshed the symbol provider: invalidations = %d, want 1", symbols.invalidations)
140
+ }
141
+ if len(plugins.recorded()) != 1 {
142
+ t.Errorf("dirty didOpen refreshed the resident daemon: calls = %v, want only the clean open's", plugins.recorded())
143
+ }
144
+ }
@@ -0,0 +1,109 @@
1
+ package lspserver
2
+
3
+ import "strings"
4
+
5
+ // completionScopeJSDoc is the one scope a hint may claim today. It mirrors
6
+ // rule.HintScopeJSDoc; the wire carries the string, not the Go constant.
7
+ const completionScopeJSDoc = "jsdoc"
8
+
9
+ // matchCompletionHints returns the items that apply at a cursor, and the prefix
10
+ // the editor should filter them against.
11
+ //
12
+ // The cursor-nearest matching After wins; at the same occurrence, the longest
13
+ // trigger wins. That lets a corpus be layered without a query language while
14
+ // ensuring a later trigger is never eclipsed by an earlier, longer one. Rules
15
+ // merge only when they name the same trigger at the winning occurrence.
16
+ func matchCompletionHints(
17
+ hints []LSPCompletionHint,
18
+ linePrefix string,
19
+ inJSDoc bool,
20
+ ) (items []LSPCompletionItem, filter string) {
21
+ bestStart := -1
22
+ bestLength := -1
23
+ bestAfter := ""
24
+ for _, hint := range hints {
25
+ if !completionHintApplies(hint, linePrefix, inJSDoc) {
26
+ continue
27
+ }
28
+ start := strings.LastIndex(linePrefix, hint.After)
29
+ if start < bestStart || (start == bestStart && len(hint.After) < bestLength) {
30
+ continue
31
+ }
32
+ if start > bestStart || len(hint.After) > bestLength {
33
+ bestStart = start
34
+ bestLength = len(hint.After)
35
+ bestAfter = hint.After
36
+ items = nil
37
+ filter = linePrefix[start+len(hint.After):]
38
+ }
39
+ if start != bestStart || hint.After != bestAfter {
40
+ continue
41
+ }
42
+ items = append(items, hint.Items...)
43
+ }
44
+ return items, filter
45
+ }
46
+
47
+ func completionHintApplies(
48
+ hint LSPCompletionHint,
49
+ linePrefix string,
50
+ inJSDoc bool,
51
+ ) bool {
52
+ return completionHintCouldApply(hint, linePrefix) && inJSDoc
53
+ }
54
+
55
+ // completionHintCouldApply reports every part of a hint's admission that does
56
+ // not depend on where the cursor sits lexically: that the hint has something to
57
+ // offer, that its scope is one this host knows, and that its trigger literal is
58
+ // on the current line.
59
+ //
60
+ // Split out because the remaining condition is the expensive one. Deciding the
61
+ // cursor's scope scans the document from byte zero, while all of this reads one
62
+ // line, so a caller can find out whether that scan can change any answer before
63
+ // paying for it.
64
+ func completionHintCouldApply(hint LSPCompletionHint, linePrefix string) bool {
65
+ if hint.After == "" || len(hint.Items) == 0 {
66
+ return false
67
+ }
68
+ if hint.Scope != completionScopeJSDoc {
69
+ // An unknown scope is refused rather than ignored. Treating it as
70
+ // "anywhere" would make a hint from a newer plugin than this host fire in
71
+ // every string literal — the one place a wrong guess is most visible and
72
+ // least explicable.
73
+ return false
74
+ }
75
+ return strings.Contains(linePrefix, hint.After)
76
+ }
77
+
78
+ // anyCompletionHintCouldApply reports whether the cursor's lexical scope can
79
+ // change any hint's answer here, and therefore whether it is worth deciding.
80
+ //
81
+ // When it answers false, completionHintApplies refuses every hint on grounds
82
+ // the scope cannot rescue, so the document scan behind that decision would
83
+ // produce an answer nothing reads. That is the overwhelmingly common case while
84
+ // typing: a corpus triggers on "@" or "@evidence ", and most lines hold neither.
85
+ func anyCompletionHintCouldApply(hints []LSPCompletionHint, linePrefix string) bool {
86
+ for _, hint := range hints {
87
+ if completionHintCouldApply(hint, linePrefix) {
88
+ return true
89
+ }
90
+ }
91
+ return false
92
+ }
93
+
94
+ // linePrefixAt returns the text from the start of the cursor's line up to the
95
+ // cursor.
96
+ //
97
+ // This is what a trigger matches against. It stops at the line start because a
98
+ // trigger is line-local by construction: a corpus that could match across lines
99
+ // would need the enclosing declaration, which the proxy does not have.
100
+ func linePrefixAt(text string, offset int) string {
101
+ if offset < 0 || offset > len(text) {
102
+ return ""
103
+ }
104
+ head := text[:offset]
105
+ if start := strings.LastIndexAny(head, "\r\n"); start != -1 {
106
+ return head[start+1:]
107
+ }
108
+ return head
109
+ }
@@ -0,0 +1,50 @@
1
+ package lspserver
2
+
3
+ import "testing"
4
+
5
+ // TestLSPCompletionGateNeverRefusesAMatchingLine verifies the cheap line test
6
+ // that now guards the document scan admits everything the full match would.
7
+ //
8
+ // completionItemsFor decides the cursor's lexical scope by scanning the document
9
+ // from byte zero, and that answer is only ever read by a hint whose trigger is
10
+ // already on the current line. The gate skips the scan when no trigger is, so it
11
+ // must never be stricter than the match it precedes: a false where
12
+ // matchCompletionHints would have produced items is a hint silently lost.
13
+ //
14
+ // 1. Publish a layered corpus: a broad trigger and two narrower ones.
15
+ // 2. For each candidate line, run the gate and the full match together.
16
+ // 3. Assert the gate is open wherever the match produced items, and closed on
17
+ // lines holding no trigger at all.
18
+ func TestLSPCompletionGateNeverRefusesAMatchingLine(t *testing.T) {
19
+ hints := []LSPCompletionHint{
20
+ {Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "param"}}},
21
+ {Scope: "jsdoc", After: "@evidence ", Items: []LSPCompletionItem{{Insert: "docs/spec.md"}}},
22
+ {Scope: "jsdoc", After: "@evidence docs/spec.md#", Items: []LSPCompletionItem{{Insert: "pricing"}}},
23
+ // Refused by the match for want of items; must not open the gate either.
24
+ {Scope: "jsdoc", After: "@nothing", Items: nil},
25
+ // An empty trigger matches every line; the match refuses it, so must the gate.
26
+ {Scope: "jsdoc", After: "", Items: []LSPCompletionItem{{Insert: "never"}}},
27
+ }
28
+
29
+ for _, entry := range []struct {
30
+ line string
31
+ open bool
32
+ }{
33
+ {" * @par", true},
34
+ {" * @evidence docs/sp", true},
35
+ {" * @evidence docs/spec.md#pri", true},
36
+ {" * plain prose with no tag", false},
37
+ {"", false},
38
+ {"const value = 1;", false},
39
+ {" * @nothing here", true}, // the broad "@" trigger still applies
40
+ } {
41
+ gate := anyCompletionHintCouldApply(hints, entry.line)
42
+ if gate != entry.open {
43
+ t.Fatalf("gate for %q: want %v, got %v", entry.line, entry.open, gate)
44
+ }
45
+ items, _ := matchCompletionHints(hints, entry.line, true)
46
+ if len(items) > 0 && !gate {
47
+ t.Fatalf("gate closed on %q while the match produced %d items", entry.line, len(items))
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,149 @@
1
+ package lspserver
2
+
3
+ import "testing"
4
+
5
+ // TestLSPCompletionHintsLongestTriggerWins pins the rule that makes a hint
6
+ // corpus layerable.
7
+ //
8
+ // A plugin publishes broad and narrow triggers at once — `@`, `@evidence `,
9
+ // `@evidence docs/spec.md#` — because it cannot ask a question per keystroke and
10
+ // so must describe every position up front. Only the longest match may answer.
11
+ // Without that, typing `@evidence docs/spec.md#pri` would offer tag names,
12
+ // document paths, and anchors together, and the narrow corpus the user actually
13
+ // wants would be buried under the broad one that also matches.
14
+ //
15
+ // 1. Publish three nested triggers.
16
+ // 2. Ask at a cursor inside the narrowest.
17
+ // 3. Assert only the narrowest answers, and that the filter is the text after
18
+ // it rather than the whole line.
19
+ func TestLSPCompletionHintsLongestTriggerWins(t *testing.T) {
20
+ hints := []LSPCompletionHint{
21
+ {Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "evidence"}}},
22
+ {Scope: "jsdoc", After: "@evidence ", Items: []LSPCompletionItem{{Insert: "docs/spec.md"}}},
23
+ {Scope: "jsdoc", After: "@evidence docs/spec.md#", Items: []LSPCompletionItem{
24
+ {Insert: "pricing"},
25
+ {Insert: "refunds"},
26
+ }},
27
+ }
28
+
29
+ cases := []struct {
30
+ line string
31
+ want []string
32
+ filter string
33
+ }{
34
+ {" * @evi", []string{"evidence"}, "evi"},
35
+ {" * @evidence docs/sp", []string{"docs/spec.md"}, "docs/sp"},
36
+ {" * @evidence docs/spec.md#pri", []string{"pricing", "refunds"}, "pri"},
37
+ // The boundary that makes the filter meaningful: at the trigger's own edge
38
+ // the filter is empty and everything is offered.
39
+ {" * @evidence docs/spec.md#", []string{"pricing", "refunds"}, ""},
40
+ }
41
+ for _, entry := range cases {
42
+ items, filter := matchCompletionHints(hints, entry.line, true)
43
+ got := inserts(items)
44
+ if !equalStrings(got, entry.want) {
45
+ t.Errorf("line %q offered %v, want %v", entry.line, got, entry.want)
46
+ }
47
+ if filter != entry.filter {
48
+ t.Errorf("line %q filtered on %q, want %q", entry.line, filter, entry.filter)
49
+ }
50
+ }
51
+ }
52
+
53
+ // TestLSPCompletionHintsRefuseOutsideScope pins the negative twin.
54
+ //
55
+ // A line prefix alone cannot tell `@evidence` in a doc comment from
56
+ // `@Injectable` above a class — both end in `@`. Scope is what separates them,
57
+ // and a corpus that ignored it would fire in every decorator position. An
58
+ // unknown scope is refused for the same reason: a hint from a newer plugin than
59
+ // this host must contribute nothing rather than fire everywhere.
60
+ //
61
+ // 1. Ask the same line outside a JSDoc block.
62
+ // 2. Ask with a scope this host does not know.
63
+ // 3. Assert silence in both.
64
+ func TestLSPCompletionHintsRefuseOutsideScope(t *testing.T) {
65
+ hints := []LSPCompletionHint{
66
+ {Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "evidence"}}},
67
+ }
68
+ if items, _ := matchCompletionHints(hints, "@Inj", false); len(items) != 0 {
69
+ t.Errorf("a decorator position was offered %v, want nothing", inserts(items))
70
+ }
71
+
72
+ future := []LSPCompletionHint{
73
+ {Scope: "markdown", After: "@", Items: []LSPCompletionItem{{Insert: "nope"}}},
74
+ }
75
+ if items, _ := matchCompletionHints(future, " * @", true); len(items) != 0 {
76
+ t.Errorf("an unknown scope was offered %v, want nothing", inserts(items))
77
+ }
78
+
79
+ empty := []LSPCompletionHint{
80
+ {Scope: "jsdoc", After: "", Items: []LSPCompletionItem{{Insert: "nope"}}},
81
+ {Scope: "jsdoc", After: "@", Items: nil},
82
+ }
83
+ if items, _ := matchCompletionHints(empty, " * @", true); len(items) != 0 {
84
+ t.Errorf("a degenerate hint was offered %v, want nothing", inserts(items))
85
+ }
86
+ }
87
+
88
+ // TestCursorInJSDocTracksTheBlock pins the scope test itself.
89
+ //
90
+ // It is a backward scan rather than a parse, so the cases that matter are the
91
+ // ones where a naive "is there a /** before me" would be wrong: after the block
92
+ // closed, and inside a line comment that only looks like one.
93
+ //
94
+ // 1. A cursor inside an open block is in scope.
95
+ // 2. A cursor after the block closed is not.
96
+ // 3. A line comment is never a doc comment.
97
+ func TestCursorInJSDocTracksTheBlock(t *testing.T) {
98
+ cases := []struct {
99
+ text string
100
+ want bool
101
+ }{
102
+ {"/**\n * @evi", true},
103
+ {"/** @evi", true},
104
+ {"/**\n * ok\n */\nconst x = @", false},
105
+ {"// @evi", false},
106
+ {"const x = 1; @", false},
107
+ // A second block after a closed one: the open must win again.
108
+ {"/** a */\n/**\n * @evi", true},
109
+ }
110
+ for _, entry := range cases {
111
+ if got := cursorInJSDoc(entry.text, len(entry.text)); got != entry.want {
112
+ t.Errorf("cursorInJSDoc(%q) = %v, want %v", entry.text, got, entry.want)
113
+ }
114
+ }
115
+ }
116
+
117
+ // TestLinePrefixStopsAtTheLine pins that a trigger is line-local.
118
+ //
119
+ // A trigger matched against the whole document would let a `@evidence` three
120
+ // lines up offer anchors on an unrelated line.
121
+ func TestLinePrefixStopsAtTheLine(t *testing.T) {
122
+ text := "/**\n * @evidence docs/spec.md#pri"
123
+ if got, want := linePrefixAt(text, len(text)), " * @evidence docs/spec.md#pri"; got != want {
124
+ t.Errorf("linePrefixAt = %q, want %q", got, want)
125
+ }
126
+ if got := linePrefixAt("no newline", 2); got != "no" {
127
+ t.Errorf("linePrefixAt with no newline = %q, want %q", got, "no")
128
+ }
129
+ }
130
+
131
+ func inserts(items []LSPCompletionItem) []string {
132
+ out := []string{}
133
+ for _, item := range items {
134
+ out = append(out, item.Insert)
135
+ }
136
+ return out
137
+ }
138
+
139
+ func equalStrings(a, b []string) bool {
140
+ if len(a) != len(b) {
141
+ return false
142
+ }
143
+ for i := range a {
144
+ if a[i] != b[i] {
145
+ return false
146
+ }
147
+ }
148
+ return true
149
+ }
@@ -0,0 +1,37 @@
1
+ package lspserver
2
+
3
+ import "testing"
4
+
5
+ // TestLSPCompletionHintsNearestTriggerWins keeps completion tied to the trigger
6
+ // nearest the cursor, not merely the longest text somewhere earlier on the
7
+ // line. At one occurrence, nested triggers remain layerable and duplicate
8
+ // producers of the same trigger may contribute together.
9
+ func TestLSPCompletionHintsNearestTriggerWins(t *testing.T) {
10
+ hints := []LSPCompletionHint{
11
+ {Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "broad"}}},
12
+ {Scope: "jsdoc", After: "@task", Items: []LSPCompletionItem{{Insert: "task"}}},
13
+ {Scope: "jsdoc", After: "@bird", Items: []LSPCompletionItem{{Insert: "bird"}}},
14
+ {Scope: "jsdoc", After: "@fish", Items: []LSPCompletionItem{{Insert: "fish"}}},
15
+ {Scope: "jsdoc", After: "@note", Items: []LSPCompletionItem{{Insert: "first"}}},
16
+ {Scope: "jsdoc", After: "@note", Items: []LSPCompletionItem{{Insert: "second"}}},
17
+ }
18
+
19
+ for _, entry := range []struct {
20
+ line string
21
+ want []string
22
+ filter string
23
+ }{
24
+ {" * @task details @", []string{"broad"}, ""},
25
+ {" * @task details", []string{"task"}, " details"},
26
+ {" * @bird then @fish tail", []string{"fish"}, " tail"},
27
+ {" * @note draft", []string{"first", "second"}, " draft"},
28
+ } {
29
+ items, filter := matchCompletionHints(hints, entry.line, true)
30
+ if got := inserts(items); !equalStrings(got, entry.want) {
31
+ t.Errorf("line %q offered %v, want %v", entry.line, got, entry.want)
32
+ }
33
+ if filter != entry.filter {
34
+ t.Errorf("line %q filtered on %q, want %q", entry.line, filter, entry.filter)
35
+ }
36
+ }
37
+ }