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
@@ -12,6 +12,7 @@ import (
12
12
  "path/filepath"
13
13
  "strings"
14
14
  "sync"
15
+ "sync/atomic"
15
16
  "unicode/utf16"
16
17
  "unicode/utf8"
17
18
  )
@@ -36,8 +37,29 @@ const (
36
37
  methodFormatting = "textDocument/formatting"
37
38
  methodDocumentSymbol = "textDocument/documentSymbol"
38
39
  methodReferences = "textDocument/references"
40
+ methodCompletion = "textDocument/completion"
41
+ methodCompletionResolve = "completionItem/resolve"
42
+ methodExit = "exit"
43
+
44
+ // methodDidChangeWatchedFiles is the only notification an editor sends for a
45
+ // file it does not have open: a tsconfig edit, a generated file, a branch
46
+ // switch. The repository's own VS Code client registers a watcher for
47
+ // `**/{tsconfig,jsconfig}*.json`, which its documentSelector excludes, so
48
+ // those edits can reach ttsc through no other notification.
49
+ methodDidChangeWatchedFiles = "workspace/didChangeWatchedFiles"
50
+ // methodDidChangeConfiguration is the editor's own settings changing. The
51
+ // proxy forwards it untouched and reads it only as a signal that a plugin's
52
+ // completion corpus may have gone stale, since a rule can be enabled there.
53
+ methodDidChangeConfiguration = "workspace/didChangeConfiguration"
54
+ // methodLogMessage carries server-side notices to the editor's output
55
+ // channel. The proxy uses it for state the user has to act on and no other
56
+ // LSP message can express.
57
+ methodLogMessage = "window/logMessage"
39
58
  )
40
59
 
60
+ // lspMessageTypeInfo is the LSP MessageType for an informational log message.
61
+ const lspMessageTypeInfo = 3
62
+
41
63
  // formatDocumentCommand is the ttsc-owned workspace command that the lint
42
64
  // sidecar advertises for whole-document formatting. The textDocument/formatting
43
65
  // handler routes the cached editor buffer through this command so formatOnSave
@@ -102,8 +124,17 @@ type Proxy struct {
102
124
  upstreamWriteMu sync.Mutex // serializes writes to upstreamIn
103
125
  asyncErrCh chan error
104
126
 
105
- pendingMu sync.Mutex
106
- pendingActions map[string]pendingCodeActionRequest
127
+ // editorExit records that the editor sent the LSP `exit` notification.
128
+ // RunLSPServer reads it to decide whether a failed upstream process is a
129
+ // fault to report or the expected end of an editor-requested shutdown; see
130
+ // editorRequestedExit.
131
+ editorExit atomic.Bool
132
+
133
+ pendingMu sync.Mutex
134
+ pendingActions map[string]pendingCodeActionRequest
135
+ // pendingCompletions holds the plugin items computed for a forwarded
136
+ // completion request, keyed by request id, until upstream answers it.
137
+ pendingCompletions map[string]pendingCompletionRequest
107
138
  pendingAugmentingActions map[string]struct{}
108
139
  pendingLocalActions map[string]struct{}
109
140
  pendingCommands map[string]struct{}
@@ -113,6 +144,15 @@ type Proxy struct {
113
144
  upstreamCodeActionProvider bool
114
145
  upstreamDocumentSymbolProvider bool
115
146
  upstreamReferencesProvider bool
147
+ // initializeAnswered records that the editor has received the augmented
148
+ // capabilities, which is what makes a later trigger character "late".
149
+ initializeAnswered bool
150
+ // advertisedCompletionTriggers is the trigger set the editor was told about:
151
+ // tsgo's own characters plus whatever the corpus contributed at that moment.
152
+ // A refresh that produces a trigger outside this set cannot reach the editor
153
+ // without a restart, so the proxy says so once per character.
154
+ advertisedCompletionTriggers map[string]struct{}
155
+ reportedCompletionTriggers map[string]struct{}
116
156
 
117
157
  diagnosticsMu sync.Mutex
118
158
  upstreamDiagnostics map[string]cachedDiagnostics
@@ -161,7 +201,7 @@ func NewProxy(opts ProxyOptions) *Proxy {
161
201
  if source == nil {
162
202
  source = NullPluginSource{}
163
203
  }
164
- return &Proxy{
204
+ proxy := &Proxy{
165
205
  editorIn: opts.EditorIn,
166
206
  editorOut: opts.EditorOut,
167
207
  upstreamIn: opts.UpstreamIn,
@@ -187,6 +227,16 @@ func NewProxy(opts ProxyOptions) *Proxy {
187
227
  dirtyVersions: make(map[string]*int),
188
228
  documentText: make(map[string]string),
189
229
  }
230
+ // Optional-interface assertion for the same reason pluginCompletionHints uses
231
+ // one: a source that publishes no corpus, NullPluginSource included, simply
232
+ // never reports one changing.
233
+ type completionHintObserverSource interface {
234
+ SetCompletionHintsObserver(func())
235
+ }
236
+ if observed, ok := source.(completionHintObserverSource); ok {
237
+ observed.SetCompletionHintsObserver(proxy.completionHintsRefreshed)
238
+ }
239
+ return proxy
190
240
  }
191
241
 
192
242
  // Run drives both pump goroutines until they return. Pumps return when
@@ -255,7 +305,7 @@ func (p *Proxy) pumpEditorToUpstream(_ context.Context) error {
255
305
  if handled {
256
306
  continue
257
307
  }
258
- if err := p.writeUpstreamFrame(body); err != nil {
308
+ if err := p.writeUpstreamFrame(constrainInitializePositionEncoding(env, body)); err != nil {
259
309
  return err
260
310
  }
261
311
  }
@@ -289,11 +339,27 @@ func (p *Proxy) handleEditorEnvelope(env Envelope, body []byte) (bool, error) {
289
339
  case methodDidSave:
290
340
  if env.IsNotification() {
291
341
  p.invalidateSymbolProvider()
342
+ p.invalidateResidentPlugins(env)
343
+ p.refreshPluginCompletionHints()
292
344
  p.publishPluginDiagnosticsForDocumentNotification(env)
293
345
  }
346
+ case methodDidChangeConfiguration:
347
+ if env.IsNotification() {
348
+ // A rule can be enabled from editor settings, so the corpus may have gone
349
+ // stale. Watched-file changes schedule the same refresh, but from inside
350
+ // invalidateForWatchedFileChanges, which knows whether anything actually
351
+ // changed.
352
+ p.refreshPluginCompletionHints()
353
+ }
294
354
  case methodDidChange:
295
355
  if env.IsNotification() {
296
356
  p.invalidateSymbolProvider()
357
+ // No resident invalidation on didChange: the buffer is dirty but disk is
358
+ // unchanged, so the warm Program (built from disk) stays valid, and plugin
359
+ // diagnostics and code actions are suppressed while dirty anyway. didSave
360
+ // updates the Program incrementally once the edit reaches disk. No corpus
361
+ // refresh either, for the same reason plus a sharper one: a refresh spawns
362
+ // a sidecar per plugin, and completion is on the live-buffer hot path.
297
363
  p.cacheDidChangeText(env)
298
364
  if err := p.markDocumentDirty(env); err != nil {
299
365
  return false, err
@@ -304,6 +370,15 @@ func (p *Proxy) handleEditorEnvelope(env Envelope, body []byte) (bool, error) {
304
370
  p.evictDocumentText(env)
305
371
  p.clearDocumentDiagnostics(env)
306
372
  }
373
+ case methodDidChangeWatchedFiles:
374
+ if env.IsNotification() {
375
+ // Refresh before the notification reaches tsgo, so no read that races
376
+ // this frame can still be answered from the pre-change snapshot. The
377
+ // notification itself keeps flowing upstream.
378
+ if err := p.invalidateForWatchedFileChanges(env); err != nil {
379
+ return false, err
380
+ }
381
+ }
307
382
  case methodExecuteCommand:
308
383
  if env.IsRequest() {
309
384
  return p.tryExecuteCommand(env)
@@ -324,6 +399,14 @@ func (p *Proxy) handleEditorEnvelope(env Envelope, body []byte) (bool, error) {
324
399
  if env.IsRequest() {
325
400
  return p.handleReferencesRequest(env)
326
401
  }
402
+ case methodCompletion:
403
+ return p.handleCompletionRequest(env)
404
+ case methodCompletionResolve:
405
+ return p.handleCompletionResolveRequest(env)
406
+ case methodExit:
407
+ if env.IsNotification() {
408
+ p.editorExit.Store(true)
409
+ }
327
410
  case methodCancelRequest:
328
411
  // $/cancelRequest names an in-flight id the editor has given up on.
329
412
  // The proxy drops any pending codeAction entry for that id so the
@@ -334,6 +417,121 @@ func (p *Proxy) handleEditorEnvelope(env Envelope, body []byte) (bool, error) {
334
417
  return false, nil
335
418
  }
336
419
 
420
+ // positionEncodingUTF16 is the one LSP PositionEncodingKind ttscserver speaks.
421
+ //
422
+ // LSP 3.17 negotiates the encoding of `Position.character` per session: the
423
+ // client offers `general.positionEncodings`, the server picks one, and every
424
+ // position on that session is counted in the winner. tsgo selects `utf-8`
425
+ // whenever a client offers it, but ttsc computes positions of its own — the
426
+ // incremental buffer cache, plugin completion, the lint sidecar's diagnostic,
427
+ // fix, suggestion and formatting ranges, and the graph symbol provider — and
428
+ // every one of those counts UTF-16 code units. Because the sidecar protocol
429
+ // carries no encoding field, a session negotiated as `utf-8` would put tsgo's
430
+ // half of each response on byte columns and ttsc's half on UTF-16 columns.
431
+ //
432
+ // ttscserver therefore settles the negotiation instead of tracking it, and
433
+ // constrains the session to UTF-16 (see constrainInitializePositionEncoding).
434
+ // UTF-16 is the LSP default and the encoding every conforming client supports,
435
+ // so no client loses a capability; what is given up is tsgo's byte-oriented fast
436
+ // path, which costs one line scan per converted position inside tsgo.
437
+ const positionEncodingUTF16 = "utf-16"
438
+
439
+ // constrainInitializePositionEncoding narrows the client's position-encoding
440
+ // offer to UTF-16 before the initialize request is forwarded upstream, so the
441
+ // encoding tsgo selects — and therefore the one it advertises back through the
442
+ // proxy to the client — is the encoding every ttsc-owned conversion counts.
443
+ //
444
+ // It is deliberately a no-op for any envelope that is not an initialize request,
445
+ // and for any offer that names no encoding other than UTF-16: an absent, null,
446
+ // empty, or UTF-16-only list already means UTF-16, and rewriting it to say the
447
+ // same thing would change bytes on the wire for nothing. Every other offer has
448
+ // its list replaced rather than filtered, so no third encoding can be selected
449
+ // either.
450
+ func constrainInitializePositionEncoding(env Envelope, body []byte) []byte {
451
+ if env.Method != methodInitialize || !env.IsRequest() || len(env.Params) == 0 {
452
+ return body
453
+ }
454
+ params := map[string]json.RawMessage{}
455
+ if json.Unmarshal(env.Params, &params) != nil {
456
+ return body
457
+ }
458
+ capabilities := map[string]json.RawMessage{}
459
+ if raw, ok := params["capabilities"]; !ok || json.Unmarshal(raw, &capabilities) != nil {
460
+ return body
461
+ }
462
+ general := map[string]json.RawMessage{}
463
+ if raw, ok := capabilities["general"]; !ok || json.Unmarshal(raw, &general) != nil {
464
+ return body
465
+ }
466
+ raw, ok := general["positionEncodings"]
467
+ if !ok {
468
+ return body
469
+ }
470
+ var offered []string
471
+ if json.Unmarshal(raw, &offered) != nil {
472
+ return body
473
+ }
474
+ if !offersEncodingOtherThanUTF16(offered) {
475
+ // Absent, null, empty, and UTF-16-only offers all already mean UTF-16.
476
+ return body
477
+ }
478
+ constrained, err := json.Marshal([]string{positionEncodingUTF16})
479
+ if err != nil {
480
+ return body
481
+ }
482
+ // Re-encode only the subtree that changed: every sibling value stays the
483
+ // client's original bytes, so a large or unusually shaped initialize payload
484
+ // reaches tsgo exactly as it was sent.
485
+ general["positionEncodings"] = constrained
486
+ encodedGeneral, err := json.Marshal(general)
487
+ if err != nil {
488
+ return body
489
+ }
490
+ capabilities["general"] = encodedGeneral
491
+ encodedCapabilities, err := json.Marshal(capabilities)
492
+ if err != nil {
493
+ return body
494
+ }
495
+ params["capabilities"] = encodedCapabilities
496
+ encodedParams, err := json.Marshal(params)
497
+ if err != nil {
498
+ return body
499
+ }
500
+ env.Params = encodedParams
501
+ encoded, err := json.Marshal(env)
502
+ if err != nil {
503
+ return body
504
+ }
505
+ return encoded
506
+ }
507
+
508
+ // offersEncodingOtherThanUTF16 reports whether a client's position-encoding
509
+ // offer could select anything but UTF-16. An absent, null, or empty list already
510
+ // means the LSP default, so those offers are left exactly as the client wrote
511
+ // them rather than rewritten to say the same thing.
512
+ func offersEncodingOtherThanUTF16(offered []string) bool {
513
+ for _, encoding := range offered {
514
+ if encoding != positionEncodingUTF16 {
515
+ return true
516
+ }
517
+ }
518
+ return false
519
+ }
520
+
521
+ // editorRequestedExit reports whether the editor sent the LSP `exit`
522
+ // notification.
523
+ //
524
+ // After `exit` the upstream process is required to terminate, and the status it
525
+ // terminates with is not ttscserver's to report. tsgo makes that distinction
526
+ // load-bearing rather than theoretical: its `exit` handler returns io.EOF, which
527
+ // cancels its dispatch loop, and whether its process ends 0 or 1 depends on
528
+ // whether that cancellation or the closed stdin reaches its errgroup first. The
529
+ // editor sees the same clean quit either way, so ttscserver must not turn one
530
+ // side of that race into a failed server.
531
+ func (p *Proxy) editorRequestedExit() bool {
532
+ return p.editorExit.Load()
533
+ }
534
+
337
535
  func (p *Proxy) rememberInitializeRequest(env Envelope) {
338
536
  key := env.IDKey()
339
537
  if key == "" {
@@ -369,6 +567,10 @@ func (p *Proxy) forgetCancelledRequest(env Envelope) {
369
567
  delete(p.pendingLocalActions, key)
370
568
  delete(p.pendingCommands, key)
371
569
  delete(p.pendingInitialize, key)
570
+ // Completion is cancelled constantly — every keystroke supersedes the last
571
+ // request — so a pending entry that outlived its cancel would leak per
572
+ // character typed.
573
+ delete(p.pendingCompletions, key)
372
574
  }
373
575
 
374
576
  func (p *Proxy) handleCodeActionRequest(env Envelope) (bool, error) {
@@ -938,7 +1140,14 @@ func (p *Proxy) augmentUpstream(env Envelope, body []byte) []byte {
938
1140
  if pendingInitialize {
939
1141
  delete(p.pendingInitialize, key)
940
1142
  }
1143
+ completions, hasCompletions := p.pendingCompletions[key]
1144
+ if hasCompletions {
1145
+ delete(p.pendingCompletions, key)
1146
+ }
941
1147
  p.pendingMu.Unlock()
1148
+ if hasCompletions {
1149
+ return mergeCompletionResponseWithRequest(body, completions)
1150
+ }
942
1151
  if pendingInitialize {
943
1152
  if augmented, augOk := p.augmentInitializeResult(env); augOk {
944
1153
  return augmented
@@ -1021,6 +1230,19 @@ func (p *Proxy) publishPluginDiagnosticsForDidOpen(env Envelope) error {
1021
1230
  return nil
1022
1231
  }
1023
1232
  p.markDocumentClean(params.TextDocument.URI)
1233
+ // A clean open is a disk-snapshot boundary, not merely a diagnostics trigger.
1234
+ // The buffer equalling disk right now says nothing about when the warm Program
1235
+ // and the symbol graph last read that file: the document may have been closed
1236
+ // across a branch switch, a `git pull`, a generator run, or an edit in a second
1237
+ // editor. Refresh both compiler-backed caches for this document before the
1238
+ // asynchronous Diagnostics call below is scheduled, so the published findings
1239
+ // describe the text the editor just opened.
1240
+ //
1241
+ // The dirty branch above deliberately does not invalidate: it publishes an
1242
+ // empty set and reports nothing until the buffer reaches disk, so there is no
1243
+ // stale answer to prevent.
1244
+ p.invalidateSymbolProvider()
1245
+ p.invalidateResidentPluginsForURIs(params.TextDocument.URI)
1024
1246
  generation, projectGeneration := p.nextDiagnosticsGenerations(params.TextDocument.URI)
1025
1247
  go p.publishMergedPluginDiagnostics(params.TextDocument.URI, params.TextDocument.Version, false, generation, projectGeneration)
1026
1248
  return nil
@@ -1285,11 +1507,13 @@ type lspRangeWire struct {
1285
1507
 
1286
1508
  // lspPositionToByteOffset converts an LSP Position into a byte offset into text.
1287
1509
  //
1288
- // LSP Position.character is a UTF-16 code-unit offset (not a byte or rune
1289
- // offset): a rune in the astral planes (>= U+10000) counts as two UTF-16 code
1290
- // units. The walk advances line by line over '\n' and '\r\n' endings to the
1291
- // target line, then advances `character` UTF-16 code units within that line and
1292
- // returns the corresponding byte index.
1510
+ // LSP Position.character is counted in the session's negotiated
1511
+ // PositionEncodingKind, which constrainInitializePositionEncoding pins to UTF-16
1512
+ // for every ttscserver session — so it is a UTF-16 code-unit offset here, not a
1513
+ // byte or rune offset: a rune in the astral planes (>= U+10000) counts as two
1514
+ // UTF-16 code units. The walk advances line by line over '\n' and '\r\n' endings
1515
+ // to the target line, then advances `character` UTF-16 code units within that
1516
+ // line and returns the corresponding byte index.
1293
1517
  //
1294
1518
  // Decision on out-of-range positions: when line/character point past the end of
1295
1519
  // the text the function returns (len, false) rather than clamping — the caller
@@ -1633,6 +1857,27 @@ func (p *Proxy) augmentInitializeResult(env Envelope) ([]byte, bool) {
1633
1857
  caps["executeCommandProvider"] = provider
1634
1858
  changed = true
1635
1859
  }
1860
+ // Merge the plugin's trigger characters into tsgo's completionProvider
1861
+ // rather than replacing it. tsgo already advertises `.`, `"`, `'`, backtick,
1862
+ // `/`, `@`, `<`, and `#`, and that list is what wakes its own completion —
1863
+ // substituting a plugin's would silence the compiler's suggestions to make
1864
+ // room for a rule's. When upstream advertises nothing, synthesize the
1865
+ // provider so the editor asks at all.
1866
+ triggers := p.pluginCompletionTriggerCharacters()
1867
+ // Record what the editor ends up being told before the merge rewrites it, so a
1868
+ // corpus that arrives after this response can tell whether its trigger already
1869
+ // reached the client. Recorded on every initialize, including the one where
1870
+ // the corpus is still empty.
1871
+ p.rememberAdvertisedCompletionTriggers(caps["completionProvider"], triggers)
1872
+ if len(triggers) > 0 {
1873
+ provider, _ := caps["completionProvider"].(map[string]any)
1874
+ if provider == nil {
1875
+ provider = map[string]any{}
1876
+ }
1877
+ provider["triggerCharacters"] = mergeCommandIDs(provider["triggerCharacters"], triggers)
1878
+ caps["completionProvider"] = provider
1879
+ changed = true
1880
+ }
1636
1881
  // Advertise documentFormattingProvider when ttsc owns the document
1637
1882
  // formatter so editors send textDocument/formatting (formatOnSave). The
1638
1883
  // proxy intercepts that method and formats the live buffer, so it forces
@@ -1750,6 +1995,158 @@ func (p *Proxy) pluginCodeActionKinds() []string {
1750
1995
  return nil
1751
1996
  }
1752
1997
 
1998
+ // pluginCompletionHints returns the corpus a plugin published, or nil when the
1999
+ // source does not offer one.
2000
+ //
2001
+ // Optional-interface assertion for the same reason CodeActionKinds uses it: a
2002
+ // PluginSource that predates this — NullPluginSource included — must keep
2003
+ // compiling and must contribute nothing rather than break.
2004
+ func (p *Proxy) pluginCompletionHints() []LSPCompletionHint {
2005
+ type completionHintSource interface {
2006
+ CompletionHints() []LSPCompletionHint
2007
+ }
2008
+ if source, ok := p.source.(completionHintSource); ok {
2009
+ return source.CompletionHints()
2010
+ }
2011
+ return nil
2012
+ }
2013
+
2014
+ // pluginCompletionTriggerCharacters returns the characters that should wake the
2015
+ // editor for a plugin hint.
2016
+ //
2017
+ // The last character of each After is what the user types to reach the hint, so
2018
+ // that is what must be advertised. These are MERGED into whatever tsgo already
2019
+ // advertises, never substituted: tsgo's own list drives its completion, and
2020
+ // replacing it would silence the compiler's suggestions to make room for a
2021
+ // plugin's.
2022
+ func (p *Proxy) pluginCompletionTriggerCharacters() []string {
2023
+ hints := p.pluginCompletionHints()
2024
+ characters := make([]string, 0, len(hints))
2025
+ for _, hint := range hints {
2026
+ if hint.After == "" {
2027
+ continue
2028
+ }
2029
+ trigger, _ := utf8.DecodeLastRuneInString(hint.After)
2030
+ characters = append(characters, string(trigger))
2031
+ }
2032
+ return characters
2033
+ }
2034
+
2035
+ // refreshPluginCompletionHints asks the source to rediscover its corpus after an
2036
+ // editor event that can invalidate it. Optional-interface assertion for the same
2037
+ // reason pluginCompletionHints uses one: a source with no corpus to refresh —
2038
+ // NullPluginSource, or one built before this — is simply unaffected.
2039
+ //
2040
+ // The call returns immediately; the source owns the scheduling, coalescing, and
2041
+ // staleness rules, so the editor's notification is never held behind a plugin.
2042
+ func (p *Proxy) refreshPluginCompletionHints() {
2043
+ type completionHintRefresher interface {
2044
+ RefreshCompletionHints()
2045
+ }
2046
+ if source, ok := p.source.(completionHintRefresher); ok {
2047
+ source.RefreshCompletionHints()
2048
+ }
2049
+ }
2050
+
2051
+ // completionHintsRefreshed runs after the source finishes a corpus refresh.
2052
+ //
2053
+ // Items from the new corpus need nothing here: completion reads the corpus per
2054
+ // request, so a refreshed corpus is live the moment it is stored. Trigger
2055
+ // characters are the exception. They were merged into the initialize response,
2056
+ // which the editor has already consumed, and LSP's only way to change them
2057
+ // afterwards is client/registerCapability — which in VS Code adds a SECOND
2058
+ // completion provider beside the static one instead of amending it, so every
2059
+ // item tsgo returns would be offered twice. Rather than corrupt the compiler's
2060
+ // own list to advertise a character, the proxy says once, per character, that
2061
+ // this one needs a restart. Everything else about the hint already works: the
2062
+ // items appear on Ctrl+Space and after any trigger the editor already knows.
2063
+ func (p *Proxy) completionHintsRefreshed() {
2064
+ for _, character := range p.takeUnadvertisedCompletionTriggers() {
2065
+ p.reportAsyncError(p.writeLateCompletionTriggerNotice(character))
2066
+ }
2067
+ }
2068
+
2069
+ // takeUnadvertisedCompletionTriggers returns the corpus trigger characters the
2070
+ // editor was never told about, marking each as reported so a later refresh that
2071
+ // still carries it stays quiet. Nothing is reported before the editor has the
2072
+ // initialize response, because until then every trigger is still in time.
2073
+ func (p *Proxy) takeUnadvertisedCompletionTriggers() []string {
2074
+ triggers := p.pluginCompletionTriggerCharacters()
2075
+ if len(triggers) == 0 {
2076
+ return nil
2077
+ }
2078
+ p.capabilityMu.Lock()
2079
+ defer p.capabilityMu.Unlock()
2080
+ if !p.initializeAnswered {
2081
+ return nil
2082
+ }
2083
+ var late []string
2084
+ for _, character := range triggers {
2085
+ if _, advertised := p.advertisedCompletionTriggers[character]; advertised {
2086
+ continue
2087
+ }
2088
+ if _, reported := p.reportedCompletionTriggers[character]; reported {
2089
+ continue
2090
+ }
2091
+ if p.reportedCompletionTriggers == nil {
2092
+ p.reportedCompletionTriggers = map[string]struct{}{}
2093
+ }
2094
+ p.reportedCompletionTriggers[character] = struct{}{}
2095
+ late = append(late, character)
2096
+ }
2097
+ return late
2098
+ }
2099
+
2100
+ // rememberAdvertisedCompletionTriggers records the completion trigger set the
2101
+ // editor is about to receive: upstream tsgo's characters plus the ones the
2102
+ // corpus contributed while initialize was in flight. Recorded even when the
2103
+ // corpus added nothing, because that is exactly the session where a later
2104
+ // refresh has something new to say.
2105
+ func (p *Proxy) rememberAdvertisedCompletionTriggers(upstream any, added []string) {
2106
+ advertised := map[string]struct{}{}
2107
+ if provider, ok := upstream.(map[string]any); ok {
2108
+ if existing, ok := provider["triggerCharacters"].([]any); ok {
2109
+ for _, value := range existing {
2110
+ if character, ok := value.(string); ok && character != "" {
2111
+ advertised[character] = struct{}{}
2112
+ }
2113
+ }
2114
+ }
2115
+ }
2116
+ for _, character := range added {
2117
+ if character != "" {
2118
+ advertised[character] = struct{}{}
2119
+ }
2120
+ }
2121
+ p.capabilityMu.Lock()
2122
+ defer p.capabilityMu.Unlock()
2123
+ p.advertisedCompletionTriggers = advertised
2124
+ p.initializeAnswered = true
2125
+ }
2126
+
2127
+ // writeLateCompletionTriggerNotice tells the editor's output channel that one
2128
+ // trigger character arrived too late to be advertised. window/logMessage rather
2129
+ // than window/showMessage: this is a state the user may want to act on, not an
2130
+ // interruption of what they are doing.
2131
+ func (p *Proxy) writeLateCompletionTriggerNotice(character string) error {
2132
+ params, err := json.Marshal(map[string]any{
2133
+ "type": lspMessageTypeInfo,
2134
+ "message": fmt.Sprintf(
2135
+ "ttscserver: a plugin now publishes completion after %q, which was not advertised when this session started. "+
2136
+ "The items are available on explicit completion (Ctrl+Space); restart the language server to have the editor open them on that character.",
2137
+ character,
2138
+ ),
2139
+ })
2140
+ if err != nil {
2141
+ return nil
2142
+ }
2143
+ body, err := json.Marshal(Envelope{JSONRPC: "2.0", Method: methodLogMessage, Params: params})
2144
+ if err != nil {
2145
+ return nil
2146
+ }
2147
+ return p.writeEditorFrame(body)
2148
+ }
2149
+
1753
2150
  func (p *Proxy) setUpstreamCodeActionProvider(value any) {
1754
2151
  provides := true
1755
2152
  if boolValue, ok := value.(bool); ok {
@@ -1813,6 +2210,201 @@ func (p *Proxy) invalidateSymbolProvider() {
1813
2210
  }
1814
2211
  }
1815
2212
 
2213
+ // invalidateResidentPlugins tells a resident plugin source that a document
2214
+ // changed on disk, passing the saved document's URI so the daemon can update
2215
+ // that file incrementally; an unparseable notification falls back to a full
2216
+ // invalidation. Optional-interface assertion for the same reason
2217
+ // pluginCodeActionKinds uses one: a PluginSource without a resident daemon
2218
+ // (NullPluginSource, or one built before this) is simply unaffected.
2219
+ func (p *Proxy) invalidateResidentPlugins(env Envelope) {
2220
+ source, ok := p.residentInvalidator()
2221
+ if !ok {
2222
+ return
2223
+ }
2224
+ var params struct {
2225
+ TextDocument struct {
2226
+ URI string `json:"uri"`
2227
+ } `json:"textDocument"`
2228
+ }
2229
+ if len(env.Params) > 0 && json.Unmarshal(env.Params, &params) == nil && params.TextDocument.URI != "" {
2230
+ source.InvalidateResidentPrograms(params.TextDocument.URI)
2231
+ return
2232
+ }
2233
+ source.InvalidateResidentPrograms()
2234
+ }
2235
+
2236
+ // residentInvalidator is the optional capability a PluginSource exposes to
2237
+ // refresh its warm compiler state. Optional-interface assertion for the same
2238
+ // reason pluginCodeActionKinds uses one: a PluginSource without a resident
2239
+ // daemon (NullPluginSource, or one built before this) is simply unaffected.
2240
+ type residentInvalidator interface {
2241
+ InvalidateResidentPrograms(...string)
2242
+ }
2243
+
2244
+ func (p *Proxy) residentInvalidator() (residentInvalidator, bool) {
2245
+ source, ok := p.source.(residentInvalidator)
2246
+ return source, ok
2247
+ }
2248
+
2249
+ // invalidateResidentPluginsForURIs names the documents that changed on disk so
2250
+ // the daemon updates exactly those files incrementally. Calling it with no URI
2251
+ // is the deliberate full-reload request for a change the host cannot localize.
2252
+ func (p *Proxy) invalidateResidentPluginsForURIs(uris ...string) {
2253
+ if source, ok := p.residentInvalidator(); ok {
2254
+ source.InvalidateResidentPrograms(uris...)
2255
+ }
2256
+ }
2257
+
2258
+ // LSP FileChangeType. Only `changed` names an edit to a file whose place in the
2259
+ // project is unchanged; created and deleted both reshape the root set, which a
2260
+ // per-file UpdateProgram cannot express.
2261
+ const (
2262
+ fileChangeTypeCreated = 1
2263
+ fileChangeTypeChanged = 2
2264
+ fileChangeTypeDeleted = 3
2265
+ )
2266
+
2267
+ // invalidateForWatchedFileChanges refreshes both compiler-backed caches for a
2268
+ // workspace/didChangeWatchedFiles batch, which is the only notification carrying
2269
+ // an on-disk change to a file the editor does not have open.
2270
+ //
2271
+ // A batch the proxy can localize — every entry a plain `changed` event on a file
2272
+ // that is not project configuration — travels as changed URIs so the daemon
2273
+ // re-parses just those files. Anything else drops the warm Program wholesale:
2274
+ // a created or deleted file changes the root set, and a tsconfig/jsconfig edit
2275
+ // changes the compiler options and the file selection, neither of which a
2276
+ // per-file update can express. A batch the proxy cannot decode is treated the
2277
+ // same conservative way, because an unread change is indistinguishable from an
2278
+ // unlocalizable one.
2279
+ //
2280
+ // A deleted document additionally withdraws whatever ttsc last published for it,
2281
+ // because refreshing the Program alone would leave the findings on screen: an
2282
+ // open document's content belongs to the client rather than to disk, so the
2283
+ // compiler has no reason to republish for that URI and trigger the merge that
2284
+ // would replace them.
2285
+ func (p *Proxy) invalidateForWatchedFileChanges(env Envelope) error {
2286
+ var params struct {
2287
+ Changes []struct {
2288
+ URI string `json:"uri"`
2289
+ Type *int `json:"type"`
2290
+ } `json:"changes"`
2291
+ }
2292
+ if len(env.Params) == 0 || json.Unmarshal(env.Params, &params) != nil {
2293
+ p.invalidateSymbolProvider()
2294
+ p.invalidateResidentPluginsForURIs()
2295
+ p.refreshPluginCompletionHints()
2296
+ return nil
2297
+ }
2298
+ if len(params.Changes) == 0 {
2299
+ // The editor reported no change; dropping warm state here would throw away
2300
+ // the residency the daemon exists to provide for nothing. A corpus refresh
2301
+ // is skipped for the same reason and at higher cost: it spawns a sidecar
2302
+ // per plugin, each loading its own Program.
2303
+ return nil
2304
+ }
2305
+ uris := make([]string, 0, len(params.Changes))
2306
+ deleted := make([]string, 0, len(params.Changes))
2307
+ localizable := true
2308
+ for _, change := range params.Changes {
2309
+ if change.URI != "" && change.Type != nil && *change.Type == fileChangeTypeDeleted {
2310
+ deleted = append(deleted, change.URI)
2311
+ }
2312
+ if !watchedFileChangeIsLocalizable(change.URI, change.Type) {
2313
+ localizable = false
2314
+ continue
2315
+ }
2316
+ uris = append(uris, change.URI)
2317
+ }
2318
+ p.invalidateSymbolProvider()
2319
+ if !localizable || len(uris) == 0 {
2320
+ p.invalidateResidentPluginsForURIs()
2321
+ } else {
2322
+ p.invalidateResidentPluginsForURIs(uris...)
2323
+ }
2324
+ // A completion corpus projects the whole Program, so it cannot be invalidated
2325
+ // per URI the way the resident daemon's is: any reported change can change it.
2326
+ p.refreshPluginCompletionHints()
2327
+ for _, uri := range deleted {
2328
+ if err := p.withdrawPluginDiagnosticsForDeletedDocument(uri); err != nil {
2329
+ return err
2330
+ }
2331
+ }
2332
+ return nil
2333
+ }
2334
+
2335
+ // withdrawPluginDiagnosticsForDeletedDocument removes ttsc's findings for a
2336
+ // document that no longer exists on disk and republishes what remains, so a
2337
+ // deleted file cannot keep a plugin squiggle from the last time it was linted.
2338
+ //
2339
+ // Upstream diagnostics are left in place: the compiler owns its own answer for
2340
+ // the same document, and the editor may legitimately keep showing it while the
2341
+ // buffer is still open. Bumping the diagnostic generation discards any in-flight
2342
+ // computation started against the pre-deletion Program. A document ttsc never
2343
+ // published for is a no-op, which is the ordinary case.
2344
+ func (p *Proxy) withdrawPluginDiagnosticsForDeletedDocument(uri string) error {
2345
+ p.writeMu.Lock()
2346
+ defer p.writeMu.Unlock()
2347
+ p.diagnosticsMu.Lock()
2348
+ defer p.diagnosticsMu.Unlock()
2349
+ if len(p.pluginDiagnostics[uri].diagnostics) == 0 {
2350
+ return nil
2351
+ }
2352
+ delete(p.pluginDiagnostics, uri)
2353
+ p.diagnosticGeneration[uri]++
2354
+ return WriteFrame(p.editorOut, p.publishDiagnosticsBody(uri, nil, p.mergedDiagnosticsLocked(uri)))
2355
+ }
2356
+
2357
+ // watchedFileChangeIsLocalizable reports whether one watched-file entry can be
2358
+ // delivered to the resident daemon as a changed URI, rather than forcing it to
2359
+ // drop the warm Program and reload.
2360
+ func watchedFileChangeIsLocalizable(uri string, changeType *int) bool {
2361
+ if uri == "" || changeType == nil {
2362
+ return false
2363
+ }
2364
+ switch *changeType {
2365
+ case fileChangeTypeChanged:
2366
+ // A configuration edit can change the compiler options and the selected root
2367
+ // files at once, which is not a single-file update however it is spelled.
2368
+ return !isProjectConfigURI(uri)
2369
+ case fileChangeTypeCreated, fileChangeTypeDeleted:
2370
+ // The root set moved, which tsgo's per-file UpdateProgram cannot express.
2371
+ return false
2372
+ default:
2373
+ // A FileChangeType outside the enum LSP defines.
2374
+ return false
2375
+ }
2376
+ }
2377
+
2378
+ // isProjectConfigURI reports whether uri names a TypeScript project
2379
+ // configuration file — `tsconfig.json`, `jsconfig.json`, and the
2380
+ // `tsconfig.<name>.json` spellings the VS Code client watches. Such an edit can
2381
+ // change the compiler options and the root file set at once, so it is never
2382
+ // localizable to a single source file.
2383
+ func isProjectConfigURI(uri string) bool {
2384
+ path := uri
2385
+ if parsed, err := url.Parse(uri); err == nil && parsed.Path != "" {
2386
+ path = parsed.Path
2387
+ }
2388
+ name := strings.ToLower(path)
2389
+ if index := strings.LastIndexAny(name, "/\\"); index >= 0 {
2390
+ name = name[index+1:]
2391
+ }
2392
+ if !strings.HasSuffix(name, ".json") {
2393
+ return false
2394
+ }
2395
+ return strings.HasPrefix(name, "tsconfig") || strings.HasPrefix(name, "jsconfig")
2396
+ }
2397
+
2398
+ // shutdownResidentPlugins kills any resident plugin daemons on server teardown.
2399
+ // The children also exit on their own when the parent closes their stdin at
2400
+ // process exit, so this is the graceful path, not the only one.
2401
+ func (p *Proxy) shutdownResidentPlugins() {
2402
+ type residentShutdown interface{ shutdownResidents() }
2403
+ if source, ok := p.source.(residentShutdown); ok {
2404
+ source.shutdownResidents()
2405
+ }
2406
+ }
2407
+
1816
2408
  // capabilityAdvertised reports whether an LSP server-capability value means the
1817
2409
  // server implements the capability: boolean true, or an options object. Absent
1818
2410
  // (nil), false, or any other shape counts as unsupported.