Skip to content

Commit d6c4afd

Browse files
authored
Content mappers round 2 (#63936)
1 parent 7286b0a commit d6c4afd

86 files changed

Lines changed: 2456 additions & 502 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/skills/compiler-and-fourslash-tests/SKILL.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,12 @@ const x/*1*/ = 42;
320320
`
321321
```
322322

323+
#### Content mapper tests
324+
325+
When writing a test for a content-mapped file, include a comment that shows the virtual
326+
TS output of the test content mapper implementation, and a description or diagram of the
327+
mapping spans.
328+
323329
### 2.3 Verification Methods (Common API)
324330

325331
The `fourslash.FourslashTest` type (variable `f`) provides these verification methods:

packages/typescript/src/ast/spanMap.ts

Lines changed: 116 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export interface SpanMapSegment {
2020
/** Internal segment representation after omitted features have been normalized to `All`. */
2121
type NormalizedSpanMapSegment = SpanMapSegment & { readonly features: SpanMapFeature; };
2222

23+
/** Lazily built interval index for original-to-virtual lookups. */
24+
interface OriginalIndex {
25+
readonly segments: readonly NormalizedSpanMapSegment[];
26+
readonly leafCount: number;
27+
readonly maxEnds: readonly number[];
28+
}
29+
2330
/** One virtual projection of an original position and its mapping fidelity. */
2431
export interface MappedPosition {
2532
readonly position: number;
@@ -35,7 +42,7 @@ export interface MappedRange {
3542
/** Provides bidirectional span-aware mapping between virtual and original text. */
3643
export class SpanMap {
3744
readonly segments: readonly NormalizedSpanMapSegment[];
38-
private originalSegments: readonly NormalizedSpanMapSegment[] | undefined;
45+
private originalIndex: OriginalIndex | undefined;
3946

4047
/** Copies and sorts segments by virtual start, normalizing omitted features to `All`. */
4148
constructor(segments: readonly SpanMapSegment[]) {
@@ -90,7 +97,7 @@ export class SpanMap {
9097
* Results are ordered by virtual position; uncovered or disabled positions produce no results.
9198
*/
9299
originalToVirtualPositions(position: number, feature: SpanMapFeature): readonly MappedPosition[] {
93-
const groups = segmentGroupsAtOriginalPosition(this.getOriginalSegments(), position);
100+
const groups = segmentGroupsAtOriginalPosition(this.getOriginalIndex(), position);
94101
const results: MappedPosition[] = [];
95102
for (const group of groups) {
96103
for (const segment of group.segments) {
@@ -108,7 +115,7 @@ export class SpanMap {
108115

109116
/**
110117
* Returns every feature-compatible virtual projection of an original range.
111-
* A range contained by one duplicate group produces one exact or atom result per matching group member.
118+
* A range contained by one or more segments produces one exact or atom result per matching segment.
112119
*
113120
* A range that starts in one group and ends in another can have several possible virtual ranges. For
114121
* example, suppose two original segments are each copied twice into the virtual text:
@@ -131,16 +138,24 @@ export class SpanMap {
131138
originalToVirtualSpans(range: ReadonlyTextRange, feature: SpanMapFeature): readonly MappedRange[] {
132139
const start = range.pos;
133140
const end = Math.max(range.end, start);
134-
const lastCharacter = end > start ? end - 1 : end;
135-
const originalSegments = this.getOriginalSegments();
136-
const startSegments = segmentsAtOriginalPosition(originalSegments, start);
137-
const endSegments = segmentsAtOriginalPosition(originalSegments, lastCharacter);
141+
if (start === end) {
142+
return this.originalToVirtualPositions(start, feature).map(({ position, fidelity }) => ({
143+
range: { pos: position, end: position },
144+
fidelity,
145+
}));
146+
}
147+
const lastCharacter = end - 1;
148+
const originalIndex = this.getOriginalIndex();
149+
const startSegments = segmentsAtOriginalPosition(originalIndex, start);
150+
const endSegments = segmentsAtOriginalPosition(originalIndex, lastCharacter);
138151
if (!startSegments || !endSegments) return [];
139-
if (sameOriginalRange(startSegments[0], endSegments[0])) {
140-
return originalToVirtualSpansInGroup(startSegments, start, end, feature);
152+
const containing = startSegments.filter(segment => end <= segment.originalEnd);
153+
if (containing.length > 0) {
154+
const results = [...originalToVirtualSpansInSegments(containing, start, end, feature)];
155+
if (results.length > 0) return results.sort((left, right) => left.range.pos - right.range.pos);
141156
}
142-
const starts = originalStartProjections(startSegments, start, feature);
143-
const ends = originalEndProjections(endSegments, end, feature);
157+
const starts = [...originalStartProjections(startSegments, start, feature)].sort((left, right) => left - right);
158+
const ends = [...originalEndProjections(endSegments, end, feature)].sort((left, right) => left - right);
144159
if (starts.length === 0 || ends.length === 0) return [];
145160
return starts.flatMap((virtualStart, index) => {
146161
const virtualEnd = ends.find(end => end >= virtualStart);
@@ -154,8 +169,12 @@ export class SpanMap {
154169
private mapRange(range: ReadonlyTextRange, segments: readonly SpanMapSegment[], reverse: boolean): MappedRange {
155170
const start = range.pos;
156171
const end = Math.max(range.end, start);
172+
if (start === end) {
173+
const { position, fidelity } = this.mapPoint(start, segments, reverse);
174+
return { range: { pos: position, end: position }, fidelity };
175+
}
157176
const [startIndex, startInside] = segmentIndexAt(segments, start, reverse);
158-
const endProbe = end > start ? end - 1 : end;
177+
const endProbe = end - 1;
159178
const [endIndex, endInside] = segmentIndexAt(segments, endProbe, reverse);
160179

161180
if (startIndex === endIndex && startInside === endInside) {
@@ -193,13 +212,20 @@ export class SpanMap {
193212
};
194213
}
195214

196-
/** Returns the lazily built segment index ordered by original start. */
197-
private getOriginalSegments(): readonly NormalizedSpanMapSegment[] {
198-
return this.originalSegments ??= [...this.segments].sort((left, right) =>
215+
/** Returns the lazily built original-text interval index. */
216+
private getOriginalIndex(): OriginalIndex {
217+
if (this.originalIndex) return this.originalIndex;
218+
const segments = [...this.segments].sort((left, right) =>
199219
left.originalStart - right.originalStart
200220
|| left.originalEnd - right.originalEnd
201221
|| left.virtualStart - right.virtualStart
202222
);
223+
let leafCount = 1;
224+
while (leafCount < segments.length) leafCount *= 2;
225+
const maxEnds = new Array<number>(2 * leafCount).fill(0);
226+
for (let i = 0; i < segments.length; i++) maxEnds[leafCount + i] = segments[i].originalEnd;
227+
for (let i = leafCount - 1; i > 0; i--) maxEnds[i] = Math.max(maxEnds[2 * i], maxEnds[2 * i + 1]);
228+
return this.originalIndex = { segments, leafCount, maxEnds };
203229
}
204230

205231
private virtualRangeSupportsFeature(range: ReadonlyTextRange, feature: SpanMapFeature): boolean {
@@ -269,8 +295,8 @@ function originalEndProjections(segments: readonly NormalizedSpanMapSegment[], e
269295
);
270296
}
271297

272-
/** Maps a range whose boundaries are known to lie in one duplicate group. */
273-
function originalToVirtualSpansInGroup(segments: readonly NormalizedSpanMapSegment[], start: number, end: number, feature: SpanMapFeature): readonly MappedRange[] {
298+
/** Maps a range fully contained by each segment. */
299+
function originalToVirtualSpansInSegments(segments: readonly NormalizedSpanMapSegment[], start: number, end: number, feature: SpanMapFeature): readonly MappedRange[] {
274300
return segments
275301
.filter(segment => supportsFeature(segment, feature))
276302
.map(segment => {
@@ -289,30 +315,70 @@ function sameOriginalRange(left: SpanMapSegment, right: SpanMapSegment): boolean
289315
}
290316

291317
/**
292-
* Returns the complete duplicate group of mapping segments containing the original-text `position`.
293-
* Segment ends are exclusive; starts, including zero-length segment starts, are included. It finds a candidate
294-
* in O(log n), then scans only the duplicate group. `segments` must be ordered by original start, original end,
295-
* and virtual start.
318+
* Returns every mapping segment containing the original-text `position`.
319+
* Segment ends are exclusive; starts, including zero-length segment starts, are included.
296320
*/
297-
function segmentsAtOriginalPosition(segments: readonly NormalizedSpanMapSegment[], position: number): readonly NormalizedSpanMapSegment[] | undefined {
321+
function segmentsAtOriginalPosition(index: OriginalIndex, position: number): readonly NormalizedSpanMapSegment[] | undefined {
322+
// Query intervals that contain position strictly before their exclusive end. Segments starting exactly at
323+
// position are appended separately so zero-length segments are included while maxEnd <= position is pruned.
324+
const start = firstOriginalSegmentAtOrAfter(index.segments, position);
325+
const results = segmentsEndingAtOrAfter(index, start, position, false);
326+
const end = firstOriginalSegmentAfter(index.segments, position);
327+
results.push(...index.segments.slice(start, end));
328+
return results.length > 0 ? results : undefined;
329+
}
330+
331+
/** Returns segments among `[0, limit)` whose original end reaches `position`. */
332+
function segmentsEndingAtOrAfter(index: OriginalIndex, limit: number, position: number, includeEnd: boolean): NormalizedSpanMapSegment[] {
333+
const results: NormalizedSpanMapSegment[] = [];
334+
collectSegmentsEndingAtOrAfter(index, 1, 0, index.leafCount, limit, position, includeEnd, results);
335+
return results;
336+
}
337+
338+
/** Walks the flat max-end tree left-to-right, preserving original-text order. */
339+
function collectSegmentsEndingAtOrAfter(
340+
index: OriginalIndex,
341+
node: number,
342+
start: number,
343+
end: number,
344+
limit: number,
345+
position: number,
346+
includeEnd: boolean,
347+
results: NormalizedSpanMapSegment[],
348+
): void {
349+
const maxEnd = index.maxEnds[node];
350+
if (start >= limit || maxEnd < position || !includeEnd && maxEnd === position) return;
351+
if (end - start === 1) {
352+
results.push(index.segments[start]);
353+
return;
354+
}
355+
const middle = start + ((end - start) >>> 1);
356+
collectSegmentsEndingAtOrAfter(index, 2 * node, start, middle, limit, position, includeEnd, results);
357+
collectSegmentsEndingAtOrAfter(index, 2 * node + 1, middle, end, limit, position, includeEnd, results);
358+
}
359+
360+
/** Returns the first original-ordered segment whose start is greater than or equal to `position`. */
361+
function firstOriginalSegmentAtOrAfter(segments: readonly NormalizedSpanMapSegment[], position: number): number {
298362
let low = 0;
299363
let high = segments.length;
300364
while (low < high) {
301365
const middle = (low + high) >>> 1;
302366
if (segments[middle].originalStart < position) low = middle + 1;
303367
else high = middle;
304368
}
305-
let index = low < segments.length && segments[low].originalStart === position ? low : low - 1;
306-
if (
307-
index < 0 || !(
308-
segments[index].originalStart === position
309-
|| position < segments[index].originalEnd
310-
)
311-
) return undefined;
312-
while (index > 0 && sameOriginalRange(segments[index - 1], segments[index])) index--;
313-
let end = index + 1;
314-
while (end < segments.length && sameOriginalRange(segments[end], segments[index])) end++;
315-
return segments.slice(index, end);
369+
return low;
370+
}
371+
372+
/** Returns the first original-ordered segment whose start is greater than `position`. */
373+
function firstOriginalSegmentAfter(segments: readonly NormalizedSpanMapSegment[], position: number): number {
374+
let low = 0;
375+
let high = segments.length;
376+
while (low < high) {
377+
const middle = (low + high) >>> 1;
378+
if (segments[middle].originalStart <= position) low = middle + 1;
379+
else high = middle;
380+
}
381+
return low;
316382
}
317383

318384
interface SegmentGroupAtOriginalPosition {
@@ -321,8 +387,8 @@ interface SegmentGroupAtOriginalPosition {
321387
}
322388

323389
/**
324-
* Returns groups of mapping segments containing or touching the original-text `position`.
325-
* At a shared boundary, segments ending at the point and segments starting there form separate groups:
390+
* Returns every group of equal-range mapping segments containing or touching the original-text `position`.
391+
* Segment ends are included for point mapping:
326392
*
327393
* ```text
328394
* original: [--- A ---)[--- B ---)
@@ -333,31 +399,23 @@ interface SegmentGroupAtOriginalPosition {
333399
* atEnd: true atEnd: false
334400
* ```
335401
*/
336-
function segmentGroupsAtOriginalPosition(segments: readonly NormalizedSpanMapSegment[], position: number): readonly SegmentGroupAtOriginalPosition[] {
337-
let low = 0;
338-
let high = segments.length;
339-
while (low < high) {
340-
const middle = (low + high) >>> 1;
341-
if (segments[middle].originalStart < position) low = middle + 1;
342-
else high = middle;
343-
}
344-
if (low < segments.length && segments[low].originalStart === position) {
345-
const right = segmentsAtOriginalPosition(segments, position)!;
346-
const groups: SegmentGroupAtOriginalPosition[] = [];
347-
if (low > 0 && segments[low - 1].originalEnd === position) {
348-
let leftStart = low - 1;
349-
while (leftStart > 0 && sameOriginalRange(segments[leftStart - 1], segments[low - 1])) leftStart--;
350-
groups.push({ segments: segments.slice(leftStart, low), atEnd: true });
402+
function segmentGroupsAtOriginalPosition(index: OriginalIndex, position: number): readonly SegmentGroupAtOriginalPosition[] {
403+
const limit = firstOriginalSegmentAfter(index.segments, position);
404+
const segments = segmentsEndingAtOrAfter(index, limit, position, true);
405+
const groups: SegmentGroupAtOriginalPosition[] = [];
406+
for (let start = 0; start < segments.length;) {
407+
let end = start + 1;
408+
while (end < segments.length && sameOriginalRange(segments[start], segments[end])) end++;
409+
const segment = segments[start];
410+
if (position <= segment.originalEnd) {
411+
groups.push({
412+
segments: segments.slice(start, end),
413+
atEnd: position === segment.originalEnd && position !== segment.originalStart,
414+
});
351415
}
352-
groups.push({ segments: right, atEnd: false });
353-
return groups;
416+
start = end;
354417
}
355-
if (low === 0) return [];
356-
const left = segments[low - 1];
357-
if (position > left.originalEnd) return [];
358-
let start = low - 1;
359-
while (start > 0 && sameOriginalRange(segments[start - 1], left)) start--;
360-
return [{ segments: segments.slice(start, low), atEnd: position === left.originalEnd }];
418+
return groups;
361419
}
362420

363421
/** Reports whether a segment participates in an original-to-virtual query for `features`. */

packages/typescript/test/spanMap.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ describe("SpanMap", () => {
7272
]);
7373
});
7474

75+
test("maps zero-length spans at segment ends", () => {
76+
assert.deepEqual(map.virtualToOriginalSpan({ pos: 18, end: 18 }), {
77+
range: { pos: 34, end: 34 },
78+
fidelity: SpanMapFidelity.Exact,
79+
});
80+
for (const originalEnd of [14, 34]) {
81+
const positions = map.originalToVirtualPositions(originalEnd, SpanMapFeature.All);
82+
assert.equal(positions.length, 1);
83+
assert.deepEqual(map.originalToVirtualSpans({ pos: originalEnd, end: originalEnd }, SpanMapFeature.All), [{
84+
range: { pos: positions[0].position, end: positions[0].position },
85+
fidelity: positions[0].fidelity,
86+
}]);
87+
}
88+
});
89+
7590
test("sorts virtual and original indexes independently", () => {
7691
const reordered = new SpanMap([
7792
{ virtualStart: 0, virtualEnd: 2, originalStart: 10, originalEnd: 12, kind: SpanMapKind.Verbatim },
@@ -110,6 +125,65 @@ describe("SpanMap", () => {
110125
]);
111126
});
112127

128+
test("maps every covering overlapping span", () => {
129+
const overlapping = new SpanMap([
130+
{ virtualStart: 0, virtualEnd: 6, originalStart: 0, originalEnd: 6, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover },
131+
{ virtualStart: 10, virtualEnd: 12, originalStart: 2, originalEnd: 4, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover },
132+
{ virtualStart: 20, virtualEnd: 24, originalStart: 3, originalEnd: 7, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover },
133+
]);
134+
135+
assert.deepEqual(overlapping.originalToVirtualPositions(3, SpanMapFeature.Hover), [
136+
{ position: 3, fidelity: SpanMapFidelity.Exact },
137+
{ position: 11, fidelity: SpanMapFidelity.Exact },
138+
{ position: 20, fidelity: SpanMapFidelity.Exact },
139+
]);
140+
assert.deepEqual(overlapping.originalToVirtualSpans({ pos: 3, end: 4 }, SpanMapFeature.Hover), [
141+
{ range: { pos: 3, end: 4 }, fidelity: SpanMapFidelity.Exact },
142+
{ range: { pos: 11, end: 12 }, fidelity: SpanMapFidelity.Exact },
143+
{ range: { pos: 20, end: 21 }, fidelity: SpanMapFidelity.Exact },
144+
]);
145+
});
146+
147+
test("finds an early covering segment through the original index", () => {
148+
// Binary search lands near [90,95), which does not contain 97. The interval index must still find the
149+
// earlier [0,100) segment without scanning every segment whose start precedes the query.
150+
const overlapping = new SpanMap([
151+
{ virtualStart: 0, virtualEnd: 100, originalStart: 0, originalEnd: 100, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover },
152+
{ virtualStart: 100, virtualEnd: 105, originalStart: 80, originalEnd: 85, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover },
153+
{ virtualStart: 105, virtualEnd: 110, originalStart: 90, originalEnd: 95, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover },
154+
{ virtualStart: 110, virtualEnd: 113, originalStart: 100, originalEnd: 103, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover },
155+
]);
156+
157+
assert.deepEqual(overlapping.originalToVirtualPositions(97, SpanMapFeature.Hover), [
158+
{ position: 97, fidelity: SpanMapFidelity.Exact },
159+
]);
160+
assert.deepEqual(overlapping.originalToVirtualSpans({ pos: 97, end: 98 }, SpanMapFeature.Hover), [
161+
{ range: { pos: 97, end: 98 }, fidelity: SpanMapFidelity.Exact },
162+
]);
163+
164+
// Point lookup includes both sides of a shared endpoint. Nonempty span lookup treats segment ends as
165+
// exclusive and uses only the segment beginning at the endpoint.
166+
assert.deepEqual(overlapping.originalToVirtualPositions(100, SpanMapFeature.Hover), [
167+
{ position: 100, fidelity: SpanMapFidelity.Exact },
168+
{ position: 110, fidelity: SpanMapFidelity.Exact },
169+
]);
170+
assert.deepEqual(overlapping.originalToVirtualSpans({ pos: 100, end: 101 }, SpanMapFeature.Hover), [
171+
{ range: { pos: 110, end: 111 }, fidelity: SpanMapFidelity.Exact },
172+
]);
173+
});
174+
175+
test("falls back from a disabled containing span", () => {
176+
const overlapping = new SpanMap([
177+
{ virtualStart: 0, virtualEnd: 6, originalStart: 0, originalEnd: 6, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Definition },
178+
{ virtualStart: 10, virtualEnd: 13, originalStart: 0, originalEnd: 3, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover },
179+
{ virtualStart: 13, virtualEnd: 16, originalStart: 3, originalEnd: 6, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover },
180+
]);
181+
182+
assert.deepEqual(overlapping.originalToVirtualSpans({ pos: 1, end: 5 }, SpanMapFeature.Hover), [
183+
{ range: { pos: 11, end: 15 }, fidelity: SpanMapFidelity.Approximate },
184+
]);
185+
});
186+
113187
test("maps minimal cross-group projections", () => {
114188
const projections = new SpanMap([
115189
{ virtualStart: 0, virtualEnd: 2, originalStart: 0, originalEnd: 2, kind: SpanMapKind.Verbatim, features: SpanMapFeature.Hover },

0 commit comments

Comments
 (0)