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
@@ -0,0 +1,60 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "reflect"
5
+ "testing"
6
+ )
7
+
8
+ // TestNativeCompletionHintsKeepGroupedWireAndRejectInvalidEntries verifies the
9
+ // compatibility and validation boundary.
10
+ //
11
+ // Third-party sidecars may already publish the grouped shape documented by the
12
+ // first proxy implementation. Those responses remain valid, while empty items,
13
+ // empty triggers, and structurally malformed JSON must not enter the corpus.
14
+ //
15
+ // 1. Decode a grouped response containing valid and empty entries.
16
+ // 2. Assert the valid group's item order survives and empty entries are dropped.
17
+ // 3. Assert a field with the wrong JSON type rejects the response.
18
+ func TestNativeCompletionHintsKeepGroupedWireAndRejectInvalidEntries(t *testing.T) {
19
+ body := []byte(`[
20
+ {
21
+ "scope":"jsdoc",
22
+ "after":"@legacy ",
23
+ "items":[
24
+ {"insert":"legacy-first","detail":"kept"},
25
+ {"insert":""},
26
+ {"insert":"legacy-second"}
27
+ ]
28
+ },
29
+ {"scope":"","after":"@missing-scope ","items":[{"insert":"drop"}]},
30
+ {"scope":"jsdoc","after":"","items":[{"insert":"drop"}]},
31
+ {"scope":"jsdoc","after":"@empty ","items":[]},
32
+ {"insert":"","trigger":{"scope":"jsdoc","after":"@flat-empty "}},
33
+ {"insert":"drop","trigger":{"scope":"","after":"@flat-empty-scope "}},
34
+ {"insert":"drop","trigger":{"scope":"jsdoc","after":""}},
35
+ {}
36
+ ]`)
37
+
38
+ got, err := decodeNativeCompletionHints(body)
39
+ if err != nil {
40
+ t.Fatalf("decode grouped completion hints: %v", err)
41
+ }
42
+ want := []LSPCompletionHint{{
43
+ Scope: "jsdoc",
44
+ After: "@legacy ",
45
+ Items: []LSPCompletionItem{
46
+ {Insert: "legacy-first", Detail: "kept"},
47
+ {Insert: "legacy-second"},
48
+ },
49
+ }}
50
+ if !reflect.DeepEqual(got, want) {
51
+ t.Errorf("grouped compatibility result:\n got %#v\nwant %#v", got, want)
52
+ }
53
+
54
+ malformed := []byte(`[
55
+ {"insert":42,"trigger":{"scope":"jsdoc","after":"@broken "}}
56
+ ]`)
57
+ if _, err := decodeNativeCompletionHints(malformed); err == nil {
58
+ t.Error("wrongly typed flat hint was accepted")
59
+ }
60
+ }
@@ -63,8 +63,38 @@ type NativePluginSource struct {
63
63
 
64
64
  commandIDs []string
65
65
  codeActionKinds []string
66
- owners map[string]NativeLSPPluginEntry
67
- logMu sync.Mutex
66
+
67
+ // completionHints is filled by a background fetch, so it needs a lock the
68
+ // static verbs do not: the proxy reads it from the completion path while
69
+ // discovery may still be writing it. It is the flattened publication-order
70
+ // view of pluginHints, materialized on every store so the completion path
71
+ // copies a ready slice instead of rebuilding one per keystroke.
72
+ hintsMu sync.RWMutex
73
+ completionHints []LSPCompletionHint
74
+ // pluginHints keeps each producer's corpus separately, keyed by plugin
75
+ // identity, so one plugin's refresh cannot disturb another's. A producer's
76
+ // entry changes only when that producer answers successfully: a refresh that
77
+ // failed to run leaves the last known-good corpus in place rather than
78
+ // blanking a working corpus over a transient spawn failure.
79
+ pluginHints map[string]completionHintRecord
80
+ // hintsObserver is told after every completed refresh cycle so the proxy can
81
+ // react to a corpus that changed mid-session. Nil for any host that did not
82
+ // register one.
83
+ hintsObserver func()
84
+ // hintsRefresh serializes and coalesces corpus refreshes. A refresh loads a
85
+ // Program per plugin, so scheduling one per editor event without coalescing
86
+ // would stack process spawns behind each other.
87
+ hintsRefresh coalescingRefresh
88
+ owners map[string]NativeLSPPluginEntry
89
+ logMu sync.Mutex
90
+
91
+ // residentMu guards the resident-daemon table below. A resident sidecar keeps
92
+ // a warm Program across verbs, so lsp-diagnostics / lsp-code-actions reuse it
93
+ // instead of respawning per verb; serveUnsupported remembers a sidecar that
94
+ // predates lsp-serve so the source stops retrying it and stays on exec.
95
+ residentMu sync.Mutex
96
+ residents map[string]*residentSidecar
97
+ serveUnsupported map[string]bool
68
98
  }
69
99
 
70
100
  type limitedBuffer struct {
@@ -140,6 +170,15 @@ func NewNativePluginSource(opts NativePluginSourceOptions) (*NativePluginSource,
140
170
  owners: map[string]NativeLSPPluginEntry{},
141
171
  }
142
172
  source.discoverCommandIDs()
173
+ // The corpus fetch loads a Program, so it runs off the construction path.
174
+ // Blocking here would delay initialize — and therefore the editor's first
175
+ // response — for a feature most projects do not use. Until it lands,
176
+ // CompletionHints answers nil and the editor sees exactly what it sees today.
177
+ //
178
+ // The first fetch goes through the same scheduler every later refresh uses,
179
+ // so startup and mid-session rediscovery share one generation counter and one
180
+ // coalescing rule rather than racing as two independent writers.
181
+ source.RefreshCompletionHints()
143
182
  return source, nil
144
183
  }
145
184
 
@@ -314,6 +353,256 @@ func (s *NativePluginSource) CommandIDs() []string {
314
353
  return out
315
354
  }
316
355
 
356
+ // CompletionHints returns the corpus plugins published, or nil until it has
357
+ // been fetched.
358
+ //
359
+ // Nil while loading is deliberate and is the whole reason the fetch is
360
+ // asynchronous. Unlike lsp-command-ids and lsp-code-action-kinds — which ignore
361
+ // their arguments and never build a Program — lsp-hints must load one, because
362
+ // a corpus is a projection of what a project rule's Check found. Paying that on
363
+ // the initialize path would delay every editor session for a feature most
364
+ // projects do not use, and paying it on the first completion would freeze the
365
+ // popup. Answering "no hints yet" degrades honestly: the editor still gets
366
+ // tsgo's completion, and ours appear once they exist.
367
+ //
368
+ // The same reasoning carries to refresh. It answers the last known-good corpus
369
+ // while a rediscovery scheduled by RefreshCompletionHints is running, so
370
+ // completion never blocks on a producer and never observes a half-cleared
371
+ // corpus.
372
+ func (s *NativePluginSource) CompletionHints() []LSPCompletionHint {
373
+ if s == nil {
374
+ return nil
375
+ }
376
+ s.hintsMu.RLock()
377
+ defer s.hintsMu.RUnlock()
378
+ if len(s.completionHints) == 0 {
379
+ return nil
380
+ }
381
+ out := make([]LSPCompletionHint, len(s.completionHints))
382
+ copy(out, s.completionHints)
383
+ return out
384
+ }
385
+
386
+ // RefreshCompletionHints schedules one asynchronous corpus rediscovery.
387
+ //
388
+ // The corpus is a projection of what a project rule's Check found, so it goes
389
+ // stale the moment the rule's inputs change: a saved contributor-indexed
390
+ // document, a rule enabled in `lint.config.*`, a watched file rewritten outside
391
+ // the editor. Without this the corpus stayed a session snapshot and only a
392
+ // language-server restart could replace it.
393
+ //
394
+ // Asynchronous for the same reason the first fetch is (see CompletionHints):
395
+ // the editor event that schedules a refresh must not wait for a Program load.
396
+ // Concurrent requests coalesce into at most one queued rerun, so a save storm
397
+ // costs one extra refresh rather than one per notification, and the previous
398
+ // corpus keeps answering completion until the new one lands.
399
+ func (s *NativePluginSource) RefreshCompletionHints() {
400
+ if s == nil || len(s.plugins) == 0 {
401
+ return
402
+ }
403
+ s.hintsRefresh.schedule(s.discoverCompletionHints)
404
+ }
405
+
406
+ // SetCompletionHintsObserver registers fn to run after each completed refresh
407
+ // cycle. The proxy uses it to notice a trigger character that appeared after
408
+ // the initialize response was already sent. A nil fn clears the observer.
409
+ func (s *NativePluginSource) SetCompletionHintsObserver(fn func()) {
410
+ if s == nil {
411
+ return
412
+ }
413
+ s.hintsMu.Lock()
414
+ defer s.hintsMu.Unlock()
415
+ s.hintsObserver = fn
416
+ }
417
+
418
+ // completionHintRecord is one producer's corpus and the refresh generation that
419
+ // produced it. The generation is what keeps a slow refresh from overwriting a
420
+ // newer one: plugin fetches run sequentially inside a cycle, but two cycles can
421
+ // still be in flight when a scheduled refresh outlives its successor's start.
422
+ type completionHintRecord struct {
423
+ hints []LSPCompletionHint
424
+ generation uint64
425
+ }
426
+
427
+ // discoverCompletionHints fetches every plugin's corpus for one generation.
428
+ //
429
+ // A separate pass from discoverCommandIDs rather than another step inside it.
430
+ // That loop abandons the rest of a plugin's discovery on any error, so folding
431
+ // hints in would mean a plugin whose corpus failed also lost its code action
432
+ // kinds — one optional feature taking down a working one.
433
+ //
434
+ // Each plugin's result is stored as it arrives instead of after the whole pass,
435
+ // so a fast producer's fresh corpus reaches the editor without waiting on a slow
436
+ // one, and a producer that failed keeps serving what it last published.
437
+ func (s *NativePluginSource) discoverCompletionHints(generation uint64) {
438
+ for _, plugin := range s.plugins {
439
+ body, err := s.run(plugin, "lsp-hints")
440
+ if err != nil {
441
+ // Silent, unlike every other discovery failure here. A plugin that does
442
+ // not know this verb rejects it as an unknown command, and that is the
443
+ // common case rather than the exceptional one: every plugin built before
444
+ // this channel existed, forever. Logging it would print an error per
445
+ // plugin per session for an optional feature nobody asked those plugins
446
+ // for — the same reasoning that makes CompletionHints an optional
447
+ // interface rather than a PluginSource method.
448
+ //
449
+ // The cost is that a plugin genuinely broken while producing hints also
450
+ // goes quiet. That is the right trade: its hints are absent either way,
451
+ // and the alternative is punishing every well-behaved old plugin to catch
452
+ // a rare new one. Refresh strengthens that trade rather than weakening it:
453
+ // a logged failure would now print per save instead of per session.
454
+ continue
455
+ }
456
+ published, err := decodeNativeCompletionHints(body)
457
+ if err != nil {
458
+ // Not silent. A plugin that answered and answered wrongly implements the
459
+ // verb and got it wrong, which is worth saying — unlike one that never
460
+ // implemented it at all.
461
+ s.log("ttscserver: %s lsp-hints returned invalid JSON: %v", pluginLabel(plugin), err)
462
+ continue
463
+ }
464
+ s.storeCompletionHints(plugin, generation, published)
465
+ }
466
+ s.notifyCompletionHintsObserver()
467
+ }
468
+
469
+ // storeCompletionHints replaces one producer's corpus and rebuilds the flattened
470
+ // snapshot the completion path reads.
471
+ //
472
+ // A successful answer is the only thing that changes a producer's corpus, and an
473
+ // empty successful answer clears it — that is how a disabled rule's items stop
474
+ // being offered. An older generation is dropped: refresh cycles are scheduled by
475
+ // editor events, and the last event's answer is the one the user is waiting for.
476
+ func (s *NativePluginSource) storeCompletionHints(plugin NativeLSPPluginEntry, generation uint64, hints []LSPCompletionHint) {
477
+ key := pluginKey(plugin)
478
+ s.hintsMu.Lock()
479
+ defer s.hintsMu.Unlock()
480
+ if existing, ok := s.pluginHints[key]; ok && generation < existing.generation {
481
+ return
482
+ }
483
+ if s.pluginHints == nil {
484
+ s.pluginHints = map[string]completionHintRecord{}
485
+ }
486
+ s.pluginHints[key] = completionHintRecord{hints: hints, generation: generation}
487
+ s.completionHints = s.flattenCompletionHintsLocked()
488
+ }
489
+
490
+ // flattenCompletionHintsLocked concatenates every producer's corpus in manifest
491
+ // order. Order is load-bearing twice over: the proxy resolves overlapping hints
492
+ // by publication order, and the editor ranks the items it is handed. Iterating
493
+ // the manifest rather than the map keeps that order identical across refreshes,
494
+ // which a Go map's randomized range would not. The caller holds hintsMu.
495
+ func (s *NativePluginSource) flattenCompletionHintsLocked() []LSPCompletionHint {
496
+ hints := []LSPCompletionHint{}
497
+ seen := make(map[string]struct{}, len(s.plugins))
498
+ for _, plugin := range s.plugins {
499
+ key := pluginKey(plugin)
500
+ if _, duplicate := seen[key]; duplicate {
501
+ continue
502
+ }
503
+ seen[key] = struct{}{}
504
+ hints = append(hints, s.pluginHints[key].hints...)
505
+ }
506
+ return hints
507
+ }
508
+
509
+ // notifyCompletionHintsObserver runs the registered observer outside hintsMu:
510
+ // the observer reads the corpus back through CompletionHints, and holding the
511
+ // lock across it would deadlock on the RLock.
512
+ func (s *NativePluginSource) notifyCompletionHintsObserver() {
513
+ s.hintsMu.RLock()
514
+ observer := s.hintsObserver
515
+ s.hintsMu.RUnlock()
516
+ if observer != nil {
517
+ observer()
518
+ }
519
+ }
520
+
521
+ // pluginKey identifies one manifest entry. It matches the identity
522
+ // pluginOwnsCommand compares, so a manifest carrying the same binary under two
523
+ // stages keeps two independent corpora instead of overwriting one with the
524
+ // other.
525
+ func pluginKey(plugin NativeLSPPluginEntry) string {
526
+ return plugin.Binary + "\x00" + plugin.Name + "\x00" + plugin.Stage
527
+ }
528
+
529
+ // decodeNativeCompletionHints accepts both generations of the lsp-hints wire.
530
+ //
531
+ // @ttsc/lint publishes one flat rule.Hint per item, with the scope and trigger
532
+ // nested under `trigger`. The first proxy implementation instead documented a
533
+ // grouped response with `scope`, `after`, and `items` at the top level. Flat
534
+ // entries are grouped here by trigger so the proxy keeps its efficient matching
535
+ // shape, while grouped responses remain valid for existing third-party
536
+ // sidecars. The first occurrence of a trigger fixes its group position and each
537
+ // later occurrence appends in publication order, preserving rule ranking.
538
+ func decodeNativeCompletionHints(body []byte) ([]LSPCompletionHint, error) {
539
+ var entries []json.RawMessage
540
+ if err := json.Unmarshal(body, &entries); err != nil {
541
+ return nil, err
542
+ }
543
+
544
+ type triggerKey struct {
545
+ scope string
546
+ after string
547
+ }
548
+ flatGroups := map[triggerKey]int{}
549
+ hints := make([]LSPCompletionHint, 0, len(entries))
550
+ for _, entry := range entries {
551
+ var fields map[string]json.RawMessage
552
+ if err := json.Unmarshal(entry, &fields); err != nil {
553
+ return nil, err
554
+ }
555
+ if _, grouped := fields["items"]; grouped {
556
+ var hint LSPCompletionHint
557
+ if err := json.Unmarshal(entry, &hint); err != nil {
558
+ return nil, err
559
+ }
560
+ hint.Items = usableNativeCompletionItems(hint.Items)
561
+ if hint.Scope == "" || hint.After == "" || len(hint.Items) == 0 {
562
+ continue
563
+ }
564
+ hints = append(hints, hint)
565
+ continue
566
+ }
567
+
568
+ var flat struct {
569
+ LSPCompletionItem
570
+ Trigger struct {
571
+ Scope string `json:"scope"`
572
+ After string `json:"after"`
573
+ } `json:"trigger"`
574
+ }
575
+ if err := json.Unmarshal(entry, &flat); err != nil {
576
+ return nil, err
577
+ }
578
+ if flat.Insert == "" || flat.Trigger.Scope == "" || flat.Trigger.After == "" {
579
+ continue
580
+ }
581
+ key := triggerKey{scope: flat.Trigger.Scope, after: flat.Trigger.After}
582
+ index, exists := flatGroups[key]
583
+ if !exists {
584
+ index = len(hints)
585
+ flatGroups[key] = index
586
+ hints = append(hints, LSPCompletionHint{
587
+ Scope: flat.Trigger.Scope,
588
+ After: flat.Trigger.After,
589
+ })
590
+ }
591
+ hints[index].Items = append(hints[index].Items, flat.LSPCompletionItem)
592
+ }
593
+ return hints, nil
594
+ }
595
+
596
+ func usableNativeCompletionItems(items []LSPCompletionItem) []LSPCompletionItem {
597
+ kept := make([]LSPCompletionItem, 0, len(items))
598
+ for _, item := range items {
599
+ if item.Insert != "" {
600
+ kept = append(kept, item)
601
+ }
602
+ }
603
+ return kept
604
+ }
605
+
317
606
  // CodeActionKinds returns the action kinds discovered from LSP-capable sidecars.
318
607
  func (s *NativePluginSource) CodeActionKinds() []string {
319
608
  if s == nil || len(s.codeActionKinds) == 0 {
@@ -385,6 +674,30 @@ func (s *NativePluginSource) pluginOwnsCommand(plugin NativeLSPPluginEntry, comm
385
674
  }
386
675
 
387
676
  func (s *NativePluginSource) run(plugin NativeLSPPluginEntry, command string, args ...string) ([]byte, error) {
677
+ // Route the Program-loading read verbs through the plugin's resident daemon so
678
+ // a warm Program is reused across verbs. serveRun returns served=false for a
679
+ // sidecar that predates lsp-serve or a transport failure, falling back to the
680
+ // spawn-per-verb path below with no behavior change. The static discovery
681
+ // verbs (lsp-command-ids / lsp-code-action-kinds) and lsp-execute-command stay
682
+ // on exec by design.
683
+ if command == serveVerbDiagnostics || command == serveVerbCodeActions {
684
+ if body, served, err := s.serveRun(plugin, command, args); served {
685
+ return body, err
686
+ }
687
+ }
688
+ if command == serveVerbHints {
689
+ // Hints join the daemon on a weaker condition than the other two: the
690
+ // answer is used only when it arrives without error. A sidecar built after
691
+ // the daemon landed but before this verb joined it answers lsp-serve and
692
+ // rejects lsp-hints as an unknown verb, which reaches this side as a
693
+ // nonzero code — indistinguishable over this protocol from a rule that
694
+ // failed. Falling back to the spawn path on any nonzero reply keeps that
695
+ // sidecar's corpus working, and a project whose hints genuinely fail pays
696
+ // one extra spawn to fail there too, exactly as it did before.
697
+ if body, served, err := s.serveRun(plugin, command, args); served && err == nil {
698
+ return body, nil
699
+ }
700
+ }
388
701
  return s.runWithStdin(plugin, command, nil, args...)
389
702
  }
390
703