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.
- package/README.md +7 -5
- package/cmd/graphdump/main.go +2 -4
- package/cmd/ttsc/filter.go +12 -4
- package/cmd/ttsc/filter_resolves_flag_names_case_insensitively_test.go +53 -0
- package/cmd/ttsc/flags_gen.go +3 -3
- package/cmd/ttscgraph/dump.go +5 -7
- package/cmd/ttscgraph/run_dump_emits_only_checker_verified_member_relations_test.go +118 -0
- package/cmd/ttscgraph/serve.go +58 -308
- package/cmd/ttscgraph/serve_session_keeps_base_url_fallback_unchanged_test.go +49 -0
- package/cmd/ttscgraph/serve_session_keeps_esm_directory_candidate_unchanged_test.go +61 -0
- package/cmd/ttscgraph/serve_session_keeps_exports_blocked_file_candidates_unchanged_test.go +64 -0
- package/cmd/ttscgraph/serve_session_keeps_inactive_conditional_export_unchanged_test.go +66 -0
- package/cmd/ttscgraph/serve_session_keeps_lower_priority_module_candidate_unchanged_test.go +67 -0
- package/cmd/ttscgraph/serve_session_keeps_lower_priority_paths_pattern_unchanged_test.go +58 -0
- package/cmd/ttscgraph/serve_session_keeps_nonlongest_root_dirs_candidate_unchanged_test.go +54 -0
- package/cmd/ttscgraph/serve_session_reloads_commonjs_package_entry_for_esm_test.go +50 -0
- package/cmd/ttscgraph/serve_session_reloads_module_suffix_candidate_test.go +41 -0
- package/cmd/ttscgraph/serve_session_reloads_superseding_module_candidates_test.go +216 -0
- package/cmd/ttscgraph/serve_session_retries_failed_dump_test.go +103 -0
- package/driver/graph.go +12 -6
- package/driver/lsp.go +30 -0
- package/driver/program.go +37 -1
- package/driver/resolution_candidates.go +819 -0
- package/driver/windowsjunction/windowsjunction.go +40 -0
- package/go.mod +2 -0
- package/internal/graph/build.go +45 -11
- package/internal/graph/dump.go +312 -108
- package/internal/graph/dump_checkout_paths_are_stable_test.go +210 -0
- package/internal/graph/dump_diagnostics_ride_the_generation_that_built_it_test.go +9 -3
- package/internal/graph/dump_paths.go +172 -0
- package/internal/graph/dump_paths_are_portable_and_injective_test.go +93 -0
- package/internal/graph/dump_schema_version_matches_the_typescript_client_test.go +1 -1
- package/internal/graph/dump_uses_ecma_line_terminators_test.go +87 -0
- package/internal/graph/duplicate_variable_object_members_keep_first_declaration_test.go +61 -0
- package/internal/graph/edges.go +220 -5
- package/internal/graph/enum_members_carry_their_names_on_the_enum_node_test.go +131 -0
- package/internal/graph/graph.go +177 -5
- package/internal/graph/line_model.go +122 -0
- package/internal/graph/literals.go +175 -0
- package/internal/graph/literals_are_absent_when_the_type_admits_more_than_it_can_name_test.go +74 -0
- package/internal/graph/literals_cover_enum_members_the_graph_has_no_nodes_for_test.go +79 -0
- package/internal/graph/literals_do_not_depend_on_declaration_line_wrapping_test.go +84 -0
- package/internal/graph/literals_render_every_value_in_typescript_source_form_test.go +77 -0
- package/internal/graph/literals_resolve_through_alias_indirection_test.go +64 -0
- package/internal/graph/member_relation_edges_follow_checker_validity_test.go +85 -0
- package/internal/graph/member_relation_edges_preserve_checker_boundaries_test.go +191 -0
- package/internal/graph/member_relation_edges_reject_checker_invalid_pairs_test.go +119 -0
- package/internal/graph/node_ids_preserve_hash_bearing_components_test.go +41 -0
- package/internal/graph/node_modifiers_emit_union_strings_test.go +4 -1
- package/internal/graph/object_literal_members_ride_on_the_snapshot_test.go +192 -0
- package/internal/graph/object_member_signatures_preserve_literal_whitespace_test.go +67 -0
- package/internal/graph/object_members.go +229 -0
- package/internal/graph/provenance.go +88 -30
- package/internal/graph/source_texts_cover_every_resident_program_source_test.go +73 -0
- package/internal/graphsymbols/position_helpers_follow_ecma_line_terminators_test.go +66 -0
- package/internal/graphsymbols/provider.go +26 -91
- package/internal/lspserver/lsp_clean_didopen_refreshes_compiler_state_test.go +144 -0
- package/internal/lspserver/lsp_completion.go +109 -0
- package/internal/lspserver/lsp_completion_gate_never_refuses_a_matching_line_test.go +50 -0
- package/internal/lspserver/lsp_completion_hints_longest_trigger_wins_test.go +149 -0
- package/internal/lspserver/lsp_completion_hints_nearest_trigger_wins_test.go +37 -0
- package/internal/lspserver/lsp_completion_merges_into_upstream_response_test.go +149 -0
- package/internal/lspserver/lsp_completion_nearest_hint_replaces_nearest_filter_test.go +43 -0
- package/internal/lspserver/lsp_completion_preserves_completion_list_extensions_test.go +99 -0
- package/internal/lspserver/lsp_completion_refuses_hints_inside_a_string_literal_test.go +69 -0
- package/internal/lspserver/lsp_completion_replaces_full_filter_in_rank_order_test.go +85 -0
- package/internal/lspserver/lsp_completion_request.go +455 -0
- package/internal/lspserver/lsp_completion_scope.go +530 -0
- package/internal/lspserver/lsp_completion_scope_bench_test.go +41 -0
- package/internal/lspserver/lsp_completion_scope_classifies_regex_statement_and_operator_boundaries_test.go +39 -0
- package/internal/lspserver/lsp_completion_scope_ignores_jsdoc_bytes_in_literals_test.go +108 -0
- package/internal/lspserver/lsp_completion_scope_refuses_regex_hints_test.go +52 -0
- package/internal/lspserver/lsp_completion_trigger_uses_last_rune_test.go +24 -0
- package/internal/lspserver/lsp_deleted_watched_file_withdraws_plugin_diagnostics_test.go +110 -0
- package/internal/lspserver/lsp_didsave_keeps_the_incremental_changed_uri_path_test.go +78 -0
- package/internal/lspserver/lsp_hints_absence_is_not_an_error_test.go +46 -0
- package/internal/lspserver/lsp_hints_refresh_coalesces_concurrent_requests_test.go +82 -0
- package/internal/lspserver/lsp_hints_refresh_ignores_a_superseded_generation_test.go +40 -0
- package/internal/lspserver/lsp_hints_refresh_keeps_each_producer_isolated_test.go +64 -0
- package/internal/lspserver/lsp_hints_refresh_replaces_the_session_snapshot_test.go +66 -0
- package/internal/lspserver/lsp_hints_refresh_scheduled_by_editor_events_test.go +90 -0
- package/internal/lspserver/lsp_hints_refresh_serves_completion_while_running_test.go +91 -0
- package/internal/lspserver/lsp_index_byte_from_bounds_its_search_start_test.go +38 -0
- package/internal/lspserver/lsp_initialize_constrains_position_encoding_to_utf16_test.go +205 -0
- package/internal/lspserver/lsp_late_completion_trigger_is_reported_once_test.go +105 -0
- package/internal/lspserver/lsp_native_completion_hints_decode_flat_lint_wire_test.go +66 -0
- package/internal/lspserver/lsp_native_completion_hints_grouped_compatibility_boundary_test.go +60 -0
- package/internal/lspserver/lsp_native_plugin_source.go +315 -2
- package/internal/lspserver/lsp_native_resident.go +304 -0
- package/internal/lspserver/lsp_plugin.go +85 -8
- package/internal/lspserver/lsp_plugin_completion_resolve_stays_local_test.go +82 -0
- package/internal/lspserver/lsp_position_conversions_span_astral_and_combining_runes_test.go +119 -0
- package/internal/lspserver/lsp_proxy.go +601 -9
- package/internal/lspserver/lsp_refresh.go +70 -0
- package/internal/lspserver/lsp_resident_falls_back_when_serve_unsupported_test.go +43 -0
- package/internal/lspserver/lsp_resident_maps_verb_args_test.go +34 -0
- package/internal/lspserver/lsp_server.go +13 -1
- package/internal/lspserver/lsp_watched_file_changes_reach_compiler_caches_test.go +172 -0
- package/lib/TtscCompiler.d.ts +3 -1
- package/lib/TtscCompiler.js +5 -1
- package/lib/TtscCompiler.js.map +1 -1
- package/lib/TtscService.d.ts +13 -3
- package/lib/TtscService.js +6 -6
- package/lib/TtscService.js.map +1 -1
- package/lib/compiler/internal/project/readConfigJson.d.ts +30 -0
- package/lib/compiler/internal/project/readConfigJson.js +208 -0
- package/lib/compiler/internal/project/readConfigJson.js.map +1 -0
- package/lib/compiler/internal/project/readProjectConfig.js +53 -149
- package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
- package/lib/compiler/internal/residentTransformProcess.d.ts +15 -2
- package/lib/compiler/internal/residentTransformProcess.js +159 -39
- package/lib/compiler/internal/residentTransformProcess.js.map +1 -1
- package/lib/compiler/internal/runBuild.d.ts +6 -0
- package/lib/compiler/internal/runBuild.js +143 -44
- package/lib/compiler/internal/runBuild.js.map +1 -1
- package/lib/compiler/internal/startResidentTransform.d.ts +5 -1
- package/lib/compiler/internal/startResidentTransform.js +3 -1
- package/lib/compiler/internal/startResidentTransform.js.map +1 -1
- package/lib/compiler/internal/transformProjectInMemory.js +12 -2
- package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
- package/lib/flags/parser.d.ts +19 -0
- package/lib/flags/parser.js +78 -36
- package/lib/flags/parser.js.map +1 -1
- package/lib/flags/schema.d.ts +49 -11
- package/lib/flags/schema.js +94 -22
- package/lib/flags/schema.js.map +1 -1
- package/lib/internal/assertSafeExplicitCacheDirectory.d.ts +5 -0
- package/lib/internal/assertSafeExplicitCacheDirectory.js +26 -0
- package/lib/internal/assertSafeExplicitCacheDirectory.js.map +1 -0
- package/lib/launcher/internal/runTtsc.js +107 -69
- package/lib/launcher/internal/runTtsc.js.map +1 -1
- package/lib/launcher/internal/runTtsx.js +28 -41
- package/lib/launcher/internal/runTtsx.js.map +1 -1
- package/lib/launcher/internal/runtimeHooks.d.ts +15 -0
- package/lib/launcher/internal/runtimeHooks.js +18 -1
- package/lib/launcher/internal/runtimeHooks.js.map +1 -1
- package/lib/launcher/internal/watchTopology.d.ts +43 -0
- package/lib/launcher/internal/watchTopology.js +360 -0
- package/lib/launcher/internal/watchTopology.js.map +1 -0
- package/lib/plugin/internal/buildSourcePlugin.d.ts +14 -3
- package/lib/plugin/internal/buildSourcePlugin.js +247 -61
- package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
- package/lib/plugin/internal/loadProjectPlugins.js +63 -2
- package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
- package/lib/structures/ITtscCompilerTransformation.d.ts +16 -0
- package/lib/structures/internal/ITtscParsedProjectConfig.d.ts +2 -0
- package/package.json +8 -8
- package/shim/ast/shim.go +48 -3
- package/shim/astnav/go.mod +14 -0
- package/shim/astnav/go.sum +20 -0
- package/shim/astnav/shim.go +15 -0
- package/shim/checker/shim.go +113 -0
- package/shim/core/shim.go +47 -0
- package/shim/diagnosticwriter/lint.go +50 -0
- package/src/TtscCompiler.ts +5 -1
- package/src/TtscService.ts +37 -10
- package/src/compiler/internal/project/readConfigJson.ts +209 -0
- package/src/compiler/internal/project/readProjectConfig.ts +62 -158
- package/src/compiler/internal/residentTransformProcess.ts +197 -39
- package/src/compiler/internal/runBuild.ts +169 -47
- package/src/compiler/internal/startResidentTransform.ts +12 -1
- package/src/compiler/internal/transformProjectInMemory.ts +12 -1
- package/src/flags/parser.ts +113 -42
- package/src/flags/schema.ts +113 -31
- package/src/internal/assertSafeExplicitCacheDirectory.ts +29 -0
- package/src/launcher/internal/runTtsc.ts +117 -68
- package/src/launcher/internal/runTtsx.ts +29 -44
- package/src/launcher/internal/runtimeHooks.ts +30 -2
- package/src/launcher/internal/watchTopology.ts +466 -0
- package/src/plugin/internal/buildSourcePlugin.ts +326 -64
- package/src/plugin/internal/loadProjectPlugins.ts +80 -2
- package/src/structures/ITtscCompilerTransformation.ts +17 -0
- package/src/structures/internal/ITtscParsedProjectConfig.ts +2 -0
- package/tools/shim_audit/baseline.json +0 -3
- package/tools/shim_audit/main.go +42 -14
- package/tools/shim_audit/shim_dir_coverage_test.go +25 -0
- package/utility/flags_gen.go +3 -3
- package/utility/host.go +9 -3
|
@@ -188,7 +188,7 @@ func (pr *Provider) References(uri string, pos lspserver.LSPPosition, includeDec
|
|
|
188
188
|
if e.To != target.ID || isStructuralEdge(e.Kind) {
|
|
189
189
|
continue
|
|
190
190
|
}
|
|
191
|
-
f :=
|
|
191
|
+
f := graph.NodeFile(e.From)
|
|
192
192
|
if f == "" {
|
|
193
193
|
continue
|
|
194
194
|
}
|
|
@@ -196,7 +196,7 @@ func (pr *Provider) References(uri string, pos lspserver.LSPPosition, includeDec
|
|
|
196
196
|
}
|
|
197
197
|
if includeDeclaration {
|
|
198
198
|
declText := sources[target.File]
|
|
199
|
-
add(target.File,
|
|
199
|
+
add(target.File, graph.FirstCodeOffset(declText, target.Pos), target.End)
|
|
200
200
|
}
|
|
201
201
|
return locations, nil
|
|
202
202
|
}
|
|
@@ -241,7 +241,7 @@ func buildSymbol(n *graph.Node, childrenOf map[string][]*graph.Node, text string
|
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
func nodeToSymbol(n *graph.Node, text string) lspserver.LSPDocumentSymbol {
|
|
244
|
-
start :=
|
|
244
|
+
start := graph.FirstCodeOffset(text, n.Pos)
|
|
245
245
|
end := n.End
|
|
246
246
|
if end < start {
|
|
247
247
|
end = start
|
|
@@ -322,7 +322,7 @@ func targetNodeAt(g *graph.Graph, file string, offset int) *graph.Node {
|
|
|
322
322
|
return best
|
|
323
323
|
}
|
|
324
324
|
for _, e := range g.Edges {
|
|
325
|
-
if !pathsEqual(
|
|
325
|
+
if !pathsEqual(graph.NodeFile(e.From), file) {
|
|
326
326
|
continue
|
|
327
327
|
}
|
|
328
328
|
if offset >= e.Pos && offset < e.End {
|
|
@@ -359,15 +359,6 @@ func sortNodes(nodes []*graph.Node) {
|
|
|
359
359
|
})
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
// nodeFile recovers the source file path embedded in a node id
|
|
363
|
-
// ("path#name:kind"); "" for an id without a path.
|
|
364
|
-
func nodeFile(id string) string {
|
|
365
|
-
if hash := strings.Index(id, "#"); hash >= 0 {
|
|
366
|
-
return id[:hash]
|
|
367
|
-
}
|
|
368
|
-
return ""
|
|
369
|
-
}
|
|
370
|
-
|
|
371
362
|
// fileFromURI maps a file:// uri onto the source map key for the same file,
|
|
372
363
|
// returning that key so text lookups by it succeed. Paths are compared
|
|
373
364
|
// case-insensitively so a Windows drive-letter case mismatch between the
|
|
@@ -423,42 +414,10 @@ func uriFromFile(path string) string {
|
|
|
423
414
|
return (&url.URL{Scheme: "file", Path: slashed}).String()
|
|
424
415
|
}
|
|
425
416
|
|
|
426
|
-
// firstCodeOffset advances past leading trivia (whitespace, // and /* */
|
|
427
|
-
// comments) so a declaration span starts at the keyword rather than its doc
|
|
428
|
-
// comment or indentation. It mirrors the graph dump's own trivia skip.
|
|
429
|
-
func firstCodeOffset(text string, pos int) int {
|
|
430
|
-
if pos < 0 {
|
|
431
|
-
return 0
|
|
432
|
-
}
|
|
433
|
-
i := pos
|
|
434
|
-
for i < len(text) {
|
|
435
|
-
switch {
|
|
436
|
-
case text[i] == ' ' || text[i] == '\t' || text[i] == '\r' || text[i] == '\n':
|
|
437
|
-
i++
|
|
438
|
-
case text[i] == '/' && i+1 < len(text) && text[i+1] == '/':
|
|
439
|
-
i += 2
|
|
440
|
-
for i < len(text) && text[i] != '\n' {
|
|
441
|
-
i++
|
|
442
|
-
}
|
|
443
|
-
case text[i] == '/' && i+1 < len(text) && text[i+1] == '*':
|
|
444
|
-
i += 2
|
|
445
|
-
for i+1 < len(text) && !(text[i] == '*' && text[i+1] == '/') {
|
|
446
|
-
i++
|
|
447
|
-
}
|
|
448
|
-
if i+1 < len(text) {
|
|
449
|
-
i += 2
|
|
450
|
-
} else {
|
|
451
|
-
i = len(text)
|
|
452
|
-
}
|
|
453
|
-
default:
|
|
454
|
-
return i
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
return i
|
|
458
|
-
}
|
|
459
|
-
|
|
460
417
|
// offsetToPosition converts a byte offset into an LSP Position (0-based line,
|
|
461
|
-
// UTF-16 code-unit column).
|
|
418
|
+
// UTF-16 code-unit column). The column unit is the session's negotiated
|
|
419
|
+
// PositionEncodingKind, which the proxy's constrainInitializePositionEncoding
|
|
420
|
+
// pins to UTF-16 for every ttscserver session.
|
|
462
421
|
func offsetToPosition(text string, offset int) lspserver.LSPPosition {
|
|
463
422
|
if offset < 0 {
|
|
464
423
|
offset = 0
|
|
@@ -466,19 +425,12 @@ func offsetToPosition(text string, offset int) lspserver.LSPPosition {
|
|
|
466
425
|
if offset > len(text) {
|
|
467
426
|
offset = len(text)
|
|
468
427
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if size == 0 {
|
|
474
|
-
break
|
|
475
|
-
}
|
|
476
|
-
if r == '\n' {
|
|
477
|
-
line++
|
|
478
|
-
lineStart = i + size
|
|
479
|
-
}
|
|
480
|
-
i += size
|
|
428
|
+
starts := graph.ECMALineStarts(text)
|
|
429
|
+
line := sort.Search(len(starts), func(i int) bool { return starts[i] > offset }) - 1
|
|
430
|
+
if line < 0 {
|
|
431
|
+
line = 0
|
|
481
432
|
}
|
|
433
|
+
lineStart := starts[line]
|
|
482
434
|
character := 0
|
|
483
435
|
for i := lineStart; i < offset; {
|
|
484
436
|
r, size := utf8.DecodeRuneInString(text[i:])
|
|
@@ -495,54 +447,37 @@ func offsetToPosition(text string, offset int) lspserver.LSPPosition {
|
|
|
495
447
|
return lspserver.LSPPosition{Line: line, Character: character}
|
|
496
448
|
}
|
|
497
449
|
|
|
498
|
-
// lspPositionToOffset converts an LSP Position (0-based line, UTF-16 column
|
|
499
|
-
//
|
|
500
|
-
//
|
|
450
|
+
// lspPositionToOffset converts an LSP Position (0-based line, UTF-16 column —
|
|
451
|
+
// the encoding the proxy's constrainInitializePositionEncoding pins for every
|
|
452
|
+
// ttscserver session) into a byte offset. It returns (offset, false) when the
|
|
453
|
+
// position points past the end of the text so the caller can treat it as "no
|
|
454
|
+
// symbol here".
|
|
501
455
|
func lspPositionToOffset(text string, pos lspserver.LSPPosition) (int, bool) {
|
|
502
456
|
if pos.Line < 0 || pos.Character < 0 {
|
|
503
457
|
return 0, false
|
|
504
458
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if i >= len(text) {
|
|
509
|
-
return len(text), false
|
|
510
|
-
}
|
|
511
|
-
r, size := utf8.DecodeRuneInString(text[i:])
|
|
512
|
-
if size == 0 {
|
|
513
|
-
return len(text), false
|
|
514
|
-
}
|
|
515
|
-
if r == '\r' {
|
|
516
|
-
i += size
|
|
517
|
-
if i < len(text) && text[i] == '\n' {
|
|
518
|
-
i++
|
|
519
|
-
}
|
|
520
|
-
line++
|
|
521
|
-
continue
|
|
522
|
-
}
|
|
523
|
-
if r == '\n' {
|
|
524
|
-
i += size
|
|
525
|
-
line++
|
|
526
|
-
continue
|
|
527
|
-
}
|
|
528
|
-
i += size
|
|
459
|
+
starts := graph.ECMALineStarts(text)
|
|
460
|
+
if pos.Line >= len(starts) {
|
|
461
|
+
return len(text), false
|
|
529
462
|
}
|
|
463
|
+
i := starts[pos.Line]
|
|
464
|
+
lineEnd := graph.LineEnd(text, starts, pos.Line)
|
|
530
465
|
units := 0
|
|
531
466
|
for units < pos.Character {
|
|
532
|
-
if i >=
|
|
467
|
+
if i >= lineEnd {
|
|
533
468
|
return len(text), false
|
|
534
469
|
}
|
|
535
470
|
r, size := utf8.DecodeRuneInString(text[i:])
|
|
536
471
|
if size == 0 {
|
|
537
472
|
return len(text), false
|
|
538
473
|
}
|
|
539
|
-
if r == '\n' || r == '\r' {
|
|
540
|
-
return i, false
|
|
541
|
-
}
|
|
542
474
|
n := utf16.RuneLen(r)
|
|
543
475
|
if n <= 0 {
|
|
544
476
|
n = 1
|
|
545
477
|
}
|
|
478
|
+
if units+n > pos.Character {
|
|
479
|
+
return i, false
|
|
480
|
+
}
|
|
546
481
|
units += n
|
|
547
482
|
i += size
|
|
548
483
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package lspserver
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// recordingResidentSource is a PluginSource that also implements the resident
|
|
14
|
+
// invalidator, recording every InvalidateResidentPrograms call so a test can
|
|
15
|
+
// assert both that an invalidation happened and how it was localized.
|
|
16
|
+
//
|
|
17
|
+
// A clean open schedules its diagnostics on a goroutine that holds this same
|
|
18
|
+
// source, so the recording is mutex-guarded: the assertions below read it while
|
|
19
|
+
// that goroutine may still be running.
|
|
20
|
+
type recordingResidentSource struct {
|
|
21
|
+
NullPluginSource
|
|
22
|
+
mu sync.Mutex
|
|
23
|
+
calls [][]string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func (s *recordingResidentSource) InvalidateResidentPrograms(uris ...string) {
|
|
27
|
+
s.mu.Lock()
|
|
28
|
+
defer s.mu.Unlock()
|
|
29
|
+
s.calls = append(s.calls, append([]string(nil), uris...))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func (s *recordingResidentSource) recorded() [][]string {
|
|
33
|
+
s.mu.Lock()
|
|
34
|
+
defer s.mu.Unlock()
|
|
35
|
+
return append([][]string(nil), s.calls...)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// recordingSymbolProvider counts Invalidate calls; the two answer methods are
|
|
39
|
+
// never reached by these notification tests. Invalidate is only ever called from
|
|
40
|
+
// the notification path, which is the test's own goroutine.
|
|
41
|
+
type recordingSymbolProvider struct{ invalidations int }
|
|
42
|
+
|
|
43
|
+
func (p *recordingSymbolProvider) DocumentSymbols(string) ([]LSPDocumentSymbol, error) {
|
|
44
|
+
return nil, nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (p *recordingSymbolProvider) References(string, LSPPosition, bool) ([]LSPLocation, error) {
|
|
48
|
+
return nil, nil
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func (p *recordingSymbolProvider) Invalidate() { p.invalidations++ }
|
|
52
|
+
|
|
53
|
+
// testFileURI renders an absolute path as the file URI an editor would send.
|
|
54
|
+
func testFileURI(path string) string {
|
|
55
|
+
return "file:///" + strings.TrimPrefix(filepath.ToSlash(path), "/")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func didOpenEnvelope(uri string, text string) Envelope {
|
|
59
|
+
params, _ := json.Marshal(map[string]any{
|
|
60
|
+
"textDocument": map[string]any{
|
|
61
|
+
"uri": uri,
|
|
62
|
+
"languageId": "typescript",
|
|
63
|
+
"version": 1,
|
|
64
|
+
"text": text,
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
return Envelope{JSONRPC: "2.0", Method: methodDidOpen, Params: params}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// TestLSPCleanDidOpenRefreshesCompilerState verifies that opening a document
|
|
71
|
+
// whose buffer already matches disk refreshes both compiler-backed caches, while
|
|
72
|
+
// opening a dirty buffer still refreshes neither.
|
|
73
|
+
//
|
|
74
|
+
// The resident lint Program and the graph symbol provider stay warm for the
|
|
75
|
+
// whole editor session, and before this only didSave refreshed them. A document
|
|
76
|
+
// closed across a branch switch, a `git pull`, or an edit from a second editor
|
|
77
|
+
// therefore came back with diagnostics computed over the pre-change AST and
|
|
78
|
+
// published against the new buffer. Equality with today's disk is not evidence
|
|
79
|
+
// that the warm Program was built from today's disk, so the clean branch is
|
|
80
|
+
// exactly the branch that must invalidate — and the dirty branch, which reports
|
|
81
|
+
// nothing at all until the buffer reaches disk, must keep not invalidating.
|
|
82
|
+
//
|
|
83
|
+
// 1. Write a file, open it with the same text, and assert both caches were
|
|
84
|
+
// refreshed and the resident refresh named that document's URI.
|
|
85
|
+
// 2. Open a second file with text that differs from disk.
|
|
86
|
+
// 3. Assert neither cache was refreshed and the notification still forwards.
|
|
87
|
+
func TestLSPCleanDidOpenRefreshesCompilerState(t *testing.T) {
|
|
88
|
+
dir := t.TempDir()
|
|
89
|
+
clean := filepath.Join(dir, "clean.ts")
|
|
90
|
+
if err := os.WriteFile(clean, []byte("export const value = 1;\n"), 0o600); err != nil {
|
|
91
|
+
t.Fatalf("write clean source: %v", err)
|
|
92
|
+
}
|
|
93
|
+
cleanURI := testFileURI(clean)
|
|
94
|
+
|
|
95
|
+
plugins := &recordingResidentSource{}
|
|
96
|
+
symbols := &recordingSymbolProvider{}
|
|
97
|
+
proxy := NewProxy(ProxyOptions{
|
|
98
|
+
EditorOut: io.Discard,
|
|
99
|
+
UpstreamIn: io.Discard,
|
|
100
|
+
Source: plugins,
|
|
101
|
+
SymbolProvider: symbols,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
handled, err := proxy.handleEditorEnvelope(
|
|
105
|
+
didOpenEnvelope(cleanURI, "export const value = 1;\n"),
|
|
106
|
+
nil,
|
|
107
|
+
)
|
|
108
|
+
if err != nil {
|
|
109
|
+
t.Fatalf("clean didOpen: %v", err)
|
|
110
|
+
}
|
|
111
|
+
if handled {
|
|
112
|
+
t.Fatal("clean didOpen was answered locally instead of forwarded to tsgo")
|
|
113
|
+
}
|
|
114
|
+
if symbols.invalidations != 1 {
|
|
115
|
+
t.Errorf("clean didOpen symbol invalidations = %d, want 1", symbols.invalidations)
|
|
116
|
+
}
|
|
117
|
+
if len(plugins.recorded()) != 1 {
|
|
118
|
+
t.Fatalf("clean didOpen resident invalidations = %d, want 1", len(plugins.recorded()))
|
|
119
|
+
}
|
|
120
|
+
if len(plugins.recorded()[0]) != 1 || plugins.recorded()[0][0] != cleanURI {
|
|
121
|
+
t.Errorf("clean didOpen resident invalidation = %v, want the opened uri %q", plugins.recorded()[0], cleanURI)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
dirty := filepath.Join(dir, "dirty.ts")
|
|
125
|
+
if err := os.WriteFile(dirty, []byte("export const value = 1;\n"), 0o600); err != nil {
|
|
126
|
+
t.Fatalf("write dirty source: %v", err)
|
|
127
|
+
}
|
|
128
|
+
handled, err = proxy.handleEditorEnvelope(
|
|
129
|
+
didOpenEnvelope(testFileURI(dirty), "export const value = 2;\n"),
|
|
130
|
+
nil,
|
|
131
|
+
)
|
|
132
|
+
if err != nil {
|
|
133
|
+
t.Fatalf("dirty didOpen: %v", err)
|
|
134
|
+
}
|
|
135
|
+
if handled {
|
|
136
|
+
t.Fatal("dirty didOpen was answered locally instead of forwarded to tsgo")
|
|
137
|
+
}
|
|
138
|
+
if symbols.invalidations != 1 {
|
|
139
|
+
t.Errorf("dirty didOpen refreshed the symbol provider: invalidations = %d, want 1", symbols.invalidations)
|
|
140
|
+
}
|
|
141
|
+
if len(plugins.recorded()) != 1 {
|
|
142
|
+
t.Errorf("dirty didOpen refreshed the resident daemon: calls = %v, want only the clean open's", plugins.recorded())
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
package lspserver
|
|
2
|
+
|
|
3
|
+
import "strings"
|
|
4
|
+
|
|
5
|
+
// completionScopeJSDoc is the one scope a hint may claim today. It mirrors
|
|
6
|
+
// rule.HintScopeJSDoc; the wire carries the string, not the Go constant.
|
|
7
|
+
const completionScopeJSDoc = "jsdoc"
|
|
8
|
+
|
|
9
|
+
// matchCompletionHints returns the items that apply at a cursor, and the prefix
|
|
10
|
+
// the editor should filter them against.
|
|
11
|
+
//
|
|
12
|
+
// The cursor-nearest matching After wins; at the same occurrence, the longest
|
|
13
|
+
// trigger wins. That lets a corpus be layered without a query language while
|
|
14
|
+
// ensuring a later trigger is never eclipsed by an earlier, longer one. Rules
|
|
15
|
+
// merge only when they name the same trigger at the winning occurrence.
|
|
16
|
+
func matchCompletionHints(
|
|
17
|
+
hints []LSPCompletionHint,
|
|
18
|
+
linePrefix string,
|
|
19
|
+
inJSDoc bool,
|
|
20
|
+
) (items []LSPCompletionItem, filter string) {
|
|
21
|
+
bestStart := -1
|
|
22
|
+
bestLength := -1
|
|
23
|
+
bestAfter := ""
|
|
24
|
+
for _, hint := range hints {
|
|
25
|
+
if !completionHintApplies(hint, linePrefix, inJSDoc) {
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
start := strings.LastIndex(linePrefix, hint.After)
|
|
29
|
+
if start < bestStart || (start == bestStart && len(hint.After) < bestLength) {
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
if start > bestStart || len(hint.After) > bestLength {
|
|
33
|
+
bestStart = start
|
|
34
|
+
bestLength = len(hint.After)
|
|
35
|
+
bestAfter = hint.After
|
|
36
|
+
items = nil
|
|
37
|
+
filter = linePrefix[start+len(hint.After):]
|
|
38
|
+
}
|
|
39
|
+
if start != bestStart || hint.After != bestAfter {
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
items = append(items, hint.Items...)
|
|
43
|
+
}
|
|
44
|
+
return items, filter
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func completionHintApplies(
|
|
48
|
+
hint LSPCompletionHint,
|
|
49
|
+
linePrefix string,
|
|
50
|
+
inJSDoc bool,
|
|
51
|
+
) bool {
|
|
52
|
+
return completionHintCouldApply(hint, linePrefix) && inJSDoc
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// completionHintCouldApply reports every part of a hint's admission that does
|
|
56
|
+
// not depend on where the cursor sits lexically: that the hint has something to
|
|
57
|
+
// offer, that its scope is one this host knows, and that its trigger literal is
|
|
58
|
+
// on the current line.
|
|
59
|
+
//
|
|
60
|
+
// Split out because the remaining condition is the expensive one. Deciding the
|
|
61
|
+
// cursor's scope scans the document from byte zero, while all of this reads one
|
|
62
|
+
// line, so a caller can find out whether that scan can change any answer before
|
|
63
|
+
// paying for it.
|
|
64
|
+
func completionHintCouldApply(hint LSPCompletionHint, linePrefix string) bool {
|
|
65
|
+
if hint.After == "" || len(hint.Items) == 0 {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
if hint.Scope != completionScopeJSDoc {
|
|
69
|
+
// An unknown scope is refused rather than ignored. Treating it as
|
|
70
|
+
// "anywhere" would make a hint from a newer plugin than this host fire in
|
|
71
|
+
// every string literal — the one place a wrong guess is most visible and
|
|
72
|
+
// least explicable.
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
return strings.Contains(linePrefix, hint.After)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// anyCompletionHintCouldApply reports whether the cursor's lexical scope can
|
|
79
|
+
// change any hint's answer here, and therefore whether it is worth deciding.
|
|
80
|
+
//
|
|
81
|
+
// When it answers false, completionHintApplies refuses every hint on grounds
|
|
82
|
+
// the scope cannot rescue, so the document scan behind that decision would
|
|
83
|
+
// produce an answer nothing reads. That is the overwhelmingly common case while
|
|
84
|
+
// typing: a corpus triggers on "@" or "@evidence ", and most lines hold neither.
|
|
85
|
+
func anyCompletionHintCouldApply(hints []LSPCompletionHint, linePrefix string) bool {
|
|
86
|
+
for _, hint := range hints {
|
|
87
|
+
if completionHintCouldApply(hint, linePrefix) {
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// linePrefixAt returns the text from the start of the cursor's line up to the
|
|
95
|
+
// cursor.
|
|
96
|
+
//
|
|
97
|
+
// This is what a trigger matches against. It stops at the line start because a
|
|
98
|
+
// trigger is line-local by construction: a corpus that could match across lines
|
|
99
|
+
// would need the enclosing declaration, which the proxy does not have.
|
|
100
|
+
func linePrefixAt(text string, offset int) string {
|
|
101
|
+
if offset < 0 || offset > len(text) {
|
|
102
|
+
return ""
|
|
103
|
+
}
|
|
104
|
+
head := text[:offset]
|
|
105
|
+
if start := strings.LastIndexAny(head, "\r\n"); start != -1 {
|
|
106
|
+
return head[start+1:]
|
|
107
|
+
}
|
|
108
|
+
return head
|
|
109
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package lspserver
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
// TestLSPCompletionGateNeverRefusesAMatchingLine verifies the cheap line test
|
|
6
|
+
// that now guards the document scan admits everything the full match would.
|
|
7
|
+
//
|
|
8
|
+
// completionItemsFor decides the cursor's lexical scope by scanning the document
|
|
9
|
+
// from byte zero, and that answer is only ever read by a hint whose trigger is
|
|
10
|
+
// already on the current line. The gate skips the scan when no trigger is, so it
|
|
11
|
+
// must never be stricter than the match it precedes: a false where
|
|
12
|
+
// matchCompletionHints would have produced items is a hint silently lost.
|
|
13
|
+
//
|
|
14
|
+
// 1. Publish a layered corpus: a broad trigger and two narrower ones.
|
|
15
|
+
// 2. For each candidate line, run the gate and the full match together.
|
|
16
|
+
// 3. Assert the gate is open wherever the match produced items, and closed on
|
|
17
|
+
// lines holding no trigger at all.
|
|
18
|
+
func TestLSPCompletionGateNeverRefusesAMatchingLine(t *testing.T) {
|
|
19
|
+
hints := []LSPCompletionHint{
|
|
20
|
+
{Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "param"}}},
|
|
21
|
+
{Scope: "jsdoc", After: "@evidence ", Items: []LSPCompletionItem{{Insert: "docs/spec.md"}}},
|
|
22
|
+
{Scope: "jsdoc", After: "@evidence docs/spec.md#", Items: []LSPCompletionItem{{Insert: "pricing"}}},
|
|
23
|
+
// Refused by the match for want of items; must not open the gate either.
|
|
24
|
+
{Scope: "jsdoc", After: "@nothing", Items: nil},
|
|
25
|
+
// An empty trigger matches every line; the match refuses it, so must the gate.
|
|
26
|
+
{Scope: "jsdoc", After: "", Items: []LSPCompletionItem{{Insert: "never"}}},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for _, entry := range []struct {
|
|
30
|
+
line string
|
|
31
|
+
open bool
|
|
32
|
+
}{
|
|
33
|
+
{" * @par", true},
|
|
34
|
+
{" * @evidence docs/sp", true},
|
|
35
|
+
{" * @evidence docs/spec.md#pri", true},
|
|
36
|
+
{" * plain prose with no tag", false},
|
|
37
|
+
{"", false},
|
|
38
|
+
{"const value = 1;", false},
|
|
39
|
+
{" * @nothing here", true}, // the broad "@" trigger still applies
|
|
40
|
+
} {
|
|
41
|
+
gate := anyCompletionHintCouldApply(hints, entry.line)
|
|
42
|
+
if gate != entry.open {
|
|
43
|
+
t.Fatalf("gate for %q: want %v, got %v", entry.line, entry.open, gate)
|
|
44
|
+
}
|
|
45
|
+
items, _ := matchCompletionHints(hints, entry.line, true)
|
|
46
|
+
if len(items) > 0 && !gate {
|
|
47
|
+
t.Fatalf("gate closed on %q while the match produced %d items", entry.line, len(items))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
package lspserver
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
// TestLSPCompletionHintsLongestTriggerWins pins the rule that makes a hint
|
|
6
|
+
// corpus layerable.
|
|
7
|
+
//
|
|
8
|
+
// A plugin publishes broad and narrow triggers at once — `@`, `@evidence `,
|
|
9
|
+
// `@evidence docs/spec.md#` — because it cannot ask a question per keystroke and
|
|
10
|
+
// so must describe every position up front. Only the longest match may answer.
|
|
11
|
+
// Without that, typing `@evidence docs/spec.md#pri` would offer tag names,
|
|
12
|
+
// document paths, and anchors together, and the narrow corpus the user actually
|
|
13
|
+
// wants would be buried under the broad one that also matches.
|
|
14
|
+
//
|
|
15
|
+
// 1. Publish three nested triggers.
|
|
16
|
+
// 2. Ask at a cursor inside the narrowest.
|
|
17
|
+
// 3. Assert only the narrowest answers, and that the filter is the text after
|
|
18
|
+
// it rather than the whole line.
|
|
19
|
+
func TestLSPCompletionHintsLongestTriggerWins(t *testing.T) {
|
|
20
|
+
hints := []LSPCompletionHint{
|
|
21
|
+
{Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "evidence"}}},
|
|
22
|
+
{Scope: "jsdoc", After: "@evidence ", Items: []LSPCompletionItem{{Insert: "docs/spec.md"}}},
|
|
23
|
+
{Scope: "jsdoc", After: "@evidence docs/spec.md#", Items: []LSPCompletionItem{
|
|
24
|
+
{Insert: "pricing"},
|
|
25
|
+
{Insert: "refunds"},
|
|
26
|
+
}},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
cases := []struct {
|
|
30
|
+
line string
|
|
31
|
+
want []string
|
|
32
|
+
filter string
|
|
33
|
+
}{
|
|
34
|
+
{" * @evi", []string{"evidence"}, "evi"},
|
|
35
|
+
{" * @evidence docs/sp", []string{"docs/spec.md"}, "docs/sp"},
|
|
36
|
+
{" * @evidence docs/spec.md#pri", []string{"pricing", "refunds"}, "pri"},
|
|
37
|
+
// The boundary that makes the filter meaningful: at the trigger's own edge
|
|
38
|
+
// the filter is empty and everything is offered.
|
|
39
|
+
{" * @evidence docs/spec.md#", []string{"pricing", "refunds"}, ""},
|
|
40
|
+
}
|
|
41
|
+
for _, entry := range cases {
|
|
42
|
+
items, filter := matchCompletionHints(hints, entry.line, true)
|
|
43
|
+
got := inserts(items)
|
|
44
|
+
if !equalStrings(got, entry.want) {
|
|
45
|
+
t.Errorf("line %q offered %v, want %v", entry.line, got, entry.want)
|
|
46
|
+
}
|
|
47
|
+
if filter != entry.filter {
|
|
48
|
+
t.Errorf("line %q filtered on %q, want %q", entry.line, filter, entry.filter)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// TestLSPCompletionHintsRefuseOutsideScope pins the negative twin.
|
|
54
|
+
//
|
|
55
|
+
// A line prefix alone cannot tell `@evidence` in a doc comment from
|
|
56
|
+
// `@Injectable` above a class — both end in `@`. Scope is what separates them,
|
|
57
|
+
// and a corpus that ignored it would fire in every decorator position. An
|
|
58
|
+
// unknown scope is refused for the same reason: a hint from a newer plugin than
|
|
59
|
+
// this host must contribute nothing rather than fire everywhere.
|
|
60
|
+
//
|
|
61
|
+
// 1. Ask the same line outside a JSDoc block.
|
|
62
|
+
// 2. Ask with a scope this host does not know.
|
|
63
|
+
// 3. Assert silence in both.
|
|
64
|
+
func TestLSPCompletionHintsRefuseOutsideScope(t *testing.T) {
|
|
65
|
+
hints := []LSPCompletionHint{
|
|
66
|
+
{Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "evidence"}}},
|
|
67
|
+
}
|
|
68
|
+
if items, _ := matchCompletionHints(hints, "@Inj", false); len(items) != 0 {
|
|
69
|
+
t.Errorf("a decorator position was offered %v, want nothing", inserts(items))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
future := []LSPCompletionHint{
|
|
73
|
+
{Scope: "markdown", After: "@", Items: []LSPCompletionItem{{Insert: "nope"}}},
|
|
74
|
+
}
|
|
75
|
+
if items, _ := matchCompletionHints(future, " * @", true); len(items) != 0 {
|
|
76
|
+
t.Errorf("an unknown scope was offered %v, want nothing", inserts(items))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
empty := []LSPCompletionHint{
|
|
80
|
+
{Scope: "jsdoc", After: "", Items: []LSPCompletionItem{{Insert: "nope"}}},
|
|
81
|
+
{Scope: "jsdoc", After: "@", Items: nil},
|
|
82
|
+
}
|
|
83
|
+
if items, _ := matchCompletionHints(empty, " * @", true); len(items) != 0 {
|
|
84
|
+
t.Errorf("a degenerate hint was offered %v, want nothing", inserts(items))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// TestCursorInJSDocTracksTheBlock pins the scope test itself.
|
|
89
|
+
//
|
|
90
|
+
// It is a backward scan rather than a parse, so the cases that matter are the
|
|
91
|
+
// ones where a naive "is there a /** before me" would be wrong: after the block
|
|
92
|
+
// closed, and inside a line comment that only looks like one.
|
|
93
|
+
//
|
|
94
|
+
// 1. A cursor inside an open block is in scope.
|
|
95
|
+
// 2. A cursor after the block closed is not.
|
|
96
|
+
// 3. A line comment is never a doc comment.
|
|
97
|
+
func TestCursorInJSDocTracksTheBlock(t *testing.T) {
|
|
98
|
+
cases := []struct {
|
|
99
|
+
text string
|
|
100
|
+
want bool
|
|
101
|
+
}{
|
|
102
|
+
{"/**\n * @evi", true},
|
|
103
|
+
{"/** @evi", true},
|
|
104
|
+
{"/**\n * ok\n */\nconst x = @", false},
|
|
105
|
+
{"// @evi", false},
|
|
106
|
+
{"const x = 1; @", false},
|
|
107
|
+
// A second block after a closed one: the open must win again.
|
|
108
|
+
{"/** a */\n/**\n * @evi", true},
|
|
109
|
+
}
|
|
110
|
+
for _, entry := range cases {
|
|
111
|
+
if got := cursorInJSDoc(entry.text, len(entry.text)); got != entry.want {
|
|
112
|
+
t.Errorf("cursorInJSDoc(%q) = %v, want %v", entry.text, got, entry.want)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// TestLinePrefixStopsAtTheLine pins that a trigger is line-local.
|
|
118
|
+
//
|
|
119
|
+
// A trigger matched against the whole document would let a `@evidence` three
|
|
120
|
+
// lines up offer anchors on an unrelated line.
|
|
121
|
+
func TestLinePrefixStopsAtTheLine(t *testing.T) {
|
|
122
|
+
text := "/**\n * @evidence docs/spec.md#pri"
|
|
123
|
+
if got, want := linePrefixAt(text, len(text)), " * @evidence docs/spec.md#pri"; got != want {
|
|
124
|
+
t.Errorf("linePrefixAt = %q, want %q", got, want)
|
|
125
|
+
}
|
|
126
|
+
if got := linePrefixAt("no newline", 2); got != "no" {
|
|
127
|
+
t.Errorf("linePrefixAt with no newline = %q, want %q", got, "no")
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func inserts(items []LSPCompletionItem) []string {
|
|
132
|
+
out := []string{}
|
|
133
|
+
for _, item := range items {
|
|
134
|
+
out = append(out, item.Insert)
|
|
135
|
+
}
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func equalStrings(a, b []string) bool {
|
|
140
|
+
if len(a) != len(b) {
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
for i := range a {
|
|
144
|
+
if a[i] != b[i] {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package lspserver
|
|
2
|
+
|
|
3
|
+
import "testing"
|
|
4
|
+
|
|
5
|
+
// TestLSPCompletionHintsNearestTriggerWins keeps completion tied to the trigger
|
|
6
|
+
// nearest the cursor, not merely the longest text somewhere earlier on the
|
|
7
|
+
// line. At one occurrence, nested triggers remain layerable and duplicate
|
|
8
|
+
// producers of the same trigger may contribute together.
|
|
9
|
+
func TestLSPCompletionHintsNearestTriggerWins(t *testing.T) {
|
|
10
|
+
hints := []LSPCompletionHint{
|
|
11
|
+
{Scope: "jsdoc", After: "@", Items: []LSPCompletionItem{{Insert: "broad"}}},
|
|
12
|
+
{Scope: "jsdoc", After: "@task", Items: []LSPCompletionItem{{Insert: "task"}}},
|
|
13
|
+
{Scope: "jsdoc", After: "@bird", Items: []LSPCompletionItem{{Insert: "bird"}}},
|
|
14
|
+
{Scope: "jsdoc", After: "@fish", Items: []LSPCompletionItem{{Insert: "fish"}}},
|
|
15
|
+
{Scope: "jsdoc", After: "@note", Items: []LSPCompletionItem{{Insert: "first"}}},
|
|
16
|
+
{Scope: "jsdoc", After: "@note", Items: []LSPCompletionItem{{Insert: "second"}}},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for _, entry := range []struct {
|
|
20
|
+
line string
|
|
21
|
+
want []string
|
|
22
|
+
filter string
|
|
23
|
+
}{
|
|
24
|
+
{" * @task details @", []string{"broad"}, ""},
|
|
25
|
+
{" * @task details", []string{"task"}, " details"},
|
|
26
|
+
{" * @bird then @fish tail", []string{"fish"}, " tail"},
|
|
27
|
+
{" * @note draft", []string{"first", "second"}, " draft"},
|
|
28
|
+
} {
|
|
29
|
+
items, filter := matchCompletionHints(hints, entry.line, true)
|
|
30
|
+
if got := inserts(items); !equalStrings(got, entry.want) {
|
|
31
|
+
t.Errorf("line %q offered %v, want %v", entry.line, got, entry.want)
|
|
32
|
+
}
|
|
33
|
+
if filter != entry.filter {
|
|
34
|
+
t.Errorf("line %q filtered on %q, want %q", entry.line, filter, entry.filter)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|