From 3e96e2e6422b2ec1d71dde19dba03167cb8f2a20 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 19 Aug 2026 14:31:20 -0700 Subject: [PATCH 01/18] Remove restriction on overlapping original ranges --- packages/typescript/src/ast/spanMap.ts | 83 +++++------- packages/typescript/test/spanMap.test.ts | 31 +++++ tsc/internal/compiler/fileloader.go | 2 - .../diagnostics/diagnostics_generated.go | 4 - .../diagnostics/extraDiagnosticMessages.json | 4 - tsc/internal/spanmap/spanmap.go | 120 +++++++----------- tsc/internal/spanmap/spanmap_test.go | 48 ++++++- 7 files changed, 152 insertions(+), 140 deletions(-) diff --git a/packages/typescript/src/ast/spanMap.ts b/packages/typescript/src/ast/spanMap.ts index 4ee2030008ee7..39cbfbdc584af 100644 --- a/packages/typescript/src/ast/spanMap.ts +++ b/packages/typescript/src/ast/spanMap.ts @@ -108,7 +108,7 @@ export class SpanMap { /** * Returns every feature-compatible virtual projection of an original range. - * A range contained by one duplicate group produces one exact or atom result per matching group member. + * A range contained by one or more segments produces one exact or atom result per matching segment. * * A range that starts in one group and ends in another can have several possible virtual ranges. For * example, suppose two original segments are each copied twice into the virtual text: @@ -136,11 +136,13 @@ export class SpanMap { const startSegments = segmentsAtOriginalPosition(originalSegments, start); const endSegments = segmentsAtOriginalPosition(originalSegments, lastCharacter); if (!startSegments || !endSegments) return []; - if (sameOriginalRange(startSegments[0], endSegments[0])) { - return originalToVirtualSpansInGroup(startSegments, start, end, feature); + const containing = startSegments.filter(segment => end <= segment.originalEnd); + if (containing.length > 0) { + const results = [...originalToVirtualSpansInSegments(containing, start, end, feature)]; + if (results.length > 0) return results.sort((left, right) => left.range.pos - right.range.pos); } - const starts = originalStartProjections(startSegments, start, feature); - const ends = originalEndProjections(endSegments, end, feature); + const starts = [...originalStartProjections(startSegments, start, feature)].sort((left, right) => left - right); + const ends = [...originalEndProjections(endSegments, end, feature)].sort((left, right) => left - right); if (starts.length === 0 || ends.length === 0) return []; return starts.flatMap((virtualStart, index) => { const virtualEnd = ends.find(end => end >= virtualStart); @@ -269,8 +271,8 @@ function originalEndProjections(segments: readonly NormalizedSpanMapSegment[], e ); } -/** Maps a range whose boundaries are known to lie in one duplicate group. */ -function originalToVirtualSpansInGroup(segments: readonly NormalizedSpanMapSegment[], start: number, end: number, feature: SpanMapFeature): readonly MappedRange[] { +/** Maps a range fully contained by each segment. */ +function originalToVirtualSpansInSegments(segments: readonly NormalizedSpanMapSegment[], start: number, end: number, feature: SpanMapFeature): readonly MappedRange[] { return segments .filter(segment => supportsFeature(segment, feature)) .map(segment => { @@ -289,30 +291,16 @@ function sameOriginalRange(left: SpanMapSegment, right: SpanMapSegment): boolean } /** - * Returns the complete duplicate group of mapping segments containing the original-text `position`. - * Segment ends are exclusive; starts, including zero-length segment starts, are included. It finds a candidate - * in O(log n), then scans only the duplicate group. `segments` must be ordered by original start, original end, - * and virtual start. + * Returns every mapping segment containing the original-text `position`. + * Segment ends are exclusive; starts, including zero-length segment starts, are included. */ function segmentsAtOriginalPosition(segments: readonly NormalizedSpanMapSegment[], position: number): readonly NormalizedSpanMapSegment[] | undefined { - let low = 0; - let high = segments.length; - while (low < high) { - const middle = (low + high) >>> 1; - if (segments[middle].originalStart < position) low = middle + 1; - else high = middle; + const results: NormalizedSpanMapSegment[] = []; + for (const segment of segments) { + if (segment.originalStart > position) break; + if (position < segment.originalEnd || position === segment.originalStart) results.push(segment); } - let index = low < segments.length && segments[low].originalStart === position ? low : low - 1; - if ( - index < 0 || !( - segments[index].originalStart === position - || position < segments[index].originalEnd - ) - ) return undefined; - while (index > 0 && sameOriginalRange(segments[index - 1], segments[index])) index--; - let end = index + 1; - while (end < segments.length && sameOriginalRange(segments[end], segments[index])) end++; - return segments.slice(index, end); + return results.length > 0 ? results : undefined; } interface SegmentGroupAtOriginalPosition { @@ -321,8 +309,8 @@ interface SegmentGroupAtOriginalPosition { } /** - * Returns groups of mapping segments containing or touching the original-text `position`. - * At a shared boundary, segments ending at the point and segments starting there form separate groups: + * Returns every group of equal-range mapping segments containing or touching the original-text `position`. + * Segment ends are included for point mapping: * * ```text * original: [--- A ---)[--- B ---) @@ -334,30 +322,21 @@ interface SegmentGroupAtOriginalPosition { * ``` */ function segmentGroupsAtOriginalPosition(segments: readonly NormalizedSpanMapSegment[], position: number): readonly SegmentGroupAtOriginalPosition[] { - let low = 0; - let high = segments.length; - while (low < high) { - const middle = (low + high) >>> 1; - if (segments[middle].originalStart < position) low = middle + 1; - else high = middle; - } - if (low < segments.length && segments[low].originalStart === position) { - const right = segmentsAtOriginalPosition(segments, position)!; - const groups: SegmentGroupAtOriginalPosition[] = []; - if (low > 0 && segments[low - 1].originalEnd === position) { - let leftStart = low - 1; - while (leftStart > 0 && sameOriginalRange(segments[leftStart - 1], segments[low - 1])) leftStart--; - groups.push({ segments: segments.slice(leftStart, low), atEnd: true }); + const groups: SegmentGroupAtOriginalPosition[] = []; + for (let start = 0; start < segments.length;) { + if (segments[start].originalStart > position) break; + let end = start + 1; + while (end < segments.length && sameOriginalRange(segments[start], segments[end])) end++; + const segment = segments[start]; + if (position <= segment.originalEnd) { + groups.push({ + segments: segments.slice(start, end), + atEnd: position === segment.originalEnd && position !== segment.originalStart, + }); } - groups.push({ segments: right, atEnd: false }); - return groups; + start = end; } - if (low === 0) return []; - const left = segments[low - 1]; - if (position > left.originalEnd) return []; - let start = low - 1; - while (start > 0 && sameOriginalRange(segments[start - 1], left)) start--; - return [{ segments: segments.slice(start, low), atEnd: position === left.originalEnd }]; + return groups; } /** Reports whether a segment participates in an original-to-virtual query for `features`. */ diff --git a/packages/typescript/test/spanMap.test.ts b/packages/typescript/test/spanMap.test.ts index 5a4876b9c75c5..31fae97bfa084 100644 --- a/packages/typescript/test/spanMap.test.ts +++ b/packages/typescript/test/spanMap.test.ts @@ -110,6 +110,37 @@ describe("SpanMap", () => { ]); }); + test("maps every covering overlapping span", () => { + const overlapping = new SpanMap([ + { virtualStart: 0, virtualEnd: 6, originalStart: 0, originalEnd: 6, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 10, virtualEnd: 12, originalStart: 2, originalEnd: 4, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 20, virtualEnd: 24, originalStart: 3, originalEnd: 7, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + ]); + + assert.deepEqual(overlapping.originalToVirtualPositions(3, SpanMapFeature.Hover), [ + { position: 3, fidelity: SpanMapFidelity.Exact }, + { position: 11, fidelity: SpanMapFidelity.Exact }, + { position: 20, fidelity: SpanMapFidelity.Exact }, + ]); + assert.deepEqual(overlapping.originalToVirtualSpans({ pos: 3, end: 4 }, SpanMapFeature.Hover), [ + { range: { pos: 3, end: 4 }, fidelity: SpanMapFidelity.Exact }, + { range: { pos: 11, end: 12 }, fidelity: SpanMapFidelity.Exact }, + { range: { pos: 20, end: 21 }, fidelity: SpanMapFidelity.Exact }, + ]); + }); + + test("falls back from a disabled containing span", () => { + const overlapping = new SpanMap([ + { virtualStart: 0, virtualEnd: 6, originalStart: 0, originalEnd: 6, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Definition }, + { virtualStart: 10, virtualEnd: 13, originalStart: 0, originalEnd: 3, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 13, virtualEnd: 16, originalStart: 3, originalEnd: 6, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + ]); + + assert.deepEqual(overlapping.originalToVirtualSpans({ pos: 1, end: 5 }, SpanMapFeature.Hover), [ + { range: { pos: 11, end: 15 }, fidelity: SpanMapFidelity.Approximate }, + ]); + }); + test("maps minimal cross-group projections", () => { const projections = new SpanMap([ { virtualStart: 0, virtualEnd: 2, originalStart: 0, originalEnd: 2, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, diff --git a/tsc/internal/compiler/fileloader.go b/tsc/internal/compiler/fileloader.go index c89d06051692c..5baed7519733f 100644 --- a/tsc/internal/compiler/fileloader.go +++ b/tsc/internal/compiler/fileloader.go @@ -546,8 +546,6 @@ func contentMapperMappingDiagnostic(file *ast.SourceFile, label string, problem return ast.NewDiagnostic(file, loc, diagnostics.The_content_mapper_0_produced_a_verbatim_mapping_that_does_not_match_the_original_content_virtual_offset_1_original_offset_2, label, int(problem.VirtualPos), int(problem.OriginalPos)) case spanmap.MappingErrorKindKind: return ast.NewDiagnostic(file, loc, diagnostics.The_content_mapper_0_produced_a_position_mapping_with_an_invalid_kind_near_virtual_offset_1, label, int(problem.VirtualPos)) - case spanmap.MappingErrorKindOriginalOverlap: - return ast.NewDiagnostic(file, loc, diagnostics.The_content_mapper_0_produced_overlapping_original_position_mappings_that_are_not_identical_near_original_offset_1, label, int(problem.OriginalPos)) case spanmap.MappingErrorKindFeature: return ast.NewDiagnostic(file, loc, diagnostics.The_content_mapper_0_produced_invalid_mapping_features_near_original_offset_1, label, int(problem.OriginalPos)) default: diff --git a/tsc/internal/diagnostics/diagnostics_generated.go b/tsc/internal/diagnostics/diagnostics_generated.go index 5c53bdcbb2641..5ea70f757aa9c 100644 --- a/tsc/internal/diagnostics/diagnostics_generated.go +++ b/tsc/internal/diagnostics/diagnostics_generated.go @@ -4352,8 +4352,6 @@ var Virtual_code_produced_by_the_content_mapper_0_has_problems_with_no_correspon var The_content_mapper_0_produced_overlapping_or_out_of_order_position_mappings_near_virtual_offset_1 = &Message{code: 100037, category: CategoryError, key: "The_content_mapper_0_produced_overlapping_or_out_of_order_position_mappings_near_virtual_offset_1_100037", text: "The content mapper '{0}' produced overlapping or out-of-order position mappings (near virtual offset {1})."} -var The_content_mapper_0_produced_overlapping_original_position_mappings_that_are_not_identical_near_original_offset_1 = &Message{code: 100038, category: CategoryError, key: "The_content_mapper_0_produced_overlapping_original_position_mappings_that_are_not_identical_near_ori_100038", text: "The content mapper '{0}' produced overlapping original position mappings that are not identical (near original offset {1})."} - var The_content_mapper_0_produced_invalid_mapping_features_near_original_offset_1 = &Message{code: 100039, category: CategoryError, key: "The_content_mapper_0_produced_invalid_mapping_features_near_original_offset_1_100039", text: "The content mapper '{0}' produced invalid mapping features near original offset {1}."} var The_content_mapper_0_produced_a_position_mapping_with_an_invalid_kind_near_virtual_offset_1 = &Message{code: 100040, category: CategoryError, key: "The_content_mapper_0_produced_a_position_mapping_with_an_invalid_kind_near_virtual_offset_1_100040", text: "The content mapper '{0}' produced a position mapping with an invalid kind (near virtual offset {1})."} @@ -8766,8 +8764,6 @@ func keyToMessage(key Key) *Message { return Virtual_code_produced_by_the_content_mapper_0_has_problems_with_no_corresponding_location_in_this_file case "The_content_mapper_0_produced_overlapping_or_out_of_order_position_mappings_near_virtual_offset_1_100037": return The_content_mapper_0_produced_overlapping_or_out_of_order_position_mappings_near_virtual_offset_1 - case "The_content_mapper_0_produced_overlapping_original_position_mappings_that_are_not_identical_near_ori_100038": - return The_content_mapper_0_produced_overlapping_original_position_mappings_that_are_not_identical_near_original_offset_1 case "The_content_mapper_0_produced_invalid_mapping_features_near_original_offset_1_100039": return The_content_mapper_0_produced_invalid_mapping_features_near_original_offset_1 case "The_content_mapper_0_produced_a_position_mapping_with_an_invalid_kind_near_virtual_offset_1_100040": diff --git a/tsc/internal/diagnostics/extraDiagnosticMessages.json b/tsc/internal/diagnostics/extraDiagnosticMessages.json index 3fe74485cb7fe..3f735d30c8389 100644 --- a/tsc/internal/diagnostics/extraDiagnosticMessages.json +++ b/tsc/internal/diagnostics/extraDiagnosticMessages.json @@ -219,10 +219,6 @@ "category": "Error", "code": 100037 }, - "The content mapper '{0}' produced overlapping original position mappings that are not identical (near original offset {1}).": { - "category": "Error", - "code": 100038 - }, "The content mapper '{0}' produced invalid mapping features near original offset {1}.": { "category": "Error", "code": 100039 diff --git a/tsc/internal/spanmap/spanmap.go b/tsc/internal/spanmap/spanmap.go index 37f44327eab43..faef971e2a02f 100644 --- a/tsc/internal/spanmap/spanmap.go +++ b/tsc/internal/spanmap/spanmap.go @@ -149,8 +149,6 @@ const ( MappingErrorKindVerbatimMismatch // MappingErrorKindKind means a segment uses an unsupported mapping kind. MappingErrorKindKind - // MappingErrorKindOriginalOverlap means original spans partially overlap or contain one another. - MappingErrorKindOriginalOverlap // MappingErrorKindFeature means a feature annotation contains unsupported flags. MappingErrorKindFeature ) @@ -175,8 +173,6 @@ func (p *MappingError) Error() string { return fmt.Sprintf("content mapper verbatim mapping does not match the original content at virtual offset %d, original offset %d", p.VirtualPos, p.OriginalPos) case MappingErrorKindKind: return fmt.Sprintf("content mapper position mapping has an invalid kind at virtual offset %d", p.VirtualPos) - case MappingErrorKindOriginalOverlap: - return fmt.Sprintf("content mapper position mappings partially overlap in the original content near offset %d", p.OriginalPos) case MappingErrorKindFeature: return fmt.Sprintf("content mapper position mappings have invalid features near original offset %d", p.OriginalPos) default: @@ -218,17 +214,6 @@ func (m *SpanMap) Validate(virtual, original string) *MappingError { return &MappingError{Kind: MappingErrorKindFeature, VirtualPos: s.VirtualStart, OriginalPos: s.OriginalStart} } } - originalSegments := m.origIndex() - for i := 0; i < len(originalSegments); { - groupEnd := i + 1 - for groupEnd < len(originalSegments) && originalSegments[groupEnd].OriginalStart == originalSegments[i].OriginalStart && originalSegments[groupEnd].OriginalEnd == originalSegments[i].OriginalEnd { - groupEnd++ - } - if i > 0 && originalSegments[i].OriginalStart < originalSegments[i-1].OriginalEnd { - return &MappingError{Kind: MappingErrorKindOriginalOverlap, VirtualPos: originalSegments[i].VirtualStart, OriginalPos: originalSegments[i].OriginalStart} - } - i = groupEnd - } return nil } @@ -455,7 +440,7 @@ func (m *SpanMap) OriginalToVirtualPositions(pos core.TextPos, feature Feature) } // OriginalToVirtualSpans returns every feature-compatible virtual projection of an original range. -// A range contained by one duplicate group produces one exact or atom result per matching group member. +// A range contained by one or more segments produces one exact or atom result per matching segment. // // A range that starts in one group and ends in another can have several possible virtual ranges. For // example, suppose two original segments are each copied twice into the virtual text: @@ -489,14 +474,26 @@ func (m *SpanMap) OriginalToVirtualSpans(r core.TextRange, feature Feature) []Ma if !startInside || !endInside { return nil } - if sameOriginalRange(startSegments[0], endSegments[0]) { - return originalToVirtualSpansInGroup(startSegments, start, end, feature) + var containing []Segment + for _, segment := range startSegments { + if end <= segment.OriginalEnd { + containing = append(containing, segment) + } + } + if len(containing) > 0 { + results := originalToVirtualSpansInSegments(containing, start, end, feature) + if len(results) > 0 { + slices.SortFunc(results, func(a, b MappedSpan) int { return a.Span.Pos() - b.Span.Pos() }) + return results + } } starts := originalStartProjections(startSegments, start, feature) ends := originalEndProjections(endSegments, end, feature) if len(starts) == 0 || len(ends) == 0 { return nil } + slices.Sort(starts) + slices.Sort(ends) results := make([]MappedSpan, 0, min(len(starts), len(ends))) for i, virtualStart := range starts { endIndex, _ := slices.BinarySearch(ends, virtualStart) @@ -602,8 +599,8 @@ func originalEndProjections(segments []Segment, end core.TextPos, feature Featur return results } -// originalToVirtualSpansInGroup maps a range whose boundaries are known to lie in segments. -func originalToVirtualSpansInGroup(segments []Segment, start core.TextPos, end core.TextPos, feature Feature) []MappedSpan { +// originalToVirtualSpansInSegments maps a range fully contained by each segment. +func originalToVirtualSpansInSegments(segments []Segment, start core.TextPos, end core.TextPos, feature Feature) []MappedSpan { results := make([]MappedSpan, 0, len(segments)) for _, segment := range segments { if !supportsFeature(segment, feature) { @@ -642,30 +639,19 @@ func (m *SpanMap) origIndex() []Segment { return m.origSorted } -// segmentsAtOriginalPosition returns the complete duplicate group of mapping segments containing the -// original-text position pos. segments must be ordered by original start, original end, and virtual start. +// segmentsAtOriginalPosition returns every mapping segment containing the original-text position pos. // Segment ends are exclusive; a segment start, including a zero-length segment, is considered contained. -// It finds a candidate in O(log n), then scans only the duplicate group. The boolean reports whether any -// group contains pos. func segmentsAtOriginalPosition(segments []Segment, pos core.TextPos) ([]Segment, bool) { - index, found := slices.BinarySearchFunc(segments, pos, func(segment Segment, position core.TextPos) int { - return int(segment.OriginalStart - position) - }) - if !found { - index-- - } - if index < 0 || !(segments[index].OriginalStart == pos || pos < segments[index].OriginalEnd) { - return nil, false - } - start := index - for start > 0 && sameOriginalRange(segments[start-1], segments[index]) { - start-- - } - end := start + 1 - for end < len(segments) && sameOriginalRange(segments[end], segments[start]) { - end++ + var results []Segment + for _, segment := range segments { + if segment.OriginalStart > pos { + break + } + if pos < segment.OriginalEnd || pos == segment.OriginalStart { + results = append(results, segment) + } } - return segments[start:end], true + return results, len(results) > 0 } type segmentGroupAtOriginalPosition struct { @@ -673,10 +659,8 @@ type segmentGroupAtOriginalPosition struct { atEnd bool } -// segmentGroupsAtOriginalPosition returns groups of mapping segments containing or touching the original-text -// position pos. Interior positions return one group. At a boundary between adjacent groups, both the group ending -// at pos and the group starting at pos are returned. segments must be ordered by original start, original end, -// then virtual start. +// segmentGroupsAtOriginalPosition returns every group of equal-range mapping segments containing or touching +// the original-text position pos. Segment ends are included for point mapping. // // At a shared boundary, segments ending at pos and segments starting there form separate groups: // @@ -687,37 +671,25 @@ type segmentGroupAtOriginalPosition struct { // left group right group // atEnd: true atEnd: false func segmentGroupsAtOriginalPosition(segments []Segment, pos core.TextPos) []segmentGroupAtOriginalPosition { - index, startsAtPosition := slices.BinarySearchFunc(segments, pos, func(segment Segment, position core.TextPos) int { - return int(segment.OriginalStart - position) - }) - if startsAtPosition { - right, _ := segmentsAtOriginalPosition(segments, pos) - var groups []segmentGroupAtOriginalPosition - if index > 0 { - leftIndex := index - 1 - if segments[leftIndex].OriginalEnd == pos { - leftStart := leftIndex - for leftStart > 0 && sameOriginalRange(segments[leftStart-1], segments[leftIndex]) { - leftStart-- - } - groups = append(groups, segmentGroupAtOriginalPosition{segments: segments[leftStart:index], atEnd: true}) - } + var groups []segmentGroupAtOriginalPosition + for start := 0; start < len(segments); { + if segments[start].OriginalStart > pos { + break } - return append(groups, segmentGroupAtOriginalPosition{segments: right}) - } - if index == 0 { - return nil - } - leftIndex := index - 1 - segment := segments[leftIndex] - if pos > segment.OriginalEnd { - return nil - } - start := leftIndex - for start > 0 && sameOriginalRange(segments[start-1], segment) { - start-- + end := start + 1 + for end < len(segments) && sameOriginalRange(segments[start], segments[end]) { + end++ + } + segment := segments[start] + if pos <= segment.OriginalEnd { + groups = append(groups, segmentGroupAtOriginalPosition{ + segments: segments[start:end], + atEnd: pos == segment.OriginalEnd && pos != segment.OriginalStart, + }) + } + start = end } - return []segmentGroupAtOriginalPosition{{segments: segments[start:index], atEnd: pos == segment.OriginalEnd}} + return groups } // supportsFeature reports whether segment participates in feature. diff --git a/tsc/internal/spanmap/spanmap_test.go b/tsc/internal/spanmap/spanmap_test.go index 8442071564b68..d26c6d885034f 100644 --- a/tsc/internal/spanmap/spanmap_test.go +++ b/tsc/internal/spanmap/spanmap_test.go @@ -321,6 +321,46 @@ func TestOriginalToVirtualDuplicateGroup(t *testing.T) { assert.Equal(t, spans[1].Span.End(), 25) } +func TestOriginalToVirtualOverlappingSpans(t *testing.T) { + t.Parallel() + + m := spanmap.New([]spanmap.Segment{ + {VirtualStart: 0, VirtualEnd: 6, OriginalStart: 0, OriginalEnd: 6, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + {VirtualStart: 10, VirtualEnd: 12, OriginalStart: 2, OriginalEnd: 4, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + {VirtualStart: 20, VirtualEnd: 24, OriginalStart: 3, OriginalEnd: 7, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + }) + + assert.DeepEqual(t, m.OriginalToVirtualPositions(3, spanmap.FeatureHover), []spanmap.MappedPosition{ + {Position: 3, Fidelity: spanmap.FidelityExact}, + {Position: 11, Fidelity: spanmap.FidelityExact}, + {Position: 20, Fidelity: spanmap.FidelityExact}, + }) + spans := m.OriginalToVirtualSpans(core.NewTextRange(3, 4), spanmap.FeatureHover) + wantSpans := []spanmap.MappedSpan{ + {Span: core.NewTextRange(3, 4), Fidelity: spanmap.FidelityExact}, + {Span: core.NewTextRange(11, 12), Fidelity: spanmap.FidelityExact}, + {Span: core.NewTextRange(20, 21), Fidelity: spanmap.FidelityExact}, + } + assert.Equal(t, len(spans), len(wantSpans)) + for i := range spans { + assert.Equal(t, spans[i], wantSpans[i]) + } +} + +func TestOriginalToVirtualOverlapFallsBackFromDisabledContainer(t *testing.T) { + t.Parallel() + + m := spanmap.New([]spanmap.Segment{ + {VirtualStart: 0, VirtualEnd: 6, OriginalStart: 0, OriginalEnd: 6, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureDefinition}, + {VirtualStart: 10, VirtualEnd: 13, OriginalStart: 0, OriginalEnd: 3, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + {VirtualStart: 13, VirtualEnd: 16, OriginalStart: 3, OriginalEnd: 6, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + }) + + spans := m.OriginalToVirtualSpans(core.NewTextRange(1, 5), spanmap.FeatureHover) + assert.Equal(t, len(spans), 1) + assert.Equal(t, spans[0], spanmap.MappedSpan{Span: core.NewTextRange(11, 15), Fidelity: spanmap.FidelityApproximate}) +} + func TestOriginalToVirtualCrossGroupProjections(t *testing.T) { t.Parallel() @@ -513,20 +553,20 @@ func TestValidateOriginalOverlapAndFeatures(t *testing.T) { valid: true, }, { - name: "partial original overlap", + name: "partial original overlap is valid", segments: []spanmap.Segment{ {VirtualStart: 0, VirtualEnd: 3, OriginalStart: 0, OriginalEnd: 3, Kind: spanmap.KindAtom}, {VirtualStart: 3, VirtualEnd: 6, OriginalStart: 2, OriginalEnd: 5, Kind: spanmap.KindAtom}, }, - wantKind: spanmap.MappingErrorKindOriginalOverlap, + valid: true, }, { - name: "nested original overlap", + name: "nested original overlap is valid", segments: []spanmap.Segment{ {VirtualStart: 0, VirtualEnd: 5, OriginalStart: 0, OriginalEnd: 5, Kind: spanmap.KindAtom}, {VirtualStart: 5, VirtualEnd: 6, OriginalStart: 1, OriginalEnd: 4, Kind: spanmap.KindAtom}, }, - wantKind: spanmap.MappingErrorKindOriginalOverlap, + valid: true, }, { name: "duplicate without explicit features is tolerant", From 30e125610007f283932a86f3702d98235b5c4abf Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 19 Aug 2026 14:44:34 -0700 Subject: [PATCH 02/18] Fix build/watch mode issues with realpath package.jsons and supplemental file conflicts --- tsc/internal/execute/build/buildtask.go | 3 +- tsc/internal/execute/build/orchestrator.go | 2 +- .../tsctests/contentmapper_watch_test.go | 61 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/tsc/internal/execute/build/buildtask.go b/tsc/internal/execute/build/buildtask.go index a3ac9c53de5fc..96d776a4ccc3a 100644 --- a/tsc/internal/execute/build/buildtask.go +++ b/tsc/internal/execute/build/buildtask.go @@ -494,7 +494,8 @@ func (t *BuildTask) getUpToDateStatus(orchestrator *Orchestrator, configPath tsp if seenRoots.Has(inputPath) || resolvedRoots.Has(inputPath) { continue } - if isContentMapperSupplementalBuildInfoPath(inputPath, getBuildInfoRootInfoReader().Roots()) { + if isContentMapperSupplementalBuildInfoPath(inputPath, getBuildInfoRootInfoReader().Roots()) && + !orchestrator.host.FS().FileExists(inputFile) { continue } inputTime := orchestrator.host.GetMTime(inputFile) diff --git a/tsc/internal/execute/build/orchestrator.go b/tsc/internal/execute/build/orchestrator.go index b896976819e08..11c13b8ba9b84 100644 --- a/tsc/internal/execute/build/orchestrator.go +++ b/tsc/internal/execute/build/orchestrator.go @@ -344,7 +344,7 @@ func (o *Orchestrator) checkTasksForEventChanges(changedPaths map[string]fswatch if mapper.PackageDirectory == "" || mapper.ContributionID != "" { continue } - manifestPath := o.toPath(tspath.CombinePaths(mapper.PackageDirectory, "package.json")) + manifestPath := o.toPath(o.host.FS().Realpath(tspath.CombinePaths(mapper.PackageDirectory, "package.json"))) if _, changed := normalizedPaths[manifestPath]; changed { task.resetConfig(o, path) needsConfigUpdate.Store(true) diff --git a/tsc/internal/execute/tsctests/contentmapper_watch_test.go b/tsc/internal/execute/tsctests/contentmapper_watch_test.go index fe725d3e80084..4043e6b51b601 100644 --- a/tsc/internal/execute/tsctests/contentmapper_watch_test.go +++ b/tsc/internal/execute/tsctests/contentmapper_watch_test.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/execute/tsc" "github.com/microsoft/TypeScript/tsc/internal/fswatch" "github.com/microsoft/TypeScript/tsc/internal/testutil/contentmappertest" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) @@ -81,6 +82,35 @@ func TestContentMapperBuildLifecycle(t *testing.T) { assert.Equal(t, spawner.closes.Load(), int32(1)) } +func TestContentMapperBuildDetectsNewPhysicalSupplementalFile(t *testing.T) { + t.Parallel() + const supplementalFileName = "/home/src/workspaces/project/app.vue.0.ts" + input := &tscInput{files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{ + "compilerOptions": { "incremental": true }, + "files": ["app.vue"], + "contentMappers": [{ "package": "mapper", "extensions": [".vue"] }] + }`, + "/home/src/workspaces/project/app.vue": `declare const value: number;`, + "/home/src/workspaces/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.SupplementalMapper), + }} + testSys := newTestSys(input, false) + sys := &recordingContentMapperSystem{ + TestSys: testSys, + spawner: &recordingContentMapperSpawner{inner: contentmappertest.NewSpawner()}, + } + args := []string{"--build", "--pretty", "false", "--runExternalCode"} + result := execute.CommandLine(t.Context(), sys, args, testSys) + assert.Equal(t, result.Status, tsc.ExitStatusSuccess, testSys.currentWrite.String()) + + testSys.clearOutput() + testSys.writeFileNoError(supplementalFileName, "export {};\n") + result = execute.CommandLine(t.Context(), sys, args, testSys) + assert.Equal(t, result.Status, tsc.ExitStatusDiagnosticsPresent_OutputsGenerated) + assert.Assert(t, strings.Contains(testSys.currentWrite.String(), "TS100025"), testSys.currentWrite.String()) + assert.Assert(t, strings.Contains(testSys.currentWrite.String(), "conflicts with an existing file"), testSys.currentWrite.String()) +} + func TestContentMapperBuildIdentityFailureExitStatus(t *testing.T) { t.Parallel() const packageJSONPath = "/home/src/workspaces/project/node_modules/mapper/package.json" @@ -323,6 +353,37 @@ func TestDynamicContentMapperBuildWatchDependency(t *testing.T) { assert.Equal(t, spawner.closes.Load(), int32(0)) } +func TestContentMapperBuildWatchSymlinkedManifestChange(t *testing.T) { + t.Parallel() + const manifestTarget = "/home/src/workspaces/mapper/package.json" + input := &tscInput{files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{ + "compilerOptions": { "composite": true }, + "contentMappers": [{ "package": "mapper", "extensions": [".vue"] }] + }`, + "/home/src/workspaces/project/app.vue": `export const app = 1;`, + "/home/src/workspaces/project/node_modules/mapper": vfstest.Symlink("/home/src/workspaces/mapper"), + manifestTarget: contentmappertest.PackageJSON(contentmappertest.VerbatimMapper), + }} + testSys := newTestSys(input, false) + spawner := &recordingContentMapperSpawner{inner: contentmappertest.NewSpawner()} + sys := &recordingContentMapperSystem{TestSys: testSys, spawner: spawner} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + result := execute.CommandLine(ctx, sys, []string{"--build", "--watch", "--runExternalCode"}, testSys) + assert.Equal(t, spawner.spawns.Load(), int32(1)) + assert.Equal(t, spawner.closes.Load(), int32(0)) + + updatedManifest := strings.Replace(contentmappertest.PackageJSON(contentmappertest.VerbatimMapper), `"version": "1.0.0"`, `"version": "2.0.0"`, 1) + testSys.writeFileNoError(manifestTarget, updatedManifest) + testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventUpdate, Path: manifestTarget}}) + result.Watcher.DoCycle() + + assert.Equal(t, spawner.spawns.Load(), int32(2)) + assert.Equal(t, spawner.closes.Load(), int32(1)) +} + func TestContentMapperBuildWatchSharedLifecycle(t *testing.T) { t.Parallel() const mapperConfig = `{ From 119ef18af4b6d32b99c781a551d01a21c7d2be7b Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 19 Aug 2026 15:19:08 -0700 Subject: [PATCH 03/18] Fix case-sensitivity issues --- packages/vscode-typescript/package.json | 2 +- .../src/contentMapperContributions.ts | 5 +- .../test/contentMapperContributions.test.ts | 16 +++++ packages/vscode-typescript/test/index.test.ts | 2 + tsc/internal/lsp/server.go | 6 +- .../lsp/server_contentmapper_internal_test.go | 18 +++++ tsc/internal/tsoptions/tsconfigparsing.go | 12 +++- .../tsoptions/tsconfigparsing_test.go | 65 +++++++++++++++++++ 8 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 packages/vscode-typescript/test/contentMapperContributions.test.ts create mode 100644 packages/vscode-typescript/test/index.test.ts diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index 323c0777e3a98..52e432dcc984b 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -370,7 +370,7 @@ "scripts": { "build": "tsc && npm run bundle", "bundle": "esbuild src/extension.ts --bundle --external:vscode --platform=node --format=cjs --outfile=dist/extension.bundle.js --sourcemap", - "test": "esbuild test/tsdkPackage.test.ts --bundle --platform=node --format=cjs --outfile=dist/test/tsdkPackage.test.cjs && node --test dist/test/tsdkPackage.test.cjs", + "test": "esbuild test/index.test.ts --bundle --platform=node --format=cjs --outfile=dist/test/index.test.cjs && node --test dist/test/index.test.cjs", "watch": "npm run bundle -- --watch", "generateLocTest": "npx @vscode/l10n-dev generate-pseudo -o ./l10n/ ./l10n/bundle.l10n.json ./package.nls.json", "generateLocBundle": "npx @vscode/l10n-dev export --outDir ./l10n ./src", diff --git a/packages/vscode-typescript/src/contentMapperContributions.ts b/packages/vscode-typescript/src/contentMapperContributions.ts index 44fe298cfe0a1..fdc48d8a0de9f 100644 --- a/packages/vscode-typescript/src/contentMapperContributions.ts +++ b/packages/vscode-typescript/src/contentMapperContributions.ts @@ -79,12 +79,13 @@ export function validateContentMapperRegistration(contributorId: string, contrib } export function documentMatchesContentMapperContributions( - document: vscode.TextDocument, + document: { readonly uri: { readonly path: string; }; }, registrations: ReadonlyMap, ): boolean { + const documentPath = document.uri.path.toLowerCase(); for (const contributions of registrations.values()) { for (const contribution of contributions) { - if (contribution.extensions.some(extension => document.uri.path.endsWith(extension))) { + if (contribution.extensions.some(extension => documentPath.endsWith(extension.toLowerCase()))) { return true; } } diff --git a/packages/vscode-typescript/test/contentMapperContributions.test.ts b/packages/vscode-typescript/test/contentMapperContributions.test.ts new file mode 100644 index 0000000000000..04dea47e5f245 --- /dev/null +++ b/packages/vscode-typescript/test/contentMapperContributions.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + type ContentMapperContribution, + documentMatchesContentMapperContributions, +} from "../src/contentMapperContributions"; + +test("content mapper extensions match document paths case-insensitively", () => { + const registrations = new Map([[ + "publisher.extension", + [{ extensions: [".vue"] }], + ]]); + const document = { uri: { path: "/workspace/Component.VUE" } }; + + assert.equal(documentMatchesContentMapperContributions(document, registrations), true); +}); diff --git a/packages/vscode-typescript/test/index.test.ts b/packages/vscode-typescript/test/index.test.ts new file mode 100644 index 0000000000000..1688ac53bc213 --- /dev/null +++ b/packages/vscode-typescript/test/index.test.ts @@ -0,0 +1,2 @@ +import "./contentMapperContributions.test"; +import "./tsdkPackage.test"; diff --git a/tsc/internal/lsp/server.go b/tsc/internal/lsp/server.go index 2c744183ecc96..9b3a4fccc7ae3 100644 --- a/tsc/internal/lsp/server.go +++ b/tsc/internal/lsp/server.go @@ -2490,7 +2490,7 @@ func parseContentMapperContributions(values []*lsproto.ContentMapperContribution } } for _, extension := range validExtensions { - if !claimedExtensions.AddIfAbsent(extension) { + if !claimedExtensions.AddIfAbsent(strings.ToLower(extension)) { return result, fmt.Errorf("content mapper contributions both claim extension %q", extension) } result.Extensions = append(result.Extensions, extension) @@ -2530,7 +2530,9 @@ func isValidContributedContentMapperExtension(extension string) bool { if len(extension) <= 1 || extension[0] != '.' || tspath.GetAnyExtensionFromPath("file"+extension, nil, false) != extension { return false } - return !slices.Contains(core.Flatten(tspath.AllSupportedExtensionsWithJson), extension) + return !slices.ContainsFunc(core.Flatten(tspath.AllSupportedExtensionsWithJson), func(nativeExtension string) bool { + return strings.EqualFold(nativeExtension, extension) + }) } func valueOrZero[T any](value *T) T { diff --git a/tsc/internal/lsp/server_contentmapper_internal_test.go b/tsc/internal/lsp/server_contentmapper_internal_test.go index e772d1a8ea9af..f07309f55f499 100644 --- a/tsc/internal/lsp/server_contentmapper_internal_test.go +++ b/tsc/internal/lsp/server_contentmapper_internal_test.go @@ -68,6 +68,24 @@ func TestParseContentMapperContributionsRejectsConflictingInlineMappers(t *testi assert.ErrorContains(t, err, `both claim extension ".vue"`) } +func TestParseContentMapperContributionsUsesCaseInsensitiveExtensions(t *testing.T) { + t.Parallel() + inferredProjectContribution := func(name string) *lsproto.InferredProjectContentMapperContribution { + return &lsproto.InferredProjectContentMapperContribution{Manifest: &lsproto.ContentMapperManifest{Name: name, Exec: []string{name}}} + } + _, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{ + {ContributorId: "first", Extensions: []string{".vue"}, InferredProjectContribution: inferredProjectContribution("first")}, + {ContributorId: "second", Extensions: []string{".VUE"}, InferredProjectContribution: inferredProjectContribution("second")}, + }) + assert.ErrorContains(t, err, `both claim extension ".VUE"`) + + _, err = parseContentMapperContributions([]*lsproto.ContentMapperContribution{{ + ContributorId: "built-in", + Extensions: []string{".TS"}, + }}) + assert.ErrorContains(t, err, `invalid extension ".TS"`) +} + func TestParseContentMapperContributionsDefaultsOptionsToObject(t *testing.T) { t.Parallel() contributions, err := parseContentMapperContributions([]*lsproto.ContentMapperContribution{{ diff --git a/tsc/internal/tsoptions/tsconfigparsing.go b/tsc/internal/tsoptions/tsconfigparsing.go index 6f4db55f300fe..e491d40221679 100644 --- a/tsc/internal/tsoptions/tsconfigparsing.go +++ b/tsc/internal/tsoptions/tsconfigparsing.go @@ -1400,20 +1400,26 @@ func parseJsonConfigFileContentWorker( seenContentMapperExtensions := make(map[string]struct{}, totalContentMapperExtensions) contentMapperExtensions := make([]string, 0, totalContentMapperExtensions) nativeExtensions := core.Flatten(tspath.AllSupportedExtensionsWithJson) + canonicalExtension := func(extension string) string { + return tspath.GetCanonicalFileName(extension, host.FS().UseCaseSensitiveFileNames()) + } for j, mapper := range contentMappers { validExtensions := make([]string, 0, len(mapper.Definition.Extensions)) for _, ext := range mapper.Definition.Extensions { extNode := getContentMapperExtensionSyntax(contentMapperSourceFile, contentMapperIndices[j], ext) + canonicalExt := canonicalExtension(ext) switch { case !strings.HasPrefix(ext, "."): errors = append(errors, setContentMapperDiagnosticLocation(ast.NewCompilerDiagnostic(diagnostics.Content_mapper_file_extension_0_must_begin_with_a, ext), contentMapperSourceFile, extNode)) - case slices.Contains(nativeExtensions, ext): + case slices.ContainsFunc(nativeExtensions, func(nativeExtension string) bool { + return strings.EqualFold(nativeExtension, ext) + }): errors = append(errors, setContentMapperDiagnosticLocation(ast.NewCompilerDiagnostic(diagnostics.Content_mapper_file_extension_0_is_a_built_in_extension_and_cannot_be_registered_by_a_content_mapper, ext), contentMapperSourceFile, extNode)) default: - if _, seen := seenContentMapperExtensions[ext]; seen { + if _, seen := seenContentMapperExtensions[canonicalExt]; seen { errors = append(errors, setContentMapperDiagnosticLocation(ast.NewCompilerDiagnostic(diagnostics.Content_mapper_file_extension_0_is_registered_by_more_than_one_content_mapper, ext), contentMapperSourceFile, extNode)) } else { - seenContentMapperExtensions[ext] = struct{}{} + seenContentMapperExtensions[canonicalExt] = struct{}{} contentMapperExtensions = append(contentMapperExtensions, ext) validExtensions = append(validExtensions, ext) } diff --git a/tsc/internal/tsoptions/tsconfigparsing_test.go b/tsc/internal/tsoptions/tsconfigparsing_test.go index 9b4d054bbacec..85d15386919dc 100644 --- a/tsc/internal/tsoptions/tsconfigparsing_test.go +++ b/tsc/internal/tsoptions/tsconfigparsing_test.go @@ -1410,6 +1410,71 @@ func TestContentMappersValidation(t *testing.T) { } } +func TestContentMapperExtensionValidationUsesHostCaseSensitivity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + useCaseSensitiveFileNames bool + contentMappers string + expectedCode int32 + }{ + { + name: "built-in extension on case-insensitive host", + useCaseSensitiveFileNames: false, + contentMappers: `[{ "package": "mapper", "extensions": [".TS"] }]`, + expectedCode: diagnostics.Content_mapper_file_extension_0_is_a_built_in_extension_and_cannot_be_registered_by_a_content_mapper.Code(), + }, + { + name: "duplicate extension on case-insensitive host", + useCaseSensitiveFileNames: false, + contentMappers: `[{ "package": "a", "extensions": [".vue"] }, { "package": "b", "extensions": [".VUE"] }]`, + expectedCode: diagnostics.Content_mapper_file_extension_0_is_registered_by_more_than_one_content_mapper.Code(), + }, + { + name: "built-in extension on case-sensitive host", + useCaseSensitiveFileNames: true, + contentMappers: `[{ "package": "mapper", "extensions": [".TS"] }]`, + expectedCode: diagnostics.Content_mapper_file_extension_0_is_a_built_in_extension_and_cannot_be_registered_by_a_content_mapper.Code(), + }, + { + name: "mapper extension casing is distinct on case-sensitive host", + useCaseSensitiveFileNames: true, + contentMappers: `[{ "package": "a", "extensions": [".vue"] }, { "package": "b", "extensions": [".VUE"] }]`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + files := map[string]string{ + "/tsconfig.json": `{ "contentMappers": ` + test.contentMappers + ` }`, + "/app.ts": "export {};", + "/node_modules/mapper/package.json": `{ "name": "mapper", "version": "1.0.0", "typescript": { "contentMapper": { "exec": ["mapper"] } } }`, + "/node_modules/a/package.json": `{ "name": "a", "version": "1.0.0", "typescript": { "contentMapper": { "exec": ["a"] } } }`, + "/node_modules/b/package.json": `{ "name": "b", "version": "1.0.0", "typescript": { "contentMapper": { "exec": ["b"] } } }`, + } + host := tsoptionstest.NewVFSParseConfigHost(files, "/", test.useCaseSensitiveFileNames) + config := testConfig{ + jsonText: files["/tsconfig.json"], + configFileName: "tsconfig.json", + basePath: "/", + allFileList: files, + existingOptions: &core.CompilerOptions{RunExternalCode: core.TSTrue}, + } + parsed := getParsedWithJsonSourceFileApi(config, host, config.basePath) + if test.expectedCode == 0 { + assert.Equal(t, len(parsed.Errors), 0, "unexpected errors: %v", parsed.Errors) + } else { + found := slices.ContainsFunc(parsed.Errors, func(diagnostic *ast.Diagnostic) bool { + return diagnostic.Code() == test.expectedCode + }) + assert.Assert(t, found, "expected diagnostic %d, got errors: %v", test.expectedCode, parsed.Errors) + } + }) + } +} + func getParsedWithJsonSourceFileApi(config testConfig, host tsoptions.ParseConfigHost, basePath string) *tsoptions.ParsedCommandLine { configFileName := tspath.GetNormalizedAbsolutePath(config.configFileName, basePath) path := tspath.ToPath(config.configFileName, basePath, host.FS().UseCaseSensitiveFileNames()) From 72fbd4c8de1ef31cba6dbd38a81c2e7bf710484c Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 19 Aug 2026 16:00:10 -0700 Subject: [PATCH 04/18] Fix zero-length spans --- packages/typescript/src/ast/spanMap.ts | 14 ++++++++-- packages/typescript/test/spanMap.test.ts | 15 +++++++++++ tsc/internal/spanmap/spanmap.go | 22 +++++++++------ tsc/internal/spanmap/spanmap_test.go | 34 ++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/packages/typescript/src/ast/spanMap.ts b/packages/typescript/src/ast/spanMap.ts index 39cbfbdc584af..61801fba5ecd0 100644 --- a/packages/typescript/src/ast/spanMap.ts +++ b/packages/typescript/src/ast/spanMap.ts @@ -131,7 +131,13 @@ export class SpanMap { originalToVirtualSpans(range: ReadonlyTextRange, feature: SpanMapFeature): readonly MappedRange[] { const start = range.pos; const end = Math.max(range.end, start); - const lastCharacter = end > start ? end - 1 : end; + if (start === end) { + return this.originalToVirtualPositions(start, feature).map(({ position, fidelity }) => ({ + range: { pos: position, end: position }, + fidelity, + })); + } + const lastCharacter = end - 1; const originalSegments = this.getOriginalSegments(); const startSegments = segmentsAtOriginalPosition(originalSegments, start); const endSegments = segmentsAtOriginalPosition(originalSegments, lastCharacter); @@ -156,8 +162,12 @@ export class SpanMap { private mapRange(range: ReadonlyTextRange, segments: readonly SpanMapSegment[], reverse: boolean): MappedRange { const start = range.pos; const end = Math.max(range.end, start); + if (start === end) { + const { position, fidelity } = this.mapPoint(start, segments, reverse); + return { range: { pos: position, end: position }, fidelity }; + } const [startIndex, startInside] = segmentIndexAt(segments, start, reverse); - const endProbe = end > start ? end - 1 : end; + const endProbe = end - 1; const [endIndex, endInside] = segmentIndexAt(segments, endProbe, reverse); if (startIndex === endIndex && startInside === endInside) { diff --git a/packages/typescript/test/spanMap.test.ts b/packages/typescript/test/spanMap.test.ts index 31fae97bfa084..98b941941f4fe 100644 --- a/packages/typescript/test/spanMap.test.ts +++ b/packages/typescript/test/spanMap.test.ts @@ -72,6 +72,21 @@ describe("SpanMap", () => { ]); }); + test("maps zero-length spans at segment ends", () => { + assert.deepEqual(map.virtualToOriginalSpan({ pos: 18, end: 18 }), { + range: { pos: 34, end: 34 }, + fidelity: SpanMapFidelity.Exact, + }); + for (const originalEnd of [14, 34]) { + const positions = map.originalToVirtualPositions(originalEnd, SpanMapFeature.All); + assert.equal(positions.length, 1); + assert.deepEqual(map.originalToVirtualSpans({ pos: originalEnd, end: originalEnd }, SpanMapFeature.All), [{ + range: { pos: positions[0].position, end: positions[0].position }, + fidelity: positions[0].fidelity, + }]); + } + }); + test("sorts virtual and original indexes independently", () => { const reordered = new SpanMap([ { virtualStart: 0, virtualEnd: 2, originalStart: 10, originalEnd: 12, kind: SpanMapKind.Verbatim }, diff --git a/tsc/internal/spanmap/spanmap.go b/tsc/internal/spanmap/spanmap.go index faef971e2a02f..5f8b7cd677d78 100644 --- a/tsc/internal/spanmap/spanmap.go +++ b/tsc/internal/spanmap/spanmap.go @@ -244,12 +244,13 @@ func (m *SpanMap) VirtualToOriginalSpan(r core.TextRange) (core.TextRange, Fidel } virtualStart := core.TextPos(r.Pos()) virtualEnd := max(core.TextPos(r.End()), virtualStart) + if virtualStart == virtualEnd { + position, fidelity := m.VirtualToOriginalPosition(virtualStart) + return core.NewTextRange(int(position), int(position)), fidelity + } startIdx, startIn := m.segmentIndexAt(virtualStart) - endProbe := virtualEnd - if virtualEnd > virtualStart { - endProbe = virtualEnd - 1 - } + endProbe := virtualEnd - 1 endIdx, endIn := m.segmentIndexAt(endProbe) if startIdx == endIdx && startIn == endIn { @@ -362,7 +363,7 @@ func (m *SpanMap) segmentIndexAt(pos core.TextPos) (int, bool) { return idx, true } prev := idx - 1 - if prev >= 0 && pos < m.segments[prev].VirtualEnd { + if prev >= 0 && (pos < m.segments[prev].VirtualEnd || prev == len(m.segments)-1 && pos == m.segments[prev].VirtualEnd) { return prev, true } return prev, false @@ -464,10 +465,15 @@ func (m *SpanMap) OriginalToVirtualSpans(r core.TextRange, feature Feature) []Ma } start := core.TextPos(r.Pos()) end := max(core.TextPos(r.End()), start) - lastCharacter := end - if end > start { - lastCharacter-- + if start == end { + return core.Map(m.OriginalToVirtualPositions(start, feature), func(position MappedPosition) MappedSpan { + return MappedSpan{ + Span: core.NewTextRange(int(position.Position), int(position.Position)), + Fidelity: position.Fidelity, + } + }) } + lastCharacter := end - 1 originalSegments := m.origIndex() startSegments, startInside := segmentsAtOriginalPosition(originalSegments, start) endSegments, endInside := segmentsAtOriginalPosition(originalSegments, lastCharacter) diff --git a/tsc/internal/spanmap/spanmap_test.go b/tsc/internal/spanmap/spanmap_test.go index d26c6d885034f..da9019852223c 100644 --- a/tsc/internal/spanmap/spanmap_test.go +++ b/tsc/internal/spanmap/spanmap_test.go @@ -160,6 +160,40 @@ func TestVirtualToOriginalPosition(t *testing.T) { } } +func TestZeroLengthSpansAtSegmentEnds(t *testing.T) { + t.Parallel() + + m := spanmap.New([]spanmap.Segment{ + {VirtualStart: 0, VirtualEnd: 10, OriginalStart: 100, OriginalEnd: 110, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + {VirtualStart: 20, VirtualEnd: 30, OriginalStart: 200, OriginalEnd: 210, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + }) + + position, fidelity := m.VirtualToOriginalPosition(30) + assert.Equal(t, position, core.TextPos(210)) + assert.Equal(t, fidelity, spanmap.FidelityExact) + virtualSpan, fidelity := m.VirtualToOriginalSpan(core.NewTextRange(30, 30)) + assert.Equal(t, virtualSpan, core.NewTextRange(210, 210)) + assert.Equal(t, fidelity, spanmap.FidelityExact) + + for _, test := range []struct { + name string + originalEnd int + }{ + {name: "before gap", originalEnd: 110}, + {name: "final", originalEnd: 210}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + positions := m.OriginalToVirtualPositions(core.TextPos(test.originalEnd), spanmap.FeatureHover) + spans := m.OriginalToVirtualSpans(core.NewTextRange(test.originalEnd, test.originalEnd), spanmap.FeatureHover) + assert.Equal(t, len(positions), 1) + assert.Equal(t, len(spans), 1) + assert.Equal(t, spans[0].Span, core.NewTextRange(int(positions[0].Position), int(positions[0].Position))) + assert.Equal(t, spans[0].Fidelity, positions[0].Fidelity) + }) + } +} + func TestMapPositionNilIdentity(t *testing.T) { t.Parallel() From ceab120488f0b6b48e7bdaaa2b95ea078e64250c Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 19 Aug 2026 16:11:08 -0700 Subject: [PATCH 05/18] Fix extension API naming --- packages/vscode-typescript/package.json | 2 +- .../src/contentMapperContributions.ts | 22 +++++------ .../test/contentMapperContributions.test.ts | 38 +++++++++++++++++++ packages/vscode-typescript/test/tsconfig.json | 6 +++ 4 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 packages/vscode-typescript/test/tsconfig.json diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index 52e432dcc984b..94675d8ada52d 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -370,7 +370,7 @@ "scripts": { "build": "tsc && npm run bundle", "bundle": "esbuild src/extension.ts --bundle --external:vscode --platform=node --format=cjs --outfile=dist/extension.bundle.js --sourcemap", - "test": "esbuild test/index.test.ts --bundle --platform=node --format=cjs --outfile=dist/test/index.test.cjs && node --test dist/test/index.test.cjs", + "test": "tsc -p test && esbuild test/index.test.ts --bundle --platform=node --format=cjs --outfile=dist/test/index.test.cjs && node --test dist/test/index.test.cjs", "watch": "npm run bundle -- --watch", "generateLocTest": "npx @vscode/l10n-dev generate-pseudo -o ./l10n/ ./l10n/bundle.l10n.json ./package.nls.json", "generateLocBundle": "npx @vscode/l10n-dev export --outDir ./l10n ./src", diff --git a/packages/vscode-typescript/src/contentMapperContributions.ts b/packages/vscode-typescript/src/contentMapperContributions.ts index fdc48d8a0de9f..a4570d7bd96bb 100644 --- a/packages/vscode-typescript/src/contentMapperContributions.ts +++ b/packages/vscode-typescript/src/contentMapperContributions.ts @@ -11,7 +11,7 @@ export interface ContentMapperManifest { export interface ContentMapperContribution { readonly extensions: readonly string[]; - readonly inferredProject?: { + readonly inferredProjectContribution?: { readonly options?: Readonly>; readonly manifest: ContentMapperManifest; }; @@ -42,13 +42,13 @@ export function serializeContentMapperContributions( result.push({ contributorId, extensions: [...contribution.extensions], - inferredProjectContribution: contribution.inferredProject && { - options: contribution.inferredProject.options, + inferredProjectContribution: contribution.inferredProjectContribution && { + options: contribution.inferredProjectContribution.options, manifest: { - ...contribution.inferredProject.manifest, - exec: [...contribution.inferredProject.manifest.exec], - cwd: contribution.inferredProject.manifest.cwd?.fsPath, - compilerOptions: contribution.inferredProject.manifest.compilerOptions && [...contribution.inferredProject.manifest.compilerOptions], + ...contribution.inferredProjectContribution.manifest, + exec: [...contribution.inferredProjectContribution.manifest.exec], + cwd: contribution.inferredProjectContribution.manifest.cwd?.fsPath, + compilerOptions: contribution.inferredProjectContribution.manifest.compilerOptions && [...contribution.inferredProjectContribution.manifest.compilerOptions], }, }, }); @@ -65,14 +65,14 @@ export function validateContentMapperRegistration(contributorId: string, contrib if (contribution.extensions.length === 0 || contribution.extensions.some(extension => !extension.startsWith(".") || extension.length === 1)) { throw new TypeError("Content mapper contributions require non-empty extensions beginning with '.'."); } - const inferredProject = contribution.inferredProject; - if (inferredProject?.options === null || Array.isArray(inferredProject?.options) || inferredProject?.options !== undefined && typeof inferredProject.options !== "object") { + const inferredProjectContribution = contribution.inferredProjectContribution; + if (inferredProjectContribution?.options === null || Array.isArray(inferredProjectContribution?.options) || inferredProjectContribution?.options !== undefined && typeof inferredProjectContribution.options !== "object") { throw new TypeError("Content mapper contribution options must be an object."); } - if (inferredProject && (!inferredProject.manifest.name || inferredProject.manifest.exec.length === 0)) { + if (inferredProjectContribution && (!inferredProjectContribution.manifest.name || inferredProjectContribution.manifest.exec.length === 0)) { throw new TypeError("Content mapper contribution manifests require a name and non-empty exec."); } - if (inferredProject?.manifest.cwd && inferredProject.manifest.cwd.scheme !== "file") { + if (inferredProjectContribution?.manifest.cwd && inferredProjectContribution.manifest.cwd.scheme !== "file") { throw new TypeError("Content mapper contribution cwd must be a file URI."); } } diff --git a/packages/vscode-typescript/test/contentMapperContributions.test.ts b/packages/vscode-typescript/test/contentMapperContributions.test.ts index 04dea47e5f245..74c4dd07116f9 100644 --- a/packages/vscode-typescript/test/contentMapperContributions.test.ts +++ b/packages/vscode-typescript/test/contentMapperContributions.test.ts @@ -3,8 +3,23 @@ import test from "node:test"; import { type ContentMapperContribution, documentMatchesContentMapperContributions, + serializeContentMapperContributions, } from "../src/contentMapperContributions"; +const documentedContribution = { + extensions: [".vue"], + inferredProjectContribution: { + options: { strictTemplates: true }, + manifest: { + name: "Vue mapper", + version: "1.2.3", + exec: ["node", "mapper.js"], + compilerOptions: ["strict"], + dynamicConfig: true, + }, + }, +} satisfies ContentMapperContribution; + test("content mapper extensions match document paths case-insensitively", () => { const registrations = new Map([[ "publisher.extension", @@ -14,3 +29,26 @@ test("content mapper extensions match document paths case-insensitively", () => assert.equal(documentMatchesContentMapperContributions(document, registrations), true); }); + +test("serializes the documented inferred project contribution", () => { + const registrations = new Map([[ + "publisher.extension", + [documentedContribution], + ]]); + + assert.deepEqual(serializeContentMapperContributions(registrations), [{ + contributorId: "publisher.extension", + extensions: [".vue"], + inferredProjectContribution: { + options: { strictTemplates: true }, + manifest: { + name: "Vue mapper", + version: "1.2.3", + exec: ["node", "mapper.js"], + cwd: undefined, + compilerOptions: ["strict"], + dynamicConfig: true, + }, + }, + }]); +}); diff --git a/packages/vscode-typescript/test/tsconfig.json b/packages/vscode-typescript/test/tsconfig.json new file mode 100644 index 0000000000000..e4aa2a217c5bd --- /dev/null +++ b/packages/vscode-typescript/test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": ".." + } +} From 36481ede1435b509659e0072b58285f6e850ddc3 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 19 Aug 2026 16:28:18 -0700 Subject: [PATCH 06/18] Filter supplemental outputs from auto-imports --- .../tests/contentMapperAutoImports_test.go | 24 +++++++++++++++++++ tsc/internal/ls/autoimport/registry.go | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tsc/internal/fourslash/tests/contentMapperAutoImports_test.go b/tsc/internal/fourslash/tests/contentMapperAutoImports_test.go index 1c7bc5fe277f9..2f306546bf36c 100644 --- a/tsc/internal/fourslash/tests/contentMapperAutoImports_test.go +++ b/tsc/internal/fourslash/tests/contentMapperAutoImports_test.go @@ -180,6 +180,30 @@ const value = help/**/; }) } +func TestContentMapperSupplementalFilesAreNotAutoImportTargets(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /lib.astro +export const supplementalOnly = 1; + +// @Filename: /main.ts +supplementalOn/**/ +`, contentmappertest.SupplementalMapper, ".astro") + defer done() + + f.VerifyCompletions(t, "", &fourslash.CompletionsExpectedList{ + UserPreferences: &lsutil.UserPreferences{ + IncludeCompletionsForModuleExports: core.TSTrue, + IncludeCompletionsForImportStatements: core.TSTrue, + }, + ItemDefaults: &fourslash.CompletionsExpectedItemDefaults{ + CommitCharacters: &DefaultCommitCharacters, + EditRange: Ignored, + }, + Items: &fourslash.CompletionsExpectedItems{Excludes: []string{"supplementalOnly"}}, + }) +} + func TestContentMapperNodeModulesAutoImports(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") diff --git a/tsc/internal/ls/autoimport/registry.go b/tsc/internal/ls/autoimport/registry.go index 226eb9ae74fc7..2106e1b44310a 100644 --- a/tsc/internal/ls/autoimport/registry.go +++ b/tsc/internal/ls/autoimport/registry.go @@ -1121,7 +1121,7 @@ func hasNewNonNodeModulesFiles(program *compiler.Program, bucket *RegistryBucket return false } for _, file := range program.GetSourceFiles() { - if strings.Contains(file.FileName(), "/node_modules/") || isIgnoredFile(program, file) { + if file.IsContentMapperSupplemental() || strings.Contains(file.FileName(), "/node_modules/") || isIgnoredFile(program, file) { continue } if _, ok := bucket.Paths[file.Path()]; !ok { @@ -1240,7 +1240,7 @@ func (b *registryBuilder) buildProjectBucket( var combinedStats extractorStats for _, file := range program.GetSourceFiles() { - if isIgnoredFile(program, file) { + if file.IsContentMapperSupplemental() || isIgnoredFile(program, file) { continue } if fileExcludePatterns != nil && fileExcludePatterns.MatchString(file.FileName()) { From 7cb54852312d8d83f4856bab1a7ec063ef2391d2 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 19 Aug 2026 17:06:04 -0700 Subject: [PATCH 07/18] Fix CLI error reporting to never show virtual filenames --- .../diagnosticwriter/diagnosticwriter.go | 22 ++++++++++++++--- .../tsctests/contentmapper_watch_test.go | 24 +++++++++++++++++++ .../testutil/tsbaseline/error_baseline.go | 15 ++++-------- ...ntMapperSupplementalDiagnostics.errors.txt | 4 ++-- ...contentMapperSupplementalModule.errors.txt | 2 +- 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/tsc/internal/diagnosticwriter/diagnosticwriter.go b/tsc/internal/diagnosticwriter/diagnosticwriter.go index 57aee4053410c..335589a29133d 100644 --- a/tsc/internal/diagnosticwriter/diagnosticwriter.go +++ b/tsc/internal/diagnosticwriter/diagnosticwriter.go @@ -59,11 +59,18 @@ func (d *ASTDiagnostic) File() FileLike { if file == nil { return nil } + fileName := file.FileName() + if canonical := file.CanonicalSourceFile(); canonical != nil { + fileName = canonical.FileName() + } if d.resolve().useOriginal { // The mapper's own diagnostics (Source != "") already carry original ranges; compiler // diagnostics have their transformed ranges mapped back. Both render against the original, // untransformed text. Diagnostics in synthesized code (see resolve) keep the virtual text. - return newOriginalTextFile(file) + return newOriginalTextFile(file, fileName) + } + if fileName != file.FileName() { + return &renamedFile{file: file, fileName: fileName} } return file } @@ -114,10 +121,10 @@ type originalTextFile struct { lineMap []core.TextPos } -func newOriginalTextFile(file *ast.SourceFile) *originalTextFile { +func newOriginalTextFile(file *ast.SourceFile, fileName string) *originalTextFile { text := file.OriginalText() return &originalTextFile{ - fileName: file.FileName(), + fileName: fileName, text: text, lineMap: []core.TextPos(core.ComputeECMALineStarts(text)), } @@ -127,6 +134,15 @@ func (f *originalTextFile) FileName() string { return f.fileName } func (f *originalTextFile) Text() string { return f.text } func (f *originalTextFile) ECMALineMap() []core.TextPos { return f.lineMap } +type renamedFile struct { + file *ast.SourceFile + fileName string +} + +func (f *renamedFile) FileName() string { return f.fileName } +func (f *renamedFile) Text() string { return f.file.Text() } +func (f *renamedFile) ECMALineMap() []core.TextPos { return f.file.ECMALineMap() } + func (d *ASTDiagnostic) MessageChain() []Diagnostic { chain := d.Diagnostic.MessageChain() result := make([]Diagnostic, 0, len(chain)+1) diff --git a/tsc/internal/execute/tsctests/contentmapper_watch_test.go b/tsc/internal/execute/tsctests/contentmapper_watch_test.go index 4043e6b51b601..236d0640ba7ce 100644 --- a/tsc/internal/execute/tsctests/contentmapper_watch_test.go +++ b/tsc/internal/execute/tsctests/contentmapper_watch_test.go @@ -82,6 +82,30 @@ func TestContentMapperBuildLifecycle(t *testing.T) { assert.Equal(t, spawner.closes.Load(), int32(1)) } +func TestContentMapperSupplementalDiagnosticUsesOriginalFileName(t *testing.T) { + t.Parallel() + input := &tscInput{files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{ + "compilerOptions": { "noEmit": true }, + "contentMappers": [{ "package": "mapper", "extensions": [".astro"] }] + }`, + "/home/src/workspaces/project/app.astro": `const value: string = 1;`, + "/home/src/workspaces/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.SupplementalDiagnosticsMapper), + }} + testSys := newTestSys(input, false) + sys := &recordingContentMapperSystem{ + TestSys: testSys, + spawner: &recordingContentMapperSpawner{inner: contentmappertest.NewSpawner()}, + } + + result := execute.CommandLine(t.Context(), sys, []string{"--pretty", "false", "--runExternalCode"}, testSys) + assert.Equal(t, result.Status, tsc.ExitStatusDiagnosticsPresent_OutputsGenerated) + output := testSys.currentWrite.String() + assert.Assert(t, strings.Contains(output, "app.astro(1,1): error TS2304"), output) + assert.Assert(t, strings.Contains(output, "app.astro(1,7): error TS2322"), output) + assert.Assert(t, !strings.Contains(output, "app.astro.0.ts"), output) +} + func TestContentMapperBuildDetectsNewPhysicalSupplementalFile(t *testing.T) { t.Parallel() const supplementalFileName = "/home/src/workspaces/project/app.vue.0.ts" diff --git a/tsc/internal/testutil/tsbaseline/error_baseline.go b/tsc/internal/testutil/tsbaseline/error_baseline.go index 1ce4cc74be5f7..9ab14f23a5bcb 100644 --- a/tsc/internal/testutil/tsbaseline/error_baseline.go +++ b/tsc/internal/testutil/tsbaseline/error_baseline.go @@ -252,20 +252,15 @@ func iterateErrorBaseline[T diagnosticwriter.Diagnostic](t *testing.T, inputFile return d.File() != nil && isTsConfigFile(d.File().FileName()) }, ) - contentMapperSupplementalFileNames := map[string]struct{}{} - for _, diagnostic := range diagnostics { - if file, ok := diagnostic.File().(*ast.SourceFile); ok && file.IsContentMapperSupplemental() { - contentMapperSupplementalFileNames[file.FileName()] = struct{}{} - } - } numContentMapperSupplementalDiagnostics := core.CountWhere( diagnostics, func(d T) bool { - if d.File() == nil { - return false + if diagnostic, ok := any(d).(*diagnosticwriter.ASTDiagnostic); ok { + file := diagnostic.Diagnostic.File() + return file != nil && file.IsContentMapperSupplemental() } - _, ok := contentMapperSupplementalFileNames[d.File().FileName()] - return ok + file, ok := d.File().(*ast.SourceFile) + return ok && file.IsContentMapperSupplemental() }, ) // Verify we didn't miss any errors in total diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperSupplementalDiagnostics.errors.txt b/tsc/testdata/baselines/reference/compiler/contentMapperSupplementalDiagnostics.errors.txt index 8d80e3a407194..a0a227846685d 100644 --- a/tsc/testdata/baselines/reference/compiler/contentMapperSupplementalDiagnostics.errors.txt +++ b/tsc/testdata/baselines/reference/compiler/contentMapperSupplementalDiagnostics.errors.txt @@ -1,6 +1,6 @@ -/component.astro.0.ts(1,1): error TS2304: Cannot find name 'missingSupplementalGlobal'. +/component.astro(1,1): error TS2304: Cannot find name 'missingSupplementalGlobal'. This location is in virtual code produced by the content mapper 'mapper@1.0.0' and has no corresponding location in the original file. -/component.astro.0.ts(1,7): error TS2322: Type 'string' is not assignable to type 'number'. +/component.astro(1,7): error TS2322: Type 'string' is not assignable to type 'number'. ==== /tsconfig.json (0 errors) ==== diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperSupplementalModule.errors.txt b/tsc/testdata/baselines/reference/compiler/contentMapperSupplementalModule.errors.txt index 2e3bb0e849740..2cd73138a208d 100644 --- a/tsc/testdata/baselines/reference/compiler/contentMapperSupplementalModule.errors.txt +++ b/tsc/testdata/baselines/reference/compiler/contentMapperSupplementalModule.errors.txt @@ -1,4 +1,4 @@ -/component.vue.0.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'. +/component.vue(1,14): error TS2322: Type 'string' is not assignable to type 'number'. This location is in virtual code produced by the content mapper 'mapper@1.0.0' and has no corresponding location in the original file. From fcd9b500a6df32e2290697bdc125718e1d2fbf50 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 20 Aug 2026 08:14:59 -0700 Subject: [PATCH 08/18] Disable file fallback navigation results for unmappable spans --- .../contentMapperDuplicateMappings_test.go | 17 ++++++ tsc/internal/ls/definition.go | 16 ----- tsc/internal/ls/findallreferences.go | 46 ++++---------- .../testutil/contentmappertest/component.go | 14 ++++- ...erDisabledNavigationTargets.baseline.jsonc | 5 ++ ...erDisabledNavigationTargets.baseline.jsonc | 5 ++ ...ntMapperDisabledNavigationTargets.baseline | 60 +++++++++++++++++++ 7 files changed, 112 insertions(+), 51 deletions(-) create mode 100644 tsc/testdata/baselines/reference/fourslash/findAllReferences/contentMapperDisabledNavigationTargets.baseline.jsonc create mode 100644 tsc/testdata/baselines/reference/fourslash/goToDefinition/contentMapperDisabledNavigationTargets.baseline.jsonc create mode 100644 tsc/testdata/baselines/reference/fourslash/vsFindAllReferences/contentMapperDisabledNavigationTargets.baseline diff --git a/tsc/internal/fourslash/tests/contentMapperDuplicateMappings_test.go b/tsc/internal/fourslash/tests/contentMapperDuplicateMappings_test.go index e074b734f5f6e..4bd575384809b 100644 --- a/tsc/internal/fourslash/tests/contentMapperDuplicateMappings_test.go +++ b/tsc/internal/fourslash/tests/contentMapperDuplicateMappings_test.go @@ -37,6 +37,23 @@ val/*query*/ue f.VerifyBaselineFindAllReferences(t, "query") } +func TestContentMapperDisabledNavigationTargets(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /disabled.dup +value + +// @Filename: /main.ts +import { value } from "./disabled.dup"; +export const result = val/*query*/ue; +`, contentmappertest.DuplicateMapper, ".dup") + defer done() + + f.VerifyBaselineGoToDefinition(t, false, "query") + f.VerifyBaselineFindAllReferences(t, "query") + f.VerifyBaselineVSFindAllReferences(t, "query") +} + func TestContentMapperConflictingDuplicateRenameMappings(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") diff --git a/tsc/internal/ls/definition.go b/tsc/internal/ls/definition.go index 29b48008199e1..bb86c998257af 100644 --- a/tsc/internal/ls/definition.go +++ b/tsc/internal/ls/definition.go @@ -219,8 +219,6 @@ func (l *LanguageService) createDefinitionLocations( ) lsproto.DefinitionResponse { locations := make([]*lsproto.LocationLink, 0) locationRanges := collections.Set[fileRange]{} - concreteTargets := collections.Set[lsproto.DocumentUri]{} - var fileFallbacks []*lsproto.LocationLink if reference != nil { targetRange := lsproto.Range{ @@ -259,20 +257,12 @@ func (l *LanguageService) createDefinitionLocations( } targetSelectionLoc, selectionFidelity := l.sourceFileRangeToLSPLocationForFeature(file, nameRange, feature) if !selectionFidelity.IsSingleSegment() { - zeroRange := lsproto.Range{} - fileFallbacks = append(fileFallbacks, &lsproto.LocationLink{ - OriginSelectionRange: &originSelectionRange, - TargetSelectionRange: zeroRange, - TargetUri: targetSelectionLoc.Uri, - TargetRange: zeroRange, - }) continue } targetLoc, contextFidelity := l.sourceFileRangeToLSPLocation(file, *contextRange) if contextFidelity.IsNone() || targetLoc.Uri != targetSelectionLoc.Uri || !lspRangeContains(targetLoc.Range, targetSelectionLoc.Range) { targetLoc = targetSelectionLoc } - concreteTargets.Add(targetSelectionLoc.Uri) locations = append(locations, &lsproto.LocationLink{ OriginSelectionRange: &originSelectionRange, TargetSelectionRange: targetSelectionLoc.Range, @@ -281,12 +271,6 @@ func (l *LanguageService) createDefinitionLocations( }) } } - for _, fallback := range fileFallbacks { - if !concreteTargets.Has(fallback.TargetUri) { - concreteTargets.Add(fallback.TargetUri) - locations = append(locations, fallback) - } - } if clientSupportsLink { return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{DefinitionLinks: &locations} diff --git a/tsc/internal/ls/findallreferences.go b/tsc/internal/ls/findallreferences.go index c31e44765eb67..2925111b9c183 100644 --- a/tsc/internal/ls/findallreferences.go +++ b/tsc/internal/ls/findallreferences.go @@ -179,11 +179,6 @@ func (l *LanguageService) getFileNameOfEntry(entry *ReferenceEntry) lsproto.Docu return l.resolveEntry(entry).lspRange.Uri } -func (l *LanguageService) getLocationOfEntry(entry *ReferenceEntry) (lsproto.Location, bool) { - resolved := l.resolveEntry(entry) - return *resolved.lspRange, !resolved.unmappable -} - func (l *LanguageService) getLocationOfEntryForFeature(entry *ReferenceEntry, feature spanmap.Feature) (lsproto.Location, bool) { l.resolveEntrySource(entry) location, fidelity := l.sourceFileRangeToLSPLocationForFeature(entry.sourceFile, *entry.textRange, feature) @@ -804,7 +799,7 @@ func (l *LanguageService) symbolAndEntriesToVSReferences(ctx context.Context, pa } // Convert definition to info - defInfo := l.definitionToReferencedSymbolDefinitionInfo(ctx, s.definition, data.OriginalNode, vsCapability) + defInfo := l.definitionToReferencedSymbolDefinitionInfo(ctx, s.definition, data.OriginalNode, vsCapability, spanmap.FeatureReferences) if defInfo == nil { continue } @@ -830,7 +825,7 @@ func (l *LanguageService) symbolAndEntriesToVSReferences(ctx context.Context, pa continue } - refLocation, ok := l.getLocationOfEntry(ref) + refLocation, ok := l.getLocationOfEntryForFeature(ref, spanmap.FeatureReferences) if !ok { continue } @@ -864,7 +859,7 @@ type referencedSymbolDefinitionInfo struct { } // definitionToReferencedSymbolDefinitionInfo converts a Definition to display info -func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context.Context, def *Definition, originalNode *ast.Node, vsCapability bool) *referencedSymbolDefinitionInfo { +func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context.Context, def *Definition, originalNode *ast.Node, vsCapability bool, feature spanmap.Feature) *referencedSymbolDefinitionInfo { switch def.Kind { case definitionKindSymbol: symbol := def.symbol @@ -883,7 +878,7 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context node = originalNode } - loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntryForFeature(&ReferenceEntry{kind: entryKindNode, node: node}, feature) if !ok { return nil } @@ -898,7 +893,7 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context if node == nil { return nil } - loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntryForFeature(&ReferenceEntry{kind: entryKindNode, node: node}, feature) if !ok { return nil } @@ -916,7 +911,7 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context return nil } name := scanner.TokenToString(node.Kind) - loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntryForFeature(&ReferenceEntry{kind: entryKindNode, node: node}, feature) if !ok { return nil } @@ -938,7 +933,7 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context return nil } element := l.getDefinitionKindAndDisplayParts(ctx, symbol, node, vsCapability) - loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntryForFeature(&ReferenceEntry{kind: entryKindNode, node: node}, feature) if !ok { return nil } @@ -953,7 +948,7 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context if node == nil { return nil } - loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntryForFeature(&ReferenceEntry{kind: entryKindNode, node: node}, feature) if !ok { return nil } @@ -970,7 +965,7 @@ func (l *LanguageService) definitionToReferencedSymbolDefinitionInfo(ctx context return nil } node := def.tripleSlashFileRef.file.AsNode() - loc, ok := l.getLocationOfEntry(&ReferenceEntry{kind: entryKindNode, node: node}) + loc, ok := l.getLocationOfEntryForFeature(&ReferenceEntry{kind: entryKindNode, node: node}, feature) if !ok { return nil } @@ -1040,7 +1035,7 @@ func (l *LanguageService) symbolAndEntriesToImplementations(ctx context.Context, links := l.convertEntriesToLocationLinks(entries, spanmap.FeatureImplementation) return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{DefinitionLinks: &links}, nil } - locations := l.convertEntriesToLocations(entries, nil /*definitionSymbol*/, spanmap.FeatureImplementation) + locations := l.convertEntriesToLocations(entries, spanmap.FeatureImplementation) return lsproto.LocationOrLocationsOrDefinitionLinksOrNull{Locations: &locations}, nil } @@ -1055,11 +1050,7 @@ func (l *LanguageService) convertSymbolAndEntriesToLocations(s *SymbolAndEntries }) } - var definitionSymbol *ast.Symbol - if includeDeclarations && s.definition != nil { - definitionSymbol = s.definition.symbol - } - return l.convertEntriesToLocations(references, definitionSymbol, feature) + return l.convertEntriesToLocations(references, feature) } func isDeclarationOfSymbol(node *ast.Node, target *ast.Symbol) bool { @@ -1086,25 +1077,12 @@ func isDeclarationOfSymbol(node *ast.Node, target *ast.Symbol) bool { }) } -func (l *LanguageService) convertEntriesToLocations(entries []*ReferenceEntry, definitionSymbol *ast.Symbol, feature spanmap.Feature) []lsproto.Location { - // A synthesized declaration has no source span, but it still represents the symbol's definition in - // that file. Mirror go-to-definition's file-level fallback while continuing to omit synthesized uses. - concreteFiles := collections.Set[lsproto.DocumentUri]{} - for _, entry := range entries { - if location, ok := l.getLocationOfEntryForFeature(entry, feature); ok { - concreteFiles.Add(location.Uri) - } - } - +func (l *LanguageService) convertEntriesToLocations(entries []*ReferenceEntry, feature spanmap.Feature) []lsproto.Location { locations := make([]lsproto.Location, 0, len(entries)) for _, entry := range entries { location, ok := l.getLocationOfEntryForFeature(entry, feature) if ok { locations = append(locations, location) - } else if isDeclarationOfSymbol(entry.node, definitionSymbol) && !concreteFiles.Has(location.Uri) { - location.Range = lsproto.Range{} - locations = append(locations, location) - concreteFiles.Add(location.Uri) } } return locations diff --git a/tsc/internal/testutil/contentmappertest/component.go b/tsc/internal/testutil/contentmappertest/component.go index 3ea2dd24c7bdf..7da16c276f944 100644 --- a/tsc/internal/testutil/contentmappertest/component.go +++ b/tsc/internal/testutil/contentmappertest/component.go @@ -52,6 +52,18 @@ func transformComponent(content string) (string, json.Value, error) { Features: spanmap.FeatureAll, }) } + writeAnchored := func(text string, originalPosition int, features spanmap.Feature) { + virtualStart := core.TextPos(virtual.Len()) + virtual.WriteString(text) + segments = append(segments, spanmap.Segment{ + VirtualStart: virtualStart, + VirtualEnd: core.TextPos(virtual.Len()), + OriginalStart: core.TextPos(originalPosition), + OriginalEnd: core.TextPos(originalPosition), + Kind: spanmap.KindAtom, + Features: features, + }) + } scriptOpen := strings.Index(content, "= 0 { @@ -103,7 +115,7 @@ func transformComponent(content string) (string, json.Value, error) { writeMapped(content[nameStart:nameEnd], nameStart, nameEnd, spanmap.KindAtom) writeSynthesized(" {}\n") } - writeSynthesized("export default {};\n") + writeAnchored("export default {};\n", 0, spanmap.FeatureDefinition|spanmap.FeatureReferences) mappings, err := spanmap.New(segments).Marshal() if err != nil { diff --git a/tsc/testdata/baselines/reference/fourslash/findAllReferences/contentMapperDisabledNavigationTargets.baseline.jsonc b/tsc/testdata/baselines/reference/fourslash/findAllReferences/contentMapperDisabledNavigationTargets.baseline.jsonc new file mode 100644 index 0000000000000..f80549a374a94 --- /dev/null +++ b/tsc/testdata/baselines/reference/fourslash/findAllReferences/contentMapperDisabledNavigationTargets.baseline.jsonc @@ -0,0 +1,5 @@ +// === findAllReferences === +// === /main.ts === +// import { [|value|] } from "./disabled.dup"; +// export const result = [|val/*FIND ALL REFS*/ue|]; +// \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/fourslash/goToDefinition/contentMapperDisabledNavigationTargets.baseline.jsonc b/tsc/testdata/baselines/reference/fourslash/goToDefinition/contentMapperDisabledNavigationTargets.baseline.jsonc new file mode 100644 index 0000000000000..992f2a0c4f2e3 --- /dev/null +++ b/tsc/testdata/baselines/reference/fourslash/goToDefinition/contentMapperDisabledNavigationTargets.baseline.jsonc @@ -0,0 +1,5 @@ +// === goToDefinition === +// === /main.ts === +// import { value } from "./disabled.dup"; +// export const result = val/*GOTO DEF*/ue; +// \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/fourslash/vsFindAllReferences/contentMapperDisabledNavigationTargets.baseline b/tsc/testdata/baselines/reference/fourslash/vsFindAllReferences/contentMapperDisabledNavigationTargets.baseline new file mode 100644 index 0000000000000..5fed0ffdd4380 --- /dev/null +++ b/tsc/testdata/baselines/reference/fourslash/vsFindAllReferences/contentMapperDisabledNavigationTargets.baseline @@ -0,0 +1,60 @@ +// === vsFindAllReferences === +// === /main.ts === +// import { [|value|] } from "./disabled.dup"; +// export const result = [|val/*FIND ALL REFS*/ue|]; +// + +[ + { + "_vs_id": 0, + "_vs_kind": [ + 17 + ], + "_vs_location": { + "uri": "file:///main.ts", + "range": { + "start": { + "line": 0, + "character": 9 + }, + "end": { + "line": 0, + "character": 14 + } + } + }, + "_vs_definitionText": { + "Runs": [ + { + "ClassificationTypeName": "text", + "Text": "(alias) const value: 1", + "_vs_type": "ClassifiedTextRun" + } + ], + "_vs_type": "ClassifiedTextElement" + }, + "_vs_projectName": "/tsconfig.json", + "_vs_containingType": "" + }, + { + "_vs_id": 1, + "_vs_definitionId": 0, + "_vs_kind": [ + 3 + ], + "_vs_location": { + "uri": "file:///main.ts", + "range": { + "start": { + "line": 1, + "character": 22 + }, + "end": { + "line": 1, + "character": 27 + } + } + }, + "_vs_projectName": "/tsconfig.json" + } +] \ No newline at end of file From 9ddced6295d7cfc8a05988b6fdcd259682810951 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 20 Aug 2026 08:34:53 -0700 Subject: [PATCH 09/18] Concat multiple hover projections, skip empty signature help projections --- .../tests/contentMapperHover_test.go | 22 ++ .../tests/contentMapperSignatureHelp_test.go | 16 ++ tsc/internal/ls/hover.go | 195 +++++++++++------- tsc/internal/ls/signaturehelp.go | 29 +-- .../testutil/contentmappertest/duplicate.go | 39 ++++ ...apperHoverConcatenatesProjections.baseline | 45 ++++ 6 files changed, 259 insertions(+), 87 deletions(-) create mode 100644 tsc/testdata/baselines/reference/fourslash/quickInfo/contentMapperHoverConcatenatesProjections.baseline diff --git a/tsc/internal/fourslash/tests/contentMapperHover_test.go b/tsc/internal/fourslash/tests/contentMapperHover_test.go index 17b3acbed461f..5747246771264 100644 --- a/tsc/internal/fourslash/tests/contentMapperHover_test.go +++ b/tsc/internal/fourslash/tests/contentMapperHover_test.go @@ -51,3 +51,25 @@ const val/*hover*/ue = target; f.VerifyQuickInfoAt(t, "hover", "const value: 1", "See [target](file:///globals.astro#1,7-1,13).") } + +func TestContentMapperHoverTriesLaterProjection(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /hover-fallback.dup +val/*hover*/ue +`, contentmappertest.DuplicateMapper, ".dup") + defer done() + + f.VerifyQuickInfoAt(t, "hover", "const value: 1", "") +} + +func TestContentMapperHoverConcatenatesProjections(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /hover-concat.dup +val/*hover*/ue +`, contentmappertest.DuplicateMapper, ".dup") + defer done() + + f.VerifyBaselineHover(t) +} diff --git a/tsc/internal/fourslash/tests/contentMapperSignatureHelp_test.go b/tsc/internal/fourslash/tests/contentMapperSignatureHelp_test.go index eb5156b4942d5..7964f68cf6f56 100644 --- a/tsc/internal/fourslash/tests/contentMapperSignatureHelp_test.go +++ b/tsc/internal/fourslash/tests/contentMapperSignatureHelp_test.go @@ -74,3 +74,19 @@ use(/*call*/target); ParameterSpan: "value: number", }) } + +func TestContentMapperSignatureHelpTriesLaterProjection(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + f, done := newContentMapperFourslash(t, `// @Filename: /signature-fallback.dup +use(/*call*/) +`, contentmappertest.DuplicateMapper, ".dup") + defer done() + + f.GoToMarker(t, "call") + f.VerifySignatureHelp(t, fourslash.VerifySignatureHelpOptions{ + Text: "use(value: number): void", + ParameterName: "value", + ParameterSpan: "value: number", + }) +} diff --git a/tsc/internal/ls/hover.go b/tsc/internal/ls/hover.go index 13fc132e0f026..c29957914f8c7 100644 --- a/tsc/internal/ls/hover.go +++ b/tsc/internal/ls/hover.go @@ -36,93 +36,140 @@ func (l *LanguageService) ProvideHover(ctx context.Context, params *lsproto.Hove program, file := l.getProgramAndFile(params.TextDocument.Uri) positions := lsconv.FromLSPPositionForSourceFile(l.converters, file, params.Position, spanmap.FeatureHover) - if len(positions) == 0 || !positions[0].Fidelity.IsSingleSegment() { - return lsproto.HoverOrNull{}, nil - } - file = positions[0].Script - position := int(positions[0].Position) - node := astnav.GetTouchingPropertyName(file, position) - if ast.IsSourceFile(node) || ast.IsPropertyAccessOrQualifiedName(node) && isInComment(file, position, node) == nil { - // Avoid giving quickInfo for the sourceFile as a whole or inside the comment of a/**/.b - return lsproto.HoverOrNull{}, nil - } - c, done := program.GetTypeCheckerForFile(ctx, file) - defer done() - rangeNode := getNodeForQuickInfo(node) - symbol := getSymbolAtLocationForQuickInfo(c, rangeNode) + var hovers []*lsproto.Hover + for _, projection := range positions { + if !projection.Fidelity.IsSingleSegment() { + continue + } + file = projection.Script + position := int(projection.Position) + node := astnav.GetTouchingPropertyName(file, position) + if ast.IsSourceFile(node) || ast.IsPropertyAccessOrQualifiedName(node) && isInComment(file, position, node) == nil { + // Avoid giving quickInfo for the sourceFile as a whole or inside the comment of a/**/.b + continue + } + c, done := program.GetTypeCheckerForFile(ctx, file) + rangeNode := getNodeForQuickInfo(node) + symbol := getSymbolAtLocationForQuickInfo(c, rangeNode) - // Always create VerbosityContext for hover so that canExpandSymbol can signal - // canIncreaseVerbosity even at Level 0. The nodebuilder also detects expandable - // types at Level 0 via shouldExpandType (maxExpansionDepth = 0). - maxTruncLen := l.UserPreferences().MaximumHoverLength - if maxTruncLen <= 0 { - maxTruncLen = 500 - } - vc := &checker.VerbosityContext{ - Level: verbosityLevel, - MaxTruncationLength: maxTruncLen, - } + // Always create VerbosityContext for hover so that canExpandSymbol can signal + // canIncreaseVerbosity even at Level 0. The nodebuilder also detects expandable + // types at Level 0 via shouldExpandType (maxExpansionDepth = 0). + maxTruncLen := l.UserPreferences().MaximumHoverLength + if maxTruncLen <= 0 { + maxTruncLen = 500 + } + vc := &checker.VerbosityContext{ + Level: verbosityLevel, + MaxTruncationLength: maxTruncLen, + } - vsCapability := caps.VSSupportsVisualStudioExtensions - quickInfo, documentation, vsDocumentation, quickInfoRuns := l.getQuickInfoAndDocumentationForSymbol(c, symbol, rangeNode, contentFormat, vc, vsCapability) - if quickInfo == "" { - return lsproto.HoverOrNull{}, nil - } - rangeFile := ast.GetSourceFileOfNode(rangeNode) - textRange := getRangeOfNode(rangeNode, rangeFile, nil /*endNode*/) - hoverRange, hoverFidelity := l.converters.ToLSPRangeForFeature(rangeFile, textRange, spanmap.FeatureHover) + vsCapability := caps.VSSupportsVisualStudioExtensions + quickInfo, documentation, vsDocumentation, quickInfoRuns := l.getQuickInfoAndDocumentationForSymbol(c, symbol, rangeNode, contentFormat, vc, vsCapability) + if quickInfo == "" { + done() + continue + } + rangeFile := ast.GetSourceFileOfNode(rangeNode) + textRange := getRangeOfNode(rangeNode, rangeFile, nil /*endNode*/) + hoverRange, hoverFidelity := l.converters.ToLSPRangeForFeature(rangeFile, textRange, spanmap.FeatureHover) - var content string - if contentFormat == lsproto.MarkupKindMarkdown { - content = formatQuickInfo(quickInfo) + documentation - } else { - content = quickInfo + documentation - } + var content string + if contentFormat == lsproto.MarkupKindMarkdown { + content = formatQuickInfo(quickInfo) + documentation + } else { + content = quickInfo + documentation + } - hover := &lsproto.Hover{ - Contents: lsproto.MarkupContentOrStringOrMarkedStringWithLanguageOrMarkedStrings{ - MarkupContent: &lsproto.MarkupContent{ - Kind: contentFormat, - Value: content, + hover := &lsproto.Hover{ + Contents: lsproto.MarkupContentOrStringOrMarkedStringWithLanguageOrMarkedStrings{ + MarkupContent: &lsproto.MarkupContent{ + Kind: contentFormat, + Value: content, + }, }, - }, + } + if hoverFidelity.IsSingleSegment() { + hover.Range = &hoverRange + } + + if caps.Experimental.HoverVerbosityLevel { + hover.CanIncreaseVerbosity = vc.CanIncreaseVerbosity && !vc.Truncated + } + + // Clients that support Visual Studio extensions (e.g. VS itself, when Corsa/Native TS Preview is + // enabled) render `_vs_rawContent` in place of `contents`. Without it, VS shows plain markdown + // with no symbol icon and no syntax coloring, unlike the legacy TSServer-backed hover path. + if vsCapability && len(quickInfoRuns) > 0 { + kind := lsutil.ScriptElementKindKeyword + var modifiers lsutil.ScriptElementKindModifier + if symbol != nil { + // Resolve aliases to their target before computing the icon kind, so e.g. `import { x }` + // shows the icon for whatever `x` actually is (const, function, ...) rather than a + // generic alias icon. GetSymbolModifiers already accounts for the alias target itself. + iconSymbol := symbol + if symbol.Flags&ast.SymbolFlagsAlias != 0 { + if resolved := c.GetAliasedSymbol(symbol); resolved != nil && resolved != symbol { + iconSymbol = resolved + } + } + kind = lsutil.GetSymbolKind(c, iconSymbol, rangeNode) + modifiers = lsutil.GetSymbolModifiers(c, symbol) + } + imageId := getVSHoverImageId(kind, modifiers) + var documentationRuns []*lsproto.VSClassifiedTextRun + if docText := strings.TrimLeft(vsDocumentation, "\n"); docText != "" { + documentationRuns = []*lsproto.VSClassifiedTextRun{{ClassificationTypeName: string(lsproto.ClassificationTypeNameText), Text: docText}} + } + hover.VSRawContent = buildVSHoverRawContent(imageId, quickInfoRuns, documentationRuns) + } + + done() + hovers = append(hovers, hover) } - if hoverFidelity.IsSingleSegment() { - hover.Range = &hoverRange + if len(hovers) == 0 { + return lsproto.HoverOrNull{}, nil } - - if caps.Experimental.HoverVerbosityLevel { - hover.CanIncreaseVerbosity = vc.CanIncreaseVerbosity && !vc.Truncated + if len(hovers) == 1 { + return lsproto.HoverOrNull{Hover: hovers[0]}, nil } - // Clients that support Visual Studio extensions (e.g. VS itself, when Corsa/Native TS Preview is - // enabled) render `_vs_rawContent` in place of `contents`. Without it, VS shows plain markdown - // with no symbol icon and no syntax coloring, unlike the legacy TSServer-backed hover path. - if vsCapability && len(quickInfoRuns) > 0 { - kind := lsutil.ScriptElementKindKeyword - var modifiers lsutil.ScriptElementKindModifier - if symbol != nil { - // Resolve aliases to their target before computing the icon kind, so e.g. `import { x }` - // shows the icon for whatever `x` actually is (const, function, ...) rather than a - // generic alias icon. GetSymbolModifiers already accounts for the alias target itself. - iconSymbol := symbol - if symbol.Flags&ast.SymbolFlagsAlias != 0 { - if resolved := c.GetAliasedSymbol(symbol); resolved != nil && resolved != symbol { - iconSymbol = resolved - } + combined := hovers[0] + contents := make([]string, 0, len(hovers)) + seenContents := collections.Set[string]{} + var rawContents []lsproto.VSImageElementOrClassifiedTextElementOrContainerElement + commonRange := combined.Range + for _, hover := range hovers { + content := strings.TrimRight(hover.Contents.MarkupContent.Value, "\n") + if seenContents.AddIfAbsent(content) { + contents = append(contents, content) + if hover.VSRawContent != nil { + rawContents = append(rawContents, lsproto.VSImageElementOrClassifiedTextElementOrContainerElement{ContainerElement: hover.VSRawContent}) } - kind = lsutil.GetSymbolKind(c, iconSymbol, rangeNode) - modifiers = lsutil.GetSymbolModifiers(c, symbol) } - imageId := getVSHoverImageId(kind, modifiers) - var documentationRuns []*lsproto.VSClassifiedTextRun - if docText := strings.TrimLeft(vsDocumentation, "\n"); docText != "" { - documentationRuns = []*lsproto.VSClassifiedTextRun{{ClassificationTypeName: string(lsproto.ClassificationTypeNameText), Text: docText}} + combined.CanIncreaseVerbosity = combined.CanIncreaseVerbosity || hover.CanIncreaseVerbosity + if commonRange == nil || hover.Range == nil || *commonRange != *hover.Range { + commonRange = nil } - hover.VSRawContent = buildVSHoverRawContent(imageId, quickInfoRuns, documentationRuns) } - - return lsproto.HoverOrNull{Hover: hover}, nil + separator := "\n\n" + if contentFormat == lsproto.MarkupKindMarkdown { + separator = "\n\n---\n\n" + } + combined.Contents.MarkupContent.Value = strings.Join(contents, separator) + combined.Range = commonRange + switch len(rawContents) { + case 0: + combined.VSRawContent = nil + case 1: + combined.VSRawContent = rawContents[0].ContainerElement + default: + combined.VSRawContent = &lsproto.VSContainerElement{ + Style: lsproto.VSContainerElementStyleStacked, + Elements: rawContents, + } + } + return lsproto.HoverOrNull{Hover: combined}, nil } func (l *LanguageService) getQuickInfoAndDocumentationForSymbol(c *checker.Checker, symbol *ast.Symbol, node *ast.Node, contentFormat lsproto.MarkupKind, vc *checker.VerbosityContext, vsCapability bool) (string, string, string, []*lsproto.VSClassifiedTextRun) { diff --git a/tsc/internal/ls/signaturehelp.go b/tsc/internal/ls/signaturehelp.go index 972afbef8e8f7..25495583a9612 100644 --- a/tsc/internal/ls/signaturehelp.go +++ b/tsc/internal/ls/signaturehelp.go @@ -55,19 +55,22 @@ func (l *LanguageService) ProvideSignatureHelp( ) (lsproto.SignatureHelpResponse, error) { program, sourceFile := l.getProgramAndFile(documentURI) positions := lsconv.FromLSPPositionForSourceFile(l.converters, sourceFile, position, spanmap.FeatureSignatureHelp) - if len(positions) == 0 || !positions[0].Fidelity.IsSingleSegment() { - return lsproto.SignatureHelpOrNull{}, nil - } - sourceFile = positions[0].Script - pos := int(positions[0].Position) - items := l.GetSignatureHelpItems( - ctx, - pos, - program, - sourceFile, - context, - ) - return lsproto.SignatureHelpOrNull{SignatureHelp: items}, nil + for _, projection := range positions { + if !projection.Fidelity.IsSingleSegment() { + continue + } + items := l.GetSignatureHelpItems( + ctx, + int(projection.Position), + program, + projection.Script, + context, + ) + if items != nil { + return lsproto.SignatureHelpOrNull{SignatureHelp: items}, nil + } + } + return lsproto.SignatureHelpOrNull{}, nil } func (l *LanguageService) GetSignatureHelpItems( diff --git a/tsc/internal/testutil/contentmappertest/duplicate.go b/tsc/internal/testutil/contentmappertest/duplicate.go index 886d3361a123e..2ccd70b3fe3da 100644 --- a/tsc/internal/testutil/contentmappertest/duplicate.go +++ b/tsc/internal/testutil/contentmappertest/duplicate.go @@ -22,6 +22,45 @@ func (duplicateHandler) HandleRequest(ctx context.Context, method string, params if err := json.Unmarshal(params, &p); err != nil { return nil, err } + if strings.Contains(p.FileName, "hover-fallback") { + virtual := "// " + p.Content + "\nconst " + p.Content + " = 1;\n" + first := len("// ") + second := first + len(p.Content) + len("\nconst ") + mappings, err := spanmap.New([]spanmap.Segment{ + {VirtualStart: core.TextPos(first), VirtualEnd: core.TextPos(first + len(p.Content)), OriginalStart: 0, OriginalEnd: core.TextPos(len(p.Content)), Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + {VirtualStart: core.TextPos(second), VirtualEnd: core.TextPos(second + len(p.Content)), OriginalStart: 0, OriginalEnd: core.TextPos(len(p.Content)), Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + }).Marshal() + if err != nil { + return nil, err + } + return contentmapper.TransformResult{MappedOutput: contentmapper.MappedOutput{Text: virtual, Extension: ".ts", Mappings: json.Value(mappings)}}, nil + } + if strings.Contains(p.FileName, "hover-concat") { + virtual := "namespace A { export const " + p.Content + " = 1; }\nnamespace B { export const " + p.Content + " = \"text\"; }\n" + first := strings.Index(virtual, p.Content) + second := strings.LastIndex(virtual, p.Content) + mappings, err := spanmap.New([]spanmap.Segment{ + {VirtualStart: core.TextPos(first), VirtualEnd: core.TextPos(first + len(p.Content)), OriginalStart: 0, OriginalEnd: core.TextPos(len(p.Content)), Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + {VirtualStart: core.TextPos(second), VirtualEnd: core.TextPos(second + len(p.Content)), OriginalStart: 0, OriginalEnd: core.TextPos(len(p.Content)), Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + }).Marshal() + if err != nil { + return nil, err + } + return contentmapper.TransformResult{MappedOutput: contentmapper.MappedOutput{Text: virtual, Extension: ".ts", Mappings: json.Value(mappings)}}, nil + } + if strings.Contains(p.FileName, "signature-fallback") { + virtual := "// " + p.Content + "\nfunction use(value: number): void {}\n" + p.Content + ";\n" + first := len("// ") + second := strings.LastIndex(virtual, p.Content) + mappings, err := spanmap.New([]spanmap.Segment{ + {VirtualStart: core.TextPos(first), VirtualEnd: core.TextPos(first + len(p.Content)), OriginalStart: 0, OriginalEnd: core.TextPos(len(p.Content)), Kind: spanmap.KindVerbatim, Features: spanmap.FeatureSignatureHelp}, + {VirtualStart: core.TextPos(second), VirtualEnd: core.TextPos(second + len(p.Content)), OriginalStart: 0, OriginalEnd: core.TextPos(len(p.Content)), Kind: spanmap.KindVerbatim, Features: spanmap.FeatureSignatureHelp}, + }).Marshal() + if err != nil { + return nil, err + } + return contentmapper.TransformResult{MappedOutput: contentmapper.MappedOutput{Text: virtual, Extension: ".ts", Mappings: json.Value(mappings)}}, nil + } if strings.Contains(p.FileName, "rename-conflict") { virtual := "export const " + p.Content + " = 1;\nconst object = { " + p.Content + " };\n" + p.Content + ";\n" first := strings.Index(virtual, p.Content) diff --git a/tsc/testdata/baselines/reference/fourslash/quickInfo/contentMapperHoverConcatenatesProjections.baseline b/tsc/testdata/baselines/reference/fourslash/quickInfo/contentMapperHoverConcatenatesProjections.baseline new file mode 100644 index 0000000000000..3d65afb20b05f --- /dev/null +++ b/tsc/testdata/baselines/reference/fourslash/quickInfo/contentMapperHoverConcatenatesProjections.baseline @@ -0,0 +1,45 @@ +// === QuickInfo === +=== /hover-concat.dup === +// value +// ^^^^^ +// | ---------------------------------------------------------------------- +// | ```typescript +// | const value: 1 +// | ``` +// | +// | --- +// | +// | ```typescript +// | const value: "text" +// | ``` +// | ---------------------------------------------------------------------- +// +[ + { + "marker": { + "Position": 3, + "LSPosition": { + "line": 0, + "character": 3 + }, + "Name": "hover", + "Data": {} + }, + "item": { + "contents": { + "kind": "markdown", + "value": "```typescript\nconst value: 1\n```\n\n---\n\n```typescript\nconst value: \"text\"\n```" + }, + "range": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 5 + } + } + } + } +] \ No newline at end of file From f3df89138b360627dddd2062b689f1606abb19b4 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 20 Aug 2026 09:04:31 -0700 Subject: [PATCH 10/18] Process folding ranges in supplemental projections --- .../compiler-and-fourslash-tests/SKILL.md | 6 + .../tests/contentMapperEditSafety_test.go | 81 ++++++++++++ tsc/internal/ls/folding.go | 119 ++++++++++++------ .../testutil/contentmappertest/editing.go | 24 +++- 4 files changed, 193 insertions(+), 37 deletions(-) diff --git a/.github/skills/compiler-and-fourslash-tests/SKILL.md b/.github/skills/compiler-and-fourslash-tests/SKILL.md index 1613d39f80bc2..7eebda2d0ac59 100644 --- a/.github/skills/compiler-and-fourslash-tests/SKILL.md +++ b/.github/skills/compiler-and-fourslash-tests/SKILL.md @@ -320,6 +320,12 @@ const x/*1*/ = 42; ` ``` +#### Content mapper tests + +When writing a test for a content-mapped file, include a comment that shows the virtual +TS output of the test content mapper implementation, and a description or diagram of the +mapping spans. + ### 2.3 Verification Methods (Common API) The `fourslash.FourslashTest` type (variable `f`) provides these verification methods: diff --git a/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go b/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go index d8086a11f2631..b819b6cea945f 100644 --- a/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go +++ b/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go @@ -77,3 +77,84 @@ host markup f.GoToFile(t, "/app.fold") f.VerifyFoldingRangeLines(t, nil) } + +func TestContentMapperSupplementalFoldingRanges(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // The canonical output has no fold; the supplemental output prefixes a comment to the original code: + // + // canonical: export {}; + // supplemental: /* generated */ + // function outer() { + // const value = 1; + // } + // + // original: [------------- function -------------) + // supplemental: /* generated */[------------- function -------------) + // `-- verbatim, FeatureFoldingRanges --' + // + // Folding must therefore visit the supplemental projection and map its function body to lines 0-2. + f, done := newContentMapperFourslash(t, `// @Filename: /app.astro +function outer() { + const value = 1; +} +`, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.GoToFile(t, "/app.astro") + f.VerifyFoldingRangeLines(t, []fourslash.FoldingRangeLineExpected{{StartLine: 0, EndLine: 2}}) +} + +func TestContentMapperDisabledSupplementalFoldingRanges(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // The virtual outputs have the same shape as TestContentMapperSupplementalFoldingRanges: + // + // canonical: export {}; + // supplemental: /* generated */ + // function outer() { + // const value = 1; + // } + // + // original: [------------- function -------------) + // supplemental: /* generated */[------------- function -------------) + // `---- verbatim, FeatureNone --------' + // + // The function is mapped but explicitly disabled for folding, so it must produce no range. + f, done := newContentMapperFourslash(t, `// @Filename: /folding-disabled.astro +function outer() { + const value = 1; +} +`, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.GoToFile(t, "/folding-disabled.astro") + f.VerifyFoldingRangeLines(t, nil) +} + +func TestContentMapperDeduplicatesProjectedFoldingRanges(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // Both virtual files contain the original function and map it verbatim with folding enabled: + // + // canonical: function outer() { ... } + // supplemental: /* generated */ + // function outer() { ... } + // + // original: [------------- function -------------) + // canonical: [------------- function -------------) + // `-- verbatim, FeatureFoldingRanges --' + // supplemental: /* generated */[------------- function -------------) + // `-- verbatim, FeatureFoldingRanges --' + // + // Both projections map to the same original fold, which must be returned only once. + f, done := newContentMapperFourslash(t, `// @Filename: /folding-duplicate.astro +function outer() { + const value = 1; +} +`, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.GoToFile(t, "/folding-duplicate.astro") + f.VerifyFoldingRangeLines(t, []fourslash.FoldingRangeLineExpected{{StartLine: 0, EndLine: 2}}) +} diff --git a/tsc/internal/ls/folding.go b/tsc/internal/ls/folding.go index c2fe2094d10e5..4c97a768797f1 100644 --- a/tsc/internal/ls/folding.go +++ b/tsc/internal/ls/folding.go @@ -9,6 +9,8 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/astnav" + "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/debug" "github.com/microsoft/TypeScript/tsc/internal/ls/lsconv" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" @@ -19,20 +21,64 @@ import ( func (l *LanguageService) ProvideFoldingRange(ctx context.Context, documentURI lsproto.DocumentUri) (lsproto.FoldingRangeResponse, error) { _, sourceFile := l.getProgramAndFile(documentURI) - res := l.addNodeOutliningSpans(ctx, sourceFile) - res = append(res, l.addRegionOutliningSpans(ctx, sourceFile)...) - if lsproto.GetClientCapabilities(ctx).TextDocument.FoldingRange.LineFoldingOnly { - res = l.adjustFoldingEnd(res, sourceFile) + projections := append([]*ast.SourceFile{sourceFile}, sourceFile.SupplementalSourceFiles()...) + var res []*lsproto.FoldingRange + for _, projection := range projections { + ranges := l.addNodeOutliningSpans(ctx, projection) + ranges = append(ranges, l.addRegionOutliningSpans(ctx, projection)...) + if lsproto.GetClientCapabilities(ctx).TextDocument.FoldingRange.LineFoldingOnly { + ranges = l.adjustFoldingEnd(ranges, projection) + } + res = append(res, ranges...) } - slices.SortFunc(res, func(a, b *lsproto.FoldingRange) int { + slices.SortStableFunc(res, func(a, b *lsproto.FoldingRange) int { if c := cmp.Compare(a.StartLine, b.StartLine); c != 0 { return c } - return cmp.Compare(*a.StartCharacter, *b.StartCharacter) + if c := cmp.Compare(*a.StartCharacter, *b.StartCharacter); c != 0 { + return c + } + if c := cmp.Compare(a.EndLine, b.EndLine); c != 0 { + return c + } + return cmp.Compare(*a.EndCharacter, *b.EndCharacter) + }) + seen := collections.Set[foldingRangeKey]{} + res = slices.DeleteFunc(res, func(foldingRange *lsproto.FoldingRange) bool { + return !seen.AddIfAbsent(keyForFoldingRange(foldingRange)) }) return lsproto.FoldingRangesOrNull{FoldingRanges: &res}, nil } +type foldingRangeKey struct { + startLine, startCharacter, endLine, endCharacter uint32 + kind lsproto.FoldingRangeKind + collapsedText string + hasStartCharacter, hasEndCharacter bool + hasKind, hasCollapsedText bool +} + +func keyForFoldingRange(foldingRange *lsproto.FoldingRange) foldingRangeKey { + key := foldingRangeKey{startLine: foldingRange.StartLine, endLine: foldingRange.EndLine} + if foldingRange.StartCharacter != nil { + key.startCharacter = *foldingRange.StartCharacter + key.hasStartCharacter = true + } + if foldingRange.EndCharacter != nil { + key.endCharacter = *foldingRange.EndCharacter + key.hasEndCharacter = true + } + if foldingRange.Kind != nil { + key.kind = *foldingRange.Kind + key.hasKind = true + } + if foldingRange.CollapsedText != nil { + key.collapsedText = *foldingRange.CollapsedText + key.hasCollapsedText = true + } + return key +} + // adjustFoldingEnd adjusts the end line of folding ranges when the client signals lineFoldingOnly. // This mirrors the behavior of VS Code's built-in TypeScript extension (workaround for vscode#47240). // When lineFoldingOnly is true, we hide lines from startLine+1 to endLine. And to keep closing @@ -46,8 +92,15 @@ func (l *LanguageService) adjustFoldingEnd(ranges []*lsproto.FoldingRange, sourc Line: r.EndLine, Character: *r.EndCharacter, }, spanmap.FeatureFoldingRanges) - if len(positions) == 1 && positions[0].Script == sourceFile && !positions[0].Fidelity.IsNone() && positions[0].Position > 0 && int(positions[0].Position) <= len(sourceText) { - endOffset := positions[0].Position + var position *lsconv.MappedPosition[*ast.SourceFile] + for i := range positions { + if positions[i].Script == sourceFile && !positions[i].Fidelity.IsNone() { + position = &positions[i] + break + } + } + if position != nil && position.Position > 0 && int(position.Position) <= len(sourceText) { + endOffset := position.Position foldEndChar := sourceText[int(endOffset)-1] if foldEndChar == '}' || foldEndChar == ']' || foldEndChar == ')' || foldEndChar == '`' || foldEndChar == '>' { if r.EndLine > r.StartLine { @@ -105,7 +158,11 @@ func (l *LanguageService) addNodeOutliningSpans(ctx context.Context, sourceFile } func (l *LanguageService) addRegionOutliningSpans(ctx context.Context, sourceFile *ast.SourceFile) []*lsproto.FoldingRange { - regions := make([]*lsproto.FoldingRange, 0, 40) + type regionStart struct { + position int + collapsedText *string + } + regions := make([]regionStart, 0, 40) out := make([]*lsproto.FoldingRange, 0, 40) lineStarts := scanner.GetECMALineStarts(sourceFile) for _, currentLineStart := range lineStarts { @@ -117,38 +174,26 @@ func (l *LanguageService) addRegionOutliningSpans(ctx context.Context, sourceFil } if result.isStart { - commentStart, fidelity := l.createLspPosition(strings.Index(sourceFile.Text()[currentLineStart:lineEnd], "//")+int(currentLineStart), sourceFile) - if fidelity.IsNone() { - continue - } - foldingRangeKindRegion := lsproto.FoldingRangeKindRegion - region := &lsproto.FoldingRange{ - StartLine: commentStart.Line, - StartCharacter: &commentStart.Character, - Kind: &foldingRangeKindRegion, - } + region := regionStart{position: strings.Index(sourceFile.Text()[currentLineStart:lineEnd], "//") + int(currentLineStart)} if supportsCollapsedText(ctx) { collapsedText := "#region" if result.name != "" { collapsedText = result.name } - region.CollapsedText = &collapsedText + region.collapsedText = &collapsedText } - // Our spans start out with some initial data. - // On every `#endregion`, we'll come back to these `FoldingRange`s - // and fill in their EndLine/EndCharacter. regions = append(regions, region) } else { if len(regions) > 0 { region := regions[len(regions)-1] regions = regions[:len(regions)-1] - endingPosition, fidelity := l.createLspPosition(lineEnd, sourceFile) + textRange, fidelity := l.createFoldingRangeFromBounds(region.position, lineEnd, sourceFile) if fidelity.IsNone() { continue } - region.EndLine = endingPosition.Line - region.EndCharacter = &endingPosition.Character - out = append(out, region) + foldingRange := createFoldingRange(ctx, textRange, lsproto.FoldingRangeKindRegion, "") + foldingRange.CollapsedText = region.collapsedText + out = append(out, foldingRange) } } } @@ -444,7 +489,7 @@ func spanForParenthesizedExpression(ctx context.Context, node *ast.Node, sourceF if printer.PositionsAreOnSameLine(start, node.End(), sourceFile) { return nil } - textRange, fidelity := l.createLspRangeFromBounds(start, node.End(), sourceFile) + textRange, fidelity := l.createFoldingRangeFromBounds(start, node.End(), sourceFile) if fidelity.IsNone() { return nil } @@ -469,7 +514,7 @@ func spanForArrowFunction(ctx context.Context, node *ast.Node, sourceFile *ast.S if ast.IsBlock(arrowFunctionNode.Body) || ast.IsParenthesizedExpression(arrowFunctionNode.Body) || printer.PositionsAreOnSameLine(arrowFunctionNode.Body.Pos(), arrowFunctionNode.Body.End(), sourceFile) { return nil } - textRange, fidelity := l.createLspRangeFromBounds(arrowFunctionNode.Body.Pos(), arrowFunctionNode.Body.End(), sourceFile) + textRange, fidelity := l.createFoldingRangeFromBounds(arrowFunctionNode.Body.Pos(), arrowFunctionNode.Body.End(), sourceFile) if fidelity.IsNone() { return nil } @@ -486,7 +531,7 @@ func spanForTemplateLiteral(ctx context.Context, node *ast.Node, sourceFile *ast func spanForJSXElement(ctx context.Context, node *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { if node.Kind == ast.KindJsxElement { jsxElement := node.AsJsxElement() - textRange, fidelity := l.createLspRangeFromBounds(astnav.GetStartOfNode(jsxElement.OpeningElement, sourceFile, false /*includeJSDoc*/), jsxElement.ClosingElement.End(), sourceFile) + textRange, fidelity := l.createFoldingRangeFromBounds(astnav.GetStartOfNode(jsxElement.OpeningElement, sourceFile, false /*includeJSDoc*/), jsxElement.ClosingElement.End(), sourceFile) if fidelity.IsNone() { return nil } @@ -496,7 +541,7 @@ func spanForJSXElement(ctx context.Context, node *ast.Node, sourceFile *ast.Sour } // JsxFragment jsxFragment := node.AsJsxFragment() - textRange, fidelity := l.createLspRangeFromBounds(astnav.GetStartOfNode(jsxFragment.OpeningFragment, sourceFile, false /*includeJSDoc*/), jsxFragment.ClosingFragment.End(), sourceFile) + textRange, fidelity := l.createFoldingRangeFromBounds(astnav.GetStartOfNode(jsxFragment.OpeningFragment, sourceFile, false /*includeJSDoc*/), jsxFragment.ClosingFragment.End(), sourceFile) if fidelity.IsNone() { return nil } @@ -518,7 +563,7 @@ func spanForJSXAttributes(ctx context.Context, node *ast.Node, sourceFile *ast.S func spanForNodeArray(ctx context.Context, statements *ast.NodeList, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { if statements != nil && len(statements.Nodes) != 0 { - textRange, fidelity := l.createLspRangeFromBounds(statements.Pos(), statements.End(), sourceFile) + textRange, fidelity := l.createFoldingRangeFromBounds(statements.Pos(), statements.End(), sourceFile) if fidelity.IsNone() { return nil } @@ -544,9 +589,9 @@ func rangeBetweenTokens(ctx context.Context, openToken *ast.Node, closeToken *as var textRange lsproto.Range var fidelity spanmap.Fidelity if useFullStart { - textRange, fidelity = l.createLspRangeFromBounds(openToken.Pos(), closeToken.End(), sourceFile) + textRange, fidelity = l.createFoldingRangeFromBounds(openToken.Pos(), closeToken.End(), sourceFile) } else { - textRange, fidelity = l.createLspRangeFromBounds(astnav.GetStartOfNode(openToken, sourceFile, false /*includeJSDoc*/), closeToken.End(), sourceFile) + textRange, fidelity = l.createFoldingRangeFromBounds(astnav.GetStartOfNode(openToken, sourceFile, false /*includeJSDoc*/), closeToken.End(), sourceFile) } if fidelity.IsNone() { return nil @@ -577,13 +622,17 @@ func createFoldingRange(ctx context.Context, textRange lsproto.Range, foldingRan } func createFoldingRangeFromBounds(ctx context.Context, pos int, end int, foldingRangeKind lsproto.FoldingRangeKind, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { - textRange, fidelity := l.createLspRangeFromBounds(pos, end, sourceFile) + textRange, fidelity := l.createFoldingRangeFromBounds(pos, end, sourceFile) if fidelity.IsNone() { return nil } return createFoldingRange(ctx, textRange, foldingRangeKind, "") } +func (l *LanguageService) createFoldingRangeFromBounds(start, end int, sourceFile *ast.SourceFile) (lsproto.Range, spanmap.Fidelity) { + return l.converters.ToLSPRangeForFeature(sourceFile, core.NewTextRange(start, end), spanmap.FeatureFoldingRanges) +} + func functionSpan(ctx context.Context, node *ast.Node, body *ast.Node, sourceFile *ast.SourceFile, l *LanguageService) *lsproto.FoldingRange { openToken := tryGetFunctionOpenToken(node, body, sourceFile) closeToken := astnav.FindChildOfKind(body, ast.KindCloseBraceToken, sourceFile) diff --git a/tsc/internal/testutil/contentmappertest/editing.go b/tsc/internal/testutil/contentmappertest/editing.go index d68221c953609..d50116728f28b 100644 --- a/tsc/internal/testutil/contentmappertest/editing.go +++ b/tsc/internal/testutil/contentmappertest/editing.go @@ -3,6 +3,7 @@ package contentmappertest import ( "context" "fmt" + "strings" "github.com/microsoft/TypeScript/tsc/internal/contentmapper" "github.com/microsoft/TypeScript/tsc/internal/core" @@ -22,19 +23,38 @@ func (prefixedSupplementalHandler) HandleRequest(ctx context.Context, method str return nil, err } const prefix = "/* generated */\n" + features := spanmap.FeatureAll + if strings.Contains(p.FileName, "folding-disabled") { + features = spanmap.FeatureNone + } mappings, err := spanmap.New([]spanmap.Segment{{ VirtualStart: core.TextPos(len(prefix)), VirtualEnd: core.TextPos(len(prefix) + len(p.Content)), OriginalStart: 0, OriginalEnd: core.TextPos(len(p.Content)), Kind: spanmap.KindVerbatim, - Features: spanmap.FeatureAll, + Features: features, }}).Marshal() if err != nil { return nil, err } + canonical := contentmapper.MappedOutput{Text: "export {};", Extension: ".ts"} + if strings.Contains(p.FileName, "folding-duplicate") { + canonicalMappings, err := spanmap.New([]spanmap.Segment{{ + VirtualStart: 0, + VirtualEnd: core.TextPos(len(p.Content)), + OriginalStart: 0, + OriginalEnd: core.TextPos(len(p.Content)), + Kind: spanmap.KindVerbatim, + Features: spanmap.FeatureAll, + }}).Marshal() + if err != nil { + return nil, err + } + canonical = contentmapper.MappedOutput{Text: p.Content, Extension: ".ts", Mappings: json.Value(canonicalMappings)} + } return contentmapper.TransformResult{ - MappedOutput: contentmapper.MappedOutput{Text: "export {};", Extension: ".ts"}, + MappedOutput: canonical, Supplemental: []contentmapper.SupplementalOutput{{MappedOutput: contentmapper.MappedOutput{ Text: prefix + p.Content, Extension: ".ts", From 5cb363b56800add820acac363a1faaa24c2cd01d Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 20 Aug 2026 10:12:23 -0700 Subject: [PATCH 11/18] Fix codelens from supplemental projections --- .../tests/contentMapperEditSafety_test.go | 101 ++++++++++++++++++ tsc/internal/ls/callhierarchy.go | 1 + tsc/internal/ls/codelens.go | 86 ++++++++++----- tsc/internal/ls/crossproject.go | 24 ++++- tsc/internal/ls/findallreferences.go | 33 ++++++ tsc/internal/ls/rename.go | 1 + .../lsp/lsproto/_generate/generate.mts | 11 ++ tsc/internal/lsp/lsproto/lsp_generated.go | 6 ++ .../testutil/contentmappertest/editing.go | 4 +- ...duplicatesProjectedCodeLens.baseline.jsonc | 5 + ...isabledSupplementalCodeLens.baseline.jsonc | 5 + ...tMapperSupplementalCodeLens.baseline.jsonc | 5 + ...entalImplementationCodeLens.baseline.jsonc | 5 + .../state/codeLensAcrossProjects.baseline | 21 ++-- 14 files changed, 267 insertions(+), 41 deletions(-) create mode 100644 tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperDeduplicatesProjectedCodeLens.baseline.jsonc create mode 100644 tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperDisabledSupplementalCodeLens.baseline.jsonc create mode 100644 tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperSupplementalCodeLens.baseline.jsonc create mode 100644 tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperSupplementalImplementationCodeLens.baseline.jsonc diff --git a/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go b/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go index b819b6cea945f..aa561f0ea3fd6 100644 --- a/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go +++ b/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go @@ -158,3 +158,104 @@ function outer() { f.GoToFile(t, "/folding-duplicate.astro") f.VerifyFoldingRangeLines(t, []fourslash.FoldingRangeLineExpected{{StartLine: 0, EndLine: 2}}) } + +func TestContentMapperSupplementalCodeLens(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // The function exists only in the supplemental projection; its declaration and call map verbatim: + // + // canonical: export {}; + // supplemental: /* generated */ + // function outer() {} + // outer(); + // + // original: [------ declaration + call ------) + // supplemental: /* generated */[------ declaration + call ------) + // `-- verbatim, FeatureAll (includes CodeLens) --' + // + // The lens must be produced from the supplemental AST and resolve to one reference. + f, done := newContentMapperFourslash(t, `// @Filename: /codelens-supplemental.astro +function outer() {} +outer(); +`, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.VerifyBaselineCodeLens(t, &lsutil.UserPreferences{CodeLens: lsutil.CodeLensUserPreferences{ + ReferencesCodeLensEnabled: core.TSTrue, + ReferencesCodeLensShowOnAllFunctions: core.TSTrue, + }}) +} + +func TestContentMapperDisabledSupplementalCodeLens(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // Both projections contain the function, but only the canonical mapping enables CodeLens: + // + // original: [------ declaration + call ------) + // canonical: [------ declaration + call ------) + // `-- verbatim, FeatureAll (includes CodeLens) --' + // supplemental: /* generated */[------ declaration + call ------) + // `---- verbatim, FeatureNone -----' + // + // Exactly one canonical lens should remain and resolve normally. + f, done := newContentMapperFourslash(t, `// @Filename: /codelens-disabled.astro +function outer() {} +outer(); +`, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.VerifyBaselineCodeLens(t, &lsutil.UserPreferences{CodeLens: lsutil.CodeLensUserPreferences{ + ReferencesCodeLensEnabled: core.TSTrue, + ReferencesCodeLensShowOnAllFunctions: core.TSTrue, + }}) +} + +func TestContentMapperDeduplicatesProjectedCodeLens(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // Canonical and supplemental projections both map the same function and call with CodeLens enabled: + // + // original: [------ declaration + call ------) + // canonical: [------ declaration + call ------) + // `-- verbatim, FeatureAll (includes CodeLens) --' + // supplemental: /* generated */[------ declaration + call ------) + // `-- verbatim, FeatureAll (includes CodeLens) --' + // + // The equivalent original lenses must deduplicate to one resolved result. + f, done := newContentMapperFourslash(t, `// @Filename: /codelens-duplicate.astro +function outer() {} +outer(); +`, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.VerifyBaselineCodeLens(t, &lsutil.UserPreferences{CodeLens: lsutil.CodeLensUserPreferences{ + ReferencesCodeLensEnabled: core.TSTrue, + ReferencesCodeLensShowOnAllFunctions: core.TSTrue, + }}) +} + +func TestContentMapperSupplementalImplementationCodeLens(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // The interface and its implementation exist only in the supplemental projection: + // + // canonical: export {}; + // supplemental: /* generated */ + // interface Service { run(): void } + // class Impl implements Service { run() {} } + // + // original: [---------- interface + class ----------) + // supplemental: /* generated */[---------- interface + class ----------) + // `---- verbatim, FeatureAll (includes CodeLens) ----' + // + // The interface lens must resolve from the supplemental AST to one implementation. + f, done := newContentMapperFourslash(t, `// @Filename: /codelens-implementation.astro +interface Service { run(): void } +class Impl implements Service { run() {} } +`, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.VerifyBaselineCodeLens(t, &lsutil.UserPreferences{CodeLens: lsutil.CodeLensUserPreferences{ + ImplementationsCodeLensEnabled: core.TSTrue, + }}) +} diff --git a/tsc/internal/ls/callhierarchy.go b/tsc/internal/ls/callhierarchy.go index 2ea40dd6e7484..cea1d95ee8301 100644 --- a/tsc/internal/ls/callhierarchy.go +++ b/tsc/internal/ls/callhierarchy.go @@ -660,6 +660,7 @@ func (l *LanguageService) getIncomingCalls(ctx context.Context, program *compile false, false, symbolEntryTransformOptions{}, + nil, /*defaultProjectData*/ ) if result.CallHierarchyIncomingCalls != nil { slices.SortFunc(*result.CallHierarchyIncomingCalls, func(a, b *lsproto.CallHierarchyIncomingCall) int { diff --git a/tsc/internal/ls/codelens.go b/tsc/internal/ls/codelens.go index bd7e444ba44af..8142f1cad24b9 100644 --- a/tsc/internal/ls/codelens.go +++ b/tsc/internal/ls/codelens.go @@ -2,8 +2,10 @@ package ls import ( "context" + "fmt" "github.com/microsoft/TypeScript/tsc/internal/ast" + "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/diagnostics" "github.com/microsoft/TypeScript/tsc/internal/locale" @@ -21,60 +23,85 @@ func (l *LanguageService) ProvideCodeLenses(ctx context.Context, documentURI lsp return lsproto.CodeLensResponse{}, nil } - // Keeps track of the last symbol to avoid duplicating code lenses across overloads. - var lastSymbol *ast.Symbol var result []*lsproto.CodeLens - var visit func(node *ast.Node) bool - visit = func(node *ast.Node) bool { - if ctx.Err() != nil { - return true - } + seen := collections.Set[codeLensKey]{} + projections := append([]*ast.SourceFile{file}, file.SupplementalSourceFiles()...) + for _, projection := range projections { + // Keeps track of the last symbol to avoid duplicating code lenses across overloads. + var lastSymbol *ast.Symbol + var visit func(node *ast.Node) bool + visit = func(node *ast.Node) bool { + if ctx.Err() != nil { + return true + } - if currentSymbol := node.Symbol(); lastSymbol != currentSymbol { - lastSymbol = currentSymbol + if currentSymbol := node.Symbol(); lastSymbol != currentSymbol { + lastSymbol = currentSymbol - if userPrefs.ReferencesCodeLensEnabled.IsTrue() && isValidReferenceLensNode(node, userPrefs) { - if codeLens := l.newCodeLensForNode(documentURI, file, node, lsproto.CodeLensKindReferences); codeLens != nil { - result = append(result, codeLens) + if userPrefs.ReferencesCodeLensEnabled.IsTrue() && isValidReferenceLensNode(node, userPrefs) { + if codeLens := l.newCodeLensForNode(documentURI, projection, node, lsproto.CodeLensKindReferences); codeLens != nil && seen.AddIfAbsent(keyForCodeLens(codeLens)) { + result = append(result, codeLens) + } } - } - if userPrefs.ImplementationsCodeLensEnabled.IsTrue() && isValidImplementationsCodeLensNode(node, userPrefs) { - if codeLens := l.newCodeLensForNode(documentURI, file, node, lsproto.CodeLensKindImplementations); codeLens != nil { - result = append(result, codeLens) + if userPrefs.ImplementationsCodeLensEnabled.IsTrue() && isValidImplementationsCodeLensNode(node, userPrefs) { + if codeLens := l.newCodeLensForNode(documentURI, projection, node, lsproto.CodeLensKindImplementations); codeLens != nil && seen.AddIfAbsent(keyForCodeLens(codeLens)) { + result = append(result, codeLens) + } } } + + savedLastSymbol := lastSymbol + node.ForEachChild(visit) + lastSymbol = savedLastSymbol + return false } - savedLastSymbol := lastSymbol - node.ForEachChild(visit) - lastSymbol = savedLastSymbol - return false + visit(projection.AsNode()) } - visit(file.AsNode()) - return lsproto.CodeLensResponse{ CodeLenses: &result, }, nil } +type codeLensKey struct { + kind lsproto.CodeLensKind + startLine, startCharacter, endLine, endCharacter uint32 +} + +func keyForCodeLens(codeLens *lsproto.CodeLens) codeLensKey { + return codeLensKey{ + kind: codeLens.Data.Kind, + startLine: codeLens.Range.Start.Line, + startCharacter: codeLens.Range.Start.Character, + endLine: codeLens.Range.End.Line, + endCharacter: codeLens.Range.End.Character, + } +} + func (l *LanguageService) ResolveCodeLens(ctx context.Context, codeLens *lsproto.CodeLens, showLocationsCommandName *string, orchestrator CrossProjectOrchestrator) (*lsproto.CodeLens, error) { uri := codeLens.Data.Uri textDoc := lsproto.TextDocumentIdentifier{Uri: uri} + program, file := l.getProgramAndFile(uri) + file = sourceFileForSupplementalFileIndex(file, codeLens.Data.SupplementalFileIndex) + if file == nil { + return nil, fmt.Errorf("supplemental source file index not found: %d", *codeLens.Data.SupplementalFileIndex) + } locale := locale.FromContext(ctx) var locs []lsproto.Location var lensTitle string switch codeLens.Data.Kind { case lsproto.CodeLensKindReferences: - referencesResp, err := l.ProvideReferences(ctx, &lsproto.ReferenceParams{ + data, _ := l.provideSymbolsAndEntriesAtPosition(ctx, program, file, int(codeLens.Data.Position), false, false) + referencesResp, err := l.provideReferencesFromData(ctx, &lsproto.ReferenceParams{ TextDocument: textDoc, Position: codeLens.Range.Start, Context: &lsproto.ReferenceContext{ // Don't include the declaration in the references count. IncludeDeclaration: false, }, - }, orchestrator) + }, orchestrator, data) if err != nil { return nil, err } @@ -88,8 +115,8 @@ func (l *LanguageService) ResolveCodeLens(ctx context.Context, codeLens *lsproto lensTitle = diagnostics.X_0_references.Localize(locale, len(locs)) } case lsproto.CodeLensKindImplementations: - - implementations, err := l.provideImplementationsEx( + data, _ := l.provideSymbolsAndEntriesAtPosition(ctx, program, file, int(codeLens.Data.Position), false, true) + implementations, err := l.provideImplementationsFromData( ctx, &lsproto.ImplementationParams{ TextDocument: textDoc, @@ -102,6 +129,7 @@ func (l *LanguageService) ResolveCodeLens(ctx context.Context, codeLens *lsproto dropOriginNodes: true, }, orchestrator, + data, ) if err != nil { return nil, err @@ -149,8 +177,10 @@ func (l *LanguageService) newCodeLensForNode(fileUri lsproto.DocumentUri, file * return &lsproto.CodeLens{ Range: lspRange, Data: &lsproto.CodeLensData{ - Kind: kind, - Uri: fileUri, + Kind: kind, + Uri: fileUri, + Position: int32(pos), + SupplementalFileIndex: supplementalFileIndex(file), }, } } diff --git a/tsc/internal/ls/crossproject.go b/tsc/internal/ls/crossproject.go index bd38bfca8cb51..6dc9b8882640a 100644 --- a/tsc/internal/ls/crossproject.go +++ b/tsc/internal/ls/crossproject.go @@ -25,6 +25,7 @@ type projectAndTextDocumentPosition struct { ls *LanguageService Uri lsproto.DocumentUri Position lsproto.Position + symbolData *SymbolAndEntriesData forOriginalLocation bool } @@ -52,13 +53,19 @@ func handleCrossProject[Req lsproto.HasTextDocumentPosition, Resp any]( isRename bool, implementations bool, options symbolEntryTransformOptions, + defaultProjectData *SymbolAndEntriesData, ) (Resp, error) { var resp Resp var err error // Single project if orchestrator == nil { - data, _ := defaultLs.provideSymbolsAndEntries(ctx, params.TextDocumentURI(), params.TextDocumentPosition(), isRename, implementations) + var data SymbolAndEntriesData + if defaultProjectData != nil { + data = *defaultProjectData + } else { + data, _ = defaultLs.provideSymbolsAndEntries(ctx, params.TextDocumentURI(), params.TextDocumentPosition(), isRename, implementations) + } return symbolAndEntriesToResp(defaultLs, ctx, params, data, options) } @@ -102,7 +109,14 @@ func handleCrossProject[Req lsproto.HasTextDocumentPosition, Resp any]( return } } - data, ok := ls.provideSymbolsAndEntries(ctx, item.Uri, item.Position, isRename, implementations) + var data SymbolAndEntriesData + var ok bool + if item.symbolData != nil { + data = *item.symbolData + ok = true + } else { + data, ok = ls.provideSymbolsAndEntries(ctx, item.Uri, item.Position, isRename, implementations) + } if ctx.Err() != nil { return } @@ -149,12 +163,14 @@ func handleCrossProject[Req lsproto.HasTextDocumentPosition, Resp any]( } // Initial set of projects and locations in the queue, starting with default project - enqueueItem(projectAndTextDocumentPosition{ + initialItem := projectAndTextDocumentPosition{ project: defaultProject, ls: defaultLs, Uri: params.TextDocumentURI(), Position: params.TextDocumentPosition(), - }) + } + initialItem.symbolData = defaultProjectData + enqueueItem(initialItem) for _, project := range allProjects { if project != defaultProject { enqueueItem(projectAndTextDocumentPosition{ diff --git a/tsc/internal/ls/findallreferences.go b/tsc/internal/ls/findallreferences.go index 2925111b9c183..84f20ad34f922 100644 --- a/tsc/internal/ls/findallreferences.go +++ b/tsc/internal/ls/findallreferences.go @@ -758,6 +758,22 @@ func (l *LanguageService) ProvideReferences(ctx context.Context, params *lsproto false, /*isRename*/ false, /*implementations*/ symbolEntryTransformOptions{}, + nil, /*defaultProjectData*/ + ) +} + +func (l *LanguageService) provideReferencesFromData(ctx context.Context, params *lsproto.ReferenceParams, orchestrator CrossProjectOrchestrator, data SymbolAndEntriesData) (lsproto.ReferencesResponse, error) { + return handleCrossProject( + l, + ctx, + params, + orchestrator, + (*LanguageService).symbolAndEntriesToReferences, + combineReferences, + false, /*isRename*/ + false, /*implementations*/ + symbolEntryTransformOptions{}, + &data, ) } @@ -772,6 +788,7 @@ func (l *LanguageService) ProvideVSReferences(ctx context.Context, params *lspro false, /*isRename*/ false, /*implementations*/ symbolEntryTransformOptions{}, + nil, /*defaultProjectData*/ ) } @@ -1017,6 +1034,22 @@ func (l *LanguageService) provideImplementationsEx(ctx context.Context, params * false, /*isRename*/ true, /*implementations*/ options, + nil, /*defaultProjectData*/ + ) +} + +func (l *LanguageService) provideImplementationsFromData(ctx context.Context, params *lsproto.ImplementationParams, options symbolEntryTransformOptions, orchestrator CrossProjectOrchestrator, data SymbolAndEntriesData) (lsproto.ImplementationResponse, error) { + return handleCrossProject( + l, + ctx, + params, + orchestrator, + (*LanguageService).symbolAndEntriesToImplementations, + combineImplementations, + false, /*isRename*/ + true, /*implementations*/ + options, + &data, ) } diff --git a/tsc/internal/ls/rename.go b/tsc/internal/ls/rename.go index 0974b1e68e29a..2ddefa4810348 100644 --- a/tsc/internal/ls/rename.go +++ b/tsc/internal/ls/rename.go @@ -73,6 +73,7 @@ func (l *LanguageService) ProvideRename(ctx context.Context, params *lsproto.Ren true, /*isRename*/ false, /*implementations*/ symbolEntryTransformOptions{}, + nil, /*defaultProjectData*/ ) } diff --git a/tsc/internal/lsp/lsproto/_generate/generate.mts b/tsc/internal/lsp/lsproto/_generate/generate.mts index 0fb164a65cf62..b8466ff18d705 100755 --- a/tsc/internal/lsp/lsproto/_generate/generate.mts +++ b/tsc/internal/lsp/lsproto/_generate/generate.mts @@ -193,6 +193,17 @@ const customStructures: Structure[] = [ type: { kind: "base", name: "DocumentUri" }, documentation: `The document in which the code lens and its range are located.`, }, + { + name: "position", + type: { kind: "base", name: "integer" }, + documentation: `The position of the code lens declaration in its virtual source file.`, + }, + { + name: "supplementalFileIndex", + type: { kind: "base", name: "integer" }, + optional: true, + documentation: `Zero-based index into the canonical file's supplemental source files. Absent for the canonical source file.`, + }, ], }, { diff --git a/tsc/internal/lsp/lsproto/lsp_generated.go b/tsc/internal/lsp/lsproto/lsp_generated.go index 31e2c37a7f617..d7fd435e7c038 100644 --- a/tsc/internal/lsp/lsproto/lsp_generated.go +++ b/tsc/internal/lsp/lsproto/lsp_generated.go @@ -8871,6 +8871,12 @@ type CodeLensData struct { // The document in which the code lens and its range are located. Uri DocumentUri `json:"uri" lsp:"required"` + + // The position of the code lens declaration in its virtual source file. + Position int32 `json:"position" lsp:"required"` + + // Zero-based index into the canonical file's supplemental source files. Absent for the canonical source file. + SupplementalFileIndex *int32 `json:"supplementalFileIndex,omitzero"` } var _ json.UnmarshalerFrom = (*CodeLensData)(nil) diff --git a/tsc/internal/testutil/contentmappertest/editing.go b/tsc/internal/testutil/contentmappertest/editing.go index d50116728f28b..0cc312d24b4b2 100644 --- a/tsc/internal/testutil/contentmappertest/editing.go +++ b/tsc/internal/testutil/contentmappertest/editing.go @@ -24,7 +24,7 @@ func (prefixedSupplementalHandler) HandleRequest(ctx context.Context, method str } const prefix = "/* generated */\n" features := spanmap.FeatureAll - if strings.Contains(p.FileName, "folding-disabled") { + if strings.Contains(p.FileName, "folding-disabled") || strings.Contains(p.FileName, "codelens-disabled") { features = spanmap.FeatureNone } mappings, err := spanmap.New([]spanmap.Segment{{ @@ -39,7 +39,7 @@ func (prefixedSupplementalHandler) HandleRequest(ctx context.Context, method str return nil, err } canonical := contentmapper.MappedOutput{Text: "export {};", Extension: ".ts"} - if strings.Contains(p.FileName, "folding-duplicate") { + if strings.Contains(p.FileName, "folding-duplicate") || strings.Contains(p.FileName, "codelens-disabled") || strings.Contains(p.FileName, "codelens-duplicate") { canonicalMappings, err := spanmap.New([]spanmap.Segment{{ VirtualStart: 0, VirtualEnd: core.TextPos(len(p.Content)), diff --git a/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperDeduplicatesProjectedCodeLens.baseline.jsonc b/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperDeduplicatesProjectedCodeLens.baseline.jsonc new file mode 100644 index 0000000000000..c60e83125ce74 --- /dev/null +++ b/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperDeduplicatesProjectedCodeLens.baseline.jsonc @@ -0,0 +1,5 @@ +// === Code Lenses === +// === /codelens-duplicate.astro === +// function /*CODELENS: 1 reference*/outer() {} +// [|outer|](); +// \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperDisabledSupplementalCodeLens.baseline.jsonc b/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperDisabledSupplementalCodeLens.baseline.jsonc new file mode 100644 index 0000000000000..5b2c516bd624a --- /dev/null +++ b/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperDisabledSupplementalCodeLens.baseline.jsonc @@ -0,0 +1,5 @@ +// === Code Lenses === +// === /codelens-disabled.astro === +// function /*CODELENS: 1 reference*/outer() {} +// [|outer|](); +// \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperSupplementalCodeLens.baseline.jsonc b/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperSupplementalCodeLens.baseline.jsonc new file mode 100644 index 0000000000000..99a7b64bf6eff --- /dev/null +++ b/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperSupplementalCodeLens.baseline.jsonc @@ -0,0 +1,5 @@ +// === Code Lenses === +// === /codelens-supplemental.astro === +// function /*CODELENS: 1 reference*/outer() {} +// [|outer|](); +// \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperSupplementalImplementationCodeLens.baseline.jsonc b/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperSupplementalImplementationCodeLens.baseline.jsonc new file mode 100644 index 0000000000000..894bea597f635 --- /dev/null +++ b/tsc/testdata/baselines/reference/fourslash/codeLenses/contentMapperSupplementalImplementationCodeLens.baseline.jsonc @@ -0,0 +1,5 @@ +// === Code Lenses === +// === /codelens-implementation.astro === +// interface /*CODELENS: 1 implementation*/Service { run(): void } +// class [|Impl|] implements Service { run() {} } +// \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/fourslash/state/codeLensAcrossProjects.baseline b/tsc/testdata/baselines/reference/fourslash/state/codeLensAcrossProjects.baseline index 660f022b21b54..38556f9e2e9df 100644 --- a/tsc/testdata/baselines/reference/fourslash/state/codeLensAcrossProjects.baseline +++ b/tsc/testdata/baselines/reference/fourslash/state/codeLensAcrossProjects.baseline @@ -211,7 +211,8 @@ Config File Names:: }, "data": { "kind": "references", - "uri": "file:///projects/container/lib/index.ts" + "uri": "file:///projects/container/lib/index.ts", + "position": 18 } } } @@ -312,7 +313,8 @@ Config:: }, "data": { "kind": "implementations", - "uri": "file:///projects/container/lib/index.ts" + "uri": "file:///projects/container/lib/index.ts", + "position": 18 } } } @@ -367,7 +369,8 @@ Config:: }, "data": { "kind": "references", - "uri": "file:///projects/container/lib/index.ts" + "uri": "file:///projects/container/lib/index.ts", + "position": 32 } } } @@ -398,7 +401,8 @@ Config:: }, "data": { "kind": "implementations", - "uri": "file:///projects/container/lib/index.ts" + "uri": "file:///projects/container/lib/index.ts", + "position": 32 } } } @@ -456,7 +460,8 @@ Config:: }, "data": { "kind": "references", - "uri": "file:///projects/container/lib/index.ts" + "uri": "file:///projects/container/lib/index.ts", + "position": 50 } } } @@ -487,7 +492,8 @@ Config:: }, "data": { "kind": "implementations", - "uri": "file:///projects/container/lib/index.ts" + "uri": "file:///projects/container/lib/index.ts", + "position": 50 } } } @@ -551,7 +557,8 @@ Config:: }, "data": { "kind": "references", - "uri": "file:///projects/container/lib/index.ts" + "uri": "file:///projects/container/lib/index.ts", + "position": 81 } } } From e06f87324c4b41f78c99695c1754bca4ce27b990 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 20 Aug 2026 11:54:46 -0700 Subject: [PATCH 12/18] Format supplemental projections --- .../tests/contentMapperEditSafety_test.go | 108 ++++++++++++++++++ tsc/internal/ls/format.go | 61 +++++++++- tsc/internal/ls/lsconv/converters.go | 9 ++ .../testutil/contentmappertest/editing.go | 37 +++++- 4 files changed, 206 insertions(+), 9 deletions(-) diff --git a/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go b/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go index aa561f0ea3fd6..af72bc63ab142 100644 --- a/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go +++ b/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go @@ -259,3 +259,111 @@ class Impl implements Service { run() {} } ImplementationsCodeLensEnabled: core.TSTrue, }}) } + +func TestContentMapperFormatsSupplementalVerbatimRange(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // Full-document formatting intersects the original file with the supplemental verbatim mapping: + // + // canonical: export {}; + // supplemental: /* generated */ + // function outer(){ + // const value={a:1}; + // } + // + // original: [------------- function -------------) + // supplemental: /* generated */[------------- function -------------) + // `-- verbatim, FeatureAll (includes Formatting) --' + // + // Only the mapped function range is formatted; the generated prefix is outside the request range. + f, done := newContentMapperFourslash(t, `// @Filename: /formatting.astro +function outer(){ +const value={a:1}; +} +`, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.GoToFile(t, "/formatting.astro") + f.FormatDocument(t, "/formatting.astro") + f.VerifyCurrentFileContent(t, `function outer() { + const value = { a: 1 }; +} +`) +} + +func TestContentMapperSkipsFormattingDisabledVerbatimRange(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // The virtual output has the same shape, but its only mapping is disabled for formatting: + // + // canonical: export {}; + // supplemental: /* generated */ + // function outer(){ + // const value={a:1}; + // } + // + // original: [------------- function -------------) + // supplemental: /* generated */[------------- function -------------) + // `---- verbatim, FeatureNone --------' + // + // Full-document formatting therefore returns no edits. + const content = `function outer(){ +const value={a:1}; +} +` + f, done := newContentMapperFourslash(t, `// @Filename: /formatting-disabled.astro +`+content, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.GoToFile(t, "/formatting-disabled.astro") + f.FormatDocument(t, "/formatting-disabled.astro") + f.VerifyCurrentFileContent(t, content) +} + +func TestContentMapperFormatsEachSupplementalVerbatimRange(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // The supplemental projection splits the original around synthesized generated code: + // + // original: [--- first ---)[--- second ---) + // supplemental: /* generated */[--- first ---)const generated={x:1};[--- second ---) + // `- verbatim, Formatting -' `- verbatim, Formatting -' + // + // Each verbatim intersection is formatted independently. Edits outside either virtual intersection, + // including edits to the synthesized generated declaration, are discarded. + f, done := newContentMapperFourslash(t, `// @Filename: /formatting-split.astro +function first(){return 1;} +function second(){return 2;} +`, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.GoToFile(t, "/formatting-split.astro") + f.FormatDocument(t, "/formatting-split.astro") + f.VerifyCurrentFileContent(t, `function first() { return 1; } +function second() { return 2; } +`) +} + +func TestContentMapperFormatsSupplementalOriginalSelection(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // The entire original file maps verbatim after a synthesized prefix: + // + // original: [--- first ---)[--- second ---) + // supplemental: /* generated */[--- first ---)[--- second ---) + // `---- verbatim, Formatting ----' + // + // The original request selects only `second`; formatter edits expanded outside that virtual + // intersection are rejected, leaving `first` unchanged. + f, done := newContentMapperFourslash(t, `// @Filename: /formatting-selection.astro +function first(){return 1;} +/*start*/function second(){return 2;}/*end*/ +`, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.GoToFile(t, "/formatting-selection.astro") + f.FormatSelection(t, "start", "end") + f.VerifyCurrentFileContent(t, `function first(){return 1;} +function second() { return 2; } +`) +} diff --git a/tsc/internal/ls/format.go b/tsc/internal/ls/format.go index 3ff1ec75038b5..19803a051cf57 100644 --- a/tsc/internal/ls/format.go +++ b/tsc/internal/ls/format.go @@ -1,8 +1,10 @@ package ls import ( + "cmp" "context" "iter" + "slices" "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/astnav" @@ -40,14 +42,59 @@ func (l *LanguageService) ProvideFormatDocument( } _, file := l.getProgramAndFile(documentURI) formatOpts := lsutil.FromLSFormatOptions(l.FormatOptions(), options) - edits := l.toLSProtoTextEdits(file, l.getFormattingEditsForDocument( - ctx, - file, - formatOpts, - )) + var edits []*lsproto.TextEdit + if file.ContentMapper() == "" { + edits = l.toLSProtoTextEdits(file, l.getFormattingEditsForDocument(ctx, file, formatOpts)) + } else { + edits = l.getFormattingEditsForMappedRange(ctx, file, formatOpts, core.NewTextRange(0, len(file.OriginalText()))) + } return lsproto.TextEditsOrNull{TextEdits: &edits}, nil } +// getFormattingEditsForMappedRange formats each formatting-enabled verbatim intersection with originalRange. +// A mapper should provide at most one such mapping for any original text; duplicate formatting projections are unsupported. +func (l *LanguageService) getFormattingEditsForMappedRange(ctx context.Context, file *ast.SourceFile, options lsutil.FormatCodeSettings, originalRange core.TextRange) []*lsproto.TextEdit { + projections := append([]*ast.SourceFile{file}, file.SupplementalSourceFiles()...) + var edits []*lsproto.TextEdit + for _, projection := range projections { + spanMap := projection.SpanMap() + if spanMap == nil { + continue + } + for _, segment := range spanMap.Segments() { + if segment.Kind != spanmap.KindVerbatim || segment.Features&spanmap.FeatureFormatting == 0 { + continue + } + originalStart := max(originalRange.Pos(), int(segment.OriginalStart)) + originalEnd := min(originalRange.End(), int(segment.OriginalEnd)) + if originalStart >= originalEnd { + continue + } + virtualRange := core.NewTextRange( + int(segment.VirtualStart)+originalStart-int(segment.OriginalStart), + int(segment.VirtualStart)+originalEnd-int(segment.OriginalStart), + ) + for _, change := range l.getFormattingEditsForRange(ctx, projection, options, virtualRange) { + if change.Pos() < virtualRange.Pos() || change.End() > virtualRange.End() { + continue + } + lspRange, fidelity := l.converters.ToLSPRangeForFeature(projection, core.NewTextRange(change.Pos(), change.End()), spanmap.FeatureFormatting) + if !fidelity.IsExact() { + continue + } + edits = append(edits, &lsproto.TextEdit{Range: lspRange, NewText: change.NewText}) + } + } + } + slices.SortStableFunc(edits, func(a, b *lsproto.TextEdit) int { + if c := lsproto.CompareRanges(a.Range, b.Range); c != 0 { + return c + } + return cmp.Compare(a.NewText, b.NewText) + }) + return edits +} + func (l *LanguageService) ProvideFormatDocumentRange( ctx context.Context, documentURI lsproto.DocumentUri, @@ -59,6 +106,10 @@ func (l *LanguageService) ProvideFormatDocumentRange( } _, file := l.getProgramAndFile(documentURI) formatOpts := lsutil.FromLSFormatOptions(l.FormatOptions(), options) + if file.ContentMapper() != "" { + edits := l.getFormattingEditsForMappedRange(ctx, file, formatOpts, lsconv.FromLSPRangeToOriginal(l.converters, file, r)) + return lsproto.TextEditsOrNull{TextEdits: &edits}, nil + } ranges := lsconv.FromLSPRangeForSourceFile(l.converters, file, r, spanmap.FeatureFormatting) if len(ranges) != 1 || !ranges[0].Fidelity.IsExact() { return lsproto.TextEditsOrNull{}, nil diff --git a/tsc/internal/ls/lsconv/converters.go b/tsc/internal/ls/lsconv/converters.go index 4bdda4f1188f6..23eb9036206ee 100644 --- a/tsc/internal/ls/lsconv/converters.go +++ b/tsc/internal/ls/lsconv/converters.go @@ -222,6 +222,15 @@ func FromLSPPositionForSourceFile(c *Converters, file *ast.SourceFile, position return lspPositionToVirtual(c, files, position, feature) } +// FromLSPRangeToOriginal converts an LSP range in a content-mapped document directly to original-text offsets. +func FromLSPRangeToOriginal(c *Converters, script Script, textRange lsproto.Range) core.TextRange { + original := originalTextScript{fileName: script.OriginalFileName(), text: script.OriginalText()} + return core.NewTextRange( + int(c.lineAndCharacterToPosition(original, textRange.Start)), + int(c.lineAndCharacterToPosition(original, textRange.End)), + ) +} + func sourceFileProjections(file *ast.SourceFile) []*ast.SourceFile { supplemental := file.SupplementalSourceFiles() files := make([]*ast.SourceFile, 1, 1+len(supplemental)) diff --git a/tsc/internal/testutil/contentmappertest/editing.go b/tsc/internal/testutil/contentmappertest/editing.go index 0cc312d24b4b2..e03f384e5a67b 100644 --- a/tsc/internal/testutil/contentmappertest/editing.go +++ b/tsc/internal/testutil/contentmappertest/editing.go @@ -2,6 +2,7 @@ package contentmappertest import ( "context" + "errors" "fmt" "strings" @@ -24,17 +25,45 @@ func (prefixedSupplementalHandler) HandleRequest(ctx context.Context, method str } const prefix = "/* generated */\n" features := spanmap.FeatureAll - if strings.Contains(p.FileName, "folding-disabled") || strings.Contains(p.FileName, "codelens-disabled") { + if strings.Contains(p.FileName, "folding-disabled") || strings.Contains(p.FileName, "codelens-disabled") || strings.Contains(p.FileName, "formatting-disabled") { features = spanmap.FeatureNone } - mappings, err := spanmap.New([]spanmap.Segment{{ + supplementalText := prefix + p.Content + segments := []spanmap.Segment{{ VirtualStart: core.TextPos(len(prefix)), VirtualEnd: core.TextPos(len(prefix) + len(p.Content)), OriginalStart: 0, OriginalEnd: core.TextPos(len(p.Content)), Kind: spanmap.KindVerbatim, Features: features, - }}).Marshal() + }} + if strings.Contains(p.FileName, "formatting-split") { + secondStart := strings.Index(p.Content, "function second") + if secondStart < 0 { + return nil, errors.New("contentmappertest: formatting-split input is missing function second") + } + const generated = "const generated={x:1};\n" + supplementalText = prefix + p.Content[:secondStart] + generated + p.Content[secondStart:] + segments = []spanmap.Segment{ + { + VirtualStart: core.TextPos(len(prefix)), + VirtualEnd: core.TextPos(len(prefix) + secondStart), + OriginalStart: 0, + OriginalEnd: core.TextPos(secondStart), + Kind: spanmap.KindVerbatim, + Features: spanmap.FeatureAll, + }, + { + VirtualStart: core.TextPos(len(prefix) + secondStart + len(generated)), + VirtualEnd: core.TextPos(len(supplementalText)), + OriginalStart: core.TextPos(secondStart), + OriginalEnd: core.TextPos(len(p.Content)), + Kind: spanmap.KindVerbatim, + Features: spanmap.FeatureAll, + }, + } + } + mappings, err := spanmap.New(segments).Marshal() if err != nil { return nil, err } @@ -56,7 +85,7 @@ func (prefixedSupplementalHandler) HandleRequest(ctx context.Context, method str return contentmapper.TransformResult{ MappedOutput: canonical, Supplemental: []contentmapper.SupplementalOutput{{MappedOutput: contentmapper.MappedOutput{ - Text: prefix + p.Content, + Text: supplementalText, Extension: ".ts", Mappings: json.Value(mappings), }}}, From c5e1ebc51148cae20df765ea1522aad9202f7361 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 20 Aug 2026 12:25:17 -0700 Subject: [PATCH 13/18] Remove protocol version, soft-require diagnostic code --- tsc/internal/compiler/fileloader.go | 4 ---- tsc/internal/contentmapper/host.go | 4 ---- tsc/internal/contentmapper/host_test.go | 14 ++++++++------ tsc/internal/contentmapper/hostimpl.go | 13 ++----------- tsc/internal/diagnostics/diagnostics_generated.go | 4 ---- .../diagnostics/extraDiagnosticMessages.json | 4 ---- .../testutil/contentmappertest/protocol.go | 1 - 7 files changed, 10 insertions(+), 34 deletions(-) diff --git a/tsc/internal/compiler/fileloader.go b/tsc/internal/compiler/fileloader.go index 5baed7519733f..0497e1e8c0e08 100644 --- a/tsc/internal/compiler/fileloader.go +++ b/tsc/internal/compiler/fileloader.go @@ -453,8 +453,6 @@ func contentMapperTransformDiagnostic(file *ast.SourceFile, label string, err er case contentmapper.TransformErrorKindInitialize: if initializeError, ok := errors.AsType[*contentmapper.InitializeError](transformError); ok { switch initializeError.Kind { - case contentmapper.InitializeErrorKindProtocolVersion: - return contentMapperTransformDiagnosticChain(file, label, diagnostics.The_content_mapper_uses_unsupported_protocol_version_0_expected_version_1, initializeError.ProtocolVersion, contentmapper.ProtocolVersion) case contentmapper.InitializeErrorKindPositionEncoding: return contentMapperTransformDiagnosticChain(file, label, diagnostics.The_content_mapper_selected_unsupported_position_encoding_0, initializeError.PositionEncoding) case contentmapper.InitializeErrorKindEmptyDiagnosticSource: @@ -597,8 +595,6 @@ func ContentMapperInitializationDiagnostic(label string, err error) *ast.Diagnos return diagnostic.AddMessageChain(ast.NewCompilerDiagnostic(diagnostics.The_content_mapper_returned_an_initialize_response_that_could_not_be_decoded_Colon_0, initializeError.Detail)) case contentmapper.InitializeErrorKindRequest: return diagnostic.AddMessageChain(ast.NewCompilerDiagnostic(diagnostics.The_content_mapper_s_initialize_request_failed_Colon_0, initializeError.Detail)) - case contentmapper.InitializeErrorKindProtocolVersion: - return diagnostic.AddMessageChain(ast.NewCompilerDiagnostic(diagnostics.The_content_mapper_uses_unsupported_protocol_version_0_expected_version_1, initializeError.ProtocolVersion, contentmapper.ProtocolVersion)) case contentmapper.InitializeErrorKindPositionEncoding: return diagnostic.AddMessageChain(ast.NewCompilerDiagnostic(diagnostics.The_content_mapper_selected_unsupported_position_encoding_0, initializeError.PositionEncoding)) case contentmapper.InitializeErrorKindEmptyDiagnosticSource: diff --git a/tsc/internal/contentmapper/host.go b/tsc/internal/contentmapper/host.go index 3bc72c1bde3d9..538e9caf8880c 100644 --- a/tsc/internal/contentmapper/host.go +++ b/tsc/internal/contentmapper/host.go @@ -113,7 +113,6 @@ const ( InitializeErrorKindNoResponse InitializeErrorKindInvalidResponse InitializeErrorKindRequest - InitializeErrorKindProtocolVersion InitializeErrorKindPositionEncoding InitializeErrorKindEmptyDiagnosticSource InitializeErrorKindReservedDiagnosticSource @@ -127,7 +126,6 @@ type InitializeError struct { Detail string ExitCode int TimeoutSeconds int - ProtocolVersion int PositionEncoding PositionEncoding DiagnosticSource string } @@ -153,8 +151,6 @@ func (e *InitializeError) Error() string { return "content mapper returned an invalid initialize response: " + e.Detail case InitializeErrorKindRequest: return "content mapper initialize request failed: " + e.Detail - case InitializeErrorKindProtocolVersion: - return fmt.Sprintf("unsupported protocol version %d (expected %d)", e.ProtocolVersion, ProtocolVersion) case InitializeErrorKindPositionEncoding: return fmt.Sprintf("unsupported position encoding %q", e.PositionEncoding) case InitializeErrorKindEmptyDiagnosticSource: diff --git a/tsc/internal/contentmapper/host_test.go b/tsc/internal/contentmapper/host_test.go index 89044636b85c1..b5a8a807f2c6b 100644 --- a/tsc/internal/contentmapper/host_test.go +++ b/tsc/internal/contentmapper/host_test.go @@ -34,7 +34,7 @@ type responseMapper struct { func (m responseMapper) HandleRequest(ctx context.Context, method string, params json.Value) (any, error) { switch method { case contentmapper.MethodInitialize: - return contentmapper.InitializeResult{ProtocolVersion: contentmapper.ProtocolVersion, PositionEncoding: contentmapper.PositionEncodingUTF8, DiagnosticSource: "mapper"}, nil + return contentmapper.InitializeResult{PositionEncoding: contentmapper.PositionEncodingUTF8, DiagnosticSource: "mapper"}, nil case contentmapper.MethodTransform: var p contentmapper.TransformParams if err := json.Unmarshal(params, &p); err != nil { @@ -53,7 +53,7 @@ func (responseMapper) HandleNotification(ctx context.Context, method string, par func (fakeMapper) HandleRequest(ctx context.Context, method string, params json.Value) (any, error) { switch method { case contentmapper.MethodInitialize: - return contentmapper.InitializeResult{ProtocolVersion: contentmapper.ProtocolVersion, PositionEncoding: contentmapper.PositionEncodingUTF8, DiagnosticSource: "vue"}, nil + return contentmapper.InitializeResult{PositionEncoding: contentmapper.PositionEncodingUTF8, DiagnosticSource: "vue"}, nil case contentmapper.MethodTransform: var p contentmapper.TransformParams if err := json.Unmarshal(params, &p); err != nil { @@ -108,7 +108,7 @@ func (m unicodeMapper) HandleRequest(ctx context.Context, method string, params if m.source != nil { source = *m.source } - return contentmapper.InitializeResult{ProtocolVersion: contentmapper.ProtocolVersion, PositionEncoding: m.encoding, DiagnosticSource: source}, nil + return contentmapper.InitializeResult{PositionEncoding: m.encoding, DiagnosticSource: source}, nil case contentmapper.MethodTransform: var p contentmapper.TransformParams if err := json.Unmarshal(params, &p); err != nil { @@ -147,6 +147,7 @@ func (m unicodeMapper) HandleRequest(ctx context.Context, method string, params MessageText: "after non-ASCII character", Start: emojiLength, Length: textLength - emojiLength, + Code: 1001, }}, }, nil default: @@ -165,13 +166,14 @@ type invalidDiagnosticMapper struct { func (m invalidDiagnosticMapper) HandleRequest(ctx context.Context, method string, params json.Value) (any, error) { switch method { case contentmapper.MethodInitialize: - return contentmapper.InitializeResult{ProtocolVersion: contentmapper.ProtocolVersion, PositionEncoding: m.encoding, DiagnosticSource: "mapper"}, nil + return contentmapper.InitializeResult{PositionEncoding: m.encoding, DiagnosticSource: "mapper"}, nil case contentmapper.MethodTransform: return contentmapper.TransformResult{ MappedOutput: contentmapper.MappedOutput{Extension: ".ts"}, Diagnostics: []contentmapper.Diagnostic{{ MessageText: "invalid boundary", Start: 1, + Code: 1002, }}, }, nil default: @@ -338,7 +340,6 @@ func TestHostClosesProcessWhenReadLoopFails(t *testing.T) { assert.NilError(t, err) assert.Equal(t, message.Method, contentmapper.MethodInitialize) assert.NilError(t, protocol.WriteResponse(message.ID, contentmapper.InitializeResult{ - ProtocolVersion: contentmapper.ProtocolVersion, PositionEncoding: contentmapper.PositionEncodingUTF8, DiagnosticSource: "mapper", })) @@ -910,7 +911,7 @@ func (m *recordingMapper) HandleRequest(ctx context.Context, method string, para m.mu.Lock() m.receivedLocale = p.Locale m.mu.Unlock() - return contentmapper.InitializeResult{ProtocolVersion: contentmapper.ProtocolVersion, PositionEncoding: contentmapper.PositionEncodingUTF8, DiagnosticSource: "mapper"}, nil + return contentmapper.InitializeResult{PositionEncoding: contentmapper.PositionEncodingUTF8, DiagnosticSource: "mapper"}, nil case contentmapper.MethodOpenProject: var p contentmapper.OpenProjectParams if err := json.Unmarshal(params, &p); err != nil { @@ -1196,6 +1197,7 @@ func TestProjectRejectsInvalidOptionDiagnosticPath(t *testing.T) { mapperProcess := &recordingMapper{optionDiagnostics: []contentmapper.OptionDiagnosticResult{{ Path: []json.Value{json.Value(`null`)}, MessageText: "Invalid option.", + Code: 123, }}} host := contentmapper.NewHost(t.Context(), &fakeSpawner{handler: mapperProcess}, locale.Default) defer host.Close() diff --git a/tsc/internal/contentmapper/hostimpl.go b/tsc/internal/contentmapper/hostimpl.go index 85f3ccd05d478..890daf15f22b2 100644 --- a/tsc/internal/contentmapper/hostimpl.go +++ b/tsc/internal/contentmapper/hostimpl.go @@ -27,9 +27,6 @@ import ( "github.com/zeebo/xxh3" ) -// ProtocolVersion is the content mapper protocol version this host speaks. -const ProtocolVersion = 1 - const initializeTimeoutSeconds = 5 const initializeTimeout = initializeTimeoutSeconds * time.Second @@ -44,7 +41,6 @@ const ( // InitializeParams is the parameter object for the initialize request. type InitializeParams struct { - ProtocolVersion int `json:"protocolVersion"` // Locale is the BCP 47 locale to use for mapper-authored diagnostic messages, when configured. Locale string `json:"locale,omitempty"` // PositionEncodings lists the coordinate spaces the host accepts. @@ -53,7 +49,6 @@ type InitializeParams struct { // InitializeResult is the mapper's response to the initialize request. type InitializeResult struct { - ProtocolVersion int `json:"protocolVersion"` // PositionEncoding selects the coordinate space for all mappings and diagnostics. PositionEncoding PositionEncoding `json:"positionEncoding"` // DiagnosticSource is the prefix used for every mapper-authored diagnostic code. @@ -86,7 +81,7 @@ type OpenProjectResult struct { type OptionDiagnosticResult struct { Path []json.Value `json:"path"` MessageText string `json:"messageText"` - Code int32 `json:"code,omitempty"` + Code int32 `json:"code"` } // CloseProjectParams is the parameter object for the closeProject request. @@ -215,7 +210,7 @@ type Diagnostic struct { // Start and Length locate the diagnostic in the original content using the selected position encoding. Start int `json:"start"` Length int `json:"length"` - Code int32 `json:"code,omitempty"` + Code int32 `json:"code"` } // dialFunc establishes a running connection to a mapper. In production it spawns the mapper's process; @@ -1095,7 +1090,6 @@ func (h *host) release(identities []string) { func handshake(ctx context.Context, conn ipc.Conn, diagnosticLocale locale.Locale) (PositionEncoding, string, error) { raw, err := conn.Call(ctx, MethodInitialize, InitializeParams{ - ProtocolVersion: ProtocolVersion, Locale: diagnosticLocale.String(), PositionEncodings: []PositionEncoding{PositionEncodingUTF8, PositionEncodingUTF16}, }) @@ -1106,9 +1100,6 @@ func handshake(ctx context.Context, conn ipc.Conn, diagnosticLocale locale.Local if err := json.Unmarshal(raw, &res); err != nil { return "", "", &InitializeError{Kind: InitializeErrorKindInvalidResponse, Detail: err.Error()} } - if res.ProtocolVersion != ProtocolVersion { - return "", "", &InitializeError{Kind: InitializeErrorKindProtocolVersion, ProtocolVersion: res.ProtocolVersion} - } if res.PositionEncoding != PositionEncodingUTF8 && res.PositionEncoding != PositionEncodingUTF16 { return "", "", &InitializeError{Kind: InitializeErrorKindPositionEncoding, PositionEncoding: res.PositionEncoding} } diff --git a/tsc/internal/diagnostics/diagnostics_generated.go b/tsc/internal/diagnostics/diagnostics_generated.go index 5ea70f757aa9c..96641c4d85818 100644 --- a/tsc/internal/diagnostics/diagnostics_generated.go +++ b/tsc/internal/diagnostics/diagnostics_generated.go @@ -4362,8 +4362,6 @@ var The_content_mapper_process_failed_while_handling_the_transform_request = &Me var The_content_mapper_returned_an_invalid_transform_response = &Message{code: 100043, category: CategoryMessage, key: "The_content_mapper_returned_an_invalid_transform_response_100043", text: "The content mapper returned an invalid transform response."} -var The_content_mapper_uses_unsupported_protocol_version_0_expected_version_1 = &Message{code: 100044, category: CategoryMessage, key: "The_content_mapper_uses_unsupported_protocol_version_0_expected_version_1_100044", text: "The content mapper uses unsupported protocol version {0}; expected version {1}."} - var The_content_mapper_selected_unsupported_position_encoding_0 = &Message{code: 100045, category: CategoryMessage, key: "The_content_mapper_selected_unsupported_position_encoding_0_100045", text: "The content mapper selected unsupported position encoding '{0}'."} var The_content_mapper_diagnostic_source_must_not_be_empty = &Message{code: 100046, category: CategoryMessage, key: "The_content_mapper_diagnostic_source_must_not_be_empty_100046", text: "The content mapper diagnostic source must not be empty."} @@ -8774,8 +8772,6 @@ func keyToMessage(key Key) *Message { return The_content_mapper_process_failed_while_handling_the_transform_request case "The_content_mapper_returned_an_invalid_transform_response_100043": return The_content_mapper_returned_an_invalid_transform_response - case "The_content_mapper_uses_unsupported_protocol_version_0_expected_version_1_100044": - return The_content_mapper_uses_unsupported_protocol_version_0_expected_version_1 case "The_content_mapper_selected_unsupported_position_encoding_0_100045": return The_content_mapper_selected_unsupported_position_encoding_0 case "The_content_mapper_diagnostic_source_must_not_be_empty_100046": diff --git a/tsc/internal/diagnostics/extraDiagnosticMessages.json b/tsc/internal/diagnostics/extraDiagnosticMessages.json index 3f735d30c8389..1673dffda0bcc 100644 --- a/tsc/internal/diagnostics/extraDiagnosticMessages.json +++ b/tsc/internal/diagnostics/extraDiagnosticMessages.json @@ -239,10 +239,6 @@ "category": "Message", "code": 100043 }, - "The content mapper uses unsupported protocol version {0}; expected version {1}.": { - "category": "Message", - "code": 100044 - }, "The content mapper selected unsupported position encoding '{0}'.": { "category": "Message", "code": 100045 diff --git a/tsc/internal/testutil/contentmappertest/protocol.go b/tsc/internal/testutil/contentmappertest/protocol.go index fa375f683213d..acff7f602f83a 100644 --- a/tsc/internal/testutil/contentmappertest/protocol.go +++ b/tsc/internal/testutil/contentmappertest/protocol.go @@ -19,7 +19,6 @@ func (noNotifications) HandleNotification(ctx context.Context, method string, pa func initializeResult(source string) contentmapper.InitializeResult { return contentmapper.InitializeResult{ - ProtocolVersion: contentmapper.ProtocolVersion, PositionEncoding: contentmapper.PositionEncodingUTF8, DiagnosticSource: source, } From acfe98d5802d88a6d1ce62d91e6098db2d353e4a Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 20 Aug 2026 15:18:03 -0700 Subject: [PATCH 14/18] Support declaration maps --- tsc/internal/compiler/emitter.go | 43 +++++- ...tentMapperDeclarationMapNavigation_test.go | 45 +++++++ tsc/internal/outputpaths/outputpaths.go | 2 +- tsc/internal/printer/printer.go | 34 ++++- tsc/internal/sourcemap/generator.go | 8 ++ tsc/internal/sourcemap/generator_test.go | 20 +++ tsc/internal/spanmap/spanmap.go | 20 +++ tsc/internal/spanmap/spanmap_test.go | 26 ++++ .../testutil/harnessutil/harnessutil.go | 9 +- .../testutil/tsbaseline/sourcemap_baseline.go | 10 +- ...contentMapperDeclarationEmit.contentmapper | 10 +- .../compiler/contentMapperDeclarationEmit.js | 9 +- .../contentMapperDeclarationEmit.js.map | 4 + ...contentMapperDeclarationEmit.sourcemap.txt | 122 ++++++++++++++++++ .../contentMapperDeclarationEmit.symbols | 12 +- .../contentMapperDeclarationEmit.types | 10 +- ...MapperDeclarationEmitFailure.contentmapper | 12 ++ .../contentMapperDeclarationEmitFailure.js | 16 +++ ...contentMapperDeclarationEmitFailure.js.map | 3 + ...MapperDeclarationEmitFailure.sourcemap.txt | 7 + ...ontentMapperDeclarationEmitFailure.symbols | 4 + .../contentMapperDeclarationEmitFailure.types | 4 + ...perDeclarationMapNavigation.baseline.jsonc | 10 ++ ...perDeclarationMapNavigation.baseline.jsonc | 10 ++ .../compiler/contentMapperDeclarationEmit.ts | 5 +- .../contentMapperDeclarationEmitFailure.ts | 22 ++++ 26 files changed, 440 insertions(+), 37 deletions(-) create mode 100644 tsc/internal/fourslash/tests/contentMapperDeclarationMapNavigation_test.go create mode 100644 tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.contentmapper create mode 100644 tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.js create mode 100644 tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.js.map create mode 100644 tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.sourcemap.txt create mode 100644 tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.symbols create mode 100644 tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.types create mode 100644 tsc/testdata/baselines/reference/fourslash/findAllReferences/contentMapperDeclarationMapNavigation.baseline.jsonc create mode 100644 tsc/testdata/baselines/reference/fourslash/goToDefinition/contentMapperDeclarationMapNavigation.baseline.jsonc create mode 100644 tsc/testdata/tests/cases/compiler/contentMapperDeclarationEmitFailure.ts diff --git a/tsc/internal/compiler/emitter.go b/tsc/internal/compiler/emitter.go index 4e21ee3fe498b..2500bafb93f3d 100644 --- a/tsc/internal/compiler/emitter.go +++ b/tsc/internal/compiler/emitter.go @@ -224,10 +224,8 @@ func (e *emitter) emitDeclarationFile(sourceFile *ast.SourceFile, declarationFil if sourceFile == nil || e.emitOnly == EmitOnlyJs || len(declarationFilePath) == 0 { return } - // Declaration files for content-mapped files don't get source maps because the mapped positions would point into - // transformed TS content that exists only in-memory during the build. As a future improvement, it may be possible - // to double-map the positions using the content-mapped file's spanmap. - emitDeclarationMap := e.emitOnly != EmitOnlyBuilderSignature && options.DeclarationMap.IsTrue() && sourceFile.ContentMapper() == "" + emitDeclarationMap := e.emitOnly != EmitOnlyBuilderSignature && options.DeclarationMap.IsTrue() + contentMappedSource := sourceFile if e.tr != nil { defer e.tr.Push(tracing.PhaseEmit, "emitDeclarationFileOrBundle", map[string]any{"declarationFilePath": declarationFilePath}, true)() @@ -269,9 +267,21 @@ func (e *emitter) emitDeclarationFile(sourceFile *ast.SourceFile, declarationFil } // create a printer to print the nodes - printer := printer.NewPrinter(printerOptions, printer.PrintHandlers{ - // !!! - }, emitContext) + printHandlers := printer.PrintHandlers{} + if spanMap := contentMappedSource.SpanMap(); emitDeclarationMap && spanMap != nil { + originalSource := newDeclarationMapSource(contentMappedSource) + printHandlers.MapSourcePosition = func(source sourcemap.Source, pos int) (sourcemap.Source, int, bool) { + if source.FileName() != contentMappedSource.FileName() { + return source, pos, true + } + mapped, ok := spanMap.VirtualToOriginalPositionExact(core.TextPos(pos)) + if !ok { + return nil, 0, false + } + return originalSource, int(mapped), true + } + } + printer := printer.NewPrinter(printerOptions, printHandlers, emitContext) declarationMapOptions := &core.CompilerOptions{ SourceMap: core.IfElse(emitDeclarationMap, core.TSTrue, core.TSFalse), @@ -282,6 +292,25 @@ func (e *emitter) emitDeclarationFile(sourceFile *ast.SourceFile, declarationFil e.printSourceFile(declarationFilePath, declarationMapPath, sourceFile, printer, declarationMapOptions, shouldEmitSourceMaps(declarationMapOptions, sourceFile)) } +type declarationMapSource struct { + fileName string + text string + lineMap []core.TextPos +} + +func newDeclarationMapSource(sourceFile *ast.SourceFile) *declarationMapSource { + text := sourceFile.OriginalText() + return &declarationMapSource{ + fileName: sourceFile.OriginalFileName(), + text: text, + lineMap: []core.TextPos(core.ComputeECMALineStarts(text)), + } +} + +func (s *declarationMapSource) FileName() string { return s.fileName } +func (s *declarationMapSource) Text() string { return s.text } +func (s *declarationMapSource) ECMALineMap() []core.TextPos { return s.lineMap } + func (e *emitter) printSourceFile(jsFilePath string, sourceMapFilePath string, sourceFile *ast.SourceFile, printer_ *printer.Printer, mapOptions *core.CompilerOptions, shouldEmitSourceMaps bool) { // !!! sourceMapGenerator options := e.host.Options() diff --git a/tsc/internal/fourslash/tests/contentMapperDeclarationMapNavigation_test.go b/tsc/internal/fourslash/tests/contentMapperDeclarationMapNavigation_test.go new file mode 100644 index 0000000000000..2f221b3a473f4 --- /dev/null +++ b/tsc/internal/fourslash/tests/contentMapperDeclarationMapNavigation_test.go @@ -0,0 +1,45 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fourslash" + "github.com/microsoft/TypeScript/tsc/internal/testutil" +) + +func TestContentMapperDeclarationMapNavigation(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = ` +// @Filename: /node_modules/component/package.json +{ + "name": "component", + "version": "1.0.0", + "types": "component.d.vue.ts" +} + +// @Filename: /node_modules/component/component.vue +export interface ComponentProps { emoji: "😀"; label: string; } +export declare const /*source*/component: ComponentProps; + +// @Filename: /node_modules/component/component.d.vue.ts +export interface ComponentProps { + emoji: "😀"; + label: string; +} +export declare const component: ComponentProps; +//# sourceMappingURL=component.d.vue.ts.map + +// @Filename: /node_modules/component/component.d.vue.ts.map +{"version":3,"file":"component.d.vue.ts","sourceRoot":"","sources":["component.vue"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAAG,KAAK,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;CAAE;AAC/D,MAAM,CAAC,OAAO,CAAC,MAAM,SAAS,EAAE,cAAc,CAAC"} + +// @Filename: /main.ts +import { component } from "component"; +/*use*/component.label; +` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.MarkTestAsStradaServer() + f.VerifyBaselineGoToDefinition(t, true, "use") + f.VerifyBaselineFindAllReferences(t, "use") +} diff --git a/tsc/internal/outputpaths/outputpaths.go b/tsc/internal/outputpaths/outputpaths.go index eb1f38c72f8d6..83a098654063f 100644 --- a/tsc/internal/outputpaths/outputpaths.go +++ b/tsc/internal/outputpaths/outputpaths.go @@ -62,7 +62,7 @@ func GetOutputPathsFor(sourceFile *ast.SourceFile, options *core.CompilerOptions } if force.Dts || options.GetEmitDeclarations() && !isJsonFile { paths.declarationFilePath = GetDeclarationEmitOutputFilePath(sourceFile.FileName(), options, host) - if sourceFile.ContentMapper() == "" && (options.GetAreDeclarationMapsEnabled() || force.DeclarationMap && options.DeclarationMap.IsTrue()) { + if options.GetAreDeclarationMapsEnabled() || force.DeclarationMap && options.DeclarationMap.IsTrue() { paths.declarationMapPath = paths.declarationFilePath + ".map" } } diff --git a/tsc/internal/printer/printer.go b/tsc/internal/printer/printer.go index 6f0386780975f..ae279d9666341 100644 --- a/tsc/internal/printer/printer.go +++ b/tsc/internal/printer/printer.go @@ -56,6 +56,9 @@ type PrintHandlers struct { // A hook used by the Printer when generating unique names to avoid collisions with // globally defined names that exist outside of the current source file. HasGlobalName func(name string) bool + // MapSourcePosition composes source-map positions before they reach the generator. + // Returning ok=false emits a generated-only mapping. + MapSourcePosition func(source sourcemap.Source, pos int) (mappedSource sourcemap.Source, mappedPos int, ok bool) // !!! ////// A hook used by the Printer to provide notifications prior to emitting a node. A @@ -5835,11 +5838,38 @@ func (p *Printer) emitPos(pos int) { return } - sourceLine, sourceCharacter := p.sourceMapLineCharCache.getLineAndCharacter(pos) + source := p.sourceMapSource + sourceIndex := p.sourceMapSourceIndex + lineCharCache := p.sourceMapLineCharCache + if p.MapSourcePosition != nil { + mappedSource, mappedPos, ok := p.MapSourcePosition(source, pos) + if !ok { + if err := p.sourceMapGenerator.AddGeneratedMapping(p.writer.GetLine(), p.writer.GetColumn()); err != nil { + panic(err) + } + return + } + pos = mappedPos + if mappedSource != source { + savedSource := p.sourceMapSource + savedSourceIndex := p.sourceMapSourceIndex + savedSourceIsJson := p.sourceMapSourceIsJson + savedLineCharCache := p.sourceMapLineCharCache + p.setSourceMapSource(mappedSource) + sourceIndex = p.sourceMapSourceIndex + lineCharCache = p.sourceMapLineCharCache + p.sourceMapSource = savedSource + p.sourceMapSourceIndex = savedSourceIndex + p.sourceMapSourceIsJson = savedSourceIsJson + p.sourceMapLineCharCache = savedLineCharCache + } + } + + sourceLine, sourceCharacter := lineCharCache.getLineAndCharacter(pos) if err := p.sourceMapGenerator.AddSourceMapping( p.writer.GetLine(), p.writer.GetColumn(), - p.sourceMapSourceIndex, + sourceIndex, sourceLine, sourceCharacter, ); err != nil { diff --git a/tsc/internal/sourcemap/generator.go b/tsc/internal/sourcemap/generator.go index ccc89631eeaae..33f0cbc897811 100644 --- a/tsc/internal/sourcemap/generator.go +++ b/tsc/internal/sourcemap/generator.go @@ -266,6 +266,8 @@ func (gen *Generator) AddGeneratedMapping(generatedLine int, generatedCharacter return errors.New("generatedCharacter cannot be negative") } gen.addMapping(generatedLine, generatedCharacter, sourceIndexNotSet, notSet /*sourceLine*/, notSetUTF16 /*sourceCharacter*/, nameIndexNotSet) + gen.hasPendingSource = false + gen.hasPendingName = false return nil } @@ -286,6 +288,9 @@ func (gen *Generator) AddSourceMapping(generatedLine int, generatedCharacter cor if sourceCharacter < 0 { return errors.New("sourceCharacter cannot be negative") } + if gen.hasPending && !gen.isNewGeneratedPosition(generatedLine, generatedCharacter) && !gen.hasPendingSource { + return nil + } gen.addMapping(generatedLine, generatedCharacter, sourceIndex, sourceLine, sourceCharacter, nameIndexNotSet) return nil } @@ -310,6 +315,9 @@ func (gen *Generator) AddNamedSourceMapping(generatedLine int, generatedCharacte if nameIndex < 0 || int(nameIndex) >= len(gen.names) { return errors.New("nameIndex is out of range") } + if gen.hasPending && !gen.isNewGeneratedPosition(generatedLine, generatedCharacter) && !gen.hasPendingSource { + return nil + } gen.addMapping(generatedLine, generatedCharacter, sourceIndex, sourceLine, sourceCharacter, nameIndex) return nil } diff --git a/tsc/internal/sourcemap/generator_test.go b/tsc/internal/sourcemap/generator_test.go index 4b00c46c46853..f429ec40f628f 100644 --- a/tsc/internal/sourcemap/generator_test.go +++ b/tsc/internal/sourcemap/generator_test.go @@ -138,6 +138,26 @@ func TestSourceMapGenerator_AddGeneratedMapping(t *testing.T) { }) } +func TestSourceMapGenerator_AddGeneratedMapping_ReplacesPendingSourceMapping(t *testing.T) { + t.Parallel() + gen := NewGenerator("main.js", "/", "/", tspath.ComparePathsOptions{}) + sourceIndex := gen.AddSource("/main.ts") + assert.NilError(t, gen.AddSourceMapping(0, 0, sourceIndex, 0, 0)) + assert.NilError(t, gen.AddGeneratedMapping(0, 0)) + sourceMap := gen.RawSourceMap() + assert.Equal(t, sourceMap.Mappings, "A") +} + +func TestSourceMapGenerator_AddGeneratedMapping_IsNotReplacedBySourceMapping(t *testing.T) { + t.Parallel() + gen := NewGenerator("main.js", "/", "/", tspath.ComparePathsOptions{}) + sourceIndex := gen.AddSource("/main.ts") + assert.NilError(t, gen.AddGeneratedMapping(0, 0)) + assert.NilError(t, gen.AddSourceMapping(0, 0, sourceIndex, 0, 0)) + sourceMap := gen.RawSourceMap() + assert.Equal(t, sourceMap.Mappings, "A") +} + func TestSourceMapGenerator_AddGeneratedMapping_OnSecondLineOnly(t *testing.T) { t.Parallel() gen := NewGenerator("main.js", "/", "/", tspath.ComparePathsOptions{}) diff --git a/tsc/internal/spanmap/spanmap.go b/tsc/internal/spanmap/spanmap.go index 5f8b7cd677d78..ae7ff2c77b0ad 100644 --- a/tsc/internal/spanmap/spanmap.go +++ b/tsc/internal/spanmap/spanmap.go @@ -325,6 +325,26 @@ func (m *SpanMap) VirtualToOriginalPosition(pos core.TextPos) (core.TextPos, Fid return seg.OriginalStart, FidelityAtom } +// VirtualToOriginalPositionExact maps a position only when it is unambiguously in verbatim content. +// A boundary touching an atom is rejected because the same virtual position can describe either side. +func (m *SpanMap) VirtualToOriginalPositionExact(pos core.TextPos) (core.TextPos, bool) { + mapped, fidelity := m.VirtualToOriginalPosition(pos) + if fidelity != FidelityExact || m == nil { + return mapped, fidelity == FidelityExact + } + index, inside := m.segmentIndexAt(pos) + if !inside || m.segments[index].Kind != KindVerbatim { + return mapped, false + } + if index > 0 { + previous := m.segments[index-1] + if previous.VirtualEnd == pos && previous.Kind != KindVerbatim { + return mapped, false + } + } + return mapped, true +} + // VirtualToOriginalPositionForFeature maps pos only when its virtual segment participates in feature. // Diagnostics and edit write-back intentionally use VirtualToOriginalPosition instead. func (m *SpanMap) VirtualToOriginalPositionForFeature(pos core.TextPos, feature Feature) (core.TextPos, Fidelity) { diff --git a/tsc/internal/spanmap/spanmap_test.go b/tsc/internal/spanmap/spanmap_test.go index da9019852223c..a32260d446c0f 100644 --- a/tsc/internal/spanmap/spanmap_test.go +++ b/tsc/internal/spanmap/spanmap_test.go @@ -160,6 +160,32 @@ func TestVirtualToOriginalPosition(t *testing.T) { } } +func TestVirtualToOriginalPositionExact(t *testing.T) { + t.Parallel() + + m := spanmap.New([]spanmap.Segment{ + {VirtualStart: 0, VirtualEnd: 10, OriginalStart: 100, OriginalEnd: 110, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureAll}, + {VirtualStart: 10, VirtualEnd: 20, OriginalStart: 110, OriginalEnd: 120, Kind: spanmap.KindAtom, Features: spanmap.FeatureAll}, + {VirtualStart: 20, VirtualEnd: 30, OriginalStart: 120, OriginalEnd: 130, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureAll}, + }) + + for _, test := range []struct { + pos core.TextPos + want core.TextPos + ok bool + }{ + {pos: 5, want: 105, ok: true}, + {pos: 10, want: 110, ok: false}, + {pos: 15, want: 110, ok: false}, + {pos: 20, want: 120, ok: false}, + {pos: 25, want: 125, ok: true}, + } { + got, ok := m.VirtualToOriginalPositionExact(test.pos) + assert.Equal(t, got, test.want) + assert.Equal(t, ok, test.ok) + } +} + func TestZeroLengthSpansAtSegmentEnds(t *testing.T) { t.Parallel() diff --git a/tsc/internal/testutil/harnessutil/harnessutil.go b/tsc/internal/testutil/harnessutil/harnessutil.go index 3d815a6904517..b4209fb9bac47 100644 --- a/tsc/internal/testutil/harnessutil/harnessutil.go +++ b/tsc/internal/testutil/harnessutil/harnessutil.go @@ -931,13 +931,14 @@ func (c *CompilationResult) GetSourceMapRecord() string { sourceMapSpanWriter := newSourceMapSpanWriter(&sourceMapRecorder, sourceMapData.SourceMap, currentFile) mapper := sourcemap.DecodeMappings(sourceMapData.SourceMap.Mappings) for decodedSourceMapping := range mapper.Values() { - var currentSourceFile *ast.SourceFile - if decodedSourceMapping.IsSourceMapping() { - currentSourceFile = c.Program.GetSourceFile(sourceMapData.InputSourceFileNames[decodedSourceMapping.SourceIndex]) + if !decodedSourceMapping.IsSourceMapping() { + sourceMapSpanWriter.recordSourceMapSpan(decodedSourceMapping) + continue } + currentSourceFile := c.Program.GetSourceFile(sourceMapData.InputSourceFileNames[decodedSourceMapping.SourceIndex]) if currentSourceFile != prevSourceFile { if currentSourceFile != nil { - sourceMapSpanWriter.recordNewSourceFileSpan(decodedSourceMapping, currentSourceFile.Text()) + sourceMapSpanWriter.recordNewSourceFileSpan(decodedSourceMapping, currentSourceFile.OriginalText()) } prevSourceFile = currentSourceFile } else { diff --git a/tsc/internal/testutil/tsbaseline/sourcemap_baseline.go b/tsc/internal/testutil/tsbaseline/sourcemap_baseline.go index 08979bd44d662..b453e45984451 100644 --- a/tsc/internal/testutil/tsbaseline/sourcemap_baseline.go +++ b/tsc/internal/testutil/tsbaseline/sourcemap_baseline.go @@ -36,7 +36,7 @@ func DoSourcemapBaseline( expectedMapCount += result.GetNumberOfJSFiles( /*includeJSON*/ false) } if declMaps { - expectedMapCount += result.GetNumberOfJSFiles( /*includeJSON*/ true) + expectedMapCount += result.DTS.Size() } if result.Maps.Size() != expectedMapCount { t.Fatal("Number of sourcemap files should be same as js files.") @@ -92,9 +92,15 @@ func createSourceMapPreviewLink(sourceMap *harnessutil.TestFile, result *harness //// sourceTDs = inputsAndOutputs.Inputs ////} else { sourceTDs = core.Map(sourcemapJSON.Sources, func(s string) *harnessutil.TestFile { - return core.Find(result.Inputs(), func(td *harnessutil.TestFile) bool { + sourceFile := core.Find(result.Inputs(), func(td *harnessutil.TestFile) bool { return strings.HasSuffix(td.UnitName, s) }) + if sourceFile != nil { + if programSource := result.Program.GetSourceFile(sourceFile.UnitName); programSource != nil { + return &harnessutil.TestFile{UnitName: sourceFile.UnitName, Content: programSource.OriginalText()} + } + } + return sourceFile }) if slices.Contains(sourceTDs, nil) { return "" diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.contentmapper b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.contentmapper index 8e9f527ead729..e9e9c0f320e8b 100644 --- a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.contentmapper +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.contentmapper @@ -1,15 +1,13 @@ //// [/component.y.z] (ScriptKind: ScriptKindTS, ContentMapper: [.y.z]) --- Original --- -export interface ComponentProps { - label: string; -} +export interface ComponentProps { emoji: "😀"; label: string; } export declare const component: ComponentProps; +export const emittedTarget = #{target}; --- Transformed --- const __VERSION = "1.0.0"; -export interface ComponentProps { - label: string; -} +export interface ComponentProps { emoji: "😀"; label: string; } export declare const component: ComponentProps; +export const emittedTarget = 7; === Diagnostics === diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js index 490c73da6e6a8..7babef9ea3cec 100644 --- a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js @@ -9,10 +9,9 @@ //// [component.y.z] const __VERSION = "1.0.0"; -export interface ComponentProps { - label: string; -} +export interface ComponentProps { emoji: "😀"; label: string; } export declare const component: ComponentProps; +export const emittedTarget = 7; //// [main.ts] export { component } from "./component.y.z"; @@ -23,9 +22,11 @@ export { component } from "./component.y.z"; //// [component.d.y.z.ts] export interface ComponentProps { + emoji: "😀"; label: string; } export declare const component: ComponentProps; -//// [main.d.ts] +export declare const emittedTarget = 7; +//# sourceMappingURL=component.d.y.z.ts.map//// [main.d.ts] export { component } from "./component.y.z"; //# sourceMappingURL=main.d.ts.map \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js.map b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js.map index e41883c691d5b..ded934a53fa18 100644 --- a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js.map +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.js.map @@ -1,3 +1,7 @@ +//// [component.d.y.z.ts.map] +{"version":3,"file":"component.d.y.z.ts","sourceRoot":"","sources":["component.y.z"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAAG,KAAK,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;CAAE;AAC/D,MAAM,CAAC,OAAO,CAAC,MAAM,SAAS,EAAE,cAAc,CAAC;AAC/C,eAAO,MAAM,aAAa,I,CAAa"} +//// https://sokra.github.io/source-map-visualization#base64,ZXhwb3J0IGludGVyZmFjZSBDb21wb25lbnRQcm9wcyB7DQogICAgZW1vamk6ICLwn5iAIjsNCiAgICBsYWJlbDogc3RyaW5nOw0KfQ0KZXhwb3J0IGRlY2xhcmUgY29uc3QgY29tcG9uZW50OiBDb21wb25lbnRQcm9wczsNCmV4cG9ydCBkZWNsYXJlIGNvbnN0IGVtaXR0ZWRUYXJnZXQgPSA3Ow0KLy8jIHNvdXJjZU1hcHBpbmdVUkw9Y29tcG9uZW50LmQueS56LnRzLm1hcA==,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29tcG9uZW50LmQueS56LnRzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY29tcG9uZW50LnkueiJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxNQUFNLFdBQVcsY0FBYztJQUFHLEtBQUssRUFBRSxJQUFJLENBQUM7SUFBQyxLQUFLLEVBQUUsTUFBTSxDQUFDO0NBQUU7QUFDL0QsTUFBTSxDQUFDLE9BQU8sQ0FBQyxNQUFNLFNBQVMsRUFBRSxjQUFjLENBQUM7QUFDL0MsZUFBTyxNQUFNLGFBQWEsSSxDQUFhIn0=,ZXhwb3J0IGludGVyZmFjZSBDb21wb25lbnRQcm9wcyB7IGVtb2ppOiAi8J+YgCI7IGxhYmVsOiBzdHJpbmc7IH0KZXhwb3J0IGRlY2xhcmUgY29uc3QgY29tcG9uZW50OiBDb21wb25lbnRQcm9wczsKZXhwb3J0IGNvbnN0IGVtaXR0ZWRUYXJnZXQgPSAje3RhcmdldH07Cg== + //// [main.d.ts.map] {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC"} //// https://sokra.github.io/source-map-visualization#base64,ZXhwb3J0IHsgY29tcG9uZW50IH0gZnJvbSAiLi9jb21wb25lbnQueS56IjsNCi8vIyBzb3VyY2VNYXBwaW5nVVJMPW1haW4uZC50cy5tYXA=,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibWFpbi5kLnRzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsibWFpbi50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUsU0FBUyxFQUFFLE1BQU0saUJBQWlCLENBQUMifQ==,ZXhwb3J0IHsgY29tcG9uZW50IH0gZnJvbSAiLi9jb21wb25lbnQueS56Ijs= diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.sourcemap.txt b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.sourcemap.txt index 1e303fdec3704..60288ed5c0960 100644 --- a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.sourcemap.txt +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.sourcemap.txt @@ -1,4 +1,126 @@ =================================================================== +JsFile: component.d.y.z.ts +mapUrl: component.d.y.z.ts.map +sourceRoot: +sources: component.y.z +=================================================================== +------------------------------------------------------------------- +emittedFile:/component.d.y.z.ts +sourceFile:component.y.z +------------------------------------------------------------------- +>>>export interface ComponentProps { +1 > +2 >^^^^^^ +3 > ^^^^^^^^^^^ +4 > ^^^^^^^^^^^^^^ +1 > +2 >export +3 > interface +4 > ComponentProps +1 >Emitted(1, 1) Source(1, 1) + SourceIndex(0) +2 >Emitted(1, 7) Source(1, 7) + SourceIndex(0) +3 >Emitted(1, 18) Source(1, 18) + SourceIndex(0) +4 >Emitted(1, 32) Source(1, 32) + SourceIndex(0) +--- +>>> emoji: "😀"; +1 >^^^^ +2 > ^^^^^ +3 > ^^ +4 > ^^^^ +5 > ^ +6 > ^^^-> +1 > { +2 > emoji +3 > : +4 > "😀" +5 > ; +1 >Emitted(2, 5) Source(1, 35) + SourceIndex(0) +2 >Emitted(2, 10) Source(1, 40) + SourceIndex(0) +3 >Emitted(2, 12) Source(1, 42) + SourceIndex(0) +4 >Emitted(2, 16) Source(1, 46) + SourceIndex(0) +5 >Emitted(2, 17) Source(1, 47) + SourceIndex(0) +--- +>>> label: string; +1->^^^^ +2 > ^^^^^ +3 > ^^ +4 > ^^^^^^ +5 > ^ +1-> +2 > label +3 > : +4 > string +5 > ; +1->Emitted(3, 5) Source(1, 48) + SourceIndex(0) +2 >Emitted(3, 10) Source(1, 53) + SourceIndex(0) +3 >Emitted(3, 12) Source(1, 55) + SourceIndex(0) +4 >Emitted(3, 18) Source(1, 61) + SourceIndex(0) +5 >Emitted(3, 19) Source(1, 62) + SourceIndex(0) +--- +>>>} +1 >^ +2 > ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-> +1 > } +1 >Emitted(4, 2) Source(1, 64) + SourceIndex(0) +--- +>>>export declare const component: ComponentProps; +1-> +2 >^^^^^^ +3 > ^ +4 > ^^^^^^^ +5 > ^ +6 > ^^^^^^ +7 > ^^^^^^^^^ +8 > ^^ +9 > ^^^^^^^^^^^^^^ +10> ^ +1-> + > +2 >export +3 > +4 > declare +5 > +6 > const +7 > component +8 > : +9 > ComponentProps +10> ; +1->Emitted(5, 1) Source(2, 1) + SourceIndex(0) +2 >Emitted(5, 7) Source(2, 7) + SourceIndex(0) +3 >Emitted(5, 8) Source(2, 8) + SourceIndex(0) +4 >Emitted(5, 15) Source(2, 15) + SourceIndex(0) +5 >Emitted(5, 16) Source(2, 16) + SourceIndex(0) +6 >Emitted(5, 22) Source(2, 22) + SourceIndex(0) +7 >Emitted(5, 31) Source(2, 31) + SourceIndex(0) +8 >Emitted(5, 33) Source(2, 33) + SourceIndex(0) +9 >Emitted(5, 47) Source(2, 47) + SourceIndex(0) +10>Emitted(5, 48) Source(2, 48) + SourceIndex(0) +--- +>>>export declare const emittedTarget = 7; +1 > +2 >^^^^^^^^^^^^^^^ +3 > ^^^^^^ +4 > ^^^^^^^^^^^^^ +5 > ^^^^ +6 > ^ +7 > ^^^-> +1 > + > +2 >export +3 > const +4 > emittedTarget +5 > +6 > export interface ComponentProps { emoji: "😀"; label: string; } + > export declare const component: ComponentProps; + > export const emittedTarget = #{target}; +1 >Emitted(6, 1) Source(3, 1) + SourceIndex(0) +2 >Emitted(6, 16) Source(3, 8) + SourceIndex(0) +3 >Emitted(6, 22) Source(3, 14) + SourceIndex(0) +4 >Emitted(6, 35) Source(3, 27) + SourceIndex(0) +5 >Emitted(6, 39) +6 >Emitted(6, 40) Source(3, 40) + SourceIndex(0) +--- +>>>//# sourceMappingURL=component.d.y.z.ts.map=================================================================== JsFile: main.d.ts mapUrl: main.d.ts.map sourceRoot: diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.symbols b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.symbols index e5a15c7e69bc9..8483f26866862 100644 --- a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.symbols +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.symbols @@ -4,16 +4,18 @@ const __VERSION = "1.0.0"; >__VERSION : Symbol(__VERSION, Decl(component.y.z, 0, 5)) -export interface ComponentProps { +export interface ComponentProps { emoji: "😀"; label: string; } >ComponentProps : Symbol(ComponentProps, Decl(component.y.z, 0, 26)) +>emoji : Symbol(ComponentProps.emoji, Decl(component.y.z, 1, 33)) +>label : Symbol(ComponentProps.label, Decl(component.y.z, 1, 46)) - label: string; ->label : Symbol(ComponentProps.label, Decl(component.y.z, 1, 33)) -} export declare const component: ComponentProps; ->component : Symbol(component, Decl(component.y.z, 4, 20)) +>component : Symbol(component, Decl(component.y.z, 2, 20)) >ComponentProps : Symbol(ComponentProps, Decl(component.y.z, 0, 26)) +export const emittedTarget = 7; +>emittedTarget : Symbol(emittedTarget, Decl(component.y.z, 3, 12)) + === /main.ts === export { component } from "./component.y.z"; >component : Symbol(component, Decl(main.ts, 0, 8)) diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.types b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.types index f6676f44ba9a1..10b5d8c0d4423 100644 --- a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.types +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmit.types @@ -5,13 +5,17 @@ const __VERSION = "1.0.0"; >__VERSION : "1.0.0" >"1.0.0" : "1.0.0" -export interface ComponentProps { - label: string; +export interface ComponentProps { emoji: "😀"; label: string; } +>emoji : "😀" >label : string -} + export declare const component: ComponentProps; >component : ComponentProps +export const emittedTarget = 7; +>emittedTarget : 7 +>7 : 7 + === /main.ts === export { component } from "./component.y.z"; >component : import("./component.y.z").ComponentProps diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.contentmapper b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.contentmapper new file mode 100644 index 0000000000000..176e6aba04254 --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.contentmapper @@ -0,0 +1,12 @@ +//// [/component.vue] (ScriptKind: ScriptKindTS, ContentMapper: [.vue]) +--- Original --- +export const component = 1; +--- Transformed --- + +=== Diagnostics === + +/component.vue:1:1 - error TS100025: The content mapper 'mapper' failed to transform this file. + The content mapper process failed while handling the transform request. + +1 + ~ diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.js b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.js new file mode 100644 index 0000000000000..f1a3f2ce24eb4 --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.js @@ -0,0 +1,16 @@ +//// [tests/cases/compiler/contentMapperDeclarationEmitFailure.ts] //// + +//// [package.json] +{ + "name": "mapper", + "version": "1.0.0", + "typescript": { "contentMapper": { "exec": ["failing-mapper"] } } +} + +//// [component.vue] + + + + +//// [component.d.vue.ts] +//# sourceMappingURL=component.d.vue.ts.map \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.js.map b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.js.map new file mode 100644 index 0000000000000..6f819fc4d7283 --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.js.map @@ -0,0 +1,3 @@ +//// [component.d.vue.ts.map] +{"version":3,"file":"component.d.vue.ts","sourceRoot":"","sources":["component.vue"],"names":[],"mappings":""} +//// https://sokra.github.io/source-map-visualization#base64,Ly8jIHNvdXJjZU1hcHBpbmdVUkw9Y29tcG9uZW50LmQudnVlLnRzLm1hcA==,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29tcG9uZW50LmQudnVlLnRzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY29tcG9uZW50LnZ1ZSJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiIn0=,ZXhwb3J0IGNvbnN0IGNvbXBvbmVudCA9IDE7Cg== diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.sourcemap.txt b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.sourcemap.txt new file mode 100644 index 0000000000000..be64e1f20155d --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.sourcemap.txt @@ -0,0 +1,7 @@ +=================================================================== +JsFile: component.d.vue.ts +mapUrl: component.d.vue.ts.map +sourceRoot: +sources: component.vue +=================================================================== +>>>//# sourceMappingURL=component.d.vue.ts.map \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.symbols b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.symbols new file mode 100644 index 0000000000000..c605a1d24390c --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.symbols @@ -0,0 +1,4 @@ +//// [tests/cases/compiler/contentMapperDeclarationEmitFailure.ts] //// + +=== /component.vue === + diff --git a/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.types b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.types new file mode 100644 index 0000000000000..c605a1d24390c --- /dev/null +++ b/tsc/testdata/baselines/reference/compiler/contentMapperDeclarationEmitFailure.types @@ -0,0 +1,4 @@ +//// [tests/cases/compiler/contentMapperDeclarationEmitFailure.ts] //// + +=== /component.vue === + diff --git a/tsc/testdata/baselines/reference/fourslash/findAllReferences/contentMapperDeclarationMapNavigation.baseline.jsonc b/tsc/testdata/baselines/reference/fourslash/findAllReferences/contentMapperDeclarationMapNavigation.baseline.jsonc new file mode 100644 index 0000000000000..b67fe197a957a --- /dev/null +++ b/tsc/testdata/baselines/reference/fourslash/findAllReferences/contentMapperDeclarationMapNavigation.baseline.jsonc @@ -0,0 +1,10 @@ +// === findAllReferences === +// === /main.ts === +// import { [|component|] } from "component"; +// /*FIND ALL REFS*/[|component|].label; +// + +// === /node_modules/component/component.vue === +// export interface ComponentProps { emoji: "😀"; label: string; } +// export declare const [|component|]: ComponentProps; +// \ No newline at end of file diff --git a/tsc/testdata/baselines/reference/fourslash/goToDefinition/contentMapperDeclarationMapNavigation.baseline.jsonc b/tsc/testdata/baselines/reference/fourslash/goToDefinition/contentMapperDeclarationMapNavigation.baseline.jsonc new file mode 100644 index 0000000000000..e502ca875250d --- /dev/null +++ b/tsc/testdata/baselines/reference/fourslash/goToDefinition/contentMapperDeclarationMapNavigation.baseline.jsonc @@ -0,0 +1,10 @@ +// === goToDefinition === +// === /node_modules/component/component.vue === +// export interface ComponentProps { emoji: "😀"; label: string; } +// export declare const [|component|]: ComponentProps; +// + +// === /main.ts === +// import { component } from "component"; +// /*GOTO DEF*/[|component|].label; +// \ No newline at end of file diff --git a/tsc/testdata/tests/cases/compiler/contentMapperDeclarationEmit.ts b/tsc/testdata/tests/cases/compiler/contentMapperDeclarationEmit.ts index f5885646ad1f5..073eb8ae03474 100644 --- a/tsc/testdata/tests/cases/compiler/contentMapperDeclarationEmit.ts +++ b/tsc/testdata/tests/cases/compiler/contentMapperDeclarationEmit.ts @@ -22,10 +22,9 @@ } // @Filename: /component.y.z -export interface ComponentProps { - label: string; -} +export interface ComponentProps { emoji: "😀"; label: string; } export declare const component: ComponentProps; +export const emittedTarget = #{target}; // @Filename: /main.ts export { component } from "./component.y.z"; \ No newline at end of file diff --git a/tsc/testdata/tests/cases/compiler/contentMapperDeclarationEmitFailure.ts b/tsc/testdata/tests/cases/compiler/contentMapperDeclarationEmitFailure.ts new file mode 100644 index 0000000000000..d17b181306da9 --- /dev/null +++ b/tsc/testdata/tests/cases/compiler/contentMapperDeclarationEmitFailure.ts @@ -0,0 +1,22 @@ +// @runExternalCode: true + +// @Filename: /tsconfig.json +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true + }, + "contentMappers": [ + { "package": "mapper", "extensions": [".vue"] } + ] +} + +// @Filename: /node_modules/mapper/package.json +{ + "name": "mapper", + "version": "1.0.0", + "typescript": { "contentMapper": { "exec": ["failing-mapper"] } } +} + +// @Filename: /component.vue +export const component = 1; From e7c59fa553e8f002b6eade044122a896b540d249 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 21 Aug 2026 08:19:31 -0700 Subject: [PATCH 15/18] Fix ambiguous case detection in declaration map output --- tsc/internal/spanmap/spanmap.go | 2 +- tsc/internal/spanmap/spanmap_test.go | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tsc/internal/spanmap/spanmap.go b/tsc/internal/spanmap/spanmap.go index ae7ff2c77b0ad..36626642e9a2b 100644 --- a/tsc/internal/spanmap/spanmap.go +++ b/tsc/internal/spanmap/spanmap.go @@ -338,7 +338,7 @@ func (m *SpanMap) VirtualToOriginalPositionExact(pos core.TextPos) (core.TextPos } if index > 0 { previous := m.segments[index-1] - if previous.VirtualEnd == pos && previous.Kind != KindVerbatim { + if previous.VirtualEnd == pos && (previous.Kind != KindVerbatim || previous.OriginalEnd != m.segments[index].OriginalStart) { return mapped, false } } diff --git a/tsc/internal/spanmap/spanmap_test.go b/tsc/internal/spanmap/spanmap_test.go index a32260d446c0f..01ec831357b66 100644 --- a/tsc/internal/spanmap/spanmap_test.go +++ b/tsc/internal/spanmap/spanmap_test.go @@ -186,6 +186,19 @@ func TestVirtualToOriginalPositionExact(t *testing.T) { } } +func TestVirtualToOriginalPositionExactRejectsDiscontinuousBoundary(t *testing.T) { + t.Parallel() + + m := spanmap.New([]spanmap.Segment{ + {VirtualStart: 0, VirtualEnd: 10, OriginalStart: 0, OriginalEnd: 10, Kind: spanmap.KindVerbatim}, + {VirtualStart: 10, VirtualEnd: 20, OriginalStart: 100, OriginalEnd: 110, Kind: spanmap.KindVerbatim}, + }) + + mapped, ok := m.VirtualToOriginalPositionExact(10) + assert.Equal(t, mapped, core.TextPos(100)) + assert.Assert(t, !ok) +} + func TestZeroLengthSpansAtSegmentEnds(t *testing.T) { t.Parallel() From 367f5261197a0c5d542c74227690a0351cade1ce Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 21 Aug 2026 09:16:41 -0700 Subject: [PATCH 16/18] Fix formatting to work the way I actually intended --- .../tsctests/contentmapper_watch_test.go | 32 +++++++ .../tests/contentMapperEditSafety_test.go | 37 ++++++++ tsc/internal/ls/format.go | 84 +++++++++++++++---- tsc/internal/ls/format_test.go | 44 ++++++++++ .../testutil/contentmappertest/editing.go | 41 +++++++++ 5 files changed, 223 insertions(+), 15 deletions(-) diff --git a/tsc/internal/execute/tsctests/contentmapper_watch_test.go b/tsc/internal/execute/tsctests/contentmapper_watch_test.go index 236d0640ba7ce..fb1c0c4fd5a8b 100644 --- a/tsc/internal/execute/tsctests/contentmapper_watch_test.go +++ b/tsc/internal/execute/tsctests/contentmapper_watch_test.go @@ -408,6 +408,38 @@ func TestContentMapperBuildWatchSymlinkedManifestChange(t *testing.T) { assert.Equal(t, spawner.closes.Load(), int32(1)) } +func TestContentMapperBuildWatchSymlinkedManifestDelete(t *testing.T) { + t.Parallel() + const manifestTarget = "/home/src/workspaces/mapper/package.json" + input := &tscInput{files: FileMap{ + "/home/src/workspaces/project/tsconfig.json": `{ + "compilerOptions": { "composite": true }, + "contentMappers": [{ "package": "mapper", "extensions": [".vue"] }] + }`, + "/home/src/workspaces/project/app.vue": `export const app = 1;`, + "/home/src/workspaces/project/node_modules/mapper": vfstest.Symlink("/home/src/workspaces/mapper"), + manifestTarget: contentmappertest.PackageJSON(contentmappertest.VerbatimMapper), + }} + testSys := newTestSys(input, false) + spawner := &recordingContentMapperSpawner{inner: contentmappertest.NewSpawner()} + sys := &recordingContentMapperSystem{TestSys: testSys, spawner: spawner} + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + result := execute.CommandLine(ctx, sys, []string{"--build", "--watch", "--runExternalCode"}, testSys) + assert.Equal(t, spawner.spawns.Load(), int32(1)) + assert.Equal(t, spawner.closes.Load(), int32(0)) + + testSys.clearOutput() + assert.NilError(t, testSys.fsFromFileMap().Remove(manifestTarget)) + testSys.mockWatchBackend.SendEvents([]fswatch.Event{{Kind: fswatch.EventDelete, Path: manifestTarget}}) + result.Watcher.DoCycle() + + assert.Equal(t, spawner.spawns.Load(), int32(1)) + assert.Equal(t, spawner.closes.Load(), int32(1)) + assert.Assert(t, strings.Contains(testSys.currentWrite.String(), "The content mapper package 'mapper' could not be resolved."), testSys.currentWrite.String()) +} + func TestContentMapperBuildWatchSharedLifecycle(t *testing.T) { t.Parallel() const mapperConfig = `{ diff --git a/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go b/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go index af72bc63ab142..3e2fc6e5da017 100644 --- a/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go +++ b/tsc/internal/fourslash/tests/contentMapperEditSafety_test.go @@ -344,6 +344,43 @@ function second() { return 2; } `) } +func TestContentMapperFormatsOnlyFirstOverlappingProjection(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + // The canonical and supplemental projections overlap on `second`, but place it in different + // syntactic contexts. Formatting both copies would produce conflicting edits for the overlap: + // + // original: [---- first ----)[---- second ----)[---- third ----) + // + // canonical: [---- first ----)[---- second ----) + // `----- verbatim, Formatting ------' + // + // supplemental: if (true) { + // [---- second ----)[---- third ----) + // `----- verbatim, Formatting ------' + // } + // + // Sorting by original start assigns each character to the earliest applicable mapping: + // + // owner: [------------ canonical ------------)[ supplemental ) + // + // Thus `second` uses the top-level canonical formatting, while the uncovered `third` suffix uses + // the supplemental formatting and gains indentation from its surrounding `if` block. + f, done := newContentMapperFourslash(t, `// @Filename: /formatting-overlap.astro +function first(){return 1;} +function second(){return 2;} +function third(){return 3;} +`, contentmappertest.PrefixedSupplementalMapper, ".astro") + defer done() + + f.GoToFile(t, "/formatting-overlap.astro") + f.FormatDocument(t, "/formatting-overlap.astro") + f.VerifyCurrentFileContent(t, `function first() { return 1; } +function second() { return 2; } + function third() { return 3; } +`) +} + func TestContentMapperFormatsSupplementalOriginalSelection(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") diff --git a/tsc/internal/ls/format.go b/tsc/internal/ls/format.go index 19803a051cf57..b9e6caf63e34b 100644 --- a/tsc/internal/ls/format.go +++ b/tsc/internal/ls/format.go @@ -52,10 +52,11 @@ func (l *LanguageService) ProvideFormatDocument( } // getFormattingEditsForMappedRange formats each formatting-enabled verbatim intersection with originalRange. -// A mapper should provide at most one such mapping for any original text; duplicate formatting projections are unsupported. +// Duplicate formatting projections are unsupported. If mappings overlap anyway, each original-text position +// is formatted only once, preferring the earliest and then longest applicable mapping. func (l *LanguageService) getFormattingEditsForMappedRange(ctx context.Context, file *ast.SourceFile, options lsutil.FormatCodeSettings, originalRange core.TextRange) []*lsproto.TextEdit { projections := append([]*ast.SourceFile{file}, file.SupplementalSourceFiles()...) - var edits []*lsproto.TextEdit + var candidates []mappedFormattingRange for _, projection := range projections { spanMap := projection.SpanMap() if spanMap == nil { @@ -70,20 +71,29 @@ func (l *LanguageService) getFormattingEditsForMappedRange(ctx context.Context, if originalStart >= originalEnd { continue } - virtualRange := core.NewTextRange( - int(segment.VirtualStart)+originalStart-int(segment.OriginalStart), - int(segment.VirtualStart)+originalEnd-int(segment.OriginalStart), - ) - for _, change := range l.getFormattingEditsForRange(ctx, projection, options, virtualRange) { - if change.Pos() < virtualRange.Pos() || change.End() > virtualRange.End() { - continue - } - lspRange, fidelity := l.converters.ToLSPRangeForFeature(projection, core.NewTextRange(change.Pos(), change.End()), spanmap.FeatureFormatting) - if !fidelity.IsExact() { - continue - } - edits = append(edits, &lsproto.TextEdit{Range: lspRange, NewText: change.NewText}) + candidates = append(candidates, mappedFormattingRange{ + projection: projection, + segment: segment, + originalRange: core.NewTextRange(originalStart, originalEnd), + }) + } + } + + var edits []*lsproto.TextEdit + for _, candidate := range nonOverlappingFormattingRanges(candidates) { + virtualRange := core.NewTextRange( + int(candidate.segment.VirtualStart)+candidate.originalRange.Pos()-int(candidate.segment.OriginalStart), + int(candidate.segment.VirtualStart)+candidate.originalRange.End()-int(candidate.segment.OriginalStart), + ) + for _, change := range l.getFormattingEditsForRange(ctx, candidate.projection, options, virtualRange) { + if change.Pos() < virtualRange.Pos() || change.End() > virtualRange.End() { + continue + } + lspRange, fidelity := l.converters.ToLSPRangeForFeature(candidate.projection, core.NewTextRange(change.Pos(), change.End()), spanmap.FeatureFormatting) + if !fidelity.IsExact() { + continue } + edits = append(edits, &lsproto.TextEdit{Range: lspRange, NewText: change.NewText}) } } slices.SortStableFunc(edits, func(a, b *lsproto.TextEdit) int { @@ -95,6 +105,50 @@ func (l *LanguageService) getFormattingEditsForMappedRange(ctx context.Context, return edits } +type mappedFormattingRange struct { + projection *ast.SourceFile + segment spanmap.Segment + originalRange core.TextRange +} + +// nonOverlappingFormattingRanges chooses at most one formatting projection for each original-text position. +// Candidates are ordered by original start and then descending end, so a longer mapping wins when several +// mappings start together: +// +// candidates: [---------- A ----------) +// [---- B ----) +// result: [---------- A ----------) +// +// Since starts are ordered, a candidate can only overlap the end of the last accepted range. Its start is +// trimmed to that end, preserving any uncovered suffix: +// +// candidates: [------- A -------) +// [------- B ----------) +// result: [------- A -------)[-- B' --) +// +// Fully covered candidates have no suffix and are discarded. The segment itself is retained so callers can +// translate a trimmed original range to the corresponding offset in its virtual projection. +func nonOverlappingFormattingRanges(candidates []mappedFormattingRange) []mappedFormattingRange { + candidates = slices.Clone(candidates) + slices.SortStableFunc(candidates, func(a, b mappedFormattingRange) int { + if c := cmp.Compare(a.originalRange.Pos(), b.originalRange.Pos()); c != 0 { + return c + } + return cmp.Compare(b.originalRange.End(), a.originalRange.End()) + }) + + result := candidates[:0] + for _, candidate := range candidates { + if len(result) > 0 { + candidate.originalRange = candidate.originalRange.WithPos(max(candidate.originalRange.Pos(), result[len(result)-1].originalRange.End())) + } + if candidate.originalRange.Len() > 0 { + result = append(result, candidate) + } + } + return result +} + func (l *LanguageService) ProvideFormatDocumentRange( ctx context.Context, documentURI lsproto.DocumentUri, diff --git a/tsc/internal/ls/format_test.go b/tsc/internal/ls/format_test.go index ea12330b9ab0d..f0eec1e2a2cae 100644 --- a/tsc/internal/ls/format_test.go +++ b/tsc/internal/ls/format_test.go @@ -4,12 +4,56 @@ import ( "context" "testing" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/parser" + "gotest.tools/v3/assert" ) +func TestNonOverlappingFormattingRanges(t *testing.T) { + t.Parallel() + tests := []struct { + name string + candidates []core.TextRange + want []core.TextRange + }{ + { + name: "sorts disjoint ranges", + candidates: []core.TextRange{core.NewTextRange(10, 15), core.NewTextRange(0, 5)}, + want: []core.TextRange{core.NewTextRange(0, 5), core.NewTextRange(10, 15)}, + }, + { + name: "prefers longest range with same start", + candidates: []core.TextRange{core.NewTextRange(0, 10), core.NewTextRange(0, 20)}, + want: []core.TextRange{core.NewTextRange(0, 20)}, + }, + { + name: "discards fully covered range", + candidates: []core.TextRange{core.NewTextRange(5, 15), core.NewTextRange(0, 20)}, + want: []core.TextRange{core.NewTextRange(0, 20)}, + }, + { + name: "trims overlapping prefix", + candidates: []core.TextRange{core.NewTextRange(5, 15), core.NewTextRange(0, 10)}, + want: []core.TextRange{core.NewTextRange(0, 10), core.NewTextRange(10, 15)}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + candidates := core.Map(test.candidates, func(r core.TextRange) mappedFormattingRange { + return mappedFormattingRange{originalRange: r} + }) + result := core.Map(nonOverlappingFormattingRanges(candidates), func(r mappedFormattingRange) core.TextRange { + return r.originalRange + }) + assert.DeepEqual(t, result, test.want, cmpopts.EquateComparable(core.TextRange{})) + }) + } +} + // Test for issue: Panic Handling textDocument/onTypeFormatting // This reproduces the panic when pressing enter in an empty file func TestGetFormattingEditsAfterKeystroke_EmptyFile(t *testing.T) { diff --git a/tsc/internal/testutil/contentmappertest/editing.go b/tsc/internal/testutil/contentmappertest/editing.go index e03f384e5a67b..b3db5a3db234e 100644 --- a/tsc/internal/testutil/contentmappertest/editing.go +++ b/tsc/internal/testutil/contentmappertest/editing.go @@ -63,6 +63,47 @@ func (prefixedSupplementalHandler) HandleRequest(ctx context.Context, method str }, } } + if strings.Contains(p.FileName, "formatting-overlap") { + secondStart := strings.Index(p.Content, "function second") + thirdStart := strings.Index(p.Content, "function third") + if secondStart < 0 || thirdStart < 0 { + return nil, errors.New("contentmappertest: formatting-overlap input is missing function second or third") + } + const wrapperStart = "if (true) {\n" + const wrapperEnd = "}\n" + supplementalText = wrapperStart + p.Content[secondStart:] + wrapperEnd + segments = []spanmap.Segment{{ + VirtualStart: core.TextPos(len(wrapperStart)), + VirtualEnd: core.TextPos(len(wrapperStart) + len(p.Content) - secondStart), + OriginalStart: core.TextPos(secondStart), + OriginalEnd: core.TextPos(len(p.Content)), + Kind: spanmap.KindVerbatim, + Features: spanmap.FeatureAll, + }} + canonicalMappings, err := spanmap.New([]spanmap.Segment{{ + VirtualStart: 0, + VirtualEnd: core.TextPos(thirdStart), + OriginalStart: 0, + OriginalEnd: core.TextPos(thirdStart), + Kind: spanmap.KindVerbatim, + Features: spanmap.FeatureAll, + }}).Marshal() + if err != nil { + return nil, err + } + mappings, err := spanmap.New(segments).Marshal() + if err != nil { + return nil, err + } + return contentmapper.TransformResult{ + MappedOutput: contentmapper.MappedOutput{Text: p.Content[:thirdStart], Extension: ".ts", Mappings: json.Value(canonicalMappings)}, + Supplemental: []contentmapper.SupplementalOutput{{MappedOutput: contentmapper.MappedOutput{ + Text: supplementalText, + Extension: ".ts", + Mappings: json.Value(mappings), + }}}, + }, nil + } mappings, err := spanmap.New(segments).Marshal() if err != nil { return nil, err From 3a793e9083a94fcd8e9bf79381e05012bcdff981 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 21 Aug 2026 09:46:40 -0700 Subject: [PATCH 17/18] Cache a range max tree for original to virtual lookups --- packages/typescript/src/ast/spanMap.ts | 99 ++++++++++++++++++++---- packages/typescript/test/spanMap.test.ts | 28 +++++++ tsc/internal/spanmap/spanmap.go | 94 +++++++++++++++------- tsc/internal/spanmap/spanmap_test.go | 50 ++++++++++++ 4 files changed, 230 insertions(+), 41 deletions(-) diff --git a/packages/typescript/src/ast/spanMap.ts b/packages/typescript/src/ast/spanMap.ts index 61801fba5ecd0..dee0610ab2bee 100644 --- a/packages/typescript/src/ast/spanMap.ts +++ b/packages/typescript/src/ast/spanMap.ts @@ -20,6 +20,13 @@ export interface SpanMapSegment { /** Internal segment representation after omitted features have been normalized to `All`. */ type NormalizedSpanMapSegment = SpanMapSegment & { readonly features: SpanMapFeature; }; +/** Lazily built interval index for original-to-virtual lookups. */ +interface OriginalIndex { + readonly segments: readonly NormalizedSpanMapSegment[]; + readonly leafCount: number; + readonly maxEnds: readonly number[]; +} + /** One virtual projection of an original position and its mapping fidelity. */ export interface MappedPosition { readonly position: number; @@ -35,7 +42,7 @@ export interface MappedRange { /** Provides bidirectional span-aware mapping between virtual and original text. */ export class SpanMap { readonly segments: readonly NormalizedSpanMapSegment[]; - private originalSegments: readonly NormalizedSpanMapSegment[] | undefined; + private originalIndex: OriginalIndex | undefined; /** Copies and sorts segments by virtual start, normalizing omitted features to `All`. */ constructor(segments: readonly SpanMapSegment[]) { @@ -90,7 +97,7 @@ export class SpanMap { * Results are ordered by virtual position; uncovered or disabled positions produce no results. */ originalToVirtualPositions(position: number, feature: SpanMapFeature): readonly MappedPosition[] { - const groups = segmentGroupsAtOriginalPosition(this.getOriginalSegments(), position); + const groups = segmentGroupsAtOriginalPosition(this.getOriginalIndex(), position); const results: MappedPosition[] = []; for (const group of groups) { for (const segment of group.segments) { @@ -138,9 +145,9 @@ export class SpanMap { })); } const lastCharacter = end - 1; - const originalSegments = this.getOriginalSegments(); - const startSegments = segmentsAtOriginalPosition(originalSegments, start); - const endSegments = segmentsAtOriginalPosition(originalSegments, lastCharacter); + const originalIndex = this.getOriginalIndex(); + const startSegments = segmentsAtOriginalPosition(originalIndex, start); + const endSegments = segmentsAtOriginalPosition(originalIndex, lastCharacter); if (!startSegments || !endSegments) return []; const containing = startSegments.filter(segment => end <= segment.originalEnd); if (containing.length > 0) { @@ -205,13 +212,20 @@ export class SpanMap { }; } - /** Returns the lazily built segment index ordered by original start. */ - private getOriginalSegments(): readonly NormalizedSpanMapSegment[] { - return this.originalSegments ??= [...this.segments].sort((left, right) => + /** Returns the lazily built original-text interval index. */ + private getOriginalIndex(): OriginalIndex { + if (this.originalIndex) return this.originalIndex; + const segments = [...this.segments].sort((left, right) => left.originalStart - right.originalStart || left.originalEnd - right.originalEnd || left.virtualStart - right.virtualStart ); + let leafCount = 1; + while (leafCount < segments.length) leafCount *= 2; + const maxEnds = new Array(2 * leafCount).fill(0); + for (let i = 0; i < segments.length; i++) maxEnds[leafCount + i] = segments[i].originalEnd; + for (let i = leafCount - 1; i > 0; i--) maxEnds[i] = Math.max(maxEnds[2 * i], maxEnds[2 * i + 1]); + return this.originalIndex = { segments, leafCount, maxEnds }; } private virtualRangeSupportsFeature(range: ReadonlyTextRange, feature: SpanMapFeature): boolean { @@ -304,13 +318,67 @@ function sameOriginalRange(left: SpanMapSegment, right: SpanMapSegment): boolean * Returns every mapping segment containing the original-text `position`. * Segment ends are exclusive; starts, including zero-length segment starts, are included. */ -function segmentsAtOriginalPosition(segments: readonly NormalizedSpanMapSegment[], position: number): readonly NormalizedSpanMapSegment[] | undefined { +function segmentsAtOriginalPosition(index: OriginalIndex, position: number): readonly NormalizedSpanMapSegment[] | undefined { + // Query intervals that contain position strictly before their exclusive end. Segments starting exactly at + // position are appended separately so zero-length segments are included while maxEnd <= position is pruned. + const start = firstOriginalSegmentAtOrAfter(index.segments, position); + const results = segmentsEndingAtOrAfter(index, start, position, false); + const end = firstOriginalSegmentAfter(index.segments, position); + results.push(...index.segments.slice(start, end)); + return results.length > 0 ? results : undefined; +} + +/** Returns segments among `[0, limit)` whose original end reaches `position`. */ +function segmentsEndingAtOrAfter(index: OriginalIndex, limit: number, position: number, includeEnd: boolean): NormalizedSpanMapSegment[] { const results: NormalizedSpanMapSegment[] = []; - for (const segment of segments) { - if (segment.originalStart > position) break; - if (position < segment.originalEnd || position === segment.originalStart) results.push(segment); + collectSegmentsEndingAtOrAfter(index, 1, 0, index.leafCount, limit, position, includeEnd, results); + return results; +} + +/** Walks the flat max-end tree left-to-right, preserving original-text order. */ +function collectSegmentsEndingAtOrAfter( + index: OriginalIndex, + node: number, + start: number, + end: number, + limit: number, + position: number, + includeEnd: boolean, + results: NormalizedSpanMapSegment[], +): void { + const maxEnd = index.maxEnds[node]; + if (start >= limit || maxEnd < position || !includeEnd && maxEnd === position) return; + if (end - start === 1) { + results.push(index.segments[start]); + return; } - return results.length > 0 ? results : undefined; + const middle = start + ((end - start) >>> 1); + collectSegmentsEndingAtOrAfter(index, 2 * node, start, middle, limit, position, includeEnd, results); + collectSegmentsEndingAtOrAfter(index, 2 * node + 1, middle, end, limit, position, includeEnd, results); +} + +/** Returns the first original-ordered segment whose start is greater than or equal to `position`. */ +function firstOriginalSegmentAtOrAfter(segments: readonly NormalizedSpanMapSegment[], position: number): number { + let low = 0; + let high = segments.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (segments[middle].originalStart < position) low = middle + 1; + else high = middle; + } + return low; +} + +/** Returns the first original-ordered segment whose start is greater than `position`. */ +function firstOriginalSegmentAfter(segments: readonly NormalizedSpanMapSegment[], position: number): number { + let low = 0; + let high = segments.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (segments[middle].originalStart <= position) low = middle + 1; + else high = middle; + } + return low; } interface SegmentGroupAtOriginalPosition { @@ -331,10 +399,11 @@ interface SegmentGroupAtOriginalPosition { * atEnd: true atEnd: false * ``` */ -function segmentGroupsAtOriginalPosition(segments: readonly NormalizedSpanMapSegment[], position: number): readonly SegmentGroupAtOriginalPosition[] { +function segmentGroupsAtOriginalPosition(index: OriginalIndex, position: number): readonly SegmentGroupAtOriginalPosition[] { + const limit = firstOriginalSegmentAfter(index.segments, position); + const segments = segmentsEndingAtOrAfter(index, limit, position, true); const groups: SegmentGroupAtOriginalPosition[] = []; for (let start = 0; start < segments.length;) { - if (segments[start].originalStart > position) break; let end = start + 1; while (end < segments.length && sameOriginalRange(segments[start], segments[end])) end++; const segment = segments[start]; diff --git a/packages/typescript/test/spanMap.test.ts b/packages/typescript/test/spanMap.test.ts index 98b941941f4fe..c0dc8314185c7 100644 --- a/packages/typescript/test/spanMap.test.ts +++ b/packages/typescript/test/spanMap.test.ts @@ -144,6 +144,34 @@ describe("SpanMap", () => { ]); }); + test("finds an early covering segment through the original index", () => { + // Binary search lands near [90,95), which does not contain 97. The interval index must still find the + // earlier [0,100) segment without scanning every segment whose start precedes the query. + const overlapping = new SpanMap([ + { virtualStart: 0, virtualEnd: 100, originalStart: 0, originalEnd: 100, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 100, virtualEnd: 105, originalStart: 80, originalEnd: 85, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 105, virtualEnd: 110, originalStart: 90, originalEnd: 95, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + { virtualStart: 110, virtualEnd: 113, originalStart: 100, originalEnd: 103, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover }, + ]); + + assert.deepEqual(overlapping.originalToVirtualPositions(97, SpanMapFeature.Hover), [ + { position: 97, fidelity: SpanMapFidelity.Exact }, + ]); + assert.deepEqual(overlapping.originalToVirtualSpans({ pos: 97, end: 98 }, SpanMapFeature.Hover), [ + { range: { pos: 97, end: 98 }, fidelity: SpanMapFidelity.Exact }, + ]); + + // Point lookup includes both sides of a shared endpoint. Nonempty span lookup treats segment ends as + // exclusive and uses only the segment beginning at the endpoint. + assert.deepEqual(overlapping.originalToVirtualPositions(100, SpanMapFeature.Hover), [ + { position: 100, fidelity: SpanMapFidelity.Exact }, + { position: 110, fidelity: SpanMapFidelity.Exact }, + ]); + assert.deepEqual(overlapping.originalToVirtualSpans({ pos: 100, end: 101 }, SpanMapFeature.Hover), [ + { range: { pos: 110, end: 111 }, fidelity: SpanMapFidelity.Exact }, + ]); + }); + test("falls back from a disabled containing span", () => { const overlapping = new SpanMap([ { virtualStart: 0, virtualEnd: 6, originalStart: 0, originalEnd: 6, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Definition }, diff --git a/tsc/internal/spanmap/spanmap.go b/tsc/internal/spanmap/spanmap.go index 36626642e9a2b..465fa24b386ec 100644 --- a/tsc/internal/spanmap/spanmap.go +++ b/tsc/internal/spanmap/spanmap.go @@ -11,6 +11,7 @@ package spanmap import ( "fmt" "slices" + "sort" "sync" "github.com/microsoft/TypeScript/tsc/internal/core" @@ -128,10 +129,9 @@ type MappedSpan struct { type SpanMap struct { segments []Segment - // origOnce guards lazy construction of origSorted, the segments ordered by OriginalStart, used for - // original-to-virtual lookups. - origOnce sync.Once - origSorted []Segment + // origOnce guards lazy construction of the interval index used for original-to-virtual lookups. + origOnce sync.Once + originalIndex *originalIndex } // Validation failures. A content mapper is required to provide a valid span map; these describe the @@ -432,7 +432,7 @@ func (m *SpanMap) OriginalToVirtualPositions(pos core.TextPos, feature Feature) if m == nil { return []MappedPosition{{Position: pos, Fidelity: FidelityExact}} } - groups := segmentGroupsAtOriginalPosition(m.origIndex(), pos) + groups := m.origIndex().segmentGroupsAtOriginalPosition(pos) if len(groups) == 0 { return nil } @@ -494,9 +494,9 @@ func (m *SpanMap) OriginalToVirtualSpans(r core.TextRange, feature Feature) []Ma }) } lastCharacter := end - 1 - originalSegments := m.origIndex() - startSegments, startInside := segmentsAtOriginalPosition(originalSegments, start) - endSegments, endInside := segmentsAtOriginalPosition(originalSegments, lastCharacter) + originalIndex := m.origIndex() + startSegments, startInside := originalIndex.segmentsAtOriginalPosition(start) + endSegments, endInside := originalIndex.segmentsAtOriginalPosition(lastCharacter) if !startInside || !endInside { return nil } @@ -648,11 +648,22 @@ func sameOriginalRange(left Segment, right Segment) bool { return left.OriginalStart == right.OriginalStart && left.OriginalEnd == right.OriginalEnd } -// origIndex returns the segments ordered by OriginalStart, building it once on first use. -func (m *SpanMap) origIndex() []Segment { +// originalIndex stores segments in original-text order and a complete binary tree whose leaves correspond +// to those segments. Each internal node stores the maximum OriginalEnd below it, allowing point lookups to +// discard a whole subtree when none of its segments can reach the queried position. +type originalIndex struct { + segments []Segment + leafCount int + maxEnds []core.TextPos +} + +// origIndex builds the immutable original-text interval index on first use. Sorting dominates the O(n) tree +// construction, so the first lookup remains O(n log n); later point lookups visit only tree branches that can +// contain a match. +func (m *SpanMap) origIndex() *originalIndex { m.origOnce.Do(func() { - m.origSorted = slices.Clone(m.segments) - slices.SortFunc(m.origSorted, func(a, b Segment) int { + segments := slices.Clone(m.segments) + slices.SortFunc(segments, func(a, b Segment) int { if c := int(a.OriginalStart - b.OriginalStart); c != 0 { return c } @@ -661,23 +672,54 @@ func (m *SpanMap) origIndex() []Segment { } return int(a.VirtualStart - b.VirtualStart) }) + leafCount := 1 + for leafCount < len(segments) { + leafCount *= 2 + } + maxEnds := make([]core.TextPos, 2*leafCount) + for i, segment := range segments { + maxEnds[leafCount+i] = segment.OriginalEnd + } + for i := leafCount - 1; i > 0; i-- { + maxEnds[i] = max(maxEnds[2*i], maxEnds[2*i+1]) + } + m.originalIndex = &originalIndex{segments: segments, leafCount: leafCount, maxEnds: maxEnds} }) - return m.origSorted + return m.originalIndex } // segmentsAtOriginalPosition returns every mapping segment containing the original-text position pos. // Segment ends are exclusive; a segment start, including a zero-length segment, is considered contained. -func segmentsAtOriginalPosition(segments []Segment, pos core.TextPos) ([]Segment, bool) { +func (i *originalIndex) segmentsAtOriginalPosition(pos core.TextPos) ([]Segment, bool) { + // Query intervals that contain pos strictly before their exclusive end. Segments starting exactly at pos + // are appended separately so zero-length segments are included without preventing maxEnd <= pos pruning. + start := sort.Search(len(i.segments), func(index int) bool { return i.segments[index].OriginalStart >= pos }) + results := i.segmentsEndingAfterPosition(start, pos) + end := sort.Search(len(i.segments), func(index int) bool { return i.segments[index].OriginalStart > pos }) + results = append(results, i.segments[start:end]...) + return results, len(results) > 0 +} + +// segmentsEndingAfterPosition returns segments among [0, limit) whose OriginalEnd is greater than pos. +func (i *originalIndex) segmentsEndingAfterPosition(limit int, pos core.TextPos) []Segment { var results []Segment - for _, segment := range segments { - if segment.OriginalStart > pos { - break - } - if pos < segment.OriginalEnd || pos == segment.OriginalStart { - results = append(results, segment) - } + i.collectSegmentsEndingAtOrAfter(1, 0, i.leafCount, limit, pos, false, &results) + return results +} + +// collectSegmentsEndingAtOrAfter walks the flat max-end tree left-to-right, preserving original-text order. +// Nodes beyond limit or whose maximum end cannot reach pos are discarded without visiting their leaves. +func (i *originalIndex) collectSegmentsEndingAtOrAfter(node int, start int, end int, limit int, pos core.TextPos, includeEnd bool, results *[]Segment) { + if start >= limit || i.maxEnds[node] < pos || !includeEnd && i.maxEnds[node] == pos { + return } - return results, len(results) > 0 + if end-start == 1 { + *results = append(*results, i.segments[start]) + return + } + middle := start + (end-start)/2 + i.collectSegmentsEndingAtOrAfter(2*node, start, middle, limit, pos, includeEnd, results) + i.collectSegmentsEndingAtOrAfter(2*node+1, middle, end, limit, pos, includeEnd, results) } type segmentGroupAtOriginalPosition struct { @@ -696,12 +738,12 @@ type segmentGroupAtOriginalPosition struct { // virtual: [ A1 ) [ A2 ) [ B1 ) [ B2 ) // left group right group // atEnd: true atEnd: false -func segmentGroupsAtOriginalPosition(segments []Segment, pos core.TextPos) []segmentGroupAtOriginalPosition { +func (i *originalIndex) segmentGroupsAtOriginalPosition(pos core.TextPos) []segmentGroupAtOriginalPosition { + limit := sort.Search(len(i.segments), func(index int) bool { return i.segments[index].OriginalStart > pos }) + var segments []Segment + i.collectSegmentsEndingAtOrAfter(1, 0, i.leafCount, limit, pos, true, &segments) var groups []segmentGroupAtOriginalPosition for start := 0; start < len(segments); { - if segments[start].OriginalStart > pos { - break - } end := start + 1 for end < len(segments) && sameOriginalRange(segments[start], segments[end]) { end++ diff --git a/tsc/internal/spanmap/spanmap_test.go b/tsc/internal/spanmap/spanmap_test.go index 01ec831357b66..f4902ca11214a 100644 --- a/tsc/internal/spanmap/spanmap_test.go +++ b/tsc/internal/spanmap/spanmap_test.go @@ -420,6 +420,56 @@ func TestOriginalToVirtualOverlappingSpans(t *testing.T) { } } +func TestOriginalToVirtualPositionFindsEarlyCoveringSegment(t *testing.T) { + t.Parallel() + + // Binary search lands near [90,95), which does not contain 97. The interval index must still find the + // earlier [0,100) segment without scanning every segment whose start precedes the query. + m := spanmap.New([]spanmap.Segment{ + {VirtualStart: 0, VirtualEnd: 100, OriginalStart: 0, OriginalEnd: 100, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + {VirtualStart: 100, VirtualEnd: 105, OriginalStart: 80, OriginalEnd: 85, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + {VirtualStart: 105, VirtualEnd: 110, OriginalStart: 90, OriginalEnd: 95, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + {VirtualStart: 110, VirtualEnd: 113, OriginalStart: 100, OriginalEnd: 103, Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover}, + }) + + assert.DeepEqual(t, m.OriginalToVirtualPositions(97, spanmap.FeatureHover), []spanmap.MappedPosition{ + {Position: 97, Fidelity: spanmap.FidelityExact}, + }) + spans := m.OriginalToVirtualSpans(core.NewTextRange(97, 98), spanmap.FeatureHover) + assert.Equal(t, len(spans), 1) + assert.Equal(t, spans[0], spanmap.MappedSpan{Span: core.NewTextRange(97, 98), Fidelity: spanmap.FidelityExact}) + + // Point lookup includes both sides of a shared endpoint, including an early interval found through the + // max-end tree. Nonempty span lookup treats segment ends as exclusive and uses only the right segment. + assert.DeepEqual(t, m.OriginalToVirtualPositions(100, spanmap.FeatureHover), []spanmap.MappedPosition{ + {Position: 100, Fidelity: spanmap.FidelityExact}, + {Position: 110, Fidelity: spanmap.FidelityExact}, + }) + spans = m.OriginalToVirtualSpans(core.NewTextRange(100, 101), spanmap.FeatureHover) + assert.Equal(t, len(spans), 1) + assert.Equal(t, spans[0], spanmap.MappedSpan{Span: core.NewTextRange(110, 111), Fidelity: spanmap.FidelityExact}) +} + +func BenchmarkOriginalToVirtualPositionNearEnd(b *testing.B) { + const segmentCount = 10_000 + segments := make([]spanmap.Segment, segmentCount) + for i := range segments { + start := core.TextPos(2 * i) + segments[i] = spanmap.Segment{ + VirtualStart: start, VirtualEnd: start + 1, + OriginalStart: start, OriginalEnd: start + 1, + Kind: spanmap.KindVerbatim, Features: spanmap.FeatureHover, + } + } + m := spanmap.New(segments) + position := core.TextPos(2 * (segmentCount - 1)) + m.OriginalToVirtualPositions(position, spanmap.FeatureHover) // Build the lazy index outside the benchmark. + b.ResetTimer() + for b.Loop() { + m.OriginalToVirtualPositions(position, spanmap.FeatureHover) + } +} + func TestOriginalToVirtualOverlapFallsBackFromDisabledContainer(t *testing.T) { t.Parallel() From b5939c52acc3d7a5e3922e2dd3882b18438c591e Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 21 Aug 2026 09:51:47 -0700 Subject: [PATCH 18/18] Remove unnecessary realpath --- tsc/internal/contentmapper/contentmapper.go | 3 ++- tsc/internal/execute/build/orchestrator.go | 4 ++-- tsc/internal/execute/watcher.go | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tsc/internal/contentmapper/contentmapper.go b/tsc/internal/contentmapper/contentmapper.go index fa4eb9c8e0663..b44dedb42cb23 100644 --- a/tsc/internal/contentmapper/contentmapper.go +++ b/tsc/internal/contentmapper/contentmapper.go @@ -48,7 +48,8 @@ type Manifest struct { // the package's package.json, plus the package directory used as the mapper's working directory. type Mapper struct { Definition - Manifest `json:"-"` + Manifest `json:"-"` + // PackageDirectory is the real path directory returned by package resolution for package-based mappers. PackageDirectory string `json:"-"` // ContributionID is provided by an LSP client extension for inferred project content mappers. ContributionID string `json:"-"` diff --git a/tsc/internal/execute/build/orchestrator.go b/tsc/internal/execute/build/orchestrator.go index 11c13b8ba9b84..93adc3be566c9 100644 --- a/tsc/internal/execute/build/orchestrator.go +++ b/tsc/internal/execute/build/orchestrator.go @@ -344,7 +344,7 @@ func (o *Orchestrator) checkTasksForEventChanges(changedPaths map[string]fswatch if mapper.PackageDirectory == "" || mapper.ContributionID != "" { continue } - manifestPath := o.toPath(o.host.FS().Realpath(tspath.CombinePaths(mapper.PackageDirectory, "package.json"))) + manifestPath := o.toPath(tspath.CombinePaths(mapper.PackageDirectory, "package.json")) if _, changed := normalizedPaths[manifestPath]; changed { task.resetConfig(o, path) needsConfigUpdate.Store(true) @@ -515,7 +515,7 @@ func (o *Orchestrator) computeDesiredWatches() map[string]bool { if mapper.PackageDirectory == "" || mapper.ContributionID != "" { continue } - manifestPath := o.host.FS().Realpath(tspath.CombinePaths(mapper.PackageDirectory, "package.json")) + manifestPath := tspath.CombinePaths(mapper.PackageDirectory, "package.json") dir := tspath.GetDirectoryPath(manifestPath) if !desiredDirs.Covered(dir) && watchmanager.CanWatchDirectory(dir) { desiredDirs.Set(dir, false) diff --git a/tsc/internal/execute/watcher.go b/tsc/internal/execute/watcher.go index 0679fc0eea59e..4467c70905b6a 100644 --- a/tsc/internal/execute/watcher.go +++ b/tsc/internal/execute/watcher.go @@ -623,7 +623,7 @@ func (w *Watcher) contentMapperManifestChanged(changedPaths map[string]fswatch.E if mapper.PackageDirectory == "" || mapper.ContributionID != "" { continue } - if _, changed := changedPaths[w.sys.FS().Realpath(tspath.CombinePaths(mapper.PackageDirectory, "package.json"))]; changed { + if _, changed := changedPaths[tspath.CombinePaths(mapper.PackageDirectory, "package.json")]; changed { return true } }