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,304 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "bufio"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "os"
9
+ "os/exec"
10
+ "strings"
11
+ "sync"
12
+ "time"
13
+ )
14
+
15
+ // residentRequestTimeout bounds one resident verb round trip, mirroring the
16
+ // spawn-per-verb path's nativePluginCommandTimeout. A rule that hangs must not
17
+ // wedge the daemon's request mutex forever; on timeout the sidecar is killed and
18
+ // the caller falls back to a fresh spawn.
19
+ const residentRequestTimeout = nativePluginCommandTimeout
20
+
21
+ // serveVerbDiagnostics, serveVerbCodeActions, and serveVerbHints are the verbs
22
+ // routed to the resident daemon: the three that load a Program.
23
+ // lsp-command-ids / lsp-code-action-kinds run once at startup and never load one
24
+ // (routing them would spawn the daemon on the initialize path);
25
+ // lsp-execute-command is user-initiated and keeps its spawn-per-verb path.
26
+ const (
27
+ serveVerbDiagnostics = "lsp-diagnostics"
28
+ serveVerbCodeActions = "lsp-code-actions"
29
+ serveVerbHints = "lsp-hints"
30
+ )
31
+
32
+ // serveClientRequest mirrors linthost's serveLSPRequest: the base project
33
+ // options travel as the daemon's argv, only the per-verb fields per request.
34
+ type serveClientRequest struct {
35
+ Verb string `json:"verb"`
36
+ URI string `json:"uri,omitempty"`
37
+ RangeJSON string `json:"rangeJson,omitempty"`
38
+ ContextJSON string `json:"contextJson,omitempty"`
39
+ Invalidate bool `json:"invalidate,omitempty"`
40
+ Changed []string `json:"changed,omitempty"`
41
+ }
42
+
43
+ // serveClientResponse mirrors linthost's serveLSPResponse: the verb's JSON
44
+ // result verbatim and the exit code the one-shot verb would have returned.
45
+ type serveClientResponse struct {
46
+ Result json.RawMessage `json:"result"`
47
+ Code int `json:"code"`
48
+ }
49
+
50
+ // residentSidecar is one long-lived `@ttsc/lint lsp-serve` child. It answers a
51
+ // serialized stream of verb requests over stdin/stdout, holding a warm Program
52
+ // across them, instead of the source respawning the sidecar per verb.
53
+ type residentSidecar struct {
54
+ mu sync.Mutex
55
+ cmd *exec.Cmd
56
+ stdin io.WriteCloser
57
+ stdout *bufio.Reader
58
+ everServed bool
59
+ // invalidate piggybacks a full "drop the warm Program" onto the next request,
60
+ // set for a change the proxy cannot localize.
61
+ invalidate bool
62
+ // changed piggybacks the document URIs that changed on disk onto the next
63
+ // request, so the daemon updates the warm Program incrementally rather than
64
+ // rebuilding it.
65
+ changed []string
66
+ }
67
+
68
+ // serveRun routes a serve-able verb through the plugin's resident daemon.
69
+ // It returns served=false to tell the caller to fall back to the spawn-per-verb
70
+ // path: a sidecar that predates lsp-serve, a spawn failure, or a pipe that broke
71
+ // mid-session all degrade to exec rather than losing the verb. served=true with
72
+ // an error mirrors a nonzero one-shot exit — the daemon answered and the verb
73
+ // failed — so the caller logs and skips exactly as it does for exec.
74
+ func (s *NativePluginSource) serveRun(plugin NativeLSPPluginEntry, verb string, args []string) ([]byte, bool, error) {
75
+ if s == nil || strings.TrimSpace(plugin.Binary) == "" {
76
+ return nil, false, nil
77
+ }
78
+ s.residentMu.Lock()
79
+ if s.serveUnsupported[plugin.Binary] {
80
+ s.residentMu.Unlock()
81
+ return nil, false, nil
82
+ }
83
+ sc := s.residents[plugin.Binary]
84
+ if sc == nil {
85
+ sc = &residentSidecar{}
86
+ if s.residents == nil {
87
+ s.residents = map[string]*residentSidecar{}
88
+ }
89
+ s.residents[plugin.Binary] = sc
90
+ }
91
+ s.residentMu.Unlock()
92
+
93
+ body, code, err := sc.call(s, plugin, serveRequestFromArgs(verb, args))
94
+ if err != nil {
95
+ // Could not talk to the daemon. If it never once answered, treat lsp-serve
96
+ // as unsupported and stop trying — every sidecar built before this verb
97
+ // rejects it, and retrying would spawn-and-fail per request forever. If it
98
+ // had been answering, the pipe broke; leave it eligible to respawn next
99
+ // call. Either way this call falls back to a fresh spawn so the verb still
100
+ // works.
101
+ s.residentMu.Lock()
102
+ if !sc.everServed {
103
+ if s.serveUnsupported == nil {
104
+ s.serveUnsupported = map[string]bool{}
105
+ }
106
+ s.serveUnsupported[plugin.Binary] = true
107
+ }
108
+ s.residentMu.Unlock()
109
+ return nil, false, nil
110
+ }
111
+ if code != 0 {
112
+ return nil, true, fmt.Errorf("ttscserver: %s %s (resident) exit %d", pluginLabel(plugin), verb, code)
113
+ }
114
+ return body, true, nil
115
+ }
116
+
117
+ // call sends one request to the daemon and reads its reply, serializing access
118
+ // to the single pipe. It spawns the child on first use or after a death, and
119
+ // enforces a per-request timeout so a hung rule cannot wedge the mutex.
120
+ func (sc *residentSidecar) call(s *NativePluginSource, plugin NativeLSPPluginEntry, req serveClientRequest) ([]byte, int, error) {
121
+ sc.mu.Lock()
122
+ defer sc.mu.Unlock()
123
+ if sc.cmd == nil {
124
+ if err := sc.spawn(s, plugin); err != nil {
125
+ return nil, 0, err
126
+ }
127
+ }
128
+ if sc.invalidate {
129
+ req.Invalidate = true
130
+ sc.invalidate = false
131
+ }
132
+ if len(sc.changed) > 0 {
133
+ req.Changed = sc.changed
134
+ sc.changed = nil
135
+ }
136
+ line, err := json.Marshal(req)
137
+ if err != nil {
138
+ return nil, 0, err
139
+ }
140
+ line = append(line, '\n')
141
+ if _, err := sc.stdin.Write(line); err != nil {
142
+ sc.kill()
143
+ return nil, 0, err
144
+ }
145
+
146
+ type readResult struct {
147
+ line []byte
148
+ err error
149
+ }
150
+ done := make(chan readResult, 1)
151
+ go func() {
152
+ raw, err := sc.stdout.ReadBytes('\n')
153
+ done <- readResult{line: raw, err: err}
154
+ }()
155
+ select {
156
+ case <-time.After(residentRequestTimeout):
157
+ sc.kill()
158
+ return nil, 0, fmt.Errorf("ttscserver: %s %s (resident) timed out", pluginLabel(plugin), req.Verb)
159
+ case res := <-done:
160
+ if res.err != nil {
161
+ sc.kill()
162
+ return nil, 0, res.err
163
+ }
164
+ var resp serveClientResponse
165
+ if err := json.Unmarshal(res.line, &resp); err != nil {
166
+ sc.kill()
167
+ return nil, 0, err
168
+ }
169
+ sc.everServed = true
170
+ return resp.Result, resp.Code, nil
171
+ }
172
+ }
173
+
174
+ // spawn starts the resident child with the base project args and the lsp-serve
175
+ // verb; per-verb fields travel per request. The caller holds sc.mu.
176
+ func (sc *residentSidecar) spawn(s *NativePluginSource, plugin NativeLSPPluginEntry) error {
177
+ allArgs := []string{
178
+ "lsp-serve",
179
+ "--cwd=" + s.cwd,
180
+ "--tsconfig=" + s.tsconfig,
181
+ "--plugins-json=" + s.pluginsJSON,
182
+ }
183
+ if plugin.ProjectContextArgs && strings.TrimSpace(s.projectContextJSON) != "" {
184
+ allArgs = append(allArgs, "--project-context-json="+s.projectContextJSON)
185
+ }
186
+ cmd := exec.Command(plugin.Binary, allArgs...)
187
+ cmd.Dir = s.cwd
188
+ cmd.Env = os.Environ()
189
+ // Drain the child's stderr to the source log so its pipe cannot fill and block
190
+ // the child, and so a resident rule panic is still visible.
191
+ cmd.Stderr = residentStderr{s: s, label: pluginLabel(plugin)}
192
+ stdin, err := cmd.StdinPipe()
193
+ if err != nil {
194
+ return err
195
+ }
196
+ stdout, err := cmd.StdoutPipe()
197
+ if err != nil {
198
+ _ = stdin.Close()
199
+ return err
200
+ }
201
+ if err := cmd.Start(); err != nil {
202
+ _ = stdin.Close()
203
+ return err
204
+ }
205
+ sc.cmd = cmd
206
+ sc.stdin = stdin
207
+ sc.stdout = bufio.NewReader(stdout)
208
+ return nil
209
+ }
210
+
211
+ // kill terminates the child and clears its handles so the next call respawns.
212
+ // The caller holds sc.mu.
213
+ func (sc *residentSidecar) kill() {
214
+ if sc.stdin != nil {
215
+ _ = sc.stdin.Close()
216
+ }
217
+ if sc.cmd != nil && sc.cmd.Process != nil {
218
+ _ = sc.cmd.Process.Kill()
219
+ _ = sc.cmd.Wait()
220
+ }
221
+ sc.cmd = nil
222
+ sc.stdin = nil
223
+ sc.stdout = nil
224
+ }
225
+
226
+ // InvalidateResidentPrograms tells every live resident daemon that documents
227
+ // changed on disk, so the next request refreshes the warm Program before
228
+ // serving. Given the changed URIs, the daemon updates those files incrementally;
229
+ // given none, it drops the whole Program (a change the proxy could not
230
+ // localize). It mirrors how the proxy already invalidates the symbol provider on
231
+ // the same editor signals.
232
+ func (s *NativePluginSource) InvalidateResidentPrograms(changedURIs ...string) {
233
+ if s == nil {
234
+ return
235
+ }
236
+ s.residentMu.Lock()
237
+ residents := make([]*residentSidecar, 0, len(s.residents))
238
+ for _, sc := range s.residents {
239
+ residents = append(residents, sc)
240
+ }
241
+ s.residentMu.Unlock()
242
+ for _, sc := range residents {
243
+ sc.mu.Lock()
244
+ if len(changedURIs) > 0 {
245
+ sc.changed = append(sc.changed, changedURIs...)
246
+ } else {
247
+ sc.invalidate = true
248
+ }
249
+ sc.mu.Unlock()
250
+ }
251
+ }
252
+
253
+ // shutdownResidents kills every resident child. Called on server teardown; the
254
+ // children also exit on their own when the parent closes their stdin at process
255
+ // exit, so this is the graceful path, not the only one.
256
+ func (s *NativePluginSource) shutdownResidents() {
257
+ if s == nil {
258
+ return
259
+ }
260
+ s.residentMu.Lock()
261
+ residents := s.residents
262
+ s.residents = map[string]*residentSidecar{}
263
+ s.residentMu.Unlock()
264
+ for _, sc := range residents {
265
+ sc.mu.Lock()
266
+ sc.kill()
267
+ sc.mu.Unlock()
268
+ }
269
+ }
270
+
271
+ // serveRequestFromArgs rebuilds a serve request from the same `--flag=value`
272
+ // argv the spawn-per-verb path passes, so the two transports share one call
273
+ // shape at the source's verb methods.
274
+ func serveRequestFromArgs(verb string, args []string) serveClientRequest {
275
+ req := serveClientRequest{Verb: verb}
276
+ for _, arg := range args {
277
+ switch {
278
+ case strings.HasPrefix(arg, "--uri="):
279
+ req.URI = strings.TrimPrefix(arg, "--uri=")
280
+ case strings.HasPrefix(arg, "--range-json="):
281
+ req.RangeJSON = strings.TrimPrefix(arg, "--range-json=")
282
+ case strings.HasPrefix(arg, "--context-json="):
283
+ req.ContextJSON = strings.TrimPrefix(arg, "--context-json=")
284
+ }
285
+ }
286
+ return req
287
+ }
288
+
289
+ // residentStderr forwards a resident child's stderr to the source log line by
290
+ // line, so it interleaves cleanly with the source's own logging.
291
+ type residentStderr struct {
292
+ s *NativePluginSource
293
+ label string
294
+ }
295
+
296
+ func (w residentStderr) Write(p []byte) (int, error) {
297
+ for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
298
+ if strings.TrimSpace(line) == "" {
299
+ continue
300
+ }
301
+ w.s.log("%s (resident): %s", w.label, line)
302
+ }
303
+ return len(p), nil
304
+ }
@@ -34,15 +34,57 @@ const (
34
34
  LSPDiagnosticSeverityHint LSPDiagnosticSeverity = 4
35
35
  )
36
36
 
37
- // LSPDiagnostic is the minimal subset of the LSP Diagnostic type
38
- // ttscserver injects into outgoing publishDiagnostics. omitempty is set
39
- // on optional fields so the merged JSON stays close to what tsgo emits.
37
+ // LSPDiagnostic is the subset of the LSP Diagnostic type ttscserver injects
38
+ // into outgoing publishDiagnostics. omitempty is set on optional fields so the
39
+ // merged JSON stays close to what tsgo emits.
40
+ //
41
+ // The proxy decodes each sidecar diagnostic into this struct and re-encodes it,
42
+ // so a field absent here is silently dropped on the way to the editor. Every
43
+ // LSP Diagnostic field a producer might set must therefore appear, or the proxy
44
+ // truncates it.
40
45
  type LSPDiagnostic struct {
41
- Range LSPRange `json:"range"`
42
- Severity LSPDiagnosticSeverity `json:"severity,omitempty"`
43
- Code any `json:"code,omitempty"`
44
- Source string `json:"source,omitempty"`
45
- Message string `json:"message"`
46
+ Range LSPRange `json:"range"`
47
+ Severity LSPDiagnosticSeverity `json:"severity,omitempty"`
48
+ Code any `json:"code,omitempty"`
49
+ CodeDescription *LSPCodeDescription `json:"codeDescription,omitempty"`
50
+ // Tags classify the diagnostic (1 = unnecessary, 2 = deprecated). Carried
51
+ // through so a plugin's tag is not silently dropped when the proxy re-encodes
52
+ // the diagnostic — the same truncation codeDescription had to be rescued from.
53
+ Tags []int `json:"tags,omitempty"`
54
+ // Data is opaque state the producer attaches to the diagnostic. The editor
55
+ // preserves it and hands it back on a codeAction request whose context
56
+ // includes this diagnostic, so a rule can recover what it computed without
57
+ // recomputing it. Carried through unread — like the other optional fields, an
58
+ // absent one it did not round-trip would be a silent truncation.
59
+ Data json.RawMessage `json:"data,omitempty"`
60
+ // RelatedInformation are secondary locations the diagnostic points at, each
61
+ // with its own message — the editor renders them as clickable lines under the
62
+ // diagnostic. Carried through so a sidecar's related locations survive the
63
+ // proxy's re-encode, the same truncation the other optional fields had to be
64
+ // rescued from.
65
+ RelatedInformation []LSPDiagnosticRelatedInformation `json:"relatedInformation,omitempty"`
66
+ Source string `json:"source,omitempty"`
67
+ Message string `json:"message"`
68
+ }
69
+
70
+ // LSPDiagnosticRelatedInformation is one entry of a diagnostic's
71
+ // relatedInformation: a secondary location with a message. It reuses the
72
+ // package's LSPLocation (a URI plus a range). The proxy does not read it — it
73
+ // exists so the field is not dropped on re-encode.
74
+ type LSPDiagnosticRelatedInformation struct {
75
+ Location LSPLocation `json:"location"`
76
+ Message string `json:"message"`
77
+ }
78
+
79
+ // LSPCodeDescription is the LSP CodeDescription type: a documentation URL for a
80
+ // diagnostic's Code. Editors render the Code as a link to Href, so a rule name
81
+ // in the Problems panel can lead to the rule's docs. The proxy carries whatever
82
+ // a sidecar supplies; it does not synthesize the URL, because only the producer
83
+ // knows what its own Code values mean. @ttsc/lint derives one per rule family
84
+ // in packages/lint/linthost/rule_docs.go and leaves it unset where no vetted
85
+ // page exists.
86
+ type LSPCodeDescription struct {
87
+ Href string `json:"href"`
46
88
  }
47
89
 
48
90
  // LSPCodeAction is the minimal Code Action shape ttscserver returns from
@@ -150,6 +192,41 @@ type PluginSource interface {
150
192
  // LSP server is hosted without any ttsc plugin pipeline (smoke tests,
151
193
  // docs build). It returns no diagnostics/actions and no command ids so the
152
194
  // proxy still exercises its merge paths even with no plugin activity.
195
+ // LSPCompletionHint is one group of completion items a plugin offers, together
196
+ // with the declarative rule saying where they apply.
197
+ //
198
+ // The rule has to be data rather than a callback because the plugin that
199
+ // produced it is a subprocess that has already exited. Asking it per keystroke
200
+ // would mean a process spawn and a Program reload per character; the corpus
201
+ // therefore travels once and the proxy answers from memory.
202
+ type LSPCompletionHint struct {
203
+ // Scope names the syntactic region the cursor must sit in.
204
+ Scope string `json:"scope"`
205
+ // After is a literal the line prefix must contain. The text following its
206
+ // LAST occurrence is what the editor filters on and what Insert replaces.
207
+ //
208
+ // Deliberately a literal and not a pattern. A regex would be unvalidatable at
209
+ // discovery time, and the one shipped example of plugin-supplied regex driving
210
+ // completion — Tailwind's experimental.classRegex — is a documented source of
211
+ // editor hangs. When several hints match one line, the occurrence nearest the
212
+ // cursor wins; at that occurrence the longest After wins, and only the same
213
+ // trigger merges. That is enough to layer a corpus without hiding a later
214
+ // trigger behind an earlier one.
215
+ After string `json:"after"`
216
+ // Items are offered in slice order; the proxy derives the sort key from it.
217
+ Items []LSPCompletionItem `json:"items"`
218
+ }
219
+
220
+ // LSPCompletionItem is one plugin-contributed completion.
221
+ //
222
+ // Fully resolved on arrival: there is no completionItem/resolve round trip,
223
+ // because resolving would require asking a rule that no longer exists.
224
+ type LSPCompletionItem struct {
225
+ Insert string `json:"insert"`
226
+ Label string `json:"label,omitempty"`
227
+ Detail string `json:"detail,omitempty"`
228
+ }
229
+
153
230
  type NullPluginSource struct{}
154
231
 
155
232
  // Diagnostics returns no plugin diagnostics.
@@ -0,0 +1,82 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "testing"
7
+ )
8
+
9
+ // TestLSPPluginCompletionResolveStaysLocal verifies completion ownership.
10
+ //
11
+ // TypeScript-Go advertises resolve support but dereferences its private data
12
+ // payload for every resolved item. A plugin hint has no such payload and is
13
+ // already complete, so forwarding it upstream can fault the language server.
14
+ //
15
+ // 1. Encode a plugin completion and send that item back as a resolve request.
16
+ // 2. Assert the proxy answers locally with the item unchanged.
17
+ // 3. Assert an upstream-owned item remains unhandled and is still forwarded.
18
+ func TestLSPPluginCompletionResolveStaysLocal(t *testing.T) {
19
+ merged := mergeCompletionResponse(
20
+ []byte(`{"jsonrpc":"2.0","id":1,"result":null}`),
21
+ []LSPCompletionItem{{Insert: "evidence"}},
22
+ )
23
+ var completion struct {
24
+ Result struct {
25
+ Items []json.RawMessage `json:"items"`
26
+ } `json:"result"`
27
+ }
28
+ if err := json.Unmarshal(merged, &completion); err != nil || len(completion.Result.Items) != 1 {
29
+ t.Fatalf("decode plugin completion item: %v\n%s", err, merged)
30
+ }
31
+ item := completion.Result.Items[0]
32
+
33
+ var editor bytes.Buffer
34
+ proxy := &Proxy{editorOut: &editor}
35
+ handled, err := proxy.handleEditorEnvelope(Envelope{
36
+ JSONRPC: "2.0",
37
+ ID: json.RawMessage("7"),
38
+ Method: methodCompletionResolve,
39
+ Params: item,
40
+ }, nil)
41
+ if err != nil {
42
+ t.Fatalf("resolve plugin completion: %v", err)
43
+ }
44
+ if !handled {
45
+ t.Fatal("plugin completion resolve was forwarded upstream")
46
+ }
47
+ _, body, err := NewFrameReader(bytes.NewReader(editor.Bytes())).Read()
48
+ if err != nil {
49
+ t.Fatalf("read local resolve response: %v", err)
50
+ }
51
+ response, err := ParseEnvelope(body)
52
+ if err != nil {
53
+ t.Fatalf("parse local resolve response: %v", err)
54
+ }
55
+ var got any
56
+ var want any
57
+ if err := json.Unmarshal(response.Result, &got); err != nil {
58
+ t.Fatalf("decode local resolve result: %v", err)
59
+ }
60
+ if err := json.Unmarshal(item, &want); err != nil {
61
+ t.Fatalf("decode original plugin item: %v", err)
62
+ }
63
+ gotJSON, _ := json.Marshal(got)
64
+ wantJSON, _ := json.Marshal(want)
65
+ if !bytes.Equal(gotJSON, wantJSON) {
66
+ t.Errorf("local resolve changed the plugin item:\n got %s\nwant %s", gotJSON, wantJSON)
67
+ }
68
+
69
+ editor.Reset()
70
+ handled, err = proxy.handleEditorEnvelope(Envelope{
71
+ JSONRPC: "2.0",
72
+ ID: json.RawMessage("8"),
73
+ Method: methodCompletionResolve,
74
+ Params: json.RawMessage(`{"label":"upstream","data":{"fileName":"/project/main.ts"}}`),
75
+ }, nil)
76
+ if err != nil {
77
+ t.Fatalf("route upstream completion resolve: %v", err)
78
+ }
79
+ if handled || editor.Len() != 0 {
80
+ t.Error("upstream completion resolve was intercepted instead of forwarded")
81
+ }
82
+ }
@@ -0,0 +1,119 @@
1
+ package lspserver
2
+
3
+ import "testing"
4
+
5
+ // TestLSPPositionConversionsSpanAstralAndCombiningRunes verifies that both
6
+ // proxy-side position converters spend a UTF-16 budget, not a byte or rune one,
7
+ // across every width a line can hold.
8
+ //
9
+ // UTF-16 is the encoding ttscserver pins for the whole session
10
+ // (constrainInitializePositionEncoding), so these two helpers are the contract
11
+ // for every feature that maps an editor column onto the cached buffer:
12
+ // incremental didChange splicing and plugin completion. An astral rune is the
13
+ // boundary that separates the three counting schemes at once — four bytes, one
14
+ // rune, two UTF-16 units — and a combining mark is the boundary where two runes
15
+ // render as one grapheme but still cost two units.
16
+ //
17
+ // 1. Convert the column just past the identifier on lines built from ASCII, BMP
18
+ // CJK, an astral rune, and a combining sequence.
19
+ // 2. Assert both converters return the same byte offset for each.
20
+ // 3. Assert the line walk, the end-of-line column, and an out-of-range column.
21
+ func TestLSPPositionConversionsSpanAstralAndCombiningRunes(t *testing.T) {
22
+ cases := []struct {
23
+ name string
24
+ text string
25
+ line int
26
+ character int
27
+ want int
28
+ wantOK bool
29
+ }{
30
+ {
31
+ name: "ascii counts one unit per byte",
32
+ text: "const value = 1;",
33
+ character: 11,
34
+ want: 11,
35
+ wantOK: true,
36
+ },
37
+ {
38
+ name: "bmp cjk is one unit and three bytes",
39
+ text: "const 变量 = 1;",
40
+ character: 8,
41
+ want: 12,
42
+ wantOK: true,
43
+ },
44
+ {
45
+ name: "an astral rune is two units and four bytes",
46
+ text: "const \U0001D499 = 1;",
47
+ character: 8,
48
+ want: 10,
49
+ wantOK: true,
50
+ },
51
+ {
52
+ name: "a combining mark costs its own unit",
53
+ text: "const e\u0301 = 1;",
54
+ character: 8,
55
+ want: 9,
56
+ wantOK: true,
57
+ },
58
+ {
59
+ name: "the column before an astral rune stops before its bytes",
60
+ text: "const \U0001D499 = 1;",
61
+ character: 6,
62
+ want: 6,
63
+ wantOK: true,
64
+ },
65
+ {
66
+ name: "the walk reaches the target line first",
67
+ text: "a\nconst \U0001D499 = 1;",
68
+ line: 1,
69
+ character: 8,
70
+ want: 12,
71
+ wantOK: true,
72
+ },
73
+ {
74
+ name: "the column at end of line is in range",
75
+ text: "const \U0001D499",
76
+ character: 8,
77
+ want: 10,
78
+ wantOK: true,
79
+ },
80
+ }
81
+
82
+ for _, testCase := range cases {
83
+ t.Run(testCase.name, func(t *testing.T) {
84
+ got, ok := lspPositionToByteOffset(testCase.text, lspPositionWire{
85
+ Line: testCase.line,
86
+ Character: testCase.character,
87
+ })
88
+ if got != testCase.want || ok != testCase.wantOK {
89
+ t.Errorf("lspPositionToByteOffset = (%d, %t), want (%d, %t)",
90
+ got, ok, testCase.want, testCase.wantOK)
91
+ }
92
+ completionOffset, completionOK := offsetForPosition(
93
+ testCase.text,
94
+ testCase.line,
95
+ testCase.character,
96
+ )
97
+ if completionOffset != testCase.want || !completionOK {
98
+ t.Errorf("offsetForPosition = (%d, %t), want (%d, true)",
99
+ completionOffset, completionOK, testCase.want)
100
+ }
101
+ })
102
+ }
103
+
104
+ // A column past the end of the line is a cache/editor divergence, not a
105
+ // clamped position: the buffer cache drops its entry rather than splice at a
106
+ // guessed byte.
107
+ if got, ok := lspPositionToByteOffset("const \U0001D499", lspPositionWire{Character: 99}); ok {
108
+ t.Errorf("lspPositionToByteOffset past end = (%d, true), want ok=false", got)
109
+ }
110
+
111
+ // The completion replace range is measured in the same units, so the filter
112
+ // width of a mixed-width prefix must agree with the columns above.
113
+ if units := utf16Length("é\U0001D499"); units != 4 {
114
+ t.Errorf("utf16Length = %d, want 4 (e + combining acute + astral pair)", units)
115
+ }
116
+ if units := utf16Length(""); units != 0 {
117
+ t.Errorf("utf16Length of the empty filter = %d, want 0", units)
118
+ }
119
+ }