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,66 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "reflect"
5
+ "testing"
6
+ )
7
+
8
+ // TestLSPHintsRefreshReplacesTheSessionSnapshot pins the lifecycle the corpus
9
+ // channel shipped without.
10
+ //
11
+ // The first release fetched the corpus once, in a goroutine started by
12
+ // NewNativePluginSource, and every later read was that same slice. A rule
13
+ // enabled after startup, or a contributor index rebuilt from a saved document,
14
+ // could not reach the editor without restarting the language server — which is
15
+ // precisely when a project-derived corpus is most wrong. Each stage below is a
16
+ // state the proxy must serve correctly, including the two that are easy to get
17
+ // backwards: a corpus that shrinks must shrink, and one that never arrived must
18
+ // stay silent rather than fail open.
19
+ //
20
+ // 1. Read the corpus before any producer has answered.
21
+ // 2. Let a producer answer, and read again through the proxy's completion seam.
22
+ // 3. Let the same producer answer differently, then answer with nothing.
23
+ func TestLSPHintsRefreshReplacesTheSessionSnapshot(t *testing.T) {
24
+ plugin := NativeLSPPluginEntry{Binary: "ttsc-lint", Name: "@ttsc/lint"}
25
+ source := &NativePluginSource{plugins: []NativeLSPPluginEntry{plugin}}
26
+ proxy := &Proxy{source: source}
27
+
28
+ if hints := proxy.pluginCompletionHints(); len(hints) != 0 {
29
+ t.Fatalf("a source whose producers have not answered published %d hints", len(hints))
30
+ }
31
+ if triggers := proxy.pluginCompletionTriggerCharacters(); len(triggers) != 0 {
32
+ t.Fatalf("an empty corpus advertised trigger characters %v", triggers)
33
+ }
34
+
35
+ source.storeCompletionHints(plugin, 1, []LSPCompletionHint{
36
+ {Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "param"}}},
37
+ })
38
+ hints := proxy.pluginCompletionHints()
39
+ if len(hints) != 1 || hints[0].After != "@" || len(hints[0].Items) != 1 {
40
+ t.Fatalf("a corpus that arrived after startup did not reach the proxy: %#v", hints)
41
+ }
42
+ if got := proxy.pluginCompletionTriggerCharacters(); !reflect.DeepEqual(got, []string{"@"}) {
43
+ t.Fatalf("trigger characters = %#v, want [\"@\"]", got)
44
+ }
45
+
46
+ // The same producer, re-asked, is the whole point: its answer replaces the
47
+ // previous one rather than accumulating beside it.
48
+ source.storeCompletionHints(plugin, 2, []LSPCompletionHint{
49
+ {Scope: "jsdoc", After: "@evidence ", Items: []LSPCompletionItem{{Insert: "docs/rfc.md"}}},
50
+ })
51
+ hints = proxy.pluginCompletionHints()
52
+ if len(hints) != 1 || hints[0].After != "@evidence " {
53
+ t.Fatalf("a changed corpus did not replace the previous one: %#v", hints)
54
+ }
55
+ if got := proxy.pluginCompletionTriggerCharacters(); !reflect.DeepEqual(got, []string{" "}) {
56
+ t.Fatalf("trigger characters = %#v, want [\" \"] from the new corpus", got)
57
+ }
58
+
59
+ // A rule turned off publishes nothing. Treating an empty successful answer as
60
+ // "keep what you had" would leave a disabled rule's items in the popup for the
61
+ // rest of the session.
62
+ source.storeCompletionHints(plugin, 3, nil)
63
+ if hints := proxy.pluginCompletionHints(); len(hints) != 0 {
64
+ t.Fatalf("a producer that stopped publishing left %d hints behind", len(hints))
65
+ }
66
+ }
@@ -0,0 +1,90 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "io"
5
+ "sync/atomic"
6
+ "testing"
7
+ )
8
+
9
+ // refreshCountingSource records how often the proxy asked for a corpus
10
+ // rediscovery. It counts atomically because didSave also starts an asynchronous
11
+ // diagnostics publication against the same source.
12
+ type refreshCountingSource struct {
13
+ NullPluginSource
14
+ refreshes atomic.Int64
15
+ }
16
+
17
+ func (s *refreshCountingSource) CompletionHints() []LSPCompletionHint { return nil }
18
+
19
+ func (s *refreshCountingSource) RefreshCompletionHints() { s.refreshes.Add(1) }
20
+
21
+ // TestLSPHintsRefreshScheduledByEditorEvents pins which editor notifications
22
+ // rediscover the corpus.
23
+ //
24
+ // The corpus projects what a rule's Check found on the saved project, so the
25
+ // events that can invalidate it are the ones that change that project: a saved
26
+ // document, a config change from the editor, a watched file rewritten outside
27
+ // it. The negative half matters just as much. didChange fires per keystroke and
28
+ // leaves disk untouched, and a refresh spawns a sidecar per plugin, so
29
+ // refreshing there would put a process spawn on the typing path — the one thing
30
+ // this channel must never do.
31
+ //
32
+ // 1. Send each notification that must schedule a refresh.
33
+ // 2. Assert one refresh per notification.
34
+ // 3. Send the notifications that must not, and assert the count is unchanged.
35
+ func TestLSPHintsRefreshScheduledByEditorEvents(t *testing.T) {
36
+ source := &refreshCountingSource{}
37
+ proxy := NewProxy(ProxyOptions{
38
+ EditorOut: io.Discard,
39
+ UpstreamIn: io.Discard,
40
+ Source: source,
41
+ })
42
+
43
+ refreshing := []string{
44
+ `{"jsonrpc":"2.0","method":"textDocument/didSave","params":{"textDocument":{"uri":"file:///a.ts"}}}`,
45
+ `{"jsonrpc":"2.0","method":"workspace/didChangeConfiguration","params":{"settings":{}}}`,
46
+ `{"jsonrpc":"2.0","method":"workspace/didChangeWatchedFiles","params":{"changes":[{"uri":"file:///lint.config.ts","type":2}]}}`,
47
+ // Params the proxy cannot read take the conservative branch: it cannot tell
48
+ // which inputs changed, so it assumes they all did — the same answer it
49
+ // gives the resident daemon there.
50
+ `{"jsonrpc":"2.0","method":"workspace/didChangeWatchedFiles"}`,
51
+ }
52
+ for index, body := range refreshing {
53
+ handleHintRefreshEditorFrame(t, proxy, body)
54
+ if got := source.refreshes.Load(); got != int64(index+1) {
55
+ t.Fatalf("after %s the source had been refreshed %d times, want %d", body, got, index+1)
56
+ }
57
+ }
58
+
59
+ quiet := []string{
60
+ // An editor that reports no change gets no refresh: the proxy already
61
+ // refuses to drop the resident daemon's warm Program for that notification,
62
+ // and a corpus refresh costs a whole Program load per plugin.
63
+ `{"jsonrpc":"2.0","method":"workspace/didChangeWatchedFiles","params":{"changes":[]}}`,
64
+ `{"jsonrpc":"2.0","method":"textDocument/didChange","params":{"textDocument":{"uri":"file:///a.ts","version":2},"contentChanges":[{"text":"const a = 1;"}]}}`,
65
+ `{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///a.ts","languageId":"typescript","version":1,"text":"const a = 1;"}}}`,
66
+ `{"jsonrpc":"2.0","method":"textDocument/didClose","params":{"textDocument":{"uri":"file:///a.ts"}}}`,
67
+ `{"jsonrpc":"2.0","id":7,"method":"textDocument/completion","params":{"textDocument":{"uri":"file:///a.ts"},"position":{"line":0,"character":0}}}`,
68
+ }
69
+ for _, body := range quiet {
70
+ handleHintRefreshEditorFrame(t, proxy, body)
71
+ }
72
+ if got := source.refreshes.Load(); got != int64(len(refreshing)) {
73
+ t.Fatalf(
74
+ "an event that cannot change the saved project refreshed the corpus (%d refreshes, want %d); "+
75
+ "a refresh spawns a sidecar per plugin",
76
+ got, len(refreshing),
77
+ )
78
+ }
79
+ }
80
+
81
+ func handleHintRefreshEditorFrame(t *testing.T, proxy *Proxy, body string) {
82
+ t.Helper()
83
+ env, err := ParseEnvelope([]byte(body))
84
+ if err != nil {
85
+ t.Fatalf("test frame is not a valid envelope: %v", err)
86
+ }
87
+ if _, err := proxy.handleEditorEnvelope(env, []byte(body)); err != nil {
88
+ t.Fatalf("handling %s failed: %v", env.Method, err)
89
+ }
90
+ }
@@ -0,0 +1,91 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "sync"
5
+ "testing"
6
+ )
7
+
8
+ // TestLSPHintsRefreshServesCompletionWhileRunning pins the reader's view during
9
+ // a refresh.
10
+ //
11
+ // Completion is answered from the corpus on the request path while a refresh
12
+ // writes it from a background goroutine, so this is a real concurrent
13
+ // reader/writer pair rather than a theoretical one — the user types while their
14
+ // save is still being re-indexed. Two failures hide here that a single-threaded
15
+ // test cannot see: a data race on the snapshot, and a reader catching the corpus
16
+ // mid-rebuild and offering a truncated list. Run with -race.
17
+ //
18
+ // 1. Seed two producers so a torn snapshot is distinguishable from a whole one.
19
+ // 2. Rewrite both producers' corpora repeatedly while readers ask for them.
20
+ // 3. Assert every snapshot a reader saw was a whole corpus of known hints.
21
+ func TestLSPHintsRefreshServesCompletionWhileRunning(t *testing.T) {
22
+ first := NativeLSPPluginEntry{Binary: "ttsc-lint", Name: "@ttsc/lint"}
23
+ second := NativeLSPPluginEntry{Binary: "ttsc-evidence", Name: "@samchon/evidence"}
24
+ source := &NativePluginSource{plugins: []NativeLSPPluginEntry{first, second}}
25
+ proxy := &Proxy{source: source}
26
+
27
+ corpus := func(plugin NativeLSPPluginEntry, insert string) []LSPCompletionHint {
28
+ after := "@"
29
+ if plugin.Binary == second.Binary {
30
+ after = "@evidence "
31
+ }
32
+ return []LSPCompletionHint{
33
+ {Scope: "jsdoc", After: after, Items: []LSPCompletionItem{{Insert: insert}}},
34
+ }
35
+ }
36
+ source.storeCompletionHints(first, 1, corpus(first, "param"))
37
+ source.storeCompletionHints(second, 1, corpus(second, "docs/rfc.md"))
38
+
39
+ const cycles = 200
40
+ var writers sync.WaitGroup
41
+ writers.Add(2)
42
+ for _, plugin := range []NativeLSPPluginEntry{first, second} {
43
+ go func(plugin NativeLSPPluginEntry) {
44
+ defer writers.Done()
45
+ for generation := uint64(2); generation < cycles; generation++ {
46
+ source.storeCompletionHints(plugin, generation, corpus(plugin, "refreshed"))
47
+ }
48
+ }(plugin)
49
+ }
50
+
51
+ var readers sync.WaitGroup
52
+ failures := make(chan string, 16)
53
+ for reader := 0; reader < 4; reader++ {
54
+ readers.Add(1)
55
+ go func() {
56
+ defer readers.Done()
57
+ for i := 0; i < cycles; i++ {
58
+ hints := proxy.pluginCompletionHints()
59
+ if len(hints) != 2 {
60
+ select {
61
+ case failures <- "a completion request saw a partially rebuilt corpus":
62
+ default:
63
+ }
64
+ return
65
+ }
66
+ if hints[0].After != "@" || hints[1].After != "@evidence " {
67
+ select {
68
+ case failures <- "a completion request saw producers out of manifest order":
69
+ default:
70
+ }
71
+ return
72
+ }
73
+ for _, hint := range hints {
74
+ if len(hint.Items) != 1 || hint.Items[0].Insert == "" {
75
+ select {
76
+ case failures <- "a completion request saw an empty hint group":
77
+ default:
78
+ }
79
+ return
80
+ }
81
+ }
82
+ }
83
+ }()
84
+ }
85
+ writers.Wait()
86
+ readers.Wait()
87
+ close(failures)
88
+ for failure := range failures {
89
+ t.Error(failure)
90
+ }
91
+ }
@@ -0,0 +1,38 @@
1
+ package lspserver
2
+
3
+ import "testing"
4
+
5
+ // TestLSPIndexByteFromBoundsItsSearchStart verifies the line scanner behind
6
+ // offsetForPosition answers identically at and past the edges of the buffer.
7
+ //
8
+ // The hand-rolled loop this replaced started at `from` and compared `from <
9
+ // len(text)` on every step, so an out-of-range start simply fell out of the
10
+ // loop. Slicing before delegating to strings.IndexByte would panic on the same
11
+ // input, and the position path reaches it for a cursor on the last line of a
12
+ // buffer that has no trailing newline.
13
+ //
14
+ // 1. Search from inside, at, and past the end of a buffer.
15
+ // 2. Search from a negative start.
16
+ // 3. Assert every answer is the offset the caller can index with, or -1.
17
+ func TestLSPIndexByteFromBoundsItsSearchStart(t *testing.T) {
18
+ const text = "alpha\nbeta\ngamma"
19
+ for _, entry := range []struct {
20
+ name string
21
+ from int
22
+ want int
23
+ }{
24
+ {"from the start", 0, 5},
25
+ {"past the first newline", 6, 10},
26
+ {"after the last newline", 11, -1},
27
+ {"exactly at the end", len(text), -1},
28
+ {"past the end", len(text) + 8, -1},
29
+ {"negative start", -4, 5},
30
+ } {
31
+ if got := indexByteFrom(text, entry.from, '\n'); got != entry.want {
32
+ t.Fatalf("%s: want %d, got %d", entry.name, entry.want, got)
33
+ }
34
+ }
35
+ if got := indexByteFrom("", 0, '\n'); got != -1 {
36
+ t.Fatalf("empty buffer: want -1, got %d", got)
37
+ }
38
+ }
@@ -0,0 +1,205 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "errors"
7
+ "io"
8
+ "testing"
9
+ )
10
+
11
+ // TestLSPInitializeConstrainsPositionEncodingToUTF16 verifies that the offer
12
+ // forwarded to tsgo can only ever select UTF-16, and that a client which did not
13
+ // offer UTF-8 reaches tsgo byte for byte as before.
14
+ //
15
+ // LSP 3.17 negotiates one PositionEncodingKind per session. The pinned
16
+ // typescript-go selects `utf-8` whenever a client offers it and advertises that
17
+ // choice back through the proxy, while every position ttsc computes itself — the
18
+ // incremental buffer cache, plugin completion, the lint sidecar's ranges, the
19
+ // graph symbol provider — counts UTF-16 code units. ttscserver therefore settles
20
+ // the negotiation at the initialize request instead of tracking it, so the two
21
+ // halves of one editor response cannot disagree.
22
+ //
23
+ // 1. Forward an initialize request offering UTF-8 first and assert the upstream
24
+ // frame offers UTF-16 alone while every sibling field survives verbatim.
25
+ // 2. Assert an offer of UTF-16 alone, an absent offer, and a non-initialize
26
+ // envelope are returned unchanged.
27
+ // 3. Drive the real editor-to-upstream pump and assert the rewrite is what tsgo
28
+ // actually receives.
29
+ func TestLSPInitializeConstrainsPositionEncodingToUTF16(t *testing.T) {
30
+ const offeringUTF8 = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` +
31
+ `{"processId":4242,"rootUri":"file:///project","capabilities":` +
32
+ `{"general":{"markdown":{"parser":"marked"},"positionEncodings":["utf-8","utf-16"]},` +
33
+ `"textDocument":{"completion":{"dynamicRegistration":true}}}}}`
34
+
35
+ rewritten := constrainInitializePositionEncoding(mustParseEnvelope(t, offeringUTF8), []byte(offeringUTF8))
36
+ assertPositionEncodings(t, rewritten, []string{positionEncodingUTF16})
37
+ assertInitializeSiblingsSurvive(t, rewritten)
38
+
39
+ const offeringUTF8Only = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` +
40
+ `{"capabilities":{"general":{"positionEncodings":["utf-8"]}}}}`
41
+ assertPositionEncodings(
42
+ t,
43
+ constrainInitializePositionEncoding(mustParseEnvelope(t, offeringUTF8Only), []byte(offeringUTF8Only)),
44
+ []string{positionEncodingUTF16},
45
+ )
46
+
47
+ const offeringUTF32 = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` +
48
+ `{"capabilities":{"general":{"positionEncodings":["utf-32","utf-8"]}}}}`
49
+ assertPositionEncodings(
50
+ t,
51
+ constrainInitializePositionEncoding(mustParseEnvelope(t, offeringUTF32), []byte(offeringUTF32)),
52
+ []string{positionEncodingUTF16},
53
+ )
54
+
55
+ // Negative twins: a client that never offered UTF-8 must reach tsgo exactly as
56
+ // it wrote its request, so no existing session changes shape.
57
+ unchanged := []struct {
58
+ name string
59
+ body string
60
+ }{
61
+ {
62
+ name: "utf-16 alone is already the settlement",
63
+ body: `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` +
64
+ `{"capabilities":{"general":{"positionEncodings":["utf-16"]}}}}`,
65
+ },
66
+ {
67
+ name: "no offer means the LSP default",
68
+ body: `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` +
69
+ `{"capabilities":{"general":{"markdown":{"parser":"marked"}}}}}`,
70
+ },
71
+ {
72
+ name: "an empty offer already means the LSP default",
73
+ body: `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` +
74
+ `{"capabilities":{"general":{"positionEncodings":[]}}}}`,
75
+ },
76
+ {
77
+ name: "a null offer already means the LSP default",
78
+ body: `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` +
79
+ `{"capabilities":{"general":{"positionEncodings":null}}}}`,
80
+ },
81
+ {
82
+ name: "no general capabilities at all",
83
+ body: `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}`,
84
+ },
85
+ {
86
+ name: "a later request is never rewritten",
87
+ body: `{"jsonrpc":"2.0","id":2,"method":"textDocument/completion","params":` +
88
+ `{"capabilities":{"general":{"positionEncodings":["utf-8"]}}}}`,
89
+ },
90
+ {
91
+ name: "an initialized notification is not the initialize request",
92
+ body: `{"jsonrpc":"2.0","method":"initialize","params":` +
93
+ `{"capabilities":{"general":{"positionEncodings":["utf-8"]}}}}`,
94
+ },
95
+ }
96
+ for _, testCase := range unchanged {
97
+ t.Run(testCase.name, func(t *testing.T) {
98
+ got := constrainInitializePositionEncoding(
99
+ mustParseEnvelope(t, testCase.body),
100
+ []byte(testCase.body),
101
+ )
102
+ if !bytes.Equal(got, []byte(testCase.body)) {
103
+ t.Errorf("forwarded frame was rewritten:\n got %s\nwant %s", got, testCase.body)
104
+ }
105
+ })
106
+ }
107
+
108
+ // The helper is only correct if it sits on the forwarding path, so drive the
109
+ // real pump and read what tsgo would have received.
110
+ var editorIn bytes.Buffer
111
+ if err := WriteFrame(&editorIn, []byte(offeringUTF8)); err != nil {
112
+ t.Fatalf("write editor frame: %v", err)
113
+ }
114
+ var upstream bytes.Buffer
115
+ proxy := NewProxy(ProxyOptions{
116
+ EditorIn: &editorIn,
117
+ EditorOut: io.Discard,
118
+ UpstreamIn: &upstream,
119
+ })
120
+ if err := proxy.pumpEditorToUpstream(t.Context()); !errors.Is(err, ErrFrameClosed) {
121
+ t.Fatalf("pump editor to upstream: %v", err)
122
+ }
123
+ _, forwarded, err := NewFrameReader(bytes.NewReader(upstream.Bytes())).Read()
124
+ if err != nil {
125
+ t.Fatalf("read forwarded initialize: %v", err)
126
+ }
127
+ assertPositionEncodings(t, forwarded, []string{positionEncodingUTF16})
128
+ }
129
+
130
+ func mustParseEnvelope(t *testing.T, body string) Envelope {
131
+ t.Helper()
132
+ env, err := ParseEnvelope([]byte(body))
133
+ if err != nil {
134
+ t.Fatalf("parse envelope %s: %v", body, err)
135
+ }
136
+ return env
137
+ }
138
+
139
+ func assertPositionEncodings(t *testing.T, body []byte, want []string) {
140
+ t.Helper()
141
+ var decoded struct {
142
+ Params struct {
143
+ Capabilities struct {
144
+ General struct {
145
+ PositionEncodings []string `json:"positionEncodings"`
146
+ } `json:"general"`
147
+ } `json:"capabilities"`
148
+ } `json:"params"`
149
+ }
150
+ if err := json.Unmarshal(body, &decoded); err != nil {
151
+ t.Fatalf("decode forwarded initialize: %v\n%s", err, body)
152
+ }
153
+ got := decoded.Params.Capabilities.General.PositionEncodings
154
+ if len(got) != len(want) {
155
+ t.Fatalf("positionEncodings = %v, want %v", got, want)
156
+ }
157
+ for index := range want {
158
+ if got[index] != want[index] {
159
+ t.Fatalf("positionEncodings = %v, want %v", got, want)
160
+ }
161
+ }
162
+ }
163
+
164
+ func assertInitializeSiblingsSurvive(t *testing.T, body []byte) {
165
+ t.Helper()
166
+ var decoded struct {
167
+ ID json.RawMessage `json:"id"`
168
+ Method string `json:"method"`
169
+ Params struct {
170
+ ProcessID int `json:"processId"`
171
+ RootURI string `json:"rootUri"`
172
+ Capabilities struct {
173
+ General struct {
174
+ Markdown struct {
175
+ Parser string `json:"parser"`
176
+ } `json:"markdown"`
177
+ } `json:"general"`
178
+ TextDocument struct {
179
+ Completion struct {
180
+ DynamicRegistration bool `json:"dynamicRegistration"`
181
+ } `json:"completion"`
182
+ } `json:"textDocument"`
183
+ } `json:"capabilities"`
184
+ } `json:"params"`
185
+ }
186
+ if err := json.Unmarshal(body, &decoded); err != nil {
187
+ t.Fatalf("decode rewritten initialize: %v\n%s", err, body)
188
+ }
189
+ if string(decoded.ID) != "1" || decoded.Method != methodInitialize {
190
+ t.Errorf("envelope identity changed: id=%s method=%q", decoded.ID, decoded.Method)
191
+ }
192
+ if decoded.Params.ProcessID != 4242 {
193
+ t.Errorf("processId = %d, want 4242 (re-encoded, not preserved)", decoded.Params.ProcessID)
194
+ }
195
+ if decoded.Params.RootURI != "file:///project" {
196
+ t.Errorf("rootUri = %q, want file:///project", decoded.Params.RootURI)
197
+ }
198
+ if decoded.Params.Capabilities.General.Markdown.Parser != "marked" {
199
+ t.Errorf("general.markdown.parser = %q, want marked",
200
+ decoded.Params.Capabilities.General.Markdown.Parser)
201
+ }
202
+ if !decoded.Params.Capabilities.TextDocument.Completion.DynamicRegistration {
203
+ t.Error("textDocument.completion.dynamicRegistration was dropped")
204
+ }
205
+ }
@@ -0,0 +1,105 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "bytes"
5
+ "strings"
6
+ "sync"
7
+ "testing"
8
+ )
9
+
10
+ // mutableCompletionHintSource stands in for a plugin source whose corpus changes
11
+ // mid-session. The lock is real: the proxy reads the corpus from the completion
12
+ // path while a refresh rewrites it.
13
+ type mutableCompletionHintSource struct {
14
+ NullPluginSource
15
+ mu sync.RWMutex
16
+ hints []LSPCompletionHint
17
+ }
18
+
19
+ func (s *mutableCompletionHintSource) CompletionHints() []LSPCompletionHint {
20
+ s.mu.RLock()
21
+ defer s.mu.RUnlock()
22
+ return append([]LSPCompletionHint(nil), s.hints...)
23
+ }
24
+
25
+ func (s *mutableCompletionHintSource) publish(hints ...LSPCompletionHint) {
26
+ s.mu.Lock()
27
+ defer s.mu.Unlock()
28
+ s.hints = hints
29
+ }
30
+
31
+ // TestLSPLateCompletionTriggerIsReportedOnce pins the one thing a corpus refresh
32
+ // still cannot deliver on its own.
33
+ //
34
+ // Trigger characters are merged into the initialize response, and the editor has
35
+ // already consumed it by the time a refresh runs. LSP's only remedy is
36
+ // client/registerCapability, which VS Code implements by adding a second
37
+ // completion provider beside the static one — every item tsgo returns would then
38
+ // be offered twice. Corrupting the compiler's list to advertise one character is
39
+ // the worse trade, so the proxy states the gap instead of hiding it. Saying it
40
+ // once per character is the whole design: a refresh runs on every save, and a
41
+ // notice repeated per save would be noise the user learns to ignore.
42
+ //
43
+ // 1. Answer initialize with tsgo's trigger characters and no corpus.
44
+ // 2. Publish a corpus whose trigger is new and refresh; expect one notice.
45
+ // 3. Refresh again, and publish a trigger tsgo already advertised; expect none.
46
+ func TestLSPLateCompletionTriggerIsReportedOnce(t *testing.T) {
47
+ var editor bytes.Buffer
48
+ source := &mutableCompletionHintSource{}
49
+ proxy := NewProxy(ProxyOptions{EditorOut: &editor, Source: source})
50
+
51
+ // Before initialize nothing is late: the corpus is still in time to be merged
52
+ // into the response the editor has not received yet.
53
+ source.publish(LSPCompletionHint{Scope: "jsdoc", After: "@evidence ", Items: []LSPCompletionItem{{Insert: "docs/rfc.md"}}})
54
+ proxy.completionHintsRefreshed()
55
+ if editor.Len() != 0 {
56
+ t.Fatalf("a corpus discovered before initialize was reported as late:\n%s", editor.String())
57
+ }
58
+
59
+ source.publish()
60
+ env, err := ParseEnvelope([]byte(`{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"completionProvider":{"triggerCharacters":["@","."]}}}}`))
61
+ if err != nil {
62
+ t.Fatalf("initialize fixture is not a valid envelope: %v", err)
63
+ }
64
+ proxy.augmentInitializeResult(env)
65
+
66
+ source.publish(LSPCompletionHint{Scope: "jsdoc", After: "@evidence ", Items: []LSPCompletionItem{{Insert: "docs/rfc.md"}}})
67
+ proxy.completionHintsRefreshed()
68
+ notices := logMessageNotices(editor.String())
69
+ if len(notices) != 1 {
70
+ t.Fatalf("a trigger character discovered after initialize produced %d notices, want 1:\n%s", len(notices), editor.String())
71
+ }
72
+ // The trigger is the LAST rune of After, a space here, and the notice quotes
73
+ // it so an invisible character is still identifiable in the output channel.
74
+ if !strings.Contains(notices[0], `\" \"`) {
75
+ t.Errorf("the notice does not name the trigger character it is about:\n%s", notices[0])
76
+ }
77
+ if !strings.Contains(notices[0], "restart") {
78
+ t.Errorf("the notice does not say what the user has to do:\n%s", notices[0])
79
+ }
80
+
81
+ editor.Reset()
82
+ proxy.completionHintsRefreshed()
83
+ if editor.Len() != 0 {
84
+ t.Fatalf("the same late trigger was reported again on the next refresh:\n%s", editor.String())
85
+ }
86
+
87
+ // A trigger tsgo already advertises is not late at all — the editor is already
88
+ // asking for completion on it, so the hint works with no restart.
89
+ source.publish(LSPCompletionHint{Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "param"}}})
90
+ proxy.completionHintsRefreshed()
91
+ if editor.Len() != 0 {
92
+ t.Fatalf("a trigger character upstream already advertises was reported as late:\n%s", editor.String())
93
+ }
94
+ }
95
+
96
+ // logMessageNotices extracts the window/logMessage frames written to the editor.
97
+ func logMessageNotices(stream string) []string {
98
+ var notices []string
99
+ for _, chunk := range strings.Split(stream, "\r\n\r\n") {
100
+ if strings.Contains(chunk, `"window/logMessage"`) {
101
+ notices = append(notices, chunk)
102
+ }
103
+ }
104
+ return notices
105
+ }
@@ -0,0 +1,66 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "reflect"
5
+ "testing"
6
+ )
7
+
8
+ // TestNativeCompletionHintsDecodeFlatLintWire verifies the real lint boundary.
9
+ //
10
+ // @ttsc/lint publishes a flat []rule.Hint rather than the proxy's grouped
11
+ // matching shape. Repeated triggers must coalesce without moving the group or
12
+ // reordering its items, because slice order is the publisher's ranking channel.
13
+ //
14
+ // 1. Decode interleaved flat hints using the exact public rule.Hint JSON shape.
15
+ // 2. Assert first-trigger group order and per-trigger item order are preserved.
16
+ // 3. Feed the decoded corpus to the live matcher and verify it is offerable.
17
+ func TestNativeCompletionHintsDecodeFlatLintWire(t *testing.T) {
18
+ body := []byte(`[
19
+ {
20
+ "insert":"docs/stable.md",
21
+ "label":"stable",
22
+ "detail":"pinned document",
23
+ "trigger":{"scope":"jsdoc","after":"@evidence "}
24
+ },
25
+ {
26
+ "insert":"param",
27
+ "detail":"JSDoc tag",
28
+ "trigger":{"scope":"jsdoc","after":"@"}
29
+ },
30
+ {
31
+ "insert":"docs/derived.md",
32
+ "label":"derived",
33
+ "trigger":{"scope":"jsdoc","after":"@evidence "}
34
+ }
35
+ ]`)
36
+
37
+ got, err := decodeNativeCompletionHints(body)
38
+ if err != nil {
39
+ t.Fatalf("decode flat lint hint wire: %v", err)
40
+ }
41
+ want := []LSPCompletionHint{
42
+ {
43
+ Scope: "jsdoc",
44
+ After: "@evidence ",
45
+ Items: []LSPCompletionItem{
46
+ {Insert: "docs/stable.md", Label: "stable", Detail: "pinned document"},
47
+ {Insert: "docs/derived.md", Label: "derived"},
48
+ },
49
+ },
50
+ {
51
+ Scope: "jsdoc",
52
+ After: "@",
53
+ Items: []LSPCompletionItem{
54
+ {Insert: "param", Detail: "JSDoc tag"},
55
+ },
56
+ },
57
+ }
58
+ if !reflect.DeepEqual(got, want) {
59
+ t.Fatalf("decoded flat hints:\n got %#v\nwant %#v", got, want)
60
+ }
61
+
62
+ items, filter := matchCompletionHints(got, " * @evidence docs/", true)
63
+ if filter != "docs/" || !reflect.DeepEqual(items, want[0].Items) {
64
+ t.Errorf("decoded corpus did not reach matcher: filter=%q items=%#v", filter, items)
65
+ }
66
+ }