diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2dbaf2b..9acb8d9 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -23,6 +23,9 @@ jobs: with: install-dependencies: true + - name: Check formatting + run: pnpm run format:check + - name: Test extension run: xvfb-run -a pnpm test diff --git a/.vscode-test.mjs b/.vscode-test.mjs index b123c06..e29a563 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,12 +1,13 @@ -import { defineConfig } from '@vscode/test-cli'; +import { defineConfig } from '@vscode/test-cli' export default defineConfig({ files: 'build-test/test/**/*.test.js', version: '1.125.0', workspaceFolder: './test/fixtures/workspace', launchArgs: ['--disable-extensions'], + mocha: { failZero: true, timeout: 60_000 } -}); +}) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8d5e80f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Repository instructions + +## Package management + +- Use `vp` for package manager commands run locally by users and agents. + +## Verification + +- After changing files, run `vp run format` before any other applicable verification command. +- Run `vp run test` after TypeScript, configuration, or test changes. +- Run `vp run package` after extension manifest or packaged runtime changes. diff --git a/package.json b/package.json index be8b911..ec4f15d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "clean": "rimraf build", "build": "pnpm run clean && tsc -p ./", "build:test": "rimraf build-test && tsc -p ./tsconfig.test.json", + "format": "worsier --write .", + "format:check": "worsier --check .", "test": "pnpm run build && pnpm run build:test && vscode-test", "watch": "tsc --watch -p ./", "vscode:prepublish": "pnpm run build", @@ -58,6 +60,11 @@ "command": "zeroReference.toggleCodeLens", "title": "Toggle CodeLens", "category": "Zero Reference" + }, + { + "command": "zeroReference.refresh", + "title": "Refresh", + "category": "Zero Reference" } ] }, @@ -76,7 +83,8 @@ "@vscode/vsce": "^3.9.2", "rimraf": "^6.1.3", "taze": "20.0.0", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "worsier": "3.2.0" }, "packageManager": "pnpm@11.21.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d484257..c21fa4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: typescript: specifier: ^7.0.2 version: 7.0.2 + worsier: + specifier: 3.2.0 + version: 3.2.0 packages: @@ -1679,6 +1682,55 @@ packages: workerpool@9.3.4: resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} + worsier-darwin-arm64@3.2.0: + resolution: {integrity: sha512-zNJO7soOlerPwY9OQHJdRRx6kqfK1YBmALK3N4r11ydgWi97EEnr+TrWWz06SMdxijDc7qWFf4EIGcIWpMGX8w==} + cpu: [arm64] + os: [darwin] + + worsier-darwin-x64@3.2.0: + resolution: {integrity: sha512-WG/MCfshPxoPup1JWip4zNCKMFjJGVG0xwHuN8a1+As9gvWdAt9YO6poMTa4PNMFsEwvg8sEWzZcPR3lKn2JCw==} + cpu: [x64] + os: [darwin] + + worsier-linux-arm64-gnu@3.2.0: + resolution: {integrity: sha512-FVyDhvz7/PCpLeaKgS9VY5C0FV9VNmgXxXPNMyreS5aJKhJJfWI/kWZoL69b1uutu618npxIcspqjyWEhjAJNw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + worsier-linux-arm64-musl@3.2.0: + resolution: {integrity: sha512-v1yVYdmPXbUsir1wGKWbRbe6Ta8tw35RLSrT4gYp0l/oj6MREuce7B9X7Y7o+Ve4NNIh+KDrG2L/4mJ84wXuIA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + worsier-linux-x64-gnu@3.2.0: + resolution: {integrity: sha512-AtGnw1V6WGyQ5bwLN91+WTZcW587ZBpodagmXErIxvINT0lGLLmz61Goj4yVVgWnRzoUI3D9sENeJ2Yx8tVB7w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + worsier-linux-x64-musl@3.2.0: + resolution: {integrity: sha512-rsQOHKZG/zGMCSkCjw+c6Tz3rVoRW6rCp59V/9ZNHxt95E4OopIPaxpMGDMi0iOtGK3/+VeBAkSno49WQYyEcg==} + cpu: [x64] + os: [linux] + libc: [musl] + + worsier-win32-arm64-msvc@3.2.0: + resolution: {integrity: sha512-/ZhMKzXY+QaWyYl0wx4mGGP3cyFUW3I9G1laoZFMWll7M2PKAwXkUNFO3EEvfhjx24L8faV+URUR/6vQCmsMZg==} + cpu: [arm64] + os: [win32] + + worsier-win32-x64-msvc@3.2.0: + resolution: {integrity: sha512-LMLPN5dM1sJHp/fKD0yjbt1cB2VniwWLfx+Wpr1iF9OVsYkVFwVv8l+izCRGjcWr29SoeWIlU51uPT/8XYZQwQ==} + cpu: [x64] + os: [win32] + + worsier@3.2.0: + resolution: {integrity: sha512-aVWaHCa14r4qiOjadcoHus7Xr5zwcWtiSKepXJoh0l+wGMCG+sDXFeADErg6LywrIzvn1kxFUL/fpgfeYiv3sw==} + engines: {node: '>=24.0.0'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3522,6 +3574,41 @@ snapshots: workerpool@9.3.4: {} + worsier-darwin-arm64@3.2.0: + optional: true + + worsier-darwin-x64@3.2.0: + optional: true + + worsier-linux-arm64-gnu@3.2.0: + optional: true + + worsier-linux-arm64-musl@3.2.0: + optional: true + + worsier-linux-x64-gnu@3.2.0: + optional: true + + worsier-linux-x64-musl@3.2.0: + optional: true + + worsier-win32-arm64-msvc@3.2.0: + optional: true + + worsier-win32-x64-msvc@3.2.0: + optional: true + + worsier@3.2.0: + optionalDependencies: + worsier-darwin-arm64: 3.2.0 + worsier-darwin-x64: 3.2.0 + worsier-linux-arm64-gnu: 3.2.0 + worsier-linux-arm64-musl: 3.2.0 + worsier-linux-x64-gnu: 3.2.0 + worsier-linux-x64-musl: 3.2.0 + worsier-win32-arm64-msvc: 3.2.0 + worsier-win32-x64-msvc: 3.2.0 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/src/analysis.ts b/src/analysis.ts new file mode 100644 index 0000000..4c78fee --- /dev/null +++ b/src/analysis.ts @@ -0,0 +1,502 @@ +import { commands, EventEmitter } from 'vscode' + +import type { + CancellationToken, + Disposable, + DocumentSymbol, + Location, + Range, + SymbolInformation, + TextDocument, + Uri +} from 'vscode' + +import { getSymbolData } from './symbols.js' +import type { SymbolData } from './symbols.js' + +const referenceConcurrency = 4 + +export type CommandExecutor = ( + command: string, + ...args: readonly unknown[] +) => Thenable + +const defaultCommandExecutor: CommandExecutor = commands.executeCommand + +export interface ZeroReferenceFinding { + readonly declarationRange: Range; + readonly name: string; + readonly range: Range; +} + +interface AnalysisIdentity { + readonly configurationEpoch: number; + readonly documentVersion: number; + readonly graphEpoch: number; +} + +interface AnalysisCacheEntry extends AnalysisIdentity { + readonly findings: readonly ZeroReferenceFinding[]; +} + +interface FindingCandidate { + readonly declarationGroupKey: string; + readonly finding: ZeroReferenceFinding; +} + +interface PendingAcquisition { + cancellationSubscription: Disposable | undefined; + readonly isCurrent: () => boolean; + readonly resolve: (release: (() => void) | undefined) => void; +} + +/** Owns shared zero-reference analysis, caching, and invalidation state. */ +export class ZeroReferenceAnalyzer implements Disposable { + private readonly cache = new Map() + private readonly invalidationEventEmitter = new EventEmitter() + private readonly referenceLookupLimiter = new ConcurrencyLimiter(referenceConcurrency) + private configurationEpoch = 0 + private graphEpoch = 0 + private isDisposed = false + + readonly onDidInvalidate = this.invalidationEventEmitter.event + + constructor( + private readonly executeCommand: CommandExecutor = defaultCommandExecutor + ) {} + + async analyze( + document: TextDocument, + token: CancellationToken + ): Promise { + const identity = this.createIdentity(document) + + if (!this.isAnalysisCurrent(document, identity, token)) { + return [] + } + + const documentKey = document.uri.toString() + const cachedAnalysis = this.cache.get(documentKey) + + if (cachedAnalysis !== undefined + && hasSameIdentity(cachedAnalysis, identity)) { + return cachedAnalysis.findings + } + + let hasLoggedError = false + let hasProviderError = false + + const logErrorOnce = (error: unknown): void => { + hasProviderError = true + + if (hasLoggedError) { + return + } + + hasLoggedError = true + console.error(error) + } + + let symbols: DocumentSymbol[] | SymbolInformation[] | undefined + + try { + symbols = await this.executeCommand( + 'vscode.executeDocumentSymbolProvider', + document.uri + ) + } catch (error: unknown) { + logErrorOnce(error) + + return [] + } + + if (symbols === undefined + || !this.isAnalysisCurrent(document, identity, token)) { + return [] + } + + const symbolData = getSymbolData(symbols, document) + const declarationIdentitiesByRange = getDeclarationIdentitiesByRange(symbolData) + + const candidates = await this.findCandidates( + document, + identity, + symbolData, + declarationIdentitiesByRange, + token, + logErrorOnce + ) + + if (!this.isAnalysisCurrent(document, identity, token)) { + return [] + } + + const findings = getUniqueFindings(candidates) + + if (!hasProviderError) { + this.cache.set(documentKey, { + configurationEpoch: identity.configurationEpoch, + documentVersion: identity.documentVersion, + findings, + graphEpoch: identity.graphEpoch + }) + } + + return findings + } + + invalidateGraph(): void { + if (this.isDisposed) { + return + } + + this.graphEpoch += 1 + this.invalidateAll() + } + + invalidateConfiguration(): void { + if (this.isDisposed) { + return + } + + this.configurationEpoch += 1 + this.invalidateAll() + } + + forgetDocument(uri: Uri): void { + const documentKey = uri.toString() + + this.cache.delete(documentKey) + } + + dispose(): void { + this.isDisposed = true + this.cache.clear() + this.invalidationEventEmitter.dispose() + } + + private createIdentity(document: TextDocument): AnalysisIdentity { + return { + configurationEpoch: this.configurationEpoch, + documentVersion: document.version, + graphEpoch: this.graphEpoch + } + } + + private invalidateAll(): void { + this.cache.clear() + this.invalidationEventEmitter.fire() + } + + private async findCandidates( + document: TextDocument, + identity: AnalysisIdentity, + symbols: readonly SymbolData[], + declarationIdentitiesByRange: ReadonlyMap>, + token: CancellationToken, + logErrorOnce: (error: unknown) => void + ): Promise { + const candidates: (FindingCandidate | null)[] = Array.from( + { length: symbols.length }, + () => null + ) + + let nextSymbolIndex = 0 + + const isCurrent = (): boolean => this.isAnalysisCurrent( + document, + identity, + token + ) + + const runWorker = async (): Promise => { + while (isCurrent()) { + const symbolIndex = nextSymbolIndex + + if (symbolIndex >= symbols.length) { + return + } + + nextSymbolIndex += 1 + + const symbol = symbols[symbolIndex] + + if (symbol === undefined || !isCurrent()) { + return + } + + const releaseReferenceLookup = await this.referenceLookupLimiter.acquire( + isCurrent, + token + ) + + if (releaseReferenceLookup === undefined) { + return + } + + if (!isCurrent()) { + releaseReferenceLookup() + + return + } + + let locations: Location[] | undefined + + try { + locations = await this.executeCommand( + 'vscode.executeReferenceProvider', + document.uri, + symbol.referencePosition + ) + } catch (error: unknown) { + logErrorOnce(error) + continue + } finally { + releaseReferenceLookup() + } + + if (!isCurrent()) { + return + } + + const fallbackDeclarationIdentity = getDeclarationIdentityKey(symbol) + + const declarationGroupKey = getDeclarationGroupKey( + document.uri, + locations, + declarationIdentitiesByRange, + fallbackDeclarationIdentity + ) + + if (declarationGroupKey === undefined) { + continue + } + + candidates[symbolIndex] = { + declarationGroupKey, + + finding: { + declarationRange: symbol.declarationRange, + name: symbol.name, + range: symbol.range + } + } + } + } + + const workerCount = Math.min(referenceConcurrency, symbols.length) + const workers = Array.from({ length: workerCount }, runWorker) + + await Promise.all(workers) + + return candidates + } + + private isAnalysisCurrent( + document: TextDocument, + identity: AnalysisIdentity, + token: CancellationToken + ): boolean { + const isCurrent = !this.isDisposed + && !document.isClosed + && document.version === identity.documentVersion + && this.graphEpoch === identity.graphEpoch + && this.configurationEpoch === identity.configurationEpoch + && !token.isCancellationRequested + + return isCurrent + } +} + +function hasSameIdentity( + cacheEntry: AnalysisCacheEntry, + identity: AnalysisIdentity +): boolean { + const hasMatchingIdentity = cacheEntry.documentVersion === identity.documentVersion + && cacheEntry.graphEpoch === identity.graphEpoch + && cacheEntry.configurationEpoch === identity.configurationEpoch + + return hasMatchingIdentity +} + +function getUniqueFindings( + candidates: readonly (FindingCandidate | null)[] +): readonly ZeroReferenceFinding[] { + const seenDeclarationGroups = new Set() + const findings: ZeroReferenceFinding[] = [] + + for (const candidate of candidates) { + if (candidate === null + || seenDeclarationGroups.has(candidate.declarationGroupKey)) { + continue + } + + seenDeclarationGroups.add(candidate.declarationGroupKey) + findings.push(candidate.finding) + } + + return findings +} + +function getDeclarationIdentitiesByRange( + symbols: readonly SymbolData[] +): ReadonlyMap> { + const identitiesByRange = new Map>() + + for (const symbol of symbols) { + const rangeKey = getRangeKey(symbol.declarationRange) + const identities = identitiesByRange.get(rangeKey) ?? new Set() + + identities.add(getDeclarationIdentityKey(symbol)) + identitiesByRange.set(rangeKey, identities) + } + + return identitiesByRange +} + +function getDeclarationIdentityKey(symbol: SymbolData): string { + const identityKey = `${symbol.kind}:${symbol.normalizedName}` + + return identityKey +} + +function getDeclarationGroupKey( + documentUri: Uri, + locations: readonly Location[] | undefined, + declarationIdentitiesByRange: ReadonlyMap>, + declarationIdentityKey: string +): string | undefined { + if (locations === undefined || locations.length === 0) { + return + } + + const locationKeys = new Set() + + for (const location of locations) { + const locationKey = getRangeKey(location.range) + const declarationIdentities = declarationIdentitiesByRange.get(locationKey) + + if (location.uri.toString() !== documentUri.toString() + || !declarationIdentities?.has(declarationIdentityKey)) { + return + } + + locationKeys.add(locationKey) + } + + const sortedLocationKeys = [...locationKeys].sort() + const declarationGroupKey = sortedLocationKeys.join('|') + + return declarationGroupKey +} + +function getRangeKey(range: Range): string { + const rangeKey = `${range.start.line}:${range.start.character}-${range.end.line}:${range.end.character}` + + return rangeKey +} + +class ConcurrencyLimiter { + private activeCount = 0 + private readonly pendingAcquisitions: PendingAcquisition[] = [] + + constructor(private readonly maximumCount: number) {} + + acquire( + isCurrent: () => boolean, + token: CancellationToken + ): Promise<(() => void) | undefined> { + if (!isCurrent()) { + return Promise.resolve(undefined) + } + + if (this.activeCount < this.maximumCount) { + this.activeCount += 1 + + const release = this.createRelease() + + return Promise.resolve(release) + } + + return new Promise(resolve => { + const acquisition: PendingAcquisition = { + cancellationSubscription: undefined, + isCurrent, + resolve + } + + this.pendingAcquisitions.push(acquisition) + + const cancellationSubscription = token.onCancellationRequested(() => { + this.cancelPendingAcquisition(acquisition) + }) + + acquisition.cancellationSubscription = cancellationSubscription + + if (!isCurrent()) { + this.cancelPendingAcquisition(acquisition) + } + }) + } + + private cancelPendingAcquisition(acquisition: PendingAcquisition): void { + const acquisitionIndex = this.pendingAcquisitions.indexOf(acquisition) + + acquisition.cancellationSubscription?.dispose() + acquisition.cancellationSubscription = undefined + + if (acquisitionIndex === -1) { + return + } + + this.pendingAcquisitions.splice(acquisitionIndex, 1) + acquisition.resolve(undefined) + } + + private getNextCurrentAcquisition(): PendingAcquisition | undefined { + while (this.pendingAcquisitions.length > 0) { + const acquisition = this.pendingAcquisitions.shift() + + if (acquisition === undefined) { + return + } + + acquisition.cancellationSubscription?.dispose() + acquisition.cancellationSubscription = undefined + + if (!acquisition.isCurrent()) { + acquisition.resolve(undefined) + + continue + } + + return acquisition + } + + return + } + + private createRelease(): () => void { + let hasReleased = false + + return () => { + if (hasReleased) { + return + } + + hasReleased = true + + const nextAcquisition = this.getNextCurrentAcquisition() + + if (nextAcquisition === undefined) { + this.activeCount -= 1 + + return + } + + const release = this.createRelease() + + nextAcquisition.resolve(release) + } + } +} diff --git a/src/analysisLifecycle.ts b/src/analysisLifecycle.ts new file mode 100644 index 0000000..d35e1ef --- /dev/null +++ b/src/analysisLifecycle.ts @@ -0,0 +1,65 @@ +import { Disposable, FileType, workspace } from 'vscode' +import type { Uri } from 'vscode' +import type { ZeroReferenceAnalyzer } from './analysis.js' +import { registerUseCodeLensListener } from './config.js' +import { isSupportedFile, isSupportedLanguage } from './symbols.js' + +/** Connects workspace and configuration changes to shared analysis state. */ +export function registerAnalysisLifecycle( + analyzer: ZeroReferenceAnalyzer +): Disposable { + const fileWatcher = workspace.createFileSystemWatcher('**') + + const subscriptions = [ + registerUseCodeLensListener(() => analyzer.invalidateConfiguration()), + workspace.onDidChangeTextDocument(event => { + const hasContentChanges = event.contentChanges.length > 0 + const hasSupportedLanguage = isSupportedLanguage(event.document.languageId) + + if (hasContentChanges && hasSupportedLanguage) { + analyzer.invalidateGraph() + } + }), + workspace.onDidCloseTextDocument(document => { + analyzer.forgetDocument(document.uri) + }), + fileWatcher, + fileWatcher.onDidCreate(uri => { + void invalidateCreatedResource(uri, analyzer) + }), + fileWatcher.onDidChange(uri => { + if (isSupportedFile(uri)) { + analyzer.invalidateGraph() + } + }), + fileWatcher.onDidDelete(() => { + analyzer.invalidateGraph() + }) + ] + + const lifecycle = Disposable.from(...subscriptions) + + return lifecycle +} + +async function invalidateCreatedResource( + uri: Uri, + analyzer: ZeroReferenceAnalyzer +): Promise { + if (isSupportedFile(uri)) { + analyzer.invalidateGraph() + + return + } + + try { + const resourceStat = await workspace.fs.stat(uri) + const isDirectory = (resourceStat.type & FileType.Directory) !== 0 + + if (isDirectory) { + analyzer.invalidateGraph() + } + } catch { + // The resource can disappear again before the asynchronous stat completes. + } +} diff --git a/src/codeLensProvider.ts b/src/codeLensProvider.ts index 70dd2c6..18633d2 100644 --- a/src/codeLensProvider.ts +++ b/src/codeLensProvider.ts @@ -1,334 +1,63 @@ -import { CodeLens, commands, EventEmitter } from 'vscode'; -import type { - CancellationToken, - CodeLensProvider, - Disposable, - DocumentSymbol, - Location, - Range, - SymbolInformation, - TextDocument, - Uri -} from 'vscode'; -import { getUseCodeLens } from './config.js'; -import { getSymbolData } from './symbols.js'; -import type { SymbolData } from './symbols.js'; - -const referenceConcurrency = 4; - -export type CommandExecutor = ( - command: string, - ...args: readonly unknown[] -) => Thenable; - -interface CodeLensCandidate { - readonly codeLens: CodeLens; - readonly declarationGroupKey: string; -} - -function executeVSCodeCommand( - command: string, - ...args: readonly unknown[] -): Thenable { - return commands.executeCommand(command, ...args); -} +import { CodeLens, EventEmitter } from 'vscode' +import type { CancellationToken, CodeLensProvider, Disposable, TextDocument } from 'vscode' +import type { ZeroReferenceAnalyzer } from './analysis.js' +import { getUseCodeLens } from './config.js' export class ZeroReferenceCodeLensProvider implements CodeLensProvider, Disposable { - private readonly referenceLookupLimiter = new ConcurrencyLimiter(referenceConcurrency); - private readonly updateEventEmitter = new EventEmitter(); - private generation = 0; + private readonly analysisInvalidationSubscription: Disposable + private readonly updateEventEmitter = new EventEmitter() + private isDisposed = false - readonly onDidChangeCodeLenses = this.updateEventEmitter.event; + readonly onDidChangeCodeLenses = this.updateEventEmitter.event constructor( - private readonly executeCommand: CommandExecutor = executeVSCodeCommand, + private readonly analyzer: ZeroReferenceAnalyzer, private readonly isEnabled: () => boolean = getUseCodeLens - ) {} - - refresh(): void { - this.generation += 1; - this.updateEventEmitter.fire(); - } - - dispose(): void { - this.generation += 1; - this.updateEventEmitter.dispose(); + ) { + this.analysisInvalidationSubscription = this.analyzer.onDidInvalidate(() => { + this.updateEventEmitter.fire() + }) } async provideCodeLenses( document: TextDocument, token: CancellationToken ): Promise { - const documentVersion = document.version; - const generation = this.generation; - - if (!this.isCalculationCurrent(document, documentVersion, generation, token)) { - return []; - } - - let hasLoggedError = false; - const logErrorOnce = (error: unknown): void => { - if (hasLoggedError) { - return; - } - - hasLoggedError = true; - console.error(error); - }; - - let symbols: DocumentSymbol[] | SymbolInformation[] | undefined; - - try { - symbols = await this.executeCommand( - 'vscode.executeDocumentSymbolProvider', - document.uri - ); - } catch (error: unknown) { - logErrorOnce(error); - return []; - } - - if (symbols === undefined - || !this.isCalculationCurrent(document, documentVersion, generation, token)) { - return []; + if (!this.isCurrent(token)) { + return [] } - const symbolData = getSymbolData(symbols, document); - const declarationIdentitiesByRange = getDeclarationIdentitiesByRange(symbolData); - const candidates = await this.findCodeLensCandidates( - document, - documentVersion, - symbolData, - declarationIdentitiesByRange, - generation, - token, - logErrorOnce - ); + const findings = await this.analyzer.analyze(document, token) - if (!this.isCalculationCurrent(document, documentVersion, generation, token)) { - return []; + if (!this.isCurrent(token)) { + return [] } - const seenDeclarationGroups = new Set(); - const codeLenses: CodeLens[] = []; - - for (const candidate of candidates) { - if (candidate === null - || seenDeclarationGroups.has(candidate.declarationGroupKey)) { - continue; - } - - seenDeclarationGroups.add(candidate.declarationGroupKey); - codeLenses.push(candidate.codeLens); - } - - return codeLenses; - } - - private async findCodeLensCandidates( - document: TextDocument, - documentVersion: number, - symbols: readonly SymbolData[], - declarationIdentitiesByRange: ReadonlyMap>, - generation: number, - token: CancellationToken, - logErrorOnce: (error: unknown) => void - ): Promise { - const candidates: (CodeLensCandidate | null)[] = Array.from( - { length: symbols.length }, - () => null - ); - let nextSymbolIndex = 0; - - const runWorker = async (): Promise => { - while (this.isCalculationCurrent( - document, - documentVersion, - generation, - token - )) { - const symbolIndex = nextSymbolIndex; - - if (symbolIndex >= symbols.length) { - return; - } - - nextSymbolIndex += 1; - const symbol = symbols[symbolIndex]; - - if (symbol === undefined - || !this.isCalculationCurrent( - document, - documentVersion, - generation, - token - )) { - return; - } - - const releaseReferenceLookup = await this.referenceLookupLimiter.acquire(); - - if (!this.isCalculationCurrent( - document, - documentVersion, - generation, - token - )) { - releaseReferenceLookup(); - return; - } - - let locations: Location[] | undefined; - - try { - locations = await this.executeCommand( - 'vscode.executeReferenceProvider', - document.uri, - symbol.referencePosition - ); - } catch (error: unknown) { - logErrorOnce(error); - continue; - } finally { - releaseReferenceLookup(); - } - - if (!this.isCalculationCurrent( - document, - documentVersion, - generation, - token - )) { - return; - } - - const declarationGroupKey = getDeclarationGroupKey( - document.uri, - locations, - declarationIdentitiesByRange, - getDeclarationIdentityKey(symbol) - ); - - if (declarationGroupKey === undefined) { - continue; - } - - candidates[symbolIndex] = { - codeLens: new CodeLens(symbol.range, { - title: `"${symbol.name}" has zero references`, - command: '' - }), - declarationGroupKey - }; + const codeLenses = findings.map(finding => { + const command = { + title: `"${finding.name}" has zero references`, + command: '' } - }; - - const workerCount = Math.min(referenceConcurrency, symbols.length); - const workers = Array.from({ length: workerCount }, runWorker); - - await Promise.all(workers); - - return candidates; - } - - private isCalculationCurrent( - document: TextDocument, - documentVersion: number, - generation: number, - token: CancellationToken - ): boolean { - return generation === this.generation - && document.version === documentVersion - && this.isEnabled() - && !token.isCancellationRequested; - } -} - -function getDeclarationIdentitiesByRange( - symbols: readonly SymbolData[] -): ReadonlyMap> { - const identitiesByRange = new Map>(); - - for (const symbol of symbols) { - const rangeKey = getRangeKey(symbol.declarationRange); - const identities = identitiesByRange.get(rangeKey) ?? new Set(); - - identities.add(getDeclarationIdentityKey(symbol)); - identitiesByRange.set(rangeKey, identities); - } - - return identitiesByRange; -} - -function getDeclarationIdentityKey(symbol: SymbolData): string { - return `${symbol.kind}:${symbol.normalizedName}`; -} -function getDeclarationGroupKey( - documentUri: Uri, - locations: readonly Location[] | undefined, - declarationIdentitiesByRange: ReadonlyMap>, - declarationIdentityKey: string -): string | undefined { - if (locations === undefined || locations.length === 0) { - return undefined; - } - - const locationKeys = new Set(); + const codeLens = new CodeLens(finding.range, command) - for (const location of locations) { - const locationKey = getRangeKey(location.range); - const declarationIdentities = declarationIdentitiesByRange.get(locationKey); + return codeLens + }) - if (location.uri.toString() !== documentUri.toString() - || !declarationIdentities?.has(declarationIdentityKey)) { - return undefined; - } - - locationKeys.add(locationKey); + return codeLenses } - return [...locationKeys].sort().join('|'); -} - -function getRangeKey(range: Range): string { - return `${range.start.line}:${range.start.character}-${range.end.line}:${range.end.character}`; -} - -class ConcurrencyLimiter { - private activeCount = 0; - private readonly pendingAcquisitions: Array<(release: () => void) => void> = []; - - constructor(private readonly maximumCount: number) {} - - acquire(): Promise<() => void> { - if (this.activeCount < this.maximumCount) { - this.activeCount += 1; - return Promise.resolve(this.createRelease()); - } - - return new Promise(resolve => { - this.pendingAcquisitions.push(resolve); - }); + dispose(): void { + this.isDisposed = true + this.analysisInvalidationSubscription.dispose() + this.updateEventEmitter.dispose() } - private createRelease(): () => void { - let hasReleased = false; - - return () => { - if (hasReleased) { - return; - } - - hasReleased = true; - const nextAcquisition = this.pendingAcquisitions.shift(); - - if (nextAcquisition === undefined) { - this.activeCount -= 1; - return; - } + private isCurrent(token: CancellationToken): boolean { + const isCurrent = !this.isDisposed + && this.isEnabled() + && !token.isCancellationRequested - nextAcquisition(this.createRelease()); - }; + return isCurrent } } diff --git a/src/config.ts b/src/config.ts index 3d5c393..791cfbd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,29 +1,30 @@ -import { ConfigurationTarget, workspace } from 'vscode'; -import type { Disposable } from 'vscode'; +import { ConfigurationTarget, workspace } from 'vscode' +import type { Disposable } from 'vscode' -const useCodeLensKey = 'useCodeLens'; -const useCodeLensConfigurationKey = `zeroReference.${useCodeLensKey}`; +const useCodeLensKey = 'useCodeLens' +const useCodeLensConfigurationKey = `zeroReference.${useCodeLensKey}` export function getUseCodeLens(): boolean { return workspace .getConfiguration('zeroReference') - .get(useCodeLensKey, true); + .get(useCodeLensKey, true) } export async function updateUseCodeLens(value: boolean): Promise { - const configuration = workspace.getConfiguration('zeroReference'); - const inspection = configuration.inspect(useCodeLensKey); + const configuration = workspace.getConfiguration('zeroReference') + const inspection = configuration.inspect(useCodeLensKey) + const target = inspection?.workspaceValue === undefined ? ConfigurationTarget.Global - : ConfigurationTarget.Workspace; + : ConfigurationTarget.Workspace - await configuration.update(useCodeLensKey, value, target); + await configuration.update(useCodeLensKey, value, target) } export function registerUseCodeLensListener(onChange: () => void): Disposable { return workspace.onDidChangeConfiguration(event => { if (event.affectsConfiguration(useCodeLensConfigurationKey)) { - onChange(); + onChange() } - }); + }) } diff --git a/src/extension.ts b/src/extension.ts index 09a5799..2f43336 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,25 +1,34 @@ -import { commands, languages } from 'vscode'; -import type { ExtensionContext } from 'vscode'; -import { ZeroReferenceCodeLensProvider } from './codeLensProvider.js'; -import { - getUseCodeLens, - registerUseCodeLensListener, - updateUseCodeLens -} from './config.js'; -import { getDocumentFilter } from './symbols.js'; +import { commands, languages } from 'vscode' +import type { ExtensionContext } from 'vscode' +import { ZeroReferenceAnalyzer } from './analysis.js' +import { registerAnalysisLifecycle } from './analysisLifecycle.js' +import { ZeroReferenceCodeLensProvider } from './codeLensProvider.js' +import { getUseCodeLens, updateUseCodeLens } from './config.js' +import { getDocumentFilter } from './symbols.js' export function activate(context: ExtensionContext): void { - const codeLensProvider = new ZeroReferenceCodeLensProvider(); - const documentFilter = getDocumentFilter(); + const analyzer = new ZeroReferenceAnalyzer() + const codeLensProvider = new ZeroReferenceCodeLensProvider(analyzer) + const documentFilter = getDocumentFilter() + const refreshAnalysis = createRefreshHandler(analyzer) context.subscriptions.push( codeLensProvider, + analyzer, commands.registerCommand('zeroReference.toggleCodeLens', async () => { - const useCodeLens = getUseCodeLens(); + const useCodeLens = getUseCodeLens() - await updateUseCodeLens(!useCodeLens); + await updateUseCodeLens(!useCodeLens) }), - registerUseCodeLensListener(() => codeLensProvider.refresh()), + commands.registerCommand('zeroReference.refresh', refreshAnalysis), + registerAnalysisLifecycle(analyzer), languages.registerCodeLensProvider(documentFilter, codeLensProvider) - ); + ) +} + +/** Creates the public refresh command handler for shared analysis state. */ +export function createRefreshHandler( + analyzer: ZeroReferenceAnalyzer +): () => void { + return () => analyzer.invalidateGraph() } diff --git a/src/symbols.ts b/src/symbols.ts index 0aeb3d6..ce05d61 100644 --- a/src/symbols.ts +++ b/src/symbols.ts @@ -1,11 +1,5 @@ -import { Range, SymbolKind } from 'vscode'; -import type { - DocumentFilter, - DocumentSymbol, - Position, - SymbolInformation, - TextDocument -} from 'vscode'; +import { Range, SymbolKind } from 'vscode' +import type { DocumentFilter, DocumentSymbol, Position, SymbolInformation, TextDocument, Uri } from 'vscode' export interface SymbolData { readonly kind: SymbolKind; @@ -56,25 +50,55 @@ const supportedKinds: ReadonlyMap = new Map([ SymbolKind.Property, SymbolKind.Variable ]] -]); +]) + +const supportedFileExtensions = [ + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.jsx', + '.mjs', + '.cjs' +] as const export function getDocumentFilter(): DocumentFilter[] { - return [...supportedKinds.keys()].map(language => ({ language })); + const supportedLanguages = [...supportedKinds.keys()] + const documentFilter = supportedLanguages.map(language => ({ language })) + + return documentFilter +} + +export function isSupportedLanguage(languageId: string): boolean { + const isSupported = supportedKinds.has(languageId) + + return isSupported +} + +export function isSupportedFile(uri: Uri): boolean { + const path = uri.path.toLowerCase() + + const isSupported = supportedFileExtensions.some(extension => + path.endsWith(extension) + ) + + return isSupported } export function getSymbolData( symbols: readonly (DocumentSymbol | SymbolInformation)[], document: TextDocument ): SymbolData[] { - const symbolData: SymbolData[] = []; + const symbolData: SymbolData[] = [] for (const symbol of symbols) { if (isDocumentSymbol(symbol)) { - appendDocumentSymbols(symbolData, symbol, document); - continue; + appendDocumentSymbols(symbolData, symbol, document) + continue } - const normalizedName = normalizeSymbolName(symbol.name); + const normalizedName = normalizeSymbolName(symbol.name) if (isSymbolEligible( symbol.kind, @@ -89,17 +113,17 @@ export function getSymbolData( range: symbol.location.range, declarationRange: symbol.location.range, referencePosition: symbol.location.range.start - }); + }) } } - return symbolData; + return symbolData } function isKindSupported(kind: SymbolKind, languageId: string): boolean { - const kinds = supportedKinds.get(languageId); + const kinds = supportedKinds.get(languageId) - return kinds?.includes(kind) ?? false; + return kinds?.includes(kind) ?? false } function isSymbolEligible( @@ -109,22 +133,22 @@ function isSymbolEligible( languageId: string ): boolean { if (!isKindSupported(kind, languageId)) { - return false; + return false } if (parentKind === undefined && normalizedName === 'default') { - return false; + return false } return kind !== SymbolKind.Property || parentKind === SymbolKind.Class - || parentKind === SymbolKind.Interface; + || parentKind === SymbolKind.Interface } function isDocumentSymbol( symbol: DocumentSymbol | SymbolInformation ): symbol is DocumentSymbol { - return 'selectionRange' in symbol; + return 'selectionRange' in symbol } function appendDocumentSymbols( @@ -135,17 +159,17 @@ function appendDocumentSymbols( const pending: PendingDocumentSymbol[] = [{ parentKind: undefined, symbol: root - }]; + }] while (pending.length > 0) { - const pendingSymbol = pending.pop(); + const pendingSymbol = pending.pop() if (pendingSymbol === undefined) { - continue; + continue } - const { parentKind, symbol } = pendingSymbol; - const normalizedName = normalizeSymbolName(symbol.name); + const { parentKind, symbol } = pendingSymbol + const normalizedName = normalizeSymbolName(symbol.name) if (isSymbolEligible( symbol.kind, @@ -153,7 +177,7 @@ function appendDocumentSymbols( parentKind, document.languageId )) { - const declarationRange = getDeclarationRange(symbol, document); + const declarationRange = getDeclarationRange(symbol, document) if (declarationRange !== undefined) { destination.push({ @@ -163,15 +187,18 @@ function appendDocumentSymbols( range: symbol.range, declarationRange, referencePosition: declarationRange.start - }); + }) } } for (let index = symbol.children.length - 1; index >= 0; index -= 1) { - const child = symbol.children[index]; + const child = symbol.children[index] if (child !== undefined) { - pending.push({ parentKind: symbol.kind, symbol: child }); + pending.push({ + parentKind: symbol.kind, + symbol: child + }) } } } @@ -182,66 +209,66 @@ function getDeclarationRange( document: TextDocument ): Range | undefined { if (!symbol.selectionRange.isEqual(symbol.range)) { - return symbol.selectionRange; + return symbol.selectionRange } - const name = normalizeSymbolName(symbol.name); + const name = normalizeSymbolName(symbol.name) if (name.length === 0) { - return undefined; + return undefined } - const declarationText = document.getText(symbol.range); - const occurrenceOffsets = findStandaloneOccurrences(declarationText, name); + const declarationText = document.getText(symbol.range) + const occurrenceOffsets = findStandaloneOccurrences(declarationText, name) if (occurrenceOffsets.length !== 1) { - return undefined; + return undefined } - const occurrenceOffset = occurrenceOffsets[0]; + const occurrenceOffset = occurrenceOffsets[0] if (occurrenceOffset === undefined) { - return undefined; + return undefined } - const declarationOffset = document.offsetAt(symbol.range.start); - const nameOffset = declarationOffset + occurrenceOffset; + const declarationOffset = document.offsetAt(symbol.range.start) + const nameOffset = declarationOffset + occurrenceOffset return new Range( document.positionAt(nameOffset), document.positionAt(nameOffset + name.length) - ); + ) } function normalizeSymbolName(name: string): string { - return name.replace(/^\((?:get|set)\)\s+/, ''); + return name.replace(/^\((?:get|set)\)\s+/, '') } function findStandaloneOccurrences(text: string, name: string): number[] { - const offsets: number[] = []; - let searchOffset = 0; + const offsets: number[] = [] + let searchOffset = 0 while (searchOffset <= text.length - name.length) { - const occurrenceOffset = text.indexOf(name, searchOffset); + const occurrenceOffset = text.indexOf(name, searchOffset) if (occurrenceOffset === -1) { - break; + break } - const precedingCharacter = text[occurrenceOffset - 1]; - const followingCharacter = text[occurrenceOffset + name.length]; + const precedingCharacter = text[occurrenceOffset - 1] + const followingCharacter = text[occurrenceOffset + name.length] if (!isIdentifierCharacter(precedingCharacter) && !isIdentifierCharacter(followingCharacter)) { - offsets.push(occurrenceOffset); + offsets.push(occurrenceOffset) } - searchOffset = occurrenceOffset + name.length; + searchOffset = occurrenceOffset + name.length } - return offsets; + return offsets } function isIdentifierCharacter(character: string | undefined): boolean { - return character !== undefined && /[$_\p{ID_Continue}]/u.test(character); + return character !== undefined && /[$_\p{ID_Continue}]/u.test(character) } diff --git a/test/analysis.test.ts b/test/analysis.test.ts new file mode 100644 index 0000000..63e5ede --- /dev/null +++ b/test/analysis.test.ts @@ -0,0 +1,648 @@ +import { strict as assert } from 'node:assert' + +import { + CancellationTokenSource, + DocumentSymbol, + Location, + Position, + Range, + SymbolKind, + Uri, + WorkspaceEdit, + workspace +} from 'vscode' + +import { ZeroReferenceAnalyzer } from '../src/analysis.js' +import type { CommandExecutor } from '../src/analysis.js' + +suite('ZeroReferenceAnalyzer', () => { + test('reuses a completed analysis with the same identity', async () => { + const fixture = await createSymbolFixture(2) + + const harness = createAnalyzerHarness(fixture, position => { + const declarationRange = fixture.declarationRanges[position.line] + + assert.ok(declarationRange !== undefined) + + return [new Location(fixture.document.uri, declarationRange)] + }) + + const tokenSource = new CancellationTokenSource() + + const firstResult = await harness.analyzer.analyze( + fixture.document, + tokenSource.token + ) + + const secondResult = await harness.analyzer.analyze( + fixture.document, + tokenSource.token + ) + + assert.deepEqual(firstResult.map(finding => finding.name), ['f0', 'f1']) + assert.equal(secondResult, firstResult) + + assert.deepEqual(harness.counts, { + documentSymbolCalls: 1, + referenceCalls: 2 + }) + + assert.ok(firstResult[0]?.declarationRange.isEqual( + fixture.declarationRanges[0] ?? new Range(0, 0, 0, 0) + )) + + harness.analyzer.dispose() + tokenSource.dispose() + }) + + test('graph and configuration invalidation refresh and rerun analysis', async () => { + const fixture = await createSymbolFixture(1) + + const harness = createAnalyzerHarness(fixture, position => { + const declarationRange = fixture.declarationRanges[position.line] + + assert.ok(declarationRange !== undefined) + + return [new Location(fixture.document.uri, declarationRange)] + }) + + const tokenSource = new CancellationTokenSource() + let invalidationCount = 0 + + const subscription = harness.analyzer.onDidInvalidate(() => { + invalidationCount += 1 + }) + + await harness.analyzer.analyze(fixture.document, tokenSource.token) + harness.analyzer.invalidateGraph() + await harness.analyzer.analyze(fixture.document, tokenSource.token) + harness.analyzer.invalidateConfiguration() + await harness.analyzer.analyze(fixture.document, tokenSource.token) + + assert.equal(invalidationCount, 2) + + assert.deepEqual(harness.counts, { + documentSymbolCalls: 3, + referenceCalls: 3 + }) + + subscription.dispose() + harness.analyzer.dispose() + tokenSource.dispose() + }) + + test('document version changes rerun analysis without emitting invalidation', async () => { + const fixture = await createSymbolFixture(1) + + const harness = createAnalyzerHarness(fixture, position => { + const declarationRange = fixture.declarationRanges[position.line] + + assert.ok(declarationRange !== undefined) + + return [new Location(fixture.document.uri, declarationRange)] + }) + + const tokenSource = new CancellationTokenSource() + let invalidationCount = 0 + + const subscription = harness.analyzer.onDidInvalidate(() => { + invalidationCount += 1 + }) + + await harness.analyzer.analyze(fixture.document, tokenSource.token) + + const edit = new WorkspaceEdit() + const endPosition = fixture.document.positionAt(fixture.document.getText().length) + + edit.insert(fixture.document.uri, endPosition, '\n') + assert.equal(await workspace.applyEdit(edit), true) + await harness.analyzer.analyze(fixture.document, tokenSource.token) + + assert.equal(invalidationCount, 0) + + assert.deepEqual(harness.counts, { + documentSymbolCalls: 2, + referenceCalls: 2 + }) + + subscription.dispose() + harness.analyzer.dispose() + tokenSource.dispose() + }) + + test('explicit eviction preserves another document cached analysis', async () => { + const firstFixture = await createSymbolFixture(1) + const secondFixture = await createSymbolFixture(1) + + const harness = createAnalyzerHarness(firstFixture, (position, uri) => { + const declarationRange = firstFixture.declarationRanges[position.line] + + assert.ok(declarationRange !== undefined) + + return [new Location(uri, declarationRange)] + }) + + const tokenSource = new CancellationTokenSource() + + const firstResult = await harness.analyzer.analyze( + firstFixture.document, + tokenSource.token + ) + + const secondResult = await harness.analyzer.analyze( + secondFixture.document, + tokenSource.token + ) + + harness.analyzer.forgetDocument(firstFixture.document.uri) + + const refreshedFirstResult = await harness.analyzer.analyze( + firstFixture.document, + tokenSource.token + ) + + const cachedSecondResult = await harness.analyzer.analyze( + secondFixture.document, + tokenSource.token + ) + + assert.notEqual(refreshedFirstResult, firstResult) + assert.equal(cachedSecondResult, secondResult) + + assert.deepEqual(harness.counts, { + documentSymbolCalls: 3, + referenceCalls: 3 + }) + + harness.analyzer.dispose() + tokenSource.dispose() + }) + + test('does not cache a cancelled analysis', async () => { + const fixture = await createSymbolFixture(1) + const gate = createDeferred() + const lookupStarted = createDeferred() + + const harness = createAnalyzerHarness(fixture, async position => { + lookupStarted.resolve() + await gate.promise + + const declarationRange = fixture.declarationRanges[position.line] + + assert.ok(declarationRange !== undefined) + + return [new Location(fixture.document.uri, declarationRange)] + }) + + const cancelledTokenSource = new CancellationTokenSource() + + const resultPromise = harness.analyzer.analyze( + fixture.document, + cancelledTokenSource.token + ) + + await lookupStarted.promise + cancelledTokenSource.cancel() + gate.resolve() + + assert.deepEqual(await resultPromise, []) + + const retryTokenSource = new CancellationTokenSource() + + const retryResult = await harness.analyzer.analyze( + fixture.document, + retryTokenSource.token + ) + + assert.deepEqual(retryResult.map(finding => finding.name), ['f0']) + + assert.deepEqual(harness.counts, { + documentSymbolCalls: 2, + referenceCalls: 2 + }) + + cancelledTokenSource.dispose() + retryTokenSource.dispose() + harness.analyzer.dispose() + }) + + test('removes a cancelled analysis from the pending lookup queue', async () => { + const activeFixture = await createSymbolFixture(4) + const queuedFixture = await createSymbolFixture(4) + const activeGate = createDeferred() + + const harness = createAnalyzerHarness(activeFixture, async (position, uri) => { + assert.notEqual(uri.toString(), queuedFixture.document.uri.toString()) + await activeGate.promise + + const declarationRange = activeFixture.declarationRanges[position.line] + + assert.ok(declarationRange !== undefined) + + return [new Location(uri, declarationRange)] + }) + + const activeTokenSource = new CancellationTokenSource() + const queuedTokenSource = new CancellationTokenSource() + + const activeAnalysis = harness.analyzer.analyze( + activeFixture.document, + activeTokenSource.token + ) + + await waitUntil(() => harness.counts.referenceCalls === 4) + + const queuedAnalysis = harness.analyzer.analyze( + queuedFixture.document, + queuedTokenSource.token + ) + + await waitUntil(() => harness.counts.documentSymbolCalls === 2) + await delay(0) + + let hasQueuedAnalysisCompleted = false + + const queuedCompletion = queuedAnalysis.then(result => { + hasQueuedAnalysisCompleted = true + + return result + }) + + queuedTokenSource.cancel() + await delay(250) + + const completedBeforeRelease = hasQueuedAnalysisCompleted + + activeGate.resolve() + + const [activeResult, queuedResult] = await Promise.all([ + activeAnalysis, + queuedCompletion + ]) + + assert.equal(completedBeforeRelease, true) + assert.deepEqual(queuedResult, []) + assert.deepEqual(activeResult.map(finding => finding.name), ['f0', 'f1', 'f2', 'f3']) + assert.equal(harness.counts.referenceCalls, 4) + + harness.analyzer.dispose() + activeTokenSource.dispose() + queuedTokenSource.dispose() + }) + + test('does not cache an analysis with a reference provider error', async () => { + const fixture = await createSymbolFixture(1) + const providerError = new Error('temporary provider failure') + let shouldFail = true + + const harness = createAnalyzerHarness(fixture, position => { + if (shouldFail) { + shouldFail = false + throw providerError + } + + const declarationRange = fixture.declarationRanges[position.line] + + assert.ok(declarationRange !== undefined) + + return [new Location(fixture.document.uri, declarationRange)] + }) + + const loggedErrors: unknown[] = [] + const originalConsoleError = console.error + + Object.defineProperty(console, 'error', { + configurable: true, + + value: (error: unknown): void => { + loggedErrors.push(error) + } + }) + + try { + const tokenSource = new CancellationTokenSource() + + const firstResult = await harness.analyzer.analyze( + fixture.document, + tokenSource.token + ) + + const secondResult = await harness.analyzer.analyze( + fixture.document, + tokenSource.token + ) + + assert.deepEqual(firstResult, []) + assert.deepEqual(secondResult.map(finding => finding.name), ['f0']) + assert.deepEqual(loggedErrors, [providerError]) + + assert.deepEqual(harness.counts, { + documentSymbolCalls: 2, + referenceCalls: 2 + }) + + tokenSource.dispose() + } finally { + Object.defineProperty(console, 'error', { + configurable: true, + value: originalConsoleError + }) + + harness.analyzer.dispose() + } + }) + + test('logs and retries a document symbol provider error', async () => { + const fixture = await createSymbolFixture(1) + const providerError = new Error('temporary document symbol provider failure') + const loggedErrors: unknown[] = [] + const originalConsoleError = console.error + let documentSymbolCalls = 0 + let referenceCalls = 0 + + const executeCommand: CommandExecutor = async ( + command: string, + ...args: readonly unknown[] + ): Promise => { + if (command === 'vscode.executeDocumentSymbolProvider') { + documentSymbolCalls += 1 + + if (documentSymbolCalls === 1) { + throw providerError + } + + return fixture.symbols as Result + } + + referenceCalls += 1 + + const uri = args[0] + const position = args[1] + + assert.ok(uri instanceof Uri) + assert.ok(position instanceof Position) + + const declarationRange = fixture.declarationRanges[position.line] + + assert.ok(declarationRange !== undefined) + + return [new Location(uri, declarationRange)] as Result + } + + const analyzer = new ZeroReferenceAnalyzer(executeCommand) + const tokenSource = new CancellationTokenSource() + + Object.defineProperty(console, 'error', { + configurable: true, + + value: (error: unknown): void => { + loggedErrors.push(error) + } + }) + + try { + const firstResult = await analyzer.analyze( + fixture.document, + tokenSource.token + ) + + const secondResult = await analyzer.analyze( + fixture.document, + tokenSource.token + ) + + assert.deepEqual(firstResult, []) + assert.deepEqual(secondResult.map(finding => finding.name), ['f0']) + assert.deepEqual(loggedErrors, [providerError]) + assert.equal(documentSymbolCalls, 2) + assert.equal(referenceCalls, 1) + } finally { + Object.defineProperty(console, 'error', { + configurable: true, + value: originalConsoleError + }) + + analyzer.dispose() + tokenSource.dispose() + } + }) + + test('discards stale work after configuration invalidation', async () => { + const fixture = await createSymbolFixture(1) + const gate = createDeferred() + const lookupStarted = createDeferred() + + const harness = createAnalyzerHarness(fixture, async position => { + lookupStarted.resolve() + await gate.promise + + const declarationRange = fixture.declarationRanges[position.line] + + assert.ok(declarationRange !== undefined) + + return [new Location(fixture.document.uri, declarationRange)] + }) + + const tokenSource = new CancellationTokenSource() + + const staleResultPromise = harness.analyzer.analyze( + fixture.document, + tokenSource.token + ) + + await lookupStarted.promise + harness.analyzer.invalidateConfiguration() + gate.resolve() + + assert.deepEqual(await staleResultPromise, []) + + const retryResult = await harness.analyzer.analyze( + fixture.document, + tokenSource.token + ) + + assert.deepEqual(retryResult.map(finding => finding.name), ['f0']) + + assert.deepEqual(harness.counts, { + documentSymbolCalls: 2, + referenceCalls: 2 + }) + + harness.analyzer.dispose() + tokenSource.dispose() + }) + + test('discards stale work after graph invalidation and disposal', async () => { + const fixture = await createSymbolFixture(1) + const firstGate = createDeferred() + const secondGate = createDeferred() + const firstLookupStarted = createDeferred() + const secondLookupStarted = createDeferred() + let referenceCall = 0 + + const harness = createAnalyzerHarness(fixture, async position => { + referenceCall += 1 + + if (referenceCall === 1) { + firstLookupStarted.resolve() + await firstGate.promise + } else { + secondLookupStarted.resolve() + await secondGate.promise + } + + const declarationRange = fixture.declarationRanges[position.line] + + assert.ok(declarationRange !== undefined) + + return [new Location(fixture.document.uri, declarationRange)] + }) + + const tokenSource = new CancellationTokenSource() + + const staleResultPromise = harness.analyzer.analyze( + fixture.document, + tokenSource.token + ) + + await firstLookupStarted.promise + harness.analyzer.invalidateGraph() + firstGate.resolve() + assert.deepEqual(await staleResultPromise, []) + + const disposedResultPromise = harness.analyzer.analyze( + fixture.document, + tokenSource.token + ) + + await secondLookupStarted.promise + harness.analyzer.dispose() + secondGate.resolve() + + assert.deepEqual(await disposedResultPromise, []) + + assert.deepEqual(harness.counts, { + documentSymbolCalls: 2, + referenceCalls: 2 + }) + + tokenSource.dispose() + }) +}) + +interface AnalyzerCounts { + documentSymbolCalls: number; + referenceCalls: number; +} + +interface AnalyzerHarness { + readonly analyzer: ZeroReferenceAnalyzer; + readonly counts: AnalyzerCounts; +} + +interface SymbolFixture { + readonly declarationRanges: readonly Range[]; + readonly document: Awaited>; + readonly symbols: readonly DocumentSymbol[]; +} + +async function createSymbolFixture(count: number): Promise { + const names = Array.from({ length: count }, (_, index) => `f${index}`) + const lines = names.map(name => `function ${name}() {}`) + + const document = await workspace.openTextDocument({ + content: lines.join('\n'), + language: 'typescript' + }) + + const declarationRanges = names.map((name, index) => + new Range(index, 9, index, 9 + name.length) + ) + + const symbols = names.map((name, index) => new DocumentSymbol( + name, + '', + SymbolKind.Function, + new Range(index, 0, index, lines[index]?.length ?? 0), + declarationRanges[index] ?? new Range(index, 0, index, 0) + )) + + return { + declarationRanges, + document, + symbols + } +} + +function createAnalyzerHarness( + fixture: SymbolFixture, + getReferences: (position: Position, uri: Uri) => unknown | Promise +): AnalyzerHarness { + const counts: AnalyzerCounts = { + documentSymbolCalls: 0, + referenceCalls: 0 + } + + const executeCommand: CommandExecutor = async ( + command: string, + ...args: readonly unknown[] + ): Promise => { + if (command === 'vscode.executeDocumentSymbolProvider') { + counts.documentSymbolCalls += 1 + + return fixture.symbols as Result + } + + counts.referenceCalls += 1 + + const uri = args[0] + const position = args[1] + + assert.ok(uri instanceof Uri) + assert.ok(position instanceof Position) + + return await getReferences(position, uri) as Result + } + + const analyzer = new ZeroReferenceAnalyzer(executeCommand) + + return { + analyzer, + counts + } +} + +interface Deferred { + readonly promise: Promise; + readonly resolve: (value: Value) => void; +} + +function createDeferred(): Deferred { + let resolvePromise: ((value: Value) => void) | undefined + + const promise = new Promise(resolve => { + resolvePromise = resolve + }) + + assert.ok(resolvePromise !== undefined) + + return { + promise, + resolve: resolvePromise + } +} + +async function waitUntil(predicate: () => boolean): Promise { + const timeoutAt = Date.now() + 2_000 + + while (!predicate()) { + assert.ok(Date.now() < timeoutAt, 'timed out waiting for analysis state') + await delay(10) + } +} + +async function delay(milliseconds: number): Promise { + await new Promise(resolve => { + setTimeout(resolve, milliseconds) + }) +} diff --git a/test/analysisLifecycle.test.ts b/test/analysisLifecycle.test.ts new file mode 100644 index 0000000..56deba6 --- /dev/null +++ b/test/analysisLifecycle.test.ts @@ -0,0 +1,238 @@ +import { strict as assert } from 'node:assert' + +import { + CancellationTokenSource, + DocumentSymbol, + Location, + Position, + Range, + SymbolKind, + Uri, + WorkspaceEdit, + workspace +} from 'vscode' + +import { ZeroReferenceAnalyzer } from '../src/analysis.js' +import type { CommandExecutor } from '../src/analysis.js' +import { registerAnalysisLifecycle } from '../src/analysisLifecycle.js' + +suite('analysis lifecycle', () => { + test('invalidates supported filesystem changes and directory deletion', async () => { + const workspaceFolder = workspace.workspaceFolders?.[0] + + assert.ok(workspaceFolder !== undefined) + + const uniqueSuffix = `${Date.now()}-${Math.random().toString(16).slice(2)}` + + const supportedUri = Uri.joinPath( + workspaceFolder.uri, + `.zero-reference-lifecycle-${uniqueSuffix}.ts` + ) + + const renamedUri = Uri.joinPath( + workspaceFolder.uri, + `.zero-reference-lifecycle-${uniqueSuffix}.txt` + ) + + const unsupportedUri = Uri.joinPath( + workspaceFolder.uri, + `.zero-reference-unsupported-${uniqueSuffix}.txt` + ) + + const directoryUri = Uri.joinPath( + workspaceFolder.uri, + `.zero-reference-directory-${uniqueSuffix}` + ) + + const nestedUri = Uri.joinPath(directoryUri, 'nested.ts') + const executeCommand: CommandExecutor = async () => [] as Result + const analyzer = new ZeroReferenceAnalyzer(executeCommand) + const lifecycle = registerAnalysisLifecycle(analyzer) + let invalidationCount = 0 + + const subscription = analyzer.onDidInvalidate(() => { + invalidationCount += 1 + }) + + try { + let previousInvalidationCount = invalidationCount + + await workspace.fs.writeFile( + supportedUri, + Buffer.from('export const value = 1;\n') + ) + + await waitUntil(() => invalidationCount > previousInvalidationCount) + await delay(50) + previousInvalidationCount = invalidationCount + + await workspace.fs.writeFile( + supportedUri, + Buffer.from('export const value = 2;\n') + ) + + await waitUntil(() => invalidationCount > previousInvalidationCount) + await delay(50) + previousInvalidationCount = invalidationCount + + await workspace.fs.rename(supportedUri, renamedUri) + await waitUntil(() => invalidationCount > previousInvalidationCount) + await delay(50) + previousInvalidationCount = invalidationCount + + await workspace.fs.rename(renamedUri, supportedUri) + await waitUntil(() => invalidationCount > previousInvalidationCount) + await delay(50) + previousInvalidationCount = invalidationCount + + await workspace.fs.writeFile( + unsupportedUri, + Buffer.from('unsupported\n') + ) + + await delay(100) + assert.equal(invalidationCount, previousInvalidationCount) + + await workspace.fs.createDirectory(directoryUri) + await waitUntil(() => invalidationCount > previousInvalidationCount) + await delay(50) + previousInvalidationCount = invalidationCount + + await workspace.fs.writeFile( + nestedUri, + Buffer.from('export const nested = true;\n') + ) + + await waitUntil(() => invalidationCount > previousInvalidationCount) + await delay(50) + previousInvalidationCount = invalidationCount + + await workspace.fs.delete(directoryUri, { recursive: true }) + await waitUntil(() => invalidationCount > previousInvalidationCount) + await delay(50) + previousInvalidationCount = invalidationCount + + await workspace.fs.delete(supportedUri) + await waitUntil(() => invalidationCount > previousInvalidationCount) + } finally { + subscription.dispose() + lifecycle.dispose() + analyzer.dispose() + await deleteIfPresent(supportedUri) + await deleteIfPresent(renamedUri) + await deleteIfPresent(unsupportedUri) + await deleteIfPresent(directoryUri) + } + }) + + test('invalidates cached findings after an unsaved cross-document edit', async () => { + const declarationDocument = await workspace.openTextDocument({ + content: 'export const value = 1;\n', + language: 'typescript' + }) + + const consumerDocument = await workspace.openTextDocument({ + content: '', + language: 'typescript' + }) + + const declarationRange = new Range(0, 13, 0, 18) + + const declarationSymbol = new DocumentSymbol( + 'value', + '', + SymbolKind.Variable, + new Range(0, 0, 0, 23), + declarationRange + ) + + let hasConsumerReference = false + let documentSymbolCalls = 0 + let referenceCalls = 0 + + const executeCommand: CommandExecutor = async ( + command: string + ): Promise => { + if (command === 'vscode.executeDocumentSymbolProvider') { + documentSymbolCalls += 1 + + return [declarationSymbol] as Result + } + + referenceCalls += 1 + + const locations = [ + new Location(declarationDocument.uri, declarationRange) + ] + + if (hasConsumerReference) { + locations.push(new Location(consumerDocument.uri, new Range(0, 0, 0, 5))) + } + + return locations as Result + } + + const analyzer = new ZeroReferenceAnalyzer(executeCommand) + const lifecycle = registerAnalysisLifecycle(analyzer) + const tokenSource = new CancellationTokenSource() + let invalidationCount = 0 + + const subscription = analyzer.onDidInvalidate(() => { + invalidationCount += 1 + }) + + try { + const initialResult = await analyzer.analyze( + declarationDocument, + tokenSource.token + ) + + assert.deepEqual(initialResult.map(finding => finding.name), ['value']) + + hasConsumerReference = true + + const addReference = new WorkspaceEdit() + + addReference.insert(consumerDocument.uri, new Position(0, 0), 'value') + assert.equal(await workspace.applyEdit(addReference), true) + await waitUntil(() => invalidationCount > 0) + + const refreshedResult = await analyzer.analyze( + declarationDocument, + tokenSource.token + ) + + assert.deepEqual(refreshedResult, []) + assert.equal(documentSymbolCalls, 2) + assert.equal(referenceCalls, 2) + } finally { + subscription.dispose() + lifecycle.dispose() + analyzer.dispose() + tokenSource.dispose() + } + }) +}) + +async function deleteIfPresent(uri: Uri): Promise { + try { + await workspace.fs.delete(uri, { recursive: true }) + } catch { + // The test may already have deleted or renamed the temporary resource. + } +} + +async function waitUntil(predicate: () => boolean): Promise { + const timeoutAt = Date.now() + 2_000 + + while (!predicate()) { + assert.ok(Date.now() < timeoutAt, 'timed out waiting for workspace event') + await delay(10) + } +} + +async function delay(milliseconds: number): Promise { + await new Promise(resolve => { + setTimeout(resolve, milliseconds) + }) +} diff --git a/test/codeLensProvider.test.ts b/test/codeLensProvider.test.ts index dc1df97..b7469d1 100644 --- a/test/codeLensProvider.test.ts +++ b/test/codeLensProvider.test.ts @@ -1,4 +1,5 @@ -import { strict as assert } from 'node:assert'; +import { strict as assert } from 'node:assert' + import { CancellationTokenSource, DocumentSymbol, @@ -9,110 +10,134 @@ import { Uri, WorkspaceEdit, workspace -} from 'vscode'; -import { - ZeroReferenceCodeLensProvider -} from '../src/codeLensProvider.js'; -import type { CommandExecutor } from '../src/codeLensProvider.js'; +} from 'vscode' + +import { ZeroReferenceAnalyzer } from '../src/analysis.js' +import type { CommandExecutor } from '../src/analysis.js' +import { ZeroReferenceCodeLensProvider } from '../src/codeLensProvider.js' suite('ZeroReferenceCodeLensProvider', () => { test('rejects absent, empty, external, and unknown reference results', async () => { - const fixture = await createSymbolFixture(1); + const fixture = await createSymbolFixture(1) + const declaration = new Location( fixture.document.uri, fixture.declarationRanges[0] ?? new Range(0, 0, 0, 0) - ); + ) + const cases: readonly (readonly [string, unknown])[] = [ ['undefined', undefined], ['empty', []], ['external URI', [new Location(Uri.file('/outside.ts'), declaration.range)]], ['unknown range', [new Location(fixture.document.uri, new Range(0, 0, 0, 1))]] - ]; + ] for (const [label, references] of cases) { - const provider = createProvider(fixture.symbols, () => references); - const tokenSource = new CancellationTokenSource(); - const result = await provider.provideCodeLenses(fixture.document, tokenSource.token); + const provider = createProvider(fixture.symbols, () => references) + const tokenSource = new CancellationTokenSource() + const result = await provider.provideCodeLenses(fixture.document, tokenSource.token) - assert.deepEqual(result, [], label); - provider.dispose(); - tokenSource.dispose(); + assert.deepEqual(result, [], label) + provider.dispose() + tokenSource.dispose() } - }); + }) test('logs reference errors once and creates no lenses', async () => { - const fixture = await createSymbolFixture(6); - const error = new Error('reference provider failed'); - const loggedErrors: unknown[] = []; - const originalConsoleError = console.error; + const fixture = await createSymbolFixture(6) + const error = new Error('reference provider failed') + const loggedErrors: unknown[] = [] + const originalConsoleError = console.error + const provider = createProvider(fixture.symbols, () => { - throw error; - }); + throw error + }) Object.defineProperty(console, 'error', { configurable: true, + value: (loggedError: unknown): void => { - loggedErrors.push(loggedError); + loggedErrors.push(loggedError) } - }); + }) try { const result = await provider.provideCodeLenses( fixture.document, new CancellationTokenSource().token - ); + ) - assert.deepEqual(result, []); - assert.deepEqual(loggedErrors, [error]); + assert.deepEqual(result, []) + assert.deepEqual(loggedErrors, [error]) } finally { Object.defineProperty(console, 'error', { configurable: true, value: originalConsoleError - }); - provider.dispose(); + }) + + provider.dispose() } - }); + }) test('creates one lens only for declaration-only locations', async () => { - const fixture = await createSymbolFixture(1); - const declarationRange = fixture.declarationRanges[0]; + const fixture = await createSymbolFixture(1) + const declarationRange = fixture.declarationRanges[0] - assert.ok(declarationRange !== undefined); + assert.ok(declarationRange !== undefined) const provider = createProvider( fixture.symbols, () => [new Location(fixture.document.uri, declarationRange)] - ); + ) + const result = await provider.provideCodeLenses( fixture.document, new CancellationTokenSource().token - ); + ) + + assert.equal(result.length, 1) + assert.equal(result[0]?.command?.title, '"f0" has zero references') + provider.dispose() + }) + + test('emits a CodeLens refresh after analysis invalidation', async () => { + const fixture = await createSymbolFixture(1) + const provider = createProvider(fixture.symbols, () => []) + let refreshCount = 0 + + const subscription = provider.onDidChangeCodeLenses(() => { + refreshCount += 1 + }) - assert.equal(result.length, 1); - assert.equal(result[0]?.command?.title, '"f0" has zero references'); - provider.dispose(); - }); + provider.refresh() + + assert.equal(refreshCount, 1) + subscription.dispose() + provider.dispose() + }) test('rejects used symbols and recursive self-calls', async () => { - const fixture = await createSymbolFixture(2); + const fixture = await createSymbolFixture(2) + const provider = createProvider(fixture.symbols, position => { - const declarationRange = fixture.declarationRanges[position.line]; + const declarationRange = fixture.declarationRanges[position.line] - assert.ok(declarationRange !== undefined); + assert.ok(declarationRange !== undefined) return [ new Location(fixture.document.uri, declarationRange), new Location(fixture.document.uri, new Range(position.line, 20, position.line, 22)) - ]; - }); + ] + }) + const result = await provider.provideCodeLenses( fixture.document, new CancellationTokenSource().token - ); + ) - assert.deepEqual(result, []); - provider.dispose(); - }); + assert.deepEqual(result, []) + provider.dispose() + }) test('deduplicates same-file overload and merged declaration groups', async () => { const fixture = await createNamedSymbolFixture([ @@ -121,185 +146,213 @@ suite('ZeroReferenceCodeLensProvider', () => { 'overloaded', 'MergedModel', 'MergedModel' - ]); - const firstGroup = fixture.declarationRanges.slice(0, 3); - const secondGroup = fixture.declarationRanges.slice(3); + ]) + + const firstGroup = fixture.declarationRanges.slice(0, 3) + const secondGroup = fixture.declarationRanges.slice(3) + const provider = createProvider(fixture.symbols, position => { - const ranges = position.line < 3 ? firstGroup : secondGroup; + const ranges = position.line < 3 ? firstGroup : secondGroup + + return ranges.map(range => new Location(fixture.document.uri, range)) + }) - return ranges.map(range => new Location(fixture.document.uri, range)); - }); const result = await provider.provideCodeLenses( fixture.document, new CancellationTokenSource().token - ); + ) assert.deepEqual( result.map(codeLens => codeLens.command?.title), ['"overloaded" has zero references', '"MergedModel" has zero references'] - ); - provider.dispose(); - }); + ) + + provider.dispose() + }) test('rejects an entire declaration group when it has an external reference', async () => { - const fixture = await createSymbolFixture(3); + const fixture = await createSymbolFixture(3) + const provider = createProvider(fixture.symbols, () => [ ...fixture.declarationRanges.map(range => new Location(fixture.document.uri, range)), new Location(Uri.file('/consumer.ts'), new Range(0, 0, 0, 2)) - ]); + ]) + const result = await provider.provideCodeLenses( fixture.document, new CancellationTokenSource().token - ); + ) - assert.deepEqual(result, []); - provider.dispose(); - }); + assert.deepEqual(result, []) + provider.dispose() + }) test('does not guess a computed member after an unresolved lookup', async () => { const document = await workspace.openTextDocument({ content: 'class Example { ["literal"](): void {} }\nnew Example()["literal"]();', language: 'typescript' - }); - const nameRange = new Range(0, 16, 0, 27); + }) + + const nameRange = new Range(0, 16, 0, 27) + const symbol = new DocumentSymbol( '["literal"]', '', SymbolKind.Method, new Range(0, 0, 0, 41), nameRange - ); - const provider = createProvider([symbol], () => []); + ) + + const provider = createProvider([symbol], () => []) + const result = await provider.provideCodeLenses( document, new CancellationTokenSource().token - ); + ) - assert.deepEqual(result, []); - provider.dispose(); - }); + assert.deepEqual(result, []) + provider.dispose() + }) test('shares four lookup slots across documents and preserves symbol order', async () => { - const firstFixture = await createSymbolFixture(10); - const secondFixture = await createSymbolFixture(10); - let activeLookups = 0; - let maximumActiveLookups = 0; + const firstFixture = await createSymbolFixture(10) + const secondFixture = await createSymbolFixture(10) + let activeLookups = 0 + let maximumActiveLookups = 0 + const provider = createProvider(firstFixture.symbols, async (position, uri) => { - activeLookups += 1; - maximumActiveLookups = Math.max(maximumActiveLookups, activeLookups); + activeLookups += 1 + maximumActiveLookups = Math.max(maximumActiveLookups, activeLookups) + + await delay((10 - position.line) % 4) + + activeLookups -= 1 + + const declarationRange = firstFixture.declarationRanges[position.line] + + assert.ok(declarationRange !== undefined) - await delay((10 - position.line) % 4); + return [new Location(uri, declarationRange)] + }) - activeLookups -= 1; - const declarationRange = firstFixture.declarationRanges[position.line]; + const tokenSource = new CancellationTokenSource() - assert.ok(declarationRange !== undefined); - return [new Location(uri, declarationRange)]; - }); - const tokenSource = new CancellationTokenSource(); const results = await Promise.all([ provider.provideCodeLenses(firstFixture.document, tokenSource.token), provider.provideCodeLenses(secondFixture.document, tokenSource.token) - ]); + ]) + const expectedTitles = Array.from( { length: 10 }, (_, index) => `"f${index}" has zero references` - ); + ) + + assert.equal(maximumActiveLookups, 4) - assert.equal(maximumActiveLookups, 4); assert.deepEqual(results.map(result => result.map(codeLens => codeLens.command?.title) - ), [expectedTitles, expectedTitles]); - provider.dispose(); - tokenSource.dispose(); - }); + ), [expectedTitles, expectedTitles]) + + provider.dispose() + tokenSource.dispose() + }) test('does not dequeue more symbols after cancellation', async () => { - const fixture = await createSymbolFixture(10); - const tokenSource = new CancellationTokenSource(); - const gate = createDeferred(); - let referenceCalls = 0; + const fixture = await createSymbolFixture(10) + const tokenSource = new CancellationTokenSource() + const gate = createDeferred() + let referenceCalls = 0 + const provider = createProvider(fixture.symbols, async position => { - referenceCalls += 1; - await gate.promise; + referenceCalls += 1 + await gate.promise - const declarationRange = fixture.declarationRanges[position.line]; + const declarationRange = fixture.declarationRanges[position.line] - assert.ok(declarationRange !== undefined); - return [new Location(fixture.document.uri, declarationRange)]; - }); - const resultPromise = provider.provideCodeLenses(fixture.document, tokenSource.token); + assert.ok(declarationRange !== undefined) - await waitUntil(() => referenceCalls === 4); - tokenSource.cancel(); - gate.resolve(); + return [new Location(fixture.document.uri, declarationRange)] + }) - assert.deepEqual(await resultPromise, []); - assert.equal(referenceCalls, 4); - provider.dispose(); - tokenSource.dispose(); - }); + const resultPromise = provider.provideCodeLenses(fixture.document, tokenSource.token) + + await waitUntil(() => referenceCalls === 4) + tokenSource.cancel() + gate.resolve() + + assert.deepEqual(await resultPromise, []) + assert.equal(referenceCalls, 4) + provider.dispose() + tokenSource.dispose() + }) test('discards a deferred lookup when refresh advances the generation', async () => { - const fixture = await createSymbolFixture(1); - const declarationRange = fixture.declarationRanges[0]; + const fixture = await createSymbolFixture(1) + const declarationRange = fixture.declarationRanges[0] - assert.ok(declarationRange !== undefined); + assert.ok(declarationRange !== undefined) + + const gate = createDeferred() + const lookupStarted = createDeferred() - const gate = createDeferred(); - const lookupStarted = createDeferred(); const provider = createProvider(fixture.symbols, async () => { - lookupStarted.resolve(); - await gate.promise; - return [new Location(fixture.document.uri, declarationRange)]; - }); + lookupStarted.resolve() + await gate.promise + + return [new Location(fixture.document.uri, declarationRange)] + }) + const resultPromise = provider.provideCodeLenses( fixture.document, new CancellationTokenSource().token - ); + ) - await lookupStarted.promise; - provider.refresh(); - gate.resolve(); + await lookupStarted.promise + provider.refresh() + gate.resolve() - assert.deepEqual(await resultPromise, []); - provider.dispose(); - }); + assert.deepEqual(await resultPromise, []) + provider.dispose() + }) test('discards a deferred lookup after the document changes', async () => { - const fixture = await createSymbolFixture(1); - const declarationRange = fixture.declarationRanges[0]; + const fixture = await createSymbolFixture(1) + const declarationRange = fixture.declarationRanges[0] + + assert.ok(declarationRange !== undefined) - assert.ok(declarationRange !== undefined); + const gate = createDeferred() + const lookupStarted = createDeferred() - const gate = createDeferred(); - const lookupStarted = createDeferred(); const provider = createProvider(fixture.symbols, async () => { - lookupStarted.resolve(); - await gate.promise; - return [new Location(fixture.document.uri, declarationRange)]; - }); - const tokenSource = new CancellationTokenSource(); - const initialVersion = fixture.document.version; + lookupStarted.resolve() + await gate.promise + + return [new Location(fixture.document.uri, declarationRange)] + }) + + const tokenSource = new CancellationTokenSource() + const initialVersion = fixture.document.version + const resultPromise = provider.provideCodeLenses( fixture.document, tokenSource.token - ); + ) - await lookupStarted.promise; + await lookupStarted.promise - const edit = new WorkspaceEdit(); + const edit = new WorkspaceEdit() - edit.insert(fixture.document.uri, new Position(0, 0), ' '); - assert.equal(await workspace.applyEdit(edit), true); - assert.ok(fixture.document.version > initialVersion); - gate.resolve(); + edit.insert(fixture.document.uri, new Position(0, 0), ' ') + assert.equal(await workspace.applyEdit(edit), true) + assert.ok(fixture.document.version > initialVersion) + gate.resolve() - assert.deepEqual(await resultPromise, []); - provider.dispose(); - tokenSource.dispose(); - }); -}); + assert.deepEqual(await resultPromise, []) + provider.dispose() + tokenSource.dispose() + }) +}) interface SymbolFixture { readonly document: Awaited>; @@ -308,54 +361,79 @@ interface SymbolFixture { } async function createSymbolFixture(count: number): Promise { - const names = Array.from({ length: count }, (_, index) => `f${index}`); + const names = Array.from({ length: count }, (_, index) => `f${index}`) - return createNamedSymbolFixture(names); + return createNamedSymbolFixture(names) } async function createNamedSymbolFixture( names: readonly string[] ): Promise { - const lines = names.map(name => `function ${name}() {}`); + const lines = names.map(name => `function ${name}() {}`) + const document = await workspace.openTextDocument({ content: lines.join('\n'), language: 'typescript' - }); + }) + const declarationRanges = names.map((name, index) => new Range(index, 9, index, 9 + name.length) - ); + ) + const symbols = names.map((name, index) => new DocumentSymbol( name, '', SymbolKind.Function, new Range(index, 0, index, lines[index]?.length ?? 0), declarationRanges[index] ?? new Range(index, 0, index, 0) - )); + )) - return { document, symbols, declarationRanges }; + return { + document, + symbols, + declarationRanges + } } function createProvider( symbols: readonly DocumentSymbol[], getReferences: (position: Position, uri: Uri) => unknown | Promise -): ZeroReferenceCodeLensProvider { +): TestCodeLensProvider { const executeCommand: CommandExecutor = async ( command: string, ...args: readonly unknown[] ): Promise => { if (command === 'vscode.executeDocumentSymbolProvider') { - return symbols as Result; + return symbols as Result } - const uri = args[0]; - const position = args[1]; + const uri = args[0] + const position = args[1] + + assert.ok(uri instanceof Uri) + assert.ok(position instanceof Position) + + return await getReferences(position, uri) as Result + } - assert.ok(uri instanceof Uri); - assert.ok(position instanceof Position); - return await getReferences(position, uri) as Result; - }; + const analyzer = new ZeroReferenceAnalyzer(executeCommand) - return new ZeroReferenceCodeLensProvider(executeCommand, () => true); + return new TestCodeLensProvider(analyzer) +} + +class TestCodeLensProvider extends ZeroReferenceCodeLensProvider { + constructor(private readonly testAnalyzer: ZeroReferenceAnalyzer) { + super(testAnalyzer, () => true) + } + + refresh(): void { + this.testAnalyzer.invalidateGraph() + } + + override dispose(): void { + super.dispose() + this.testAnalyzer.dispose() + } } interface Deferred { @@ -364,23 +442,28 @@ interface Deferred { } function createDeferred(): Deferred { - let resolvePromise: ((value: Value) => void) | undefined; + let resolvePromise: ((value: Value) => void) | undefined + const promise = new Promise(resolve => { - resolvePromise = resolve; - }); + resolvePromise = resolve + }) + + assert.ok(resolvePromise !== undefined) - assert.ok(resolvePromise !== undefined); - return { promise, resolve: resolvePromise }; + return { + promise, + resolve: resolvePromise + } } async function waitUntil(predicate: () => boolean): Promise { while (!predicate()) { - await delay(0); + await delay(0) } } async function delay(milliseconds: number): Promise { await new Promise(resolve => { - setTimeout(resolve, milliseconds); - }); + setTimeout(resolve, milliseconds) + }) } diff --git a/test/config.test.ts b/test/config.test.ts index 2389173..f859afc 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,71 +1,65 @@ -import { strict as assert } from 'node:assert'; -import { - commands, - ConfigurationTarget, - extensions, - workspace -} from 'vscode'; -import { - registerUseCodeLensListener -} from '../src/config.js'; +import { strict as assert } from 'node:assert' +import { commands, ConfigurationTarget, extensions, workspace } from 'vscode' +import { registerUseCodeLensListener } from '../src/config.js' suite('useCodeLens configuration', () => { suiteSetup(async () => { const extension = extensions.all.find(candidate => candidate.packageJSON.name === 'zero-reference' - ); + ) - assert.ok(extension !== undefined); - await extension.activate(); - }); + assert.ok(extension !== undefined) + await extension.activate() + }) teardown(async () => { - const configuration = workspace.getConfiguration('zeroReference'); + const configuration = workspace.getConfiguration('zeroReference') - await configuration.update('useCodeLens', undefined, ConfigurationTarget.Workspace); - await configuration.update('useCodeLens', undefined, ConfigurationTarget.Global); - }); + await configuration.update('useCodeLens', undefined, ConfigurationTarget.Workspace) + await configuration.update('useCodeLens', undefined, ConfigurationTarget.Global) + }) test('toggle command preserves and updates an existing workspace override', async () => { - const configuration = workspace.getConfiguration('zeroReference'); + const configuration = workspace.getConfiguration('zeroReference') - await configuration.update('useCodeLens', false, ConfigurationTarget.Global); - await configuration.update('useCodeLens', true, ConfigurationTarget.Workspace); + await configuration.update('useCodeLens', false, ConfigurationTarget.Global) + await configuration.update('useCodeLens', true, ConfigurationTarget.Workspace) - await commands.executeCommand('zeroReference.toggleCodeLens'); + await commands.executeCommand('zeroReference.toggleCodeLens') - let inspection = configuration.inspect('useCodeLens'); + let inspection = configuration.inspect('useCodeLens') - assert.equal(inspection?.globalValue, false); - assert.equal(inspection?.workspaceValue, false); + assert.equal(inspection?.globalValue, false) + assert.equal(inspection?.workspaceValue, false) - await commands.executeCommand('zeroReference.toggleCodeLens'); + await commands.executeCommand('zeroReference.toggleCodeLens') - inspection = configuration.inspect('useCodeLens'); - assert.equal(inspection?.globalValue, false); - assert.equal(inspection?.workspaceValue, true); - }); + inspection = configuration.inspect('useCodeLens') + assert.equal(inspection?.globalValue, false) + assert.equal(inspection?.workspaceValue, true) + }) test('a direct setting update emits exactly one refresh notification', async () => { - const configuration = workspace.getConfiguration('zeroReference'); - const change = createDeferred(); - let refreshCount = 0; + const configuration = workspace.getConfiguration('zeroReference') + const change = createDeferred() + let refreshCount = 0 + const listener = registerUseCodeLensListener(() => { - refreshCount += 1; - change.resolve(); - }); + refreshCount += 1 + change.resolve() + }) try { - await configuration.update('useCodeLens', false, ConfigurationTarget.Workspace); - await change.promise; - await delay(25); + await configuration.update('useCodeLens', false, ConfigurationTarget.Workspace) + await change.promise + await delay(25) - assert.equal(refreshCount, 1); + assert.equal(refreshCount, 1) } finally { - listener.dispose(); + listener.dispose() } - }); -}); + }) +}) interface Deferred { readonly promise: Promise; @@ -73,17 +67,22 @@ interface Deferred { } function createDeferred(): Deferred { - let resolvePromise: ((value: Value) => void) | undefined; + let resolvePromise: ((value: Value) => void) | undefined + const promise = new Promise(resolve => { - resolvePromise = resolve; - }); + resolvePromise = resolve + }) + + assert.ok(resolvePromise !== undefined) - assert.ok(resolvePromise !== undefined); - return { promise, resolve: resolvePromise }; + return { + promise, + resolve: resolvePromise + } } async function delay(milliseconds: number): Promise { await new Promise(resolve => { - setTimeout(resolve, milliseconds); - }); + setTimeout(resolve, milliseconds) + }) } diff --git a/test/extension.test.ts b/test/extension.test.ts index 5469f50..1bc932d 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -1,39 +1,66 @@ -import { strict as assert } from 'node:assert'; -import { - commands, - extensions, - Uri, - workspace -} from 'vscode'; -import type { CodeLens } from 'vscode'; +import { strict as assert } from 'node:assert' +import { commands, extensions, Uri, workspace } from 'vscode' +import type { CodeLens } from 'vscode' +import { ZeroReferenceAnalyzer } from '../src/analysis.js' +import type { CommandExecutor } from '../src/analysis.js' +import { createRefreshHandler } from '../src/extension.js' suite('extension integration', () => { + test('registers the manual refresh command', async () => { + const extension = extensions.all.find(candidate => + candidate.packageJSON.name === 'zero-reference' + ) + + assert.ok(extension !== undefined) + await extension.activate() + await commands.executeCommand('zeroReference.refresh') + }) + + test('manual refresh handler invalidates analysis state', () => { + const executeCommand: CommandExecutor = async () => [] as Result + const analyzer = new ZeroReferenceAnalyzer(executeCommand) + const refreshAnalysis = createRefreshHandler(analyzer) + let invalidationCount = 0 + + const subscription = analyzer.onDidInvalidate(() => { + invalidationCount += 1 + }) + + refreshAnalysis() + + assert.equal(invalidationCount, 1) + subscription.dispose() + analyzer.dispose() + }) + test('classifies the TypeScript fixture conservatively', async () => { const extension = extensions.all.find(candidate => candidate.packageJSON.name === 'zero-reference' - ); - const workspaceFolder = workspace.workspaceFolders?.[0]; + ) - assert.ok(extension !== undefined); - assert.ok(workspaceFolder !== undefined); - await extension.activate(); + const workspaceFolder = workspace.workspaceFolders?.[0] - const documentUri = Uri.joinPath(workspaceFolder.uri, 'fixture.ts'); + assert.ok(extension !== undefined) + assert.ok(workspaceFolder !== undefined) + await extension.activate() - await workspace.openTextDocument(documentUri); + const documentUri = Uri.joinPath(workspaceFolder.uri, 'fixture.ts') + + await workspace.openTextDocument(documentUri) const codeLenses = await commands.executeCommand( 'vscode.executeCodeLensProvider', documentUri - ); + ) + const titles = codeLenses?.flatMap(codeLens => codeLens.command === undefined ? [] : [codeLens.command.title] - ) ?? []; + ) ?? [] - assert.equal(titles.filter(title => title.includes('MergedModel')).length, 1); - assert.equal(titles.filter(title => title.includes('overloaded')).length, 1); - assert.equal(titles.some(title => title.includes('["literal"]')), false); - assert.equal(titles.some(title => title.includes('shorthandValue')), false); + assert.equal(titles.filter(title => title.includes('MergedModel')).length, 1) + assert.equal(titles.filter(title => title.includes('overloaded')).length, 1) + assert.equal(titles.some(title => title.includes('["literal"]')), false) + assert.equal(titles.some(title => title.includes('shorthandValue')), false) const ignoredTitles = [ '"Bindings" has zero references', @@ -42,15 +69,15 @@ suite('extension integration', () => { '"status" has zero references', '"error" has zero references', '"default" has zero references' - ]; + ] for (const ignoredTitle of ignoredTitles) { - assert.equal(titles.includes(ignoredTitle), false, ignoredTitle); + assert.equal(titles.includes(ignoredTitle), false, ignoredTitle) } assert.equal( titles.includes('"preservedClassProperty" has zero references'), true - ); - }); -}); + ) + }) +}) diff --git a/test/fixtures/workspace/fixture.ts b/test/fixtures/workspace/fixture.ts index 4c9c2b9..785720e 100644 --- a/test/fixtures/workspace/fixture.ts +++ b/test/fixtures/workspace/fixture.ts @@ -6,21 +6,21 @@ export interface MergedModel { label: string; } -export function overloaded(value: string): string; -export function overloaded(value: number): number; +export function overloaded(value: string): string +export function overloaded(value: number): number export function overloaded(value: string | number): string | number { - return value; + return value } export class ComputedMember { ["literal"](): void {} } -new ComputedMember()["literal"](); +new ComputedMember()["literal"]() -export const shorthandValue = 1; +export const shorthandValue = 1 -export const shorthandObject = { shorthandValue }; +export const shorthandObject = { shorthandValue } interface ContractEnvironment { REGISTRY_ENVIRONMENT: string; @@ -32,11 +32,11 @@ interface ContractContext { } declare class ContractApp { - get(path: string, handler: (context: ContractContext) => unknown): void; - notFound(handler: (context: ContractContext) => unknown): void; + get(path: string, handler: (context: ContractContext) => unknown): void + notFound(handler: (context: ContractContext) => unknown): void } -const contractApp = new ContractApp<{ Bindings: ContractEnvironment }>(); +const contractApp = new ContractApp<{ Bindings: ContractEnvironment }>() contractApp.get('/api/health', context => context.json({ @@ -44,14 +44,14 @@ contractApp.get('/api/health', context => runtime: 'workerd', status: 'ok' }) -); +) contractApp.notFound(context => context.json({ error: 'Not found' }, 404) -); +) export class PreservedClassMember { - readonly preservedClassProperty = true; + readonly preservedClassProperty = true } -export default contractApp; +export default contractApp diff --git a/test/symbols.test.ts b/test/symbols.test.ts index a6fcebb..3908688 100644 --- a/test/symbols.test.ts +++ b/test/symbols.test.ts @@ -1,169 +1,182 @@ -import { strict as assert } from 'node:assert'; -import { - DocumentSymbol, - Location, - Position, - Range, - SymbolInformation, - SymbolKind, - workspace -} from 'vscode'; -import { getSymbolData } from '../src/symbols.js'; +import { strict as assert } from 'node:assert' +import { DocumentSymbol, Location, Position, Range, SymbolInformation, SymbolKind, Uri, workspace } from 'vscode' +import { getSymbolData, isSupportedFile, isSupportedLanguage } from '../src/symbols.js' suite('symbol classification', () => { test('preserves preorder across nested and unsupported symbols', async () => { const document = await workspace.openTextDocument({ content: 'root child nested sibling info', language: 'typescript' - }); - const root = createSymbol('root', SymbolKind.Function, 0, 4); - const unsupportedChild = createSymbol('child', SymbolKind.String, 5, 10); - const nested = createSymbol('nested', SymbolKind.Method, 11, 17); - const sibling = createSymbol('sibling', SymbolKind.Variable, 18, 25); + }) + + const root = createSymbol('root', SymbolKind.Function, 0, 4) + const unsupportedChild = createSymbol('child', SymbolKind.String, 5, 10) + const nested = createSymbol('nested', SymbolKind.Method, 11, 17) + const sibling = createSymbol('sibling', SymbolKind.Variable, 18, 25) + const information = new SymbolInformation( 'info', SymbolKind.Interface, '', new Location(document.uri, range(26, 30)) - ); + ) - unsupportedChild.children.push(nested); - root.children.push(unsupportedChild, sibling); + unsupportedChild.children.push(nested) + root.children.push(unsupportedChild, sibling) - const result = getSymbolData([root, information], document); + const result = getSymbolData([root, information], document) assert.deepEqual(result.map(symbol => symbol.name), [ 'root', 'nested', 'sibling', 'info' - ]); - assert.ok(result[0]?.declarationRange.isEqual(range(0, 4))); - assert.ok(result[3]?.declarationRange.isEqual(range(26, 30))); - }); + ]) + + assert.ok(result[0]?.declarationRange.isEqual(range(0, 4))) + assert.ok(result[3]?.declarationRange.isEqual(range(26, 30))) + }) test('walks a deep symbol tree without using the call stack', async () => { const document = await workspace.openTextDocument({ content: 'node', language: 'typescript' - }); - const root = createSymbol('node', SymbolKind.String, 0, 4); - let parent = root; + }) + + const root = createSymbol('node', SymbolKind.String, 0, 4) + let parent = root for (let index = 0; index < 20_000; index += 1) { - const childKind = index === 19_999 ? SymbolKind.Method : SymbolKind.String; - const child = createSymbol('node', childKind, 0, 4); + const childKind = index === 19_999 ? SymbolKind.Method : SymbolKind.String + const child = createSymbol('node', childKind, 0, 4) - parent.children.push(child); - parent = child; + parent.children.push(child) + parent = child } - const result = getSymbolData([root], document); + const result = getSymbolData([root], document) - assert.equal(result.length, 1); - assert.equal(result[0]?.name, 'node'); - }); + assert.equal(result.length, 1) + assert.equal(result[0]?.name, 'node') + }) test('recovers exactly one normalized name from a full declaration range', async () => { const document = await workspace.openTextDocument({ content: 'function foo(value: string): string;\nclass Example { get value(): string; }', language: 'typescript' - }); - const overloadRange = new Range(0, 0, 0, 36); + }) + + const overloadRange = new Range(0, 0, 0, 36) + const overload = new DocumentSymbol( 'foo', '', SymbolKind.Function, overloadRange, overloadRange - ); + ) + const classSymbol = new DocumentSymbol( 'Example', '', SymbolKind.Class, new Range(1, 0, 1, 38), new Range(1, 6, 1, 13) - ); - const getterRange = new Range(1, 16, 1, 36); + ) + + const getterRange = new Range(1, 16, 1, 36) + const getter = new DocumentSymbol( '(get) value', '', SymbolKind.Property, getterRange, getterRange - ); + ) - classSymbol.children.push(getter); + classSymbol.children.push(getter) - const result = getSymbolData([overload, classSymbol], document); + const result = getSymbolData([overload, classSymbol], document) - assert.equal(result.length, 3); - assert.ok(result[0]?.declarationRange.isEqual(new Range(0, 9, 0, 12))); - assert.ok(result[2]?.declarationRange.isEqual(new Range(1, 20, 1, 25))); - }); + assert.equal(result.length, 3) + assert.ok(result[0]?.declarationRange.isEqual(new Range(0, 9, 0, 12))) + assert.ok(result[2]?.declarationRange.isEqual(new Range(1, 20, 1, 25))) + }) test('keeps member properties and omits contract properties', async () => { const content = [ 'variableContainer variableProperty functionContainer functionProperty', 'classContainer classProperty interfaceContainer interfaceProperty', 'default flatProperty' - ].join('\n'); + ].join('\n') + const document = await workspace.openTextDocument({ content, language: 'typescript' - }); + }) + const createNamedSymbol = ( name: string, kind: SymbolKind ): DocumentSymbol => { - const nameOffset = content.indexOf(name); + const nameOffset = content.indexOf(name) - assert.notEqual(nameOffset, -1); + assert.notEqual(nameOffset, -1) const nameRange = new Range( document.positionAt(nameOffset), document.positionAt(nameOffset + name.length) - ); + ) + + return new DocumentSymbol(name, '', kind, nameRange, nameRange) + } - return new DocumentSymbol(name, '', kind, nameRange, nameRange); - }; const variableContainer = createNamedSymbol( 'variableContainer', SymbolKind.Variable - ); + ) + const functionContainer = createNamedSymbol( 'functionContainer', SymbolKind.Function - ); - const classContainer = createNamedSymbol('classContainer', SymbolKind.Class); + ) + + const classContainer = createNamedSymbol('classContainer', SymbolKind.Class) + const interfaceContainer = createNamedSymbol( 'interfaceContainer', SymbolKind.Interface - ); - const defaultExport = createNamedSymbol('default', SymbolKind.Variable); + ) + + const defaultExport = createNamedSymbol('default', SymbolKind.Variable) + const flatPropertyRange = createNamedSymbol( 'flatProperty', SymbolKind.Property - ).range; + ).range + const flatProperty = new SymbolInformation( 'flatProperty', SymbolKind.Property, '', new Location(document.uri, flatPropertyRange) - ); + ) variableContainer.children.push( createNamedSymbol('variableProperty', SymbolKind.Property) - ); + ) + functionContainer.children.push( createNamedSymbol('functionProperty', SymbolKind.Property) - ); + ) + classContainer.children.push( createNamedSymbol('classProperty', SymbolKind.Property) - ); + ) + interfaceContainer.children.push( createNamedSymbol('interfaceProperty', SymbolKind.Property) - ); + ) const result = getSymbolData([ variableContainer, @@ -172,7 +185,7 @@ suite('symbol classification', () => { interfaceContainer, defaultExport, flatProperty - ], document); + ], document) assert.deepEqual(result.map(symbol => symbol.name), [ 'variableContainer', @@ -181,26 +194,53 @@ suite('symbol classification', () => { 'classProperty', 'interfaceContainer', 'interfaceProperty' - ]); - }); + ]) + }) test('omits a full declaration range with multiple name candidates', async () => { const document = await workspace.openTextDocument({ content: 'function foo(foo: string): string;', language: 'typescript' - }); - const declarationRange = new Range(0, 0, 0, 34); + }) + + const declarationRange = new Range(0, 0, 0, 34) + const symbol = new DocumentSymbol( 'foo', '', SymbolKind.Function, declarationRange, declarationRange - ); + ) + + assert.deepEqual(getSymbolData([symbol], document), []) + }) + + test('recognizes supported language ids and file extensions', () => { + const supportedExtensions = [ + 'ts', + 'tsx', + 'mts', + 'cts', + 'js', + 'jsx', + 'mjs', + 'cjs' + ] + + assert.equal(isSupportedLanguage('typescript'), true) + assert.equal(isSupportedLanguage('typescriptreact'), true) + assert.equal(isSupportedLanguage('javascript'), true) + assert.equal(isSupportedLanguage('javascriptreact'), true) + assert.equal(isSupportedLanguage('json'), false) + + for (const extension of supportedExtensions) { + assert.equal(isSupportedFile(Uri.file(`/workspace/file.${extension}`)), true) + } - assert.deepEqual(getSymbolData([symbol], document), []); - }); -}); + assert.equal(isSupportedFile(Uri.file('/workspace/file.json')), false) + }) +}) function createSymbol( name: string, @@ -208,11 +248,11 @@ function createSymbol( start: number, end: number ): DocumentSymbol { - const symbolRange = range(start, end); + const symbolRange = range(start, end) - return new DocumentSymbol(name, '', kind, symbolRange, symbolRange); + return new DocumentSymbol(name, '', kind, symbolRange, symbolRange) } function range(start: number, end: number): Range { - return new Range(new Position(0, start), new Position(0, end)); + return new Range(new Position(0, start), new Position(0, end)) }