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,108 @@
1
+ package lspserver
2
+
3
+ import "testing"
4
+
5
+ // TestLSPCompletionScopeIgnoresJSDocBytesInLiterals pins the lexical table the
6
+ // JSDoc scope decision rests on.
7
+ //
8
+ // `/**` is three ordinary bytes. A string, a template, a regex class, and a line
9
+ // comment can all contain them, and a backward search for the nearest opener
10
+ // calls every one of those positions a doc comment — which is how
11
+ // `const example = "/** @par"` came to be offered JSDoc tag completions. Every
12
+ // impostor below is a position that search answered wrongly; every real block
13
+ // below is what stops a replacement from passing by refusing everything.
14
+ //
15
+ // 1. Put the cursor after JSDoc-shaped bytes in every token kind that can hold
16
+ // them.
17
+ // 2. Assert the scanner's scope and the `cursorInJSDoc` answer derived from it.
18
+ // 3. Keep a real block adjacent to each impostor, so a scanner that simply
19
+ // refused everything could not pass.
20
+ func TestLSPCompletionScopeIgnoresJSDocBytesInLiterals(t *testing.T) {
21
+ cases := []struct {
22
+ name string
23
+ text string
24
+ want lexicalScope
25
+ }{
26
+ {name: "real block", text: "/** @par", want: lexicalScopeJSDoc},
27
+ {name: "multi line block", text: "/**\n * ok\n * @par", want: lexicalScopeJSDoc},
28
+ // The buffer an editor sends on Windows, with prose the rule may cite.
29
+ {name: "crlf block with non ascii prose", text: "/**\r\n * 가격 😀\r\n * @par", want: lexicalScopeJSDoc},
30
+ {name: "closed block", text: "/** ok */ @par", want: lexicalScopeCode},
31
+ {name: "empty block comment", text: "/**/ @par", want: lexicalScopeCode},
32
+ {name: "plain block comment", text: "/* @par", want: lexicalScopeBlockComment},
33
+ {name: "line comment", text: "// /** @par", want: lexicalScopeLineComment},
34
+ // CR ends a line for TypeScript, so a CR-only buffer must not leave the
35
+ // rest of the file inside one `//`.
36
+ {name: "line comment ended by a lone cr", text: "// note\r/** @par", want: lexicalScopeJSDoc},
37
+ {name: "line comment ended by crlf", text: "// note\r\n/** @par", want: lexicalScopeJSDoc},
38
+ {name: "no delimiter", text: "const x = 1; @par", want: lexicalScopeCode},
39
+
40
+ {name: "double quoted string", text: "const x = \"/** @par", want: lexicalScopeString},
41
+ {name: "single quoted string", text: "const x = '/** @par", want: lexicalScopeString},
42
+ {name: "template literal", text: "const x = `/** @par", want: lexicalScopeTemplate},
43
+ // The interpolation returns to code, so the template text after it is still
44
+ // template text.
45
+ {name: "template after interpolation", text: "const x = `${ y } /** @par", want: lexicalScopeTemplate},
46
+ {name: "string inside interpolation", text: "const x = `${ \"/** @par", want: lexicalScopeString},
47
+ {name: "nested template inside interpolation", text: "const x = `${ `inner /** @par", want: lexicalScopeTemplate},
48
+ // A comment inside an interpolation is a real comment: nesting must not be
49
+ // answered by refusing everything under a backtick.
50
+ {name: "block comment inside interpolation", text: "const x = `${ /** @par", want: lexicalScopeJSDoc},
51
+ // An escaped quote does not end the string.
52
+ {name: "escaped quote inside string", text: "const x = 'it\\'s /** @par", want: lexicalScopeString},
53
+ // An escaped backslash does end it, so the block that follows is real.
54
+ {name: "escaped backslash ends the string", text: "const x = \"a\\\\\" /** @par", want: lexicalScopeJSDoc},
55
+ // A backslash before CRLF is one line continuation, so the string keeps
56
+ // going and the line-end recovery must not fire on that `\n`.
57
+ {name: "escaped crlf continues the string", text: "const s = \"a\\\r\nb /** @par", want: lexicalScopeString},
58
+ {name: "block after a string holding the opener", text: "const x = \"/**\";\n/** @par", want: lexicalScopeJSDoc},
59
+ // A `*/` inside a string closes nothing, so the block after it still opens.
60
+ {name: "block after a string holding the terminator", text: "const x = \"*/\";\n/** @par", want: lexicalScopeJSDoc},
61
+ // The mirror image: a quote inside a doc comment is prose, not a string.
62
+ {name: "quote inside a block", text: "/** the user's @par", want: lexicalScopeJSDoc},
63
+ // A live buffer is usually invalid mid-edit. Without line-end recovery one
64
+ // stray quote would hide every doc comment below it.
65
+ {name: "unterminated string recovers at the line end", text: "const bad = \"oops\nconst y = 1;\n/** @par", want: lexicalScopeJSDoc},
66
+
67
+ {name: "regex class holding the opener", text: "const re = /[/**]/; @par", want: lexicalScopeCode},
68
+ // Without regex skipping the quote in the class would open a string and
69
+ // swallow the real block that follows on the same line.
70
+ {name: "block after a regex holding a quote", text: "const re = /[\"]/; /** @par", want: lexicalScopeJSDoc},
71
+ {name: "regex after a keyword", text: "function f() { return /[\"]/; } /** @par", want: lexicalScopeJSDoc},
72
+ // The negative twin: `/` after a value divides, and reading it as a literal
73
+ // would swallow the block that follows.
74
+ {name: "division is not a regex", text: "const ratio = a / b; /** @par", want: lexicalScopeJSDoc},
75
+ // A keyword is a legal property name, and a member access is a value.
76
+ {name: "keyword as a property name divides", text: "const n = obj.in / 2; /** @par", want: lexicalScopeJSDoc},
77
+ // What follows a completed literal divides, flags or not.
78
+ {name: "division after a flagless regex", text: "const n = /re/ / 2; /** @par", want: lexicalScopeJSDoc},
79
+ }
80
+
81
+ for _, entry := range cases {
82
+ offset := len(entry.text)
83
+ if got := lexicalScopeAt(entry.text, offset); got != entry.want {
84
+ t.Errorf("%s: lexicalScopeAt(%q, %d) = %s, want %s", entry.name, entry.text, offset, got, entry.want)
85
+ }
86
+ wantJSDoc := entry.want == lexicalScopeJSDoc
87
+ if got := cursorInJSDoc(entry.text, offset); got != wantJSDoc {
88
+ t.Errorf("%s: cursorInJSDoc(%q, %d) = %v, want %v", entry.name, entry.text, offset, got, wantJSDoc)
89
+ }
90
+ }
91
+
92
+ // A cursor before the opener is outside the block the opener will start.
93
+ if got := lexicalScopeAt("/** @par", 0); got != lexicalScopeCode {
94
+ t.Errorf("a cursor before the opener is in %s, want code", got)
95
+ }
96
+ // A cursor inside a terminated regex literal is inside the literal, not in
97
+ // the comment its bytes spell. Offset 15 is the second `*` of the character
98
+ // class, the byte a backward search reads as an open doc comment.
99
+ if got := lexicalScopeAt("const re = /[/**]/; @par", 15); got != lexicalScopeRegex {
100
+ t.Errorf("a cursor inside a regex literal is in %s, want regex", got)
101
+ }
102
+
103
+ // Out-of-range offsets stay refusals rather than panics, because the position
104
+ // comes from the editor and the buffer from the proxy's own splice.
105
+ if cursorInJSDoc("/** @par", -1) || cursorInJSDoc("/** @par", 99) {
106
+ t.Error("an out-of-range offset must be refused rather than answered")
107
+ }
108
+ }
@@ -0,0 +1,52 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ type regexScopeCompletionHintSource struct{ NullPluginSource }
10
+
11
+ func (regexScopeCompletionHintSource) CompletionHints() []LSPCompletionHint {
12
+ return []LSPCompletionHint{{
13
+ Scope: "jsdoc",
14
+ After: "@tag",
15
+ Items: []LSPCompletionItem{{Insert: "tag"}},
16
+ }}
17
+ }
18
+
19
+ // TestLSPCompletionScopeRefusesRegexHints verifies the request path never
20
+ // offers JSDoc hints inside a regex while still offering one in a real JSDoc
21
+ // block immediately after executable code.
22
+ func TestLSPCompletionScopeRefusesRegexHints(t *testing.T) {
23
+ const uri = "file:///project/src/main.ts"
24
+ proxy := &Proxy{source: regexScopeCompletionHintSource{}}
25
+ regex := "if /* c */ (ok) /[/** @tag]/.test(value)"
26
+ real := "const value = 1;\n/** @tag"
27
+
28
+ if pending := completionScopePending(proxy, uri, regex, 0, strings.Index(regex, "@tag")+4); len(pending.items) != 0 {
29
+ t.Fatalf("regex completion items = %#v, want none", pending.items)
30
+ }
31
+ if pending := completionScopePending(proxy, uri, real, 1, len("/** @tag")); len(pending.items) != 1 {
32
+ t.Fatalf("real JSDoc completion items = %#v, want one", pending.items)
33
+ }
34
+ }
35
+
36
+ func completionScopePending(
37
+ proxy *Proxy,
38
+ uri string,
39
+ text string,
40
+ line int,
41
+ character int,
42
+ ) pendingCompletionRequest {
43
+ proxy.documentText = map[string]string{uri: text}
44
+ params, _ := json.Marshal(map[string]any{
45
+ "textDocument": map[string]any{"uri": uri},
46
+ "position": map[string]any{
47
+ "line": line,
48
+ "character": character,
49
+ },
50
+ })
51
+ return proxy.completionItemsFor(Envelope{Params: params})
52
+ }
@@ -0,0 +1,24 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "reflect"
5
+ "testing"
6
+ )
7
+
8
+ type unicodeCompletionTriggerSource struct{ NullPluginSource }
9
+
10
+ func (unicodeCompletionTriggerSource) CompletionHints() []LSPCompletionHint {
11
+ return []LSPCompletionHint{
12
+ {Scope: "jsdoc", After: "@"},
13
+ {Scope: "jsdoc", After: "문서/"},
14
+ }
15
+ }
16
+
17
+ func TestLSPCompletionTriggerUsesLastRune(t *testing.T) {
18
+ proxy := &Proxy{source: unicodeCompletionTriggerSource{}}
19
+ got := proxy.pluginCompletionTriggerCharacters()
20
+ want := []string{"@", "/"}
21
+ if !reflect.DeepEqual(got, want) {
22
+ t.Fatalf("trigger characters = %#v, want %#v", got, want)
23
+ }
24
+ }
@@ -0,0 +1,110 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "io"
7
+ "testing"
8
+ )
9
+
10
+ // TestLSPDeletedWatchedFileWithdrawsPluginDiagnostics verifies that deleting a
11
+ // file on disk removes the findings ttsc last published for it, while leaving
12
+ // the compiler's own diagnostics for the same document alone.
13
+ //
14
+ // Dropping the warm Program is not enough on its own. An open document's content
15
+ // belongs to the client rather than to disk, so the compiler has no reason to
16
+ // republish for a URI whose file was deleted underneath it, and without that
17
+ // republish nothing triggers the merge that would replace ttsc's cached set. The
18
+ // user would keep looking at lint findings for a file that no longer exists.
19
+ //
20
+ // 1. Seed one upstream and one plugin diagnostic for a document.
21
+ // 2. Send a watched-file deletion for that document.
22
+ // 3. Assert the republished set keeps the upstream diagnostic and drops the
23
+ // plugin one, and that a plain change publishes nothing by itself.
24
+ func TestLSPDeletedWatchedFileWithdrawsPluginDiagnostics(t *testing.T) {
25
+ const uri = "file:///project/src/gone.ts"
26
+ var editor bytes.Buffer
27
+ proxy := NewProxy(ProxyOptions{
28
+ EditorOut: &editor,
29
+ UpstreamIn: io.Discard,
30
+ Source: NullPluginSource{},
31
+ })
32
+ proxy.rememberUpstreamDiagnostics(uri, nil, []json.RawMessage{
33
+ json.RawMessage(`{"message":"from the compiler"}`),
34
+ })
35
+ proxy.rememberPluginDiagnostics(uri, nil, []LSPDiagnostic{{Message: "from the rule"}})
36
+
37
+ deletion, _ := json.Marshal(map[string]any{
38
+ "changes": []any{map[string]any{"uri": uri, "type": fileChangeTypeDeleted}},
39
+ })
40
+ handled, err := proxy.handleEditorEnvelope(Envelope{
41
+ JSONRPC: "2.0",
42
+ Method: methodDidChangeWatchedFiles,
43
+ Params: deletion,
44
+ }, nil)
45
+ if err != nil {
46
+ t.Fatalf("watched-file deletion: %v", err)
47
+ }
48
+ if handled {
49
+ t.Fatal("watched-file deletion was swallowed instead of forwarded to tsgo")
50
+ }
51
+
52
+ _, body, err := NewFrameReader(bytes.NewReader(editor.Bytes())).Read()
53
+ if err != nil {
54
+ t.Fatalf("read republished diagnostics: %v", err)
55
+ }
56
+ published := decodePublishedDiagnostics(t, body)
57
+ if published.URI != uri {
58
+ t.Fatalf("republished uri = %q, want %q", published.URI, uri)
59
+ }
60
+ if len(published.Diagnostics) != 1 {
61
+ t.Fatalf("republished %d diagnostics, want only the compiler's: %s", len(published.Diagnostics), body)
62
+ }
63
+ if published.Diagnostics[0].Message != "from the compiler" {
64
+ t.Errorf("republished diagnostic = %q, want the compiler's", published.Diagnostics[0].Message)
65
+ }
66
+
67
+ // Negative twin: an ordinary edit withdraws nothing. Its findings are still
68
+ // valid until the sidecar recomputes them, and the ordinary republish path
69
+ // owns that replacement.
70
+ editor.Reset()
71
+ proxy.rememberPluginDiagnostics(uri, nil, []LSPDiagnostic{{Message: "from the rule"}})
72
+ change, _ := json.Marshal(map[string]any{
73
+ "changes": []any{map[string]any{"uri": uri, "type": fileChangeTypeChanged}},
74
+ })
75
+ if _, err := proxy.handleEditorEnvelope(Envelope{
76
+ JSONRPC: "2.0",
77
+ Method: methodDidChangeWatchedFiles,
78
+ Params: change,
79
+ }, nil); err != nil {
80
+ t.Fatalf("watched-file change: %v", err)
81
+ }
82
+ if editor.Len() != 0 {
83
+ t.Errorf("a plain watched-file change republished diagnostics: %s", editor.Bytes())
84
+ }
85
+ }
86
+
87
+ // publishedDiagnostics is the slice of a publishDiagnostics notification this
88
+ // test asserts on: which document it targets and the message of each entry.
89
+ type publishedDiagnostics struct {
90
+ URI string `json:"uri"`
91
+ Diagnostics []struct {
92
+ Message string `json:"message"`
93
+ } `json:"diagnostics"`
94
+ }
95
+
96
+ func decodePublishedDiagnostics(t *testing.T, body []byte) publishedDiagnostics {
97
+ t.Helper()
98
+ env, err := ParseEnvelope(body)
99
+ if err != nil {
100
+ t.Fatalf("parse published frame: %v", err)
101
+ }
102
+ if env.Method != methodPublishDiagnostics {
103
+ t.Fatalf("published method = %q, want %q", env.Method, methodPublishDiagnostics)
104
+ }
105
+ var params publishedDiagnostics
106
+ if err := json.Unmarshal(env.Params, &params); err != nil {
107
+ t.Fatalf("decode published diagnostics: %v\n%s", err, body)
108
+ }
109
+ return params
110
+ }
@@ -0,0 +1,78 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "testing"
7
+ )
8
+
9
+ // incrementalSaveSource records how a save and a dirty edit reach the daemon.
10
+ type incrementalSaveSource struct {
11
+ NullPluginSource
12
+ calls [][]string
13
+ }
14
+
15
+ func (s *incrementalSaveSource) InvalidateResidentPrograms(uris ...string) {
16
+ s.calls = append(s.calls, append([]string(nil), uris...))
17
+ }
18
+
19
+ // TestLSPDidSaveKeepsTheIncrementalChangedURIPath verifies the two boundaries the
20
+ // new open and watched-file invalidations must not erode: a save of a known
21
+ // source still travels as one changed URI, and a didChange on a dirty buffer
22
+ // still sends the resident daemon nothing at all.
23
+ //
24
+ // The resident daemon exists to avoid a cold parse+bind+checker per verb. A save
25
+ // that degraded into a full reload, or an invalidation per keystroke, would trade
26
+ // exactly that away — so widening the set of signals that reach the daemon has to
27
+ // leave these two untouched.
28
+ //
29
+ // 1. Save a document and assert the daemon is told that one URI changed.
30
+ // 2. Send a ranged didChange for the same document.
31
+ // 3. Assert no further resident invalidation was recorded.
32
+ func TestLSPDidSaveKeepsTheIncrementalChangedURIPath(t *testing.T) {
33
+ const uri = "file:///project/src/main.ts"
34
+ plugins := &incrementalSaveSource{}
35
+ proxy := NewProxy(ProxyOptions{
36
+ EditorOut: io.Discard,
37
+ UpstreamIn: io.Discard,
38
+ Source: plugins,
39
+ })
40
+
41
+ saveParams, _ := json.Marshal(map[string]any{
42
+ "textDocument": map[string]any{"uri": uri},
43
+ })
44
+ if _, err := proxy.handleEditorEnvelope(Envelope{
45
+ JSONRPC: "2.0",
46
+ Method: methodDidSave,
47
+ Params: saveParams,
48
+ }, nil); err != nil {
49
+ t.Fatalf("didSave: %v", err)
50
+ }
51
+ if len(plugins.calls) != 1 {
52
+ t.Fatalf("didSave resident invalidations = %v, want exactly one", plugins.calls)
53
+ }
54
+ if len(plugins.calls[0]) != 1 || plugins.calls[0][0] != uri {
55
+ t.Errorf("didSave resident invalidation = %v, want the incremental changed uri %q", plugins.calls[0], uri)
56
+ }
57
+
58
+ changeParams, _ := json.Marshal(map[string]any{
59
+ "textDocument": map[string]any{"uri": uri, "version": 2},
60
+ "contentChanges": []any{map[string]any{
61
+ "range": map[string]any{
62
+ "start": map[string]any{"line": 0, "character": 0},
63
+ "end": map[string]any{"line": 0, "character": 0},
64
+ },
65
+ "text": "x",
66
+ }},
67
+ })
68
+ if _, err := proxy.handleEditorEnvelope(Envelope{
69
+ JSONRPC: "2.0",
70
+ Method: methodDidChange,
71
+ Params: changeParams,
72
+ }, nil); err != nil {
73
+ t.Fatalf("didChange: %v", err)
74
+ }
75
+ if len(plugins.calls) != 1 {
76
+ t.Errorf("didChange on a dirty buffer reached the resident daemon: calls = %v", plugins.calls)
77
+ }
78
+ }
@@ -0,0 +1,46 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "bytes"
5
+ "testing"
6
+ )
7
+
8
+ // TestLSPHintsAbsenceIsNotAnError pins that a plugin without the hint verb is
9
+ // silent, not broken.
10
+ //
11
+ // Every plugin built before this channel existed rejects `lsp-hints` as an
12
+ // unknown command, and that is permanent and universal rather than a
13
+ // transitional state. Treating it as a discovery failure would print an error
14
+ // per plugin per editor session, forever, for a feature those plugins were never
15
+ // asked to provide. It is the optional-interface reasoning applied to the wire:
16
+ // absence is an answer.
17
+ //
18
+ // A real sidecar that cannot run reaches the same path as one that rejects the
19
+ // verb — both return an error from run — so an unresolvable binary is a faithful
20
+ // stand-in and keeps the test from needing a compiled fixture.
21
+ //
22
+ // 1. Discover hints from a plugin whose sidecar cannot answer.
23
+ // 2. Assert the corpus is empty.
24
+ // 3. Assert nothing was written to the log.
25
+ func TestLSPHintsAbsenceIsNotAnError(t *testing.T) {
26
+ var log bytes.Buffer
27
+ source := &NativePluginSource{
28
+ err: &log,
29
+ plugins: []NativeLSPPluginEntry{
30
+ {Binary: "ttsc-no-such-plugin-binary", Name: "@ttsc/legacy"},
31
+ },
32
+ }
33
+
34
+ source.discoverCompletionHints(1)
35
+
36
+ if hints := source.CompletionHints(); len(hints) != 0 {
37
+ t.Errorf("a plugin that could not answer contributed %d hints", len(hints))
38
+ }
39
+ if log.Len() != 0 {
40
+ t.Errorf(
41
+ "a plugin without the hint verb was logged as a failure:\n%s\n"+
42
+ "every plugin predating this channel would print that every session",
43
+ log.String(),
44
+ )
45
+ }
46
+ }
@@ -0,0 +1,82 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "sync"
5
+ "testing"
6
+ "time"
7
+ )
8
+
9
+ // TestLSPHintsRefreshCoalescesConcurrentRequests pins the scheduler that keeps
10
+ // rediscovery off the editor's hot path.
11
+ //
12
+ // One refresh costs a process spawn and a Program load per plugin, and the
13
+ // events that schedule it arrive in bursts: a multi-file save, a formatter
14
+ // rewriting several documents, a watched config change landing with them. Both
15
+ // naive answers are wrong — running one refresh per event stacks spawns behind
16
+ // each other, and dropping events while one runs leaves the corpus a generation
17
+ // behind the edit that caused it. Exactly one rerun observes the newest state
18
+ // for the cost of one refresh.
19
+ //
20
+ // 1. Hold a run open and schedule several more requests behind it.
21
+ // 2. Release it and assert exactly one rerun followed, with a newer generation.
22
+ // 3. Schedule again once idle and assert the refresher restarts.
23
+ func TestLSPHintsRefreshCoalescesConcurrentRequests(t *testing.T) {
24
+ var refresh coalescingRefresh
25
+ started := make(chan struct{}, 8)
26
+ release := make(chan struct{})
27
+
28
+ var mu sync.Mutex
29
+ var generations []uint64
30
+ task := func(generation uint64) {
31
+ mu.Lock()
32
+ generations = append(generations, generation)
33
+ mu.Unlock()
34
+ started <- struct{}{}
35
+ <-release
36
+ }
37
+
38
+ refresh.schedule(task)
39
+ waitForRefreshRun(t, started, "the first scheduled refresh never ran")
40
+ for i := 0; i < 5; i++ {
41
+ refresh.schedule(task)
42
+ }
43
+ close(release)
44
+ waitForRefreshRun(t, started, "the coalesced requests never produced a rerun")
45
+
46
+ // Nothing else may follow: five requests during one run are one rerun, not
47
+ // five. A stray extra run would show up as a third start.
48
+ select {
49
+ case <-started:
50
+ t.Fatal("coalesced refresh requests ran more than once")
51
+ case <-time.After(200 * time.Millisecond):
52
+ }
53
+
54
+ mu.Lock()
55
+ ran := append([]uint64(nil), generations...)
56
+ mu.Unlock()
57
+ if len(ran) != 2 || ran[0] != 1 || ran[1] != 2 {
58
+ t.Fatalf("refresh generations = %v, want [1 2] — each run needs a newer stamp", ran)
59
+ }
60
+
61
+ // A request that arrives once the refresher is idle starts a fresh run rather
62
+ // than being swallowed by the finished one.
63
+ idle := make(chan uint64, 1)
64
+ refresh.schedule(func(generation uint64) { idle <- generation })
65
+ select {
66
+ case generation := <-idle:
67
+ if generation != 3 {
68
+ t.Fatalf("a refresh scheduled while idle ran as generation %d, want 3", generation)
69
+ }
70
+ case <-time.After(5 * time.Second):
71
+ t.Fatal("a refresh scheduled while idle never ran")
72
+ }
73
+ }
74
+
75
+ func waitForRefreshRun(t *testing.T, started <-chan struct{}, message string) {
76
+ t.Helper()
77
+ select {
78
+ case <-started:
79
+ case <-time.After(5 * time.Second):
80
+ t.Fatal(message)
81
+ }
82
+ }
@@ -0,0 +1,40 @@
1
+ package lspserver
2
+
3
+ import "testing"
4
+
5
+ // TestLSPHintsRefreshIgnoresASupersededGeneration pins the staleness guard.
6
+ //
7
+ // Refresh cycles are driven by editor events, and the answer the user is waiting
8
+ // for is the newest one. A cycle that started earlier can still finish later —
9
+ // its sidecar took longer, or a spawn was slow — and without a generation stamp
10
+ // its result would silently restore the corpus the newer cycle just replaced.
11
+ // The user would then see items for a rule they had already disabled, with no
12
+ // event left to correct it.
13
+ //
14
+ // 1. Store a corpus produced by a newer generation.
15
+ // 2. Store the older generation's result afterwards.
16
+ // 3. Assert the newer corpus stands, and that a still-newer one replaces it.
17
+ func TestLSPHintsRefreshIgnoresASupersededGeneration(t *testing.T) {
18
+ plugin := NativeLSPPluginEntry{Binary: "ttsc-lint", Name: "@ttsc/lint"}
19
+ source := &NativePluginSource{plugins: []NativeLSPPluginEntry{plugin}}
20
+
21
+ source.storeCompletionHints(plugin, 2, []LSPCompletionHint{
22
+ {Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "returns"}}},
23
+ })
24
+ source.storeCompletionHints(plugin, 1, []LSPCompletionHint{
25
+ {Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "stale"}}},
26
+ })
27
+
28
+ hints := source.CompletionHints()
29
+ if len(hints) != 1 || len(hints[0].Items) != 1 || hints[0].Items[0].Insert != "returns" {
30
+ t.Fatalf("an older refresh generation overwrote a newer corpus: %#v", hints)
31
+ }
32
+
33
+ source.storeCompletionHints(plugin, 3, []LSPCompletionHint{
34
+ {Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "param"}}},
35
+ })
36
+ hints = source.CompletionHints()
37
+ if len(hints) != 1 || hints[0].Items[0].Insert != "param" {
38
+ t.Fatalf("the guard rejected a newer generation as well: %#v", hints)
39
+ }
40
+ }
@@ -0,0 +1,64 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "bytes"
5
+ "testing"
6
+ )
7
+
8
+ // TestLSPHintsRefreshKeepsEachProducerIsolated pins the failure boundary between
9
+ // producers.
10
+ //
11
+ // A refresh re-asks every plugin, and plugins fail independently: one sidecar
12
+ // can be mid-rebuild, locked by a virus scanner, or simply gone while the others
13
+ // answer normally. A refresh that rebuilt one shared slice would let that single
14
+ // failure blank a working producer's corpus — the editor would lose completions
15
+ // it had a second ago for a reason unrelated to the rule that published them.
16
+ // The rule is that only a producer's own successful answer changes its corpus.
17
+ //
18
+ // 1. Give two producers a good corpus.
19
+ // 2. Run a whole refresh generation in which neither sidecar can be executed.
20
+ // 3. Assert both corpora survived, in manifest order, with nothing logged.
21
+ // 4. Let only the first producer answer, and assert the second is untouched.
22
+ func TestLSPHintsRefreshKeepsEachProducerIsolated(t *testing.T) {
23
+ var log bytes.Buffer
24
+ first := NativeLSPPluginEntry{Binary: "ttsc-no-such-plugin-binary-a", Name: "@ttsc/lint"}
25
+ second := NativeLSPPluginEntry{Binary: "ttsc-no-such-plugin-binary-b", Name: "@samchon/evidence"}
26
+ source := &NativePluginSource{
27
+ err: &log,
28
+ plugins: []NativeLSPPluginEntry{first, second},
29
+ }
30
+ source.storeCompletionHints(first, 1, []LSPCompletionHint{
31
+ {Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "param"}}},
32
+ })
33
+ source.storeCompletionHints(second, 1, []LSPCompletionHint{
34
+ {Scope: "jsdoc", After: "@evidence ", Items: []LSPCompletionItem{{Insert: "docs/rfc.md"}}},
35
+ })
36
+
37
+ source.discoverCompletionHints(2)
38
+
39
+ hints := source.CompletionHints()
40
+ if len(hints) != 2 || hints[0].After != "@" || hints[1].After != "@evidence " {
41
+ t.Fatalf("a refresh that could reach no producer disturbed the corpus: %#v", hints)
42
+ }
43
+ if log.Len() != 0 {
44
+ t.Errorf(
45
+ "a producer that could not answer was logged as a failure:\n%s\n"+
46
+ "refresh runs per save, so that line would print on every save",
47
+ log.String(),
48
+ )
49
+ }
50
+
51
+ source.storeCompletionHints(first, 3, []LSPCompletionHint{
52
+ {Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "param"}, {Insert: "returns"}}},
53
+ })
54
+ hints = source.CompletionHints()
55
+ if len(hints) != 2 {
56
+ t.Fatalf("one producer's refresh changed the number of published hints: %#v", hints)
57
+ }
58
+ if len(hints[0].Items) != 2 {
59
+ t.Errorf("the refreshed producer still serves its old corpus: %#v", hints[0])
60
+ }
61
+ if hints[1].After != "@evidence " || len(hints[1].Items) != 1 {
62
+ t.Errorf("an unrelated producer's corpus was disturbed: %#v", hints[1])
63
+ }
64
+ }