diff --git a/src/FileIndexer.ts b/src/FileIndexer.ts index 01b7212a..9491459d 100644 --- a/src/FileIndexer.ts +++ b/src/FileIndexer.ts @@ -33,6 +33,8 @@ import { import { Input } from './Input.js' import { Packages } from './Packages.js' import { formatByteSizeAsHumanReadable } from './parseHumanByteSizeIntoNumber.js' +import { Position } from './Position.js' +import { Range } from './Range.js' import { ScipSymbol } from './ScipSymbol.js' import { SourceInfo } from './SourceInfo.js' import * as ts_inline from './TypeScriptInternal.js' @@ -93,8 +95,13 @@ export class FileIndexer { } this.pushOccurrence( create(OccurrenceSchema, { - range: [0, 0, 0], - enclosingRange: this.sourceInfo.range(this.sourceFile), + typedRange: new Range( + new Position(0, 0), + new Position(0, 0) + ).toTypedRange(), + typedEnclosingRange: this.sourceInfo + .range(this.sourceFile) + ?.toTypedEnclosingRange(), symbol: symbol.value, symbolRoles: SymbolRole.Definition, }) @@ -260,7 +267,7 @@ export class FileIndexer { for (const declaration of declarations) { let scipSymbol = this.scipSymbol(declaration) - let enclosingRange: number[] | undefined + let enclosingRange: Range | undefined if (!isDefinitionNode || scipSymbol.isEmpty() || scipSymbol.isLocal()) { // Skip enclosing ranges for these cases @@ -311,8 +318,8 @@ export class FileIndexer { } this.pushOccurrence( create(OccurrenceSchema, { - enclosingRange: enclosingRange, - range, + typedEnclosingRange: enclosingRange?.toTypedEnclosingRange(), + typedRange: range.toTypedRange(), symbol: scipSymbol.value, symbolRoles: role, @@ -339,7 +346,7 @@ export class FileIndexer { * // ^^^^^^^^ references `Props.property` and defines a local parameter `property` * ``` */ - private handleObjectBindingPattern(node: ts.Node, range: number[]): void { + private handleObjectBindingPattern(node: ts.Node, range: Range): void { const isObjectBindingPatternProperty = ts.isIdentifier(node) && ts.isBindingElement(node.parent) && @@ -356,7 +363,7 @@ export class FileIndexer { } this.pushOccurrence( create(OccurrenceSchema, { - range, + typedRange: range.toTypedRange(), symbol: scipSymbol.value, }) ) @@ -378,7 +385,7 @@ export class FileIndexer { */ private handleShorthandPropertyValue( declaration: ts.Node, - range: number[] + range: Range ): void { if (!ts.isShorthandPropertyAssignment(declaration)) { return @@ -398,7 +405,7 @@ export class FileIndexer { } this.pushOccurrence( create(OccurrenceSchema, { - range, + typedRange: range.toTypedRange(), symbol: scipSymbol.value, }) ) @@ -468,7 +475,7 @@ export class FileIndexer { } private pushOccurrence(occurrence: Occurrence): void { - const key = `${occurrence.range.join(':')} ${occurrence.symbolRoles} ${occurrence.symbol}` + const key = `${Range.fromOccurrence(occurrence).toString()} ${occurrence.symbolRoles} ${occurrence.symbol}` if (this.occurrenceKeys.has(key)) { return } diff --git a/src/ProjectIndexer.test.ts b/src/ProjectIndexer.test.ts index d8216cfb..5df7f843 100644 --- a/src/ProjectIndexer.test.ts +++ b/src/ProjectIndexer.test.ts @@ -14,12 +14,14 @@ import { test } from 'uvu' import * as assert from 'uvu/assert' import { GlobalCache, ProjectOptions } from './CommandLineOptions.js' +import { Position } from './Position.js' import { deduplicateOccurrences, languageForFileName, prettyMilliseconds, ProjectIndexer, } from './ProjectIndexer.js' +import { Range } from './Range.js' function minute(x: number): number { return x * 60 * 1000 @@ -101,13 +103,15 @@ test('only deduplicates documents after successful emission', () => { }) test('Svelte occurrence deduplication preserves definition metadata', () => { + const range = new Range(new Position(1, 2), new Position(1, 3)) + const enclosingRange = new Range(new Position(1, 0), new Position(5, 0)) const reference = create(OccurrenceSchema, { - range: [1, 2, 3], + typedRange: range.toTypedRange(), symbol: 'local 0', }) const definition = create(OccurrenceSchema, { - range: [1, 2, 3], - enclosingRange: [1, 0, 5, 0], + typedRange: range.toTypedRange(), + typedEnclosingRange: enclosingRange.toTypedEnclosingRange(), symbol: 'local 0', symbolRoles: SymbolRole.Definition, diagnostics: [ @@ -122,7 +126,10 @@ test('Svelte occurrence deduplication preserves definition metadata', () => { assert.is(document.occurrences.length, 1) assert.is(document.occurrences[0], definition) - assert.equal(document.occurrences[0].enclosingRange, [1, 0, 5, 0]) + assert.equal( + Range.fromTypedRange(document.occurrences[0].typedEnclosingRange), + enclosingRange + ) assert.is( document.occurrences[0].diagnostics[0].message, 'definition diagnostic' diff --git a/src/ProjectIndexer.ts b/src/ProjectIndexer.ts index 9259f5f9..f2477084 100644 --- a/src/ProjectIndexer.ts +++ b/src/ProjectIndexer.ts @@ -16,6 +16,7 @@ import { GlobalCache, ProjectOptions } from './CommandLineOptions.js' import { FileIndexer } from './FileIndexer.js' import { Input } from './Input.js' import { Packages } from './Packages.js' +import { Range } from './Range.js' import { ScipSymbol } from './ScipSymbol.js' import { SourceInfo, typescriptSourceInfo } from './SourceInfo.js' import { @@ -299,7 +300,7 @@ export function languageForFileName(fileName: string): string | undefined { export function deduplicateOccurrences(document: Document): void { const occurrences = new Map() for (const occurrence of document.occurrences) { - const key = `${occurrence.range.join(':')} ${occurrence.symbol}` + const key = `${Range.fromOccurrence(occurrence).toString()} ${occurrence.symbol}` const existing = occurrences.get(key) if (existing) { const symbolRoles = existing.symbolRoles | occurrence.symbolRoles @@ -312,8 +313,8 @@ export function deduplicateOccurrences(document: Document): void { // the enclosing range and diagnostics associated with the declaration. if (occurrenceIsDefinition && !existingIsDefinition) { occurrence.symbolRoles = symbolRoles - if (occurrence.enclosingRange.length === 0) { - occurrence.enclosingRange = existing.enclosingRange + if (occurrence.typedEnclosingRange.case === undefined) { + occurrence.typedEnclosingRange = existing.typedEnclosingRange } if (occurrence.diagnostics.length === 0) { occurrence.diagnostics = existing.diagnostics @@ -321,8 +322,8 @@ export function deduplicateOccurrences(document: Document): void { occurrences.set(key, occurrence) } else { existing.symbolRoles = symbolRoles - if (existing.enclosingRange.length === 0) { - existing.enclosingRange = occurrence.enclosingRange + if (existing.typedEnclosingRange.case === undefined) { + existing.typedEnclosingRange = occurrence.typedEnclosingRange } if (existing.diagnostics.length === 0) { existing.diagnostics = occurrence.diagnostics diff --git a/src/Range.ts b/src/Range.ts index ea3c9478..ff5e802d 100644 --- a/src/Range.ts +++ b/src/Range.ts @@ -1,3 +1,11 @@ +import { create } from '@bufbuild/protobuf' +import { + type MultiLineRange, + MultiLineRangeSchema, + type Occurrence, + type SingleLineRange, + SingleLineRangeSchema, +} from '@scip-code/scip' import * as ts from 'typescript' import { Position } from './Position.js' @@ -14,25 +22,59 @@ export class Range { } return this.end.compare(other.end) } - public toPackedRange(): number[] { - if (this.isSingleLine()) { - return [this.start.line, this.start.character, this.end.character] + public toTypedRange(): Occurrence['typedRange'] { + return this.isSingleLine() + ? { case: 'singleLineRange', value: this.toSingleLineRange() } + : { case: 'multiLineRange', value: this.toMultiLineRange() } + } + public toTypedEnclosingRange(): Occurrence['typedEnclosingRange'] { + return this.isSingleLine() + ? { case: 'singleLineEnclosingRange', value: this.toSingleLineRange() } + : { case: 'multiLineEnclosingRange', value: this.toMultiLineRange() } + } + private toSingleLineRange(): SingleLineRange { + return create(SingleLineRangeSchema, { + line: this.start.line, + startCharacter: this.start.character, + endCharacter: this.end.character, + }) + } + private toMultiLineRange(): MultiLineRange { + return create(MultiLineRangeSchema, { + startLine: this.start.line, + startCharacter: this.start.character, + endLine: this.end.line, + endCharacter: this.end.character, + }) + } + public static fromTypedRange( + range: Occurrence['typedRange'] | Occurrence['typedEnclosingRange'] + ): Range | undefined { + const value = range.value + if (value === undefined) { + return undefined + } + if (value.$typeName === 'scip.SingleLineRange') { + return new Range( + new Position(value.line, value.startCharacter), + new Position(value.line, value.endCharacter) + ) } - return [ - this.start.line, - this.start.character, - this.end.line, - this.end.character, - ] - } - public static fromPackedRange(range: number[]): Range { - const endLine = range.length === 3 ? range[0] : range[2] - const endCharacter = range.length === 3 ? range[2] : range[3] return new Range( - new Position(range[0], range[1]), - new Position(endLine, endCharacter) + new Position(value.startLine, value.startCharacter), + new Position(value.endLine, value.endCharacter) ) } + public static fromOccurrence(occurrence: Occurrence): Range { + const range = Range.fromTypedRange(occurrence.typedRange) + if (!range) { + throw new Error(`occurrence of '${occurrence.symbol}' has no range`) + } + return range + } + public toString(): string { + return `${this.start.line}:${this.start.character}:${this.end.line}:${this.end.character}` + } public static fromNode(node: ts.Node): Range { const sourceFile = node.getSourceFile() diff --git a/src/SnapshotTesting.ts b/src/SnapshotTesting.ts index 478c6b23..20b496f5 100644 --- a/src/SnapshotTesting.ts +++ b/src/SnapshotTesting.ts @@ -87,11 +87,9 @@ export function formatSnapshot( symbolsWithDefinitions.add(occurrence.symbol) } - if (formatOptions.showRanges && occurrence.enclosingRange.length > 0) { - enclosingRanges.push({ - range: Range.fromPackedRange(occurrence.enclosingRange), - symbol: occurrence.symbol, - }) + const enclosingRange = Range.fromTypedRange(occurrence.typedEnclosingRange) + if (formatOptions.showRanges && enclosingRange) { + enclosingRanges.push({ range: enclosingRange, symbol: occurrence.symbol }) } } @@ -244,7 +242,7 @@ export function formatSnapshot( // This is the only case where we would need to do this. if (occurrenceIndex === 0) { const occurrence = doc.occurrences[occurrenceIndex] - const range = Range.fromPackedRange(occurrence.range) + const range = Range.fromOccurrence(occurrence) // This is essentially a "file-based" item. // This guarantees that this sits above everything else in the file. @@ -273,7 +271,8 @@ export function formatSnapshot( out.push('\n') while ( occurrenceIndex < doc.occurrences.length && - doc.occurrences[occurrenceIndex].range[0] === lineNumber + Range.fromOccurrence(doc.occurrences[occurrenceIndex]).start.line === + lineNumber ) { const occurrence = doc.occurrences[occurrenceIndex] occurrenceIndex++ @@ -282,12 +281,11 @@ export function formatSnapshot( continue } - if (occurrence.range.length > 3) { + const range = Range.fromOccurrence(occurrence) + if (!range.isSingleLine()) { throw new Error('not yet implemented, multi-line ranges') } - const range = Range.fromPackedRange(occurrence.range) - out.push(commentSyntax) const isStartOfLine = range.start.character === 0 if (!isStartOfLine && range.start.character > 2) { @@ -336,7 +334,7 @@ export function formatSnapshot( } function occurrencesByLine(a: Occurrence, b: Occurrence): number { - return Range.fromPackedRange(a.range).compare(Range.fromPackedRange(b.range)) + return Range.fromOccurrence(a).compare(Range.fromOccurrence(b)) } function enclosingRangesByLine( diff --git a/src/SourceInfo.ts b/src/SourceInfo.ts index c8a48e84..2762b14c 100644 --- a/src/SourceInfo.ts +++ b/src/SourceInfo.ts @@ -10,7 +10,7 @@ export interface SourceInfo { readonly language?: string readonly isSvelte: boolean - range(node: ts.Node): number[] | undefined + range(node: ts.Node): Range | undefined /** * Overrides symbol assignment for declarations in this source file. * Return undefined to use normal TypeScript symbol assignment. @@ -32,7 +32,7 @@ export function typescriptSourceInfo(sourceFile: ts.SourceFile): SourceInfo { fileName: sourceFile.fileName, originalText: sourceFile.getText(), isSvelte: false, - range: node => Range.fromNode(node).toPackedRange(), + range: node => Range.fromNode(node), declarationSymbol: () => undefined, releaseCaches: () => {}, } diff --git a/src/Svelte.ts b/src/Svelte.ts index 5d18a58d..5d48feda 100644 --- a/src/Svelte.ts +++ b/src/Svelte.ts @@ -8,6 +8,8 @@ import { svelte2tsx } from 'svelte2tsx' import * as ts from 'typescript' import { termDescriptor, typeDescriptor } from './Descriptor.js' +import { Position } from './Position.js' +import { Range } from './Range.js' import { ScipSymbol } from './ScipSymbol.js' import { SourceInfo } from './SourceInfo.js' @@ -208,7 +210,7 @@ class SvelteSourceInfo implements SourceInfo { public readonly isSvelte = true private readonly lines: string[] private readonly storeDeclarations = new Map() - private readonly legacyPropRanges = new Map() + private readonly legacyPropRanges = new Map() // svelte2tsx emits high-resolution source maps, with a segment for most // generated characters, and each decoded segment is a small array. Keep the // encoded mappings and decode them only while this file is in use. @@ -230,13 +232,13 @@ class SvelteSourceInfo implements SourceInfo { this.traceMap = undefined } - public range(node: ts.Node): number[] | undefined { + public range(node: ts.Node): Range | undefined { if (node.getSourceFile() !== this.sourceFile) { return undefined } if (ts.isSourceFile(node)) { const endLine = this.lines.length - 1 - return toScipRange(0, 0, endLine, this.lines[endLine].length) + return toRange(0, 0, endLine, this.lines[endLine].length) } const legacyPropRange = this.legacyPropRanges.get(node) if (legacyPropRange) { @@ -275,7 +277,7 @@ class SvelteSourceInfo implements SourceInfo { ) { return undefined } - return toScipRange( + return toRange( start.line, start.column, lastCharacter.line, @@ -409,7 +411,7 @@ class SvelteSourceInfo implements SourceInfo { node: ts.Identifier | ts.PrivateIdentifier | ts.StringLiteralLike, line: number, column: number - ): number[] | undefined { + ): Range | undefined { const lineText = this.lines[line] if (lineText === undefined || column < 0 || column > lineText.length) { return undefined @@ -419,7 +421,7 @@ class SvelteSourceInfo implements SourceInfo { : [node.getText()] for (const candidate of candidates) { if (lineText.slice(column, column + candidate.length) === candidate) { - return [line, column, column + candidate.length] + return toRange(line, column, line, column + candidate.length) } } return undefined @@ -462,7 +464,7 @@ class SvelteSourceInfo implements SourceInfo { // svelte2tsx does not source-map names in its generated legacy props type. // Reuse the corresponding top-level $$render variable range so the Props // member gets a definition on the original `export let` declaration. - const declarations = new Map() + const declarations = new Map() const visit = (node: ts.Node): void => { if ( ts.isVariableDeclaration(node) && @@ -543,13 +545,14 @@ function declarationName(node: ts.Node): ts.DeclarationName | undefined { return undefined } -function toScipRange( +function toRange( startLine: number, startColumn: number, endLine: number, endColumn: number -): number[] { - return startLine === endLine - ? [startLine, startColumn, endColumn] - : [startLine, startColumn, endLine, endColumn] +): Range { + return new Range( + new Position(startLine, startColumn), + new Position(endLine, endColumn) + ) } diff --git a/src/main.test.ts b/src/main.test.ts index 051b2f57..9dec18f0 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -16,6 +16,7 @@ import * as assert from 'uvu/assert' import { Input } from './Input.js' import { indexCommand } from './main.js' +import { Range } from './Range.js' import { formatSnapshot } from './SnapshotTesting.js' function isUpdateSnapshot(): boolean { @@ -159,7 +160,7 @@ for (const snapshotDirectory of snapshotDirectories) { const occurrences = new Set() const duplicateOccurrences: string[] = [] for (const occurrence of document.occurrences) { - const key = `${occurrence.range.join(':')} ${occurrence.symbolRoles} ${occurrence.symbol}` + const key = `${Range.fromOccurrence(occurrence).toString()} ${occurrence.symbolRoles} ${occurrence.symbol}` if (occurrences.has(key)) { duplicateOccurrences.push(key) }