diff --git a/.vscode-test.mjs b/.vscode-test.mjs index e29a563..fac462d 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,13 +1,52 @@ import { defineConfig } from '@vscode/test-cli' +import { cpSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' -export default defineConfig({ - files: 'build-test/test/**/*.test.js', +// Tests update settings and workspace folders only in disposable copies of the fixtures. +const temporaryRoot = process.platform === 'darwin' ? '/tmp' : tmpdir() +const fixtureDirectory = mkdtempSync(join(temporaryRoot, 'zero-reference-tests-')) +const singleRoot = join(fixtureDirectory, 'single-root') +const multiRoot = join(fixtureDirectory, 'multi-root') + +cpSync(new URL('./test/fixtures/workspace', import.meta.url), singleRoot, { recursive: true }) +cpSync(new URL('./test/fixtures/multi-root', import.meta.url), multiRoot, { recursive: true }) + +for (const folder of [singleRoot, join(multiRoot, 'first'), join(multiRoot, 'second')]) { + mkdirSync(join(folder, '.vscode'), { recursive: true }) +} + +process.on('exit', () => rmSync(fixtureDirectory, { + recursive: true, + force: true +})) + +const shared = { version: '1.125.0', - workspaceFolder: './test/fixtures/workspace', - launchArgs: ['--disable-extensions'], + launchArgs: ['--disable-extensions', '--new-window'], mocha: { failZero: true, timeout: 60_000 } -}) +} + +export default defineConfig([ + { + ...shared, + label: 'single-root', + files: 'build-test/test/*.test.js', + workspaceFolder: singleRoot + }, + { + ...shared, + label: 'multi-root', + files: 'build-test/test/multiRoot/*.test.js', + workspaceFolder: join(multiRoot, 'workspace.code-workspace') + }, + { + ...shared, + label: 'no-workspace', + files: 'build-test/test/noWorkspace/*.test.js' + } +]) diff --git a/README.md b/README.md index 4ac8e53..51d3cfd 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,38 @@ Conservative Matching Properties are analyzed only when Visual Studio Code reports them as direct class or interface members. Default export assignments are also ignored because their consumers can live outside the static reference graph. +Zero references describes the current workspace reference graph, not proof that code is unused everywhere. External consumers and dynamic calls may not be visible to the language provider. + +Exclusions and Suppression +-------------------------- + +Use `zeroReference.exclude` to skip declarations in selected files: + +```json +{ + "zeroReference.exclude": ["generated/**", "**/*.generated.ts"] +} +``` + +Patterns use VS Code glob syntax and are relative to the file's workspace folder, including in multi-root workspaces. User, workspace, and folder settings follow VS Code's normal precedence. The default is an empty array; `files.exclude` is not applied automatically. Files outside a workspace folder are not excluded. References **from** excluded files still count when analyzing other files. + +To suppress one declaration, place this comment immediately before its declaration line: + +```ts +// zero-reference-ignore-next-line +export function externalEntryPoint() {} +``` + +Place the comment after JSDoc and before the first decorator, if present. Blank lines or other comments between the directive and declaration break the suppression. Suppressing a class or namespace does not suppress its members; suppressing an overload or merged declaration hides its whole confirmed declaration group. + +If declaration keywords span multiple lines (for example, `export const` followed by the variable name on the next line), place the directive before the first keyword. Quick Fix uses that same line and its indentation, not the variable name's line. + +With the cursor on a reported declaration's name, open VS Code's Quick Fix menu and choose **Zero Reference: Ignore this symbol**. The action uses current cached findings and inserts the comment without saving the file. Undo the edit or remove the comment to restore analysis. No action is offered while analysis is pending, when CodeLens is disabled, or when the selection covers multiple findings. + +Suppression requires an unambiguous declaration-line boundary. Multiple declarations on the same line and declarations embedded in expressions must be moved onto separate declaration lines before suppression or the Quick Fix is available. Directive text inside strings, JSX text, or block comments is not a suppression comment. + +Invalid exclusion settings are reported in the **Zero Reference** output channel. Invalid array entries are skipped; a non-array value falls back to no exclusions. + Installation ------------ diff --git a/package.json b/package.json index afe8eb3..fb08191 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,16 @@ "configuration": { "title": "Zero Reference configuration", "properties": { + "zeroReference.exclude": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "default": [], + "scope": "resource", + "description": "Skip declarations in files matching these glob patterns relative to their workspace folder. References from excluded files still count." + }, "zeroReference.useCodeLens": { "type": "boolean", "default": true, diff --git a/src/analysis.ts b/src/analysis.ts index 4c78fee..5f3abcb 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -13,6 +13,8 @@ import type { import { getSymbolData } from './symbols.js' import type { SymbolData } from './symbols.js' +import { isDocumentExcluded } from './config.js' +import { getSuppressionLines, hasSuppressionComment } from './suppression.js' const referenceConcurrency = 4 @@ -27,6 +29,7 @@ export interface ZeroReferenceFinding { readonly declarationRange: Range; readonly name: string; readonly range: Range; + readonly suppressionLine?: number | undefined; } interface AnalysisIdentity { @@ -62,9 +65,28 @@ export class ZeroReferenceAnalyzer implements Disposable { readonly onDidInvalidate = this.invalidationEventEmitter.event constructor( - private readonly executeCommand: CommandExecutor = defaultCommandExecutor + private readonly executeCommand: CommandExecutor = defaultCommandExecutor, + private readonly reportConfigurationWarning: (message: string) => void = console.warn ) {} + /** Reads only current completed analysis; editor actions must not trigger reference lookups. */ + getCachedFindings( + document: TextDocument, + token: CancellationToken + ): readonly ZeroReferenceFinding[] { + const identity = this.createIdentity(document) + const documentKey = document.uri.toString() + const cachedAnalysis = this.cache.get(documentKey) + + if (cachedAnalysis === undefined + || !hasSameIdentity(cachedAnalysis, identity) + || !this.isAnalysisCurrent(document, identity, token)) { + return [] + } + + return cachedAnalysis.findings + } + async analyze( document: TextDocument, token: CancellationToken @@ -83,6 +105,10 @@ export class ZeroReferenceAnalyzer implements Disposable { return cachedAnalysis.findings } + if (isDocumentExcluded(document, this.reportConfigurationWarning)) { + return [] + } + let hasLoggedError = false let hasProviderError = false @@ -117,12 +143,26 @@ export class ZeroReferenceAnalyzer implements Disposable { const symbolData = getSymbolData(symbols, document) const declarationIdentitiesByRange = getDeclarationIdentitiesByRange(symbolData) + const suppressionLines = getSuppressionLines(symbols, document) + const suppressedRanges = new Set() + + for (const symbol of symbolData) { + const line = suppressionLines.get(symbol.range.start.line) + + if (line !== undefined && hasSuppressionComment(document, line)) { + const rangeKey = getRangeKey(symbol.declarationRange) + + suppressedRanges.add(rangeKey) + } + } const candidates = await this.findCandidates( document, identity, symbolData, declarationIdentitiesByRange, + suppressionLines, + suppressedRanges, token, logErrorOnce ) @@ -193,6 +233,8 @@ export class ZeroReferenceAnalyzer implements Disposable { identity: AnalysisIdentity, symbols: readonly SymbolData[], declarationIdentitiesByRange: ReadonlyMap>, + suppressionLines: ReadonlyMap, + suppressedRanges: ReadonlySet, token: CancellationToken, logErrorOnce: (error: unknown) => void ): Promise { @@ -225,6 +267,12 @@ export class ZeroReferenceAnalyzer implements Disposable { return } + const symbolRangeKey = getRangeKey(symbol.declarationRange) + + if (suppressedRanges.has(symbolRangeKey)) { + continue + } + const releaseReferenceLookup = await this.referenceLookupLimiter.acquire( isCurrent, token @@ -265,6 +313,7 @@ export class ZeroReferenceAnalyzer implements Disposable { document.uri, locations, declarationIdentitiesByRange, + suppressedRanges, fallbackDeclarationIdentity ) @@ -272,13 +321,16 @@ export class ZeroReferenceAnalyzer implements Disposable { continue } + const suppressionLine = suppressionLines.get(symbol.range.start.line) + candidates[symbolIndex] = { declarationGroupKey, finding: { declarationRange: symbol.declarationRange, name: symbol.name, - range: symbol.range + range: symbol.range, + suppressionLine } } } @@ -364,6 +416,7 @@ function getDeclarationGroupKey( documentUri: Uri, locations: readonly Location[] | undefined, declarationIdentitiesByRange: ReadonlyMap>, + suppressedRanges: ReadonlySet, declarationIdentityKey: string ): string | undefined { if (locations === undefined || locations.length === 0) { @@ -377,6 +430,7 @@ function getDeclarationGroupKey( const declarationIdentities = declarationIdentitiesByRange.get(locationKey) if (location.uri.toString() !== documentUri.toString() + || suppressedRanges.has(locationKey) || !declarationIdentities?.has(declarationIdentityKey)) { return } diff --git a/src/analysisLifecycle.ts b/src/analysisLifecycle.ts index d35e1ef..f459a67 100644 --- a/src/analysisLifecycle.ts +++ b/src/analysisLifecycle.ts @@ -1,7 +1,7 @@ import { Disposable, FileType, workspace } from 'vscode' import type { Uri } from 'vscode' import type { ZeroReferenceAnalyzer } from './analysis.js' -import { registerUseCodeLensListener } from './config.js' +import { registerAnalysisConfigurationListener } from './config.js' import { isSupportedFile, isSupportedLanguage } from './symbols.js' /** Connects workspace and configuration changes to shared analysis state. */ @@ -11,7 +11,8 @@ export function registerAnalysisLifecycle( const fileWatcher = workspace.createFileSystemWatcher('**') const subscriptions = [ - registerUseCodeLensListener(() => analyzer.invalidateConfiguration()), + registerAnalysisConfigurationListener(() => analyzer.invalidateConfiguration()), + workspace.onDidChangeWorkspaceFolders(() => analyzer.invalidateConfiguration()), workspace.onDidChangeTextDocument(event => { const hasContentChanges = event.contentChanges.length > 0 const hasSupportedLanguage = isSupportedLanguage(event.document.languageId) diff --git a/src/codeActionProvider.ts b/src/codeActionProvider.ts new file mode 100644 index 0000000..3a33ff4 --- /dev/null +++ b/src/codeActionProvider.ts @@ -0,0 +1,54 @@ +import { CodeAction, CodeActionKind, EndOfLine, Position, WorkspaceEdit } from 'vscode' +import type { CancellationToken, CodeActionContext, CodeActionProvider, Range, TextDocument } from 'vscode' +import type { ZeroReferenceAnalyzer } from './analysis.js' +import { getUseCodeLens } from './config.js' +import { suppressionComment } from './suppression.js' + +export class ZeroReferenceCodeActionProvider implements CodeActionProvider { + constructor( + private readonly analyzer: ZeroReferenceAnalyzer, + private readonly isEnabled: () => boolean = getUseCodeLens + ) {} + + provideCodeActions( + document: TextDocument, + range: Range, + context: CodeActionContext, + token: CancellationToken + ): CodeAction[] { + if (!this.isEnabled() + || token.isCancellationRequested + || (context.only !== undefined && !context.only.contains(CodeActionKind.QuickFix))) { + return [] + } + + const findings = this.analyzer.getCachedFindings(document, token) + + const matchingFindings = findings.filter(finding => { + const intersection = finding.declarationRange.intersection(range) + + return intersection !== undefined && (range.isEmpty || !intersection.isEmpty) + }) + + const finding = matchingFindings[0] + + if (matchingFindings.length !== 1 || finding?.suppressionLine === undefined) { + return [] + } + + const line = document.lineAt(finding.suppressionLine) + const indentation = line.text.slice(0, line.firstNonWhitespaceCharacterIndex) + const newline = document.eol === EndOfLine.CRLF ? '\r\n' : '\n' + const text = `${indentation}${suppressionComment}${newline}` + const position = new Position(finding.suppressionLine, 0) + const edit = new WorkspaceEdit() + + edit.insert(document.uri, position, text) + + const action = new CodeAction('Zero Reference: Ignore this symbol', CodeActionKind.QuickFix) + + action.edit = edit + + return [action] + } +} diff --git a/src/config.ts b/src/config.ts index 791cfbd..726d33a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,56 @@ -import { ConfigurationTarget, workspace } from 'vscode' -import type { Disposable } from 'vscode' +import { ConfigurationTarget, languages, RelativePattern, workspace } from 'vscode' +import type { Disposable, TextDocument } from 'vscode' const useCodeLensKey = 'useCodeLens' const useCodeLensConfigurationKey = `zeroReference.${useCodeLensKey}` +const excludeConfigurationKey = 'zeroReference.exclude' + +export function isDocumentExcluded( + document: TextDocument, + reportWarning: (message: string) => void +): boolean { + const folder = workspace.getWorkspaceFolder(document.uri) + + if (folder === undefined) { + return false + } + + const configuration = workspace.getConfiguration('zeroReference', document.uri) + const value = configuration.get('exclude', []) + const patterns = parseExcludePatterns(value, reportWarning) + + return patterns.some(pattern => { + const relativePattern = new RelativePattern(folder, pattern) + const score = languages.match({ pattern: relativePattern }, document) + + return score > 0 + }) +} + +/** Validates user settings without discarding valid exclusions alongside invalid entries. */ +export function parseExcludePatterns( + value: unknown, + reportWarning: (message: string) => void +): readonly string[] { + if (!Array.isArray(value)) { + reportWarning('zeroReference.exclude must be an array of glob patterns; using no exclusions.') + + return [] + } + + const patterns: string[] = [] + + for (const [index, pattern] of value.entries()) { + if (typeof pattern !== 'string' || pattern.trim().length === 0) { + reportWarning(`zeroReference.exclude[${index}] must be a non-empty string; ignoring this entry.`) + continue + } + + patterns.push(pattern) + } + + return patterns +} export function getUseCodeLens(): boolean { return workspace @@ -21,9 +69,10 @@ export async function updateUseCodeLens(value: boolean): Promise { await configuration.update(useCodeLensKey, value, target) } -export function registerUseCodeLensListener(onChange: () => void): Disposable { +export function registerAnalysisConfigurationListener(onChange: () => void): Disposable { return workspace.onDidChangeConfiguration(event => { - if (event.affectsConfiguration(useCodeLensConfigurationKey)) { + if (event.affectsConfiguration(useCodeLensConfigurationKey) + || event.affectsConfiguration(excludeConfigurationKey)) { onChange() } }) diff --git a/src/extension.ts b/src/extension.ts index 2f43336..147d106 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,20 +1,25 @@ -import { commands, languages } from 'vscode' +import { CodeActionKind, commands, languages, window } from 'vscode' import type { ExtensionContext } from 'vscode' import { ZeroReferenceAnalyzer } from './analysis.js' import { registerAnalysisLifecycle } from './analysisLifecycle.js' import { ZeroReferenceCodeLensProvider } from './codeLensProvider.js' +import { ZeroReferenceCodeActionProvider } from './codeActionProvider.js' import { getUseCodeLens, updateUseCodeLens } from './config.js' import { getDocumentFilter } from './symbols.js' export function activate(context: ExtensionContext): void { - const analyzer = new ZeroReferenceAnalyzer() + const outputChannel = window.createOutputChannel('Zero Reference', { log: true }) + const reportConfigurationWarning = outputChannel.warn.bind(outputChannel) + const analyzer = new ZeroReferenceAnalyzer(undefined, reportConfigurationWarning) const codeLensProvider = new ZeroReferenceCodeLensProvider(analyzer) + const codeActionProvider = new ZeroReferenceCodeActionProvider(analyzer) const documentFilter = getDocumentFilter() const refreshAnalysis = createRefreshHandler(analyzer) context.subscriptions.push( codeLensProvider, analyzer, + outputChannel, commands.registerCommand('zeroReference.toggleCodeLens', async () => { const useCodeLens = getUseCodeLens() @@ -22,7 +27,10 @@ export function activate(context: ExtensionContext): void { }), commands.registerCommand('zeroReference.refresh', refreshAnalysis), registerAnalysisLifecycle(analyzer), - languages.registerCodeLensProvider(documentFilter, codeLensProvider) + languages.registerCodeLensProvider(documentFilter, codeLensProvider), + languages.registerCodeActionsProvider(documentFilter, codeActionProvider, { + providedCodeActionKinds: [CodeActionKind.QuickFix] + }) ) } diff --git a/src/suppression.ts b/src/suppression.ts new file mode 100644 index 0000000..987c3a2 --- /dev/null +++ b/src/suppression.ts @@ -0,0 +1,193 @@ +import { SymbolKind } from 'vscode' +import type { DocumentSymbol, Position, SymbolInformation, TextDocument } from 'vscode' + +export const suppressionComment = '// zero-reference-ignore-next-line' + +interface CodeLine { + readonly line: number; + readonly text: string; +} + +const variablePrefix = /^\s*(?:export\s+)?(?:declare\s+)?(?:const|let|var|using|await\s+using)\s+$/ +const variableStart = /^[\t ]*(?:export[\t ]+)?(?:declare[\t ]+)?(?:const|let|var|using|await[\t ]+using)[\t ]+/ +const declarationKeywords = new Set(['export', 'declare', 'await', 'const', 'let', 'var', 'using']) + +/** Maps provider symbol-start lines to declaration lines shared by directives and Quick Fix edits. */ +export function getSuppressionLines( + symbols: readonly (DocumentSymbol | SymbolInformation)[], + document: TextDocument +): ReadonlyMap { + const countsByLine = new Map() + const linesBySymbolLine = new Map() + const pending = Array.from(symbols) + + while (pending.length > 0) { + const symbol = pending.pop() + + if (symbol === undefined) { + continue + } + + const range = 'selectionRange' in symbol ? symbol.range : symbol.location.range + const line = range.start.line + const count = countsByLine.get(line) ?? 0 + + countsByLine.set(line, count + 1) + + const declarationLine = getDeclarationLine(document, range.start, symbol.kind) + const isStatementStart = declarationLine !== undefined && hasStatementBoundary(document, declarationLine) + + if (isStatementStart) { + linesBySymbolLine.set(line, declarationLine) + } + + if ('children' in symbol) { + for (const child of symbol.children) { + pending.push(child) + } + } + } + + for (const [line, count] of countsByLine) { + if (count !== 1) { + linesBySymbolLine.delete(line) + } + } + + return linesBySymbolLine +} + +function getDeclarationLine(document: TextDocument, start: Position, kind: SymbolKind): number | undefined { + let line = start.line + const lineText = document.lineAt(line).text + let prefix = lineText.slice(0, start.character) + const startsLine = prefix.trim().length === 0 + + if (kind !== SymbolKind.Variable) { + return startsLine ? line : undefined + } + + // Flat providers may include the keywords; TS/JS variable spans begin at the binding instead. + if (startsLine) { + const keywords = variableStart.exec(lineText) + + prefix = keywords?.[0] ?? prefix + } + + // Recover only whitespace-separated keywords, never an initializer or another declarator. + while (line > 0) { + const previousText = document.lineAt(line - 1).text + const trimmedText = previousText.trim() + const words = trimmedText.split(/\s+/) + const onlyKeywords = words.every(word => declarationKeywords.has(word)) + + if (!onlyKeywords) { + break + } + + prefix = `${previousText}\n${prefix}` + line -= 1 + } + + const hasVariableKeywords = variablePrefix.test(prefix) + + return hasVariableKeywords ? line : undefined +} + +function hasStatementBoundary(document: TextDocument, line: number): boolean { + const previous = getPreviousCodeLine(document, line) + + if (previous === undefined) { + return true + } + + // Mixed code/comments cannot prove a boundary without interpreting their lexical context. + const hasMixedComments = /\/\/|\/\*|\*\//.test(previous.text) + + if (hasMixedComments) { + return false + } + + const opensBody = previous.text.endsWith('{') + + if (opensBody) { + const beforeBrace = previous.text.slice(0, -1) + let header = beforeBrace.trimEnd() + + if (header.length === 0) { + const previousHeader = getPreviousCodeLine(document, previous.line) + + header = previousHeader?.text ?? '' + } + + // Recognize block bodies, but not object literals, JSX braces or template interpolations. + const bodyHeader = /(?:=>|\)(?:\s*:\s*[^{}]+)?)$/.test(header) + || /\b(?:class|interface|namespace|module)\b[^{};=]*$/.test(header) + || /^(?:try|else|do|finally)$/.test(header) + + return bodyHeader + } + + const continuesExpression = /[([=,:?+*/%&|^!~<>.\-]$/.test(previous.text) + || /\b(?:return|throw|yield|await|new|in|instanceof|typeof|void|delete|extends|of|case)$/.test(previous.text) + + return !continuesExpression +} + +function getPreviousCodeLine(document: TextDocument, beforeLine: number): CodeLine | undefined { + let inBlockComment = false + + for (let line = beforeLine - 1; line >= 0; line -= 1) { + const lineText = document.lineAt(line).text + let text = lineText.trim() + + while (text.length > 0) { + if (inBlockComment) { + const opening = text.lastIndexOf('/*') + + if (opening === -1) { + break + } + + const beforeComment = text.slice(0, opening) + + text = beforeComment.trimEnd() + inBlockComment = false + continue + } + + const isLineComment = text.startsWith('//') + + if (isLineComment) { + break + } + + const endsBlockComment = text.endsWith('*/') + + if (endsBlockComment) { + const beforeClosing = text.slice(0, -2) + + text = beforeClosing.trimEnd() + inBlockComment = true + continue + } + + return { + line, + text + } + } + } + + return +} + +export function hasSuppressionComment(document: TextDocument, line: number): boolean { + if (line === 0) { + return false + } + + const previousLine = document.lineAt(line - 1).text.trim() + + return previousLine === suppressionComment +} diff --git a/test/analysisHarness.ts b/test/analysisHarness.ts new file mode 100644 index 0000000..e1cd999 --- /dev/null +++ b/test/analysisHarness.ts @@ -0,0 +1,79 @@ +import { strict as assert } from 'node:assert' +import { CancellationTokenSource, commands, Location, Position } from 'vscode' +import type { DocumentSymbol, SymbolInformation, TextDocument } from 'vscode' +import { ZeroReferenceAnalyzer } from '../src/analysis.js' +import type { CommandExecutor } from '../src/analysis.js' +import { getSymbolData } from '../src/symbols.js' +import type { SymbolData } from '../src/symbols.js' + +type ReferenceLookup = ( + symbol: SymbolData, + symbols: readonly SymbolData[] +) => readonly Location[] | Promise + +interface AnalysisCounts { + documentSymbolCalls: number; + readonly referenceNames: string[]; +} + +/** Uses real language-provider ranges with controllable references and observable lookup counts. */ +export function createAnalysisHarness(document: TextDocument, getReferences?: ReferenceLookup) { + const counts: AnalysisCounts = { + documentSymbolCalls: 0, + referenceNames: [] + } + + const warnings: string[] = [] + let symbols: readonly SymbolData[] = [] + + const executeCommand: CommandExecutor = async ( + command: string, + ...args: readonly unknown[] + ): Promise => { + if (command === 'vscode.executeDocumentSymbolProvider') { + counts.documentSymbolCalls += 1 + + const result = await commands.executeCommand<(DocumentSymbol | SymbolInformation)[]>(command, document.uri) + + symbols = getSymbolData(result ?? [], document) + + return result as Result + } + + assert.equal(command, 'vscode.executeReferenceProvider') + + const position = args[1] + + assert.ok(position instanceof Position) + + const symbol = symbols.find(candidate => candidate.referencePosition.isEqual(position)) + + assert.ok(symbol !== undefined, `missing symbol at ${position.line}:${position.character}`) + counts.referenceNames.push(symbol.normalizedName) + + const references = getReferences === undefined + ? [new Location(document.uri, symbol.declarationRange)] + : await getReferences(symbol, symbols) + + return references as Result + } + + const reportWarning = (message: string): void => { + warnings.push(message) + } + + const analyzer = new ZeroReferenceAnalyzer(executeCommand, reportWarning) + const cancellation = new CancellationTokenSource() + + return { + analyzer, + cancellation, + counts, + warnings, + + dispose(): void { + analyzer.dispose() + cancellation.dispose() + } + } +} diff --git a/test/codeActionProvider.test.ts b/test/codeActionProvider.test.ts new file mode 100644 index 0000000..9d353bf --- /dev/null +++ b/test/codeActionProvider.test.ts @@ -0,0 +1,265 @@ +import { strict as assert } from 'node:assert' + +import { + CancellationTokenSource, + CodeActionKind, + CodeActionTriggerKind, + Location, + Position, + Range, + WorkspaceEdit, + workspace +} from 'vscode' + +import type { CodeActionContext } from 'vscode' +import { ZeroReferenceCodeActionProvider } from '../src/codeActionProvider.js' +import { createAnalysisHarness } from './analysisHarness.js' + +const context: CodeActionContext = { + diagnostics: [], + only: undefined, + triggerKind: CodeActionTriggerKind.Invoke +} + +suite('ZeroReferenceCodeActionProvider', () => { + for (const newline of ['\n', '\r\n']) { + test(`inserts before split declaration keywords with ${JSON.stringify(newline)}`, async () => { + const content = ['namespace Owner {', ' export', ' const', ' entry = 1;', '}'].join(newline) + + const document = await workspace.openTextDocument({ + content, + language: 'typescript' + }) + + const harness = createAnalysisHarness(document) + const provider = new ZeroReferenceCodeActionProvider(harness.analyzer) + const selection = new Range(3, 5, 3, 5) + + try { + await harness.analyzer.analyze(document, harness.cancellation.token) + + const actions = provider.provideCodeActions(document, selection, context, harness.cancellation.token) + const edit = actions[0]?.edit + + assert.equal(actions.length, 1) + assert.ok(edit !== undefined) + + const insertion = edit.get(document.uri)[0] + + assert.equal(insertion?.newText, ` // zero-reference-ignore-next-line${newline}`) + assert.ok(insertion.range.isEqual(new Range(1, 0, 1, 0))) + assert.equal(harness.counts.documentSymbolCalls, 1) + assert.equal(harness.counts.referenceNames.length, 2) + assert.equal(await workspace.applyEdit(edit), true) + assert.equal(document.getText(), ['namespace Owner {', ' // zero-reference-ignore-next-line', ' export', ' const', ' entry = 1;', '}'].join(newline)) + + const suppressed = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(suppressed.map(finding => finding.name), ['Owner']) + assert.equal(harness.counts.referenceNames.length, 3) + + const undo = new WorkspaceEdit() + + undo.delete(document.uri, new Range(1, 0, 2, 0)) + assert.equal(await workspace.applyEdit(undo), true) + assert.equal(document.getText(), content) + await harness.analyzer.analyze(document, harness.cancellation.token) + assert.equal(provider.provideCodeActions(document, selection, context, harness.cancellation.token).length, 1) + assert.equal(harness.counts.referenceNames.length, 5) + } finally { + harness.dispose() + } + }) + } + + for (const [opening, closing, language] of [ + ['const outer = (', ');', 'typescript'], + ['consume(', ');', 'typescript'], + ['const outer = [', '];', 'javascript'], + ['const outer = typeof', ';', 'typescript'], + ['const outer = void', ';', 'javascript'], + ['const markup =
{', '}
;', 'typescriptreact'], + ['const text = `${', '}`;', 'typescript'] + ]) { + test(`does not offer an edit inside ${opening}`, async () => { + assert.ok(opening !== undefined && closing !== undefined && language !== undefined) + + const content = [opening, ' /** Callback documentation. */', ' function entry() {}', closing].join('\n') + + const document = await workspace.openTextDocument({ + content, + language + }) + + const harness = createAnalysisHarness(document) + const provider = new ZeroReferenceCodeActionProvider(harness.analyzer) + const selection = new Range(2, 12, 2, 12) + + try { + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.ok(findings.some(finding => finding.name === 'entry')) + assert.deepEqual(provider.provideCodeActions(document, selection, context, harness.cancellation.token), []) + + const calls = harness.counts.referenceNames.length + const directive = new WorkspaceEdit() + + directive.insert(document.uri, new Position(2, 0), ' // zero-reference-ignore-next-line\n') + assert.equal(await workspace.applyEdit(directive), true) + + const stillVisible = await harness.analyzer.analyze(document, harness.cancellation.token) + const shiftedSelection = new Range(3, 12, 3, 12) + + assert.ok(stillVisible.some(finding => finding.name === 'entry')) + assert.deepEqual(provider.provideCodeActions(document, shiftedSelection, context, harness.cancellation.token), []) + assert.equal(harness.counts.documentSymbolCalls, 2) + assert.equal(harness.counts.referenceNames.length, calls * 2) + } finally { + harness.dispose() + } + }) + } + + for (const newline of ['\n', '\r\n']) { + test(`inserts an undoable suppression with original indentation and ${JSON.stringify(newline)}`, async () => { + const content = ['class Owner {', '\tmember = 1;', '}'].join(newline) + + const document = await workspace.openTextDocument({ + content, + language: 'typescript' + }) + + const harness = createAnalysisHarness(document) + const provider = new ZeroReferenceCodeActionProvider(harness.analyzer) + const selection = new Range(1, 2, 1, 2) + + try { + assert.deepEqual(provider.provideCodeActions(document, selection, context, harness.cancellation.token), []) + assert.equal(harness.counts.documentSymbolCalls, 0) + assert.equal(harness.counts.referenceNames.length, 0) + + await harness.analyzer.analyze(document, harness.cancellation.token) + + const actions = provider.provideCodeActions(document, selection, context, harness.cancellation.token) + + assert.equal(actions.length, 1) + + const action = actions[0] + + assert.equal(action?.title, 'Zero Reference: Ignore this symbol') + assert.equal(action.kind, CodeActionKind.QuickFix) + assert.equal(action.isPreferred, undefined) + assert.equal(action.command, undefined) + assert.ok(action.edit !== undefined) + + const edits = action.edit.get(document.uri) + + assert.equal(edits.length, 1) + assert.equal(edits[0]?.newText, `\t// zero-reference-ignore-next-line${newline}`) + assert.ok(edits[0]?.range.isEqual(new Range(1, 0, 1, 0))) + assert.equal(harness.counts.documentSymbolCalls, 1) + assert.equal(harness.counts.referenceNames.length, 2) + assert.equal(await workspace.applyEdit(action.edit), true) + assert.equal(document.getText(), ['class Owner {', '\t// zero-reference-ignore-next-line', '\tmember = 1;', '}'].join(newline)) + assert.equal(document.isDirty, true) + + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(findings.map(finding => finding.name), ['Owner']) + assert.deepEqual(provider.provideCodeActions(document, new Range(2, 2, 2, 2), context, harness.cancellation.token), []) + + const undo = new WorkspaceEdit() + + undo.delete(document.uri, new Range(1, 0, 2, 0)) + assert.equal(await workspace.applyEdit(undo), true) + assert.equal(document.getText(), content) + await harness.analyzer.analyze(document, harness.cancellation.token) + assert.equal(provider.provideCodeActions(document, selection, context, harness.cancellation.token).length, 1) + } finally { + harness.dispose() + } + }) + } + + test('requires one current finding, an enabled display, a safe line, and Quick Fix context', async () => { + const document = await workspace.openTextDocument({ + content: 'function first() {}\nfunction second() {}\nconst compactA = 1, compactB = 2;', + language: 'typescript' + }) + + const harness = createAnalysisHarness(document) + let enabled = true + const provider = new ZeroReferenceCodeActionProvider(harness.analyzer, () => enabled) + const selection = new Range(0, 10, 0, 10) + + try { + await harness.analyzer.analyze(document, harness.cancellation.token) + assert.equal(provider.provideCodeActions(document, selection, context, harness.cancellation.token).length, 1) + assert.deepEqual(provider.provideCodeActions(document, new Range(0, 0, 0, 8), context, harness.cancellation.token), []) + assert.deepEqual(provider.provideCodeActions(document, new Range(0, 0, 1, 20), context, harness.cancellation.token), []) + assert.deepEqual(provider.provideCodeActions(document, new Range(2, 7, 2, 7), context, harness.cancellation.token), []) + + const refactorContext: CodeActionContext = { + diagnostics: [], + triggerKind: CodeActionTriggerKind.Invoke, + only: CodeActionKind.Refactor + } + + assert.deepEqual(provider.provideCodeActions(document, selection, refactorContext, harness.cancellation.token), []) + enabled = false + assert.deepEqual(provider.provideCodeActions(document, selection, context, harness.cancellation.token), []) + enabled = true + + const cancelled = new CancellationTokenSource() + + cancelled.cancel() + assert.deepEqual(provider.provideCodeActions(document, selection, context, cancelled.token), []) + cancelled.dispose() + assert.equal(harness.counts.documentSymbolCalls, 1) + assert.equal(harness.counts.referenceNames.length, 4) + + harness.analyzer.invalidateConfiguration() + assert.deepEqual(provider.provideCodeActions(document, selection, context, harness.cancellation.token), []) + await harness.analyzer.analyze(document, harness.cancellation.token) + harness.analyzer.invalidateGraph() + assert.deepEqual(provider.provideCodeActions(document, selection, context, harness.cancellation.token), []) + await harness.analyzer.analyze(document, harness.cancellation.token) + + const edit = new WorkspaceEdit() + + edit.insert(document.uri, new Position(0, 0), '\n') + assert.equal(await workspace.applyEdit(edit), true) + assert.deepEqual(provider.provideCodeActions(document, selection, context, harness.cancellation.token), []) + await harness.analyzer.analyze(document, harness.cancellation.token) + harness.analyzer.forgetDocument(document.uri) + assert.deepEqual(provider.provideCodeActions(document, selection, context, harness.cancellation.token), []) + await harness.analyzer.analyze(document, harness.cancellation.token) + harness.analyzer.dispose() + assert.deepEqual(provider.provideCodeActions(document, new Range(1, 10, 1, 10), context, harness.cancellation.token), []) + } finally { + harness.dispose() + } + }) + + test('does not offer suppression for a used declaration', async () => { + const document = await workspace.openTextDocument({ + content: 'function entry() {}\nentry();', + language: 'typescript' + }) + + const harness = createAnalysisHarness(document, symbol => [ + new Location(document.uri, symbol.declarationRange), + new Location(document.uri, new Range(1, 0, 1, 5)) + ]) + + const provider = new ZeroReferenceCodeActionProvider(harness.analyzer) + + try { + await harness.analyzer.analyze(document, harness.cancellation.token) + assert.deepEqual(provider.provideCodeActions(document, new Range(0, 10, 0, 10), context, harness.cancellation.token), []) + assert.deepEqual(harness.counts.referenceNames, ['entry']) + } finally { + harness.dispose() + } + }) +}) diff --git a/test/config.test.ts b/test/config.test.ts index f859afc..1f77e2e 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert' import { commands, ConfigurationTarget, extensions, workspace } from 'vscode' -import { registerUseCodeLensListener } from '../src/config.js' +import { registerAnalysisConfigurationListener } from '../src/config.js' suite('useCodeLens configuration', () => { suiteSetup(async () => { @@ -44,7 +44,7 @@ suite('useCodeLens configuration', () => { const change = createDeferred() let refreshCount = 0 - const listener = registerUseCodeLensListener(() => { + const listener = registerAnalysisConfigurationListener(() => { refreshCount += 1 change.resolve() }) diff --git a/test/exclusions.test.ts b/test/exclusions.test.ts new file mode 100644 index 0000000..4a18ad8 --- /dev/null +++ b/test/exclusions.test.ts @@ -0,0 +1,238 @@ +import { strict as assert } from 'node:assert' + +import { + CancellationTokenSource, + commands, + ConfigurationTarget, + Location, + Range, + Uri, + WorkspaceEdit, + workspace +} from 'vscode' + +import { ZeroReferenceAnalyzer } from '../src/analysis.js' +import { registerAnalysisLifecycle } from '../src/analysisLifecycle.js' +import { isDocumentExcluded, parseExcludePatterns } from '../src/config.js' +import { createAnalysisHarness } from './analysisHarness.js' + +suite('file exclusions', () => { + let originalExclusions: unknown + let originalFilesExclude: unknown + + setup(() => { + originalExclusions = workspace.getConfiguration('zeroReference').inspect('exclude')?.workspaceValue + originalFilesExclude = workspace.getConfiguration('files').inspect('exclude')?.workspaceValue + }) + + teardown(async () => { + await workspace.getConfiguration('zeroReference').update('exclude', originalExclusions, ConfigurationTarget.Workspace) + await workspace.getConfiguration('files').update('exclude', originalFilesExclude, ConfigurationTarget.Workspace) + }) + + test('skips excluded files before both providers and restores analysis after settings change', async () => { + const folder = workspace.workspaceFolders?.[0] + + assert.ok(folder !== undefined) + + const excluded = await workspace.openTextDocument(Uri.joinPath(folder.uri, 'generated', 'excluded.ts')) + const included = await workspace.openTextDocument(Uri.joinPath(folder.uri, 'included.ts')) + const excludedHarness = createAnalysisHarness(excluded) + const includedHarness = createAnalysisHarness(included) + const lifecycle = registerAnalysisLifecycle(excludedHarness.analyzer) + const configuration = workspace.getConfiguration('zeroReference') + + try { + const original = await excludedHarness.analyzer.analyze(excluded, excludedHarness.cancellation.token) + + assert.ok(original.some(finding => finding.name === 'ignored')) + await configuration.update('exclude', ['generated/**'], ConfigurationTarget.Workspace) + assert.deepEqual(excludedHarness.analyzer.getCachedFindings(excluded, excludedHarness.cancellation.token), []) + excludedHarness.counts.documentSymbolCalls = 0 + excludedHarness.counts.referenceNames.length = 0 + + for (let attempt = 0; attempt < 2; attempt += 1) { + assert.deepEqual(await excludedHarness.analyzer.analyze(excluded, excludedHarness.cancellation.token), []) + } + + assert.equal(excludedHarness.counts.documentSymbolCalls, 0) + assert.deepEqual(excludedHarness.counts.referenceNames, []) + + const includedFindings = await includedHarness.analyzer.analyze(included, includedHarness.cancellation.token) + + assert.deepEqual(includedFindings.map(finding => finding.name), ['entry']) + assert.equal(includedHarness.counts.documentSymbolCalls, 1) + await configuration.update('exclude', [], ConfigurationTarget.Workspace) + + const restored = await excludedHarness.analyzer.analyze(excluded, excludedHarness.cancellation.token) + + assert.ok(restored.some(finding => finding.name === 'ignored')) + assert.equal(excludedHarness.counts.documentSymbolCalls, 1) + assert.deepEqual(excludedHarness.counts.referenceNames, ['ignored']) + } finally { + lifecycle.dispose() + excludedHarness.dispose() + includedHarness.dispose() + } + }) + + test('uses workspace-relative VS Code globs and does not inherit files.exclude', async () => { + const folder = workspace.workspaceFolders?.[0] + + assert.ok(folder !== undefined) + + const included = await workspace.openTextDocument(Uri.joinPath(folder.uri, 'included.ts')) + const nested = await workspace.openTextDocument(Uri.joinPath(folder.uri, 'generated', 'excluded.ts')) + + const outside = await workspace.openTextDocument({ + content: 'const outside = 1;', + language: 'typescript' + }) + + const configuration = workspace.getConfiguration('zeroReference') + const warnings: string[] = [] + const reportWarning = (message: string): void => { warnings.push(message) } + + await workspace.getConfiguration('files').update('exclude', { '**/*.ts': true }, ConfigurationTarget.Workspace) + await configuration.update('exclude', [], ConfigurationTarget.Workspace) + assert.equal(isDocumentExcluded(included, reportWarning), false) + await configuration.update('exclude', ['*.ts'], ConfigurationTarget.Workspace) + assert.equal(isDocumentExcluded(included, reportWarning), true) + assert.equal(isDocumentExcluded(nested, reportWarning), false) + await configuration.update('exclude', ['**/*.{ts,js}'], ConfigurationTarget.Workspace) + assert.equal(isDocumentExcluded(included, reportWarning), true) + assert.equal(isDocumentExcluded(nested, reportWarning), true) + assert.equal(isDocumentExcluded(outside, reportWarning), false) + assert.deepEqual(warnings, []) + }) + + test('keeps references from excluded files and invalidates their unsaved edits', async () => { + const folder = workspace.workspaceFolders?.[0] + + assert.ok(folder !== undefined) + + const declaration = await workspace.openTextDocument(Uri.joinPath(folder.uri, 'included.ts')) + const consumer = await workspace.openTextDocument(Uri.joinPath(folder.uri, 'generated', 'excluded.ts')) + const originalContent = consumer.getText() + const analyzer = new ZeroReferenceAnalyzer() + const cancellation = new CancellationTokenSource() + const lifecycle = registerAnalysisLifecycle(analyzer) + + try { + await workspace.getConfiguration('zeroReference').update('exclude', ['generated/**'], ConfigurationTarget.Workspace) + + const references = await commands.executeCommand('vscode.executeReferenceProvider', declaration.uri, new Range(0, 16, 0, 16).start) + + assert.ok(references?.some(reference => reference.uri.toString() === consumer.uri.toString())) + + const used = await analyzer.analyze(declaration, cancellation.token) + + assert.deepEqual(used, []) + + const edit = new WorkspaceEdit() + + edit.replace(consumer.uri, new Range(0, 0, consumer.lineCount, 0), 'export const ignored = 1;\n') + assert.equal(await workspace.applyEdit(edit), true) + + const unused = await analyzer.analyze(declaration, cancellation.token) + + assert.deepEqual(unused.map(finding => finding.name), ['entry']) + } finally { + lifecycle.dispose() + analyzer.dispose() + cancellation.dispose() + + const restore = new WorkspaceEdit() + + restore.replace(consumer.uri, new Range(0, 0, consumer.lineCount, 0), originalContent) + await workspace.applyEdit(restore) + } + }) + + test('discards in-flight findings when a file becomes excluded', async () => { + const folder = workspace.workspaceFolders?.[0] + + assert.ok(folder !== undefined) + + const document = await workspace.openTextDocument(Uri.joinPath(folder.uri, 'included.ts')) + let release: (() => void) | undefined + let started: (() => void) | undefined + const gate = new Promise(resolve => { release = resolve }) + const lookupStarted = new Promise(resolve => { started = resolve }) + + const harness = createAnalysisHarness(document, async symbol => { + started?.() + await gate + + return [new Location(document.uri, symbol.declarationRange)] + }) + + const lifecycle = registerAnalysisLifecycle(harness.analyzer) + + try { + const pending = harness.analyzer.analyze(document, harness.cancellation.token) + + await lookupStarted + assert.deepEqual(harness.analyzer.getCachedFindings(document, harness.cancellation.token), []) + await workspace.getConfiguration('zeroReference').update('exclude', ['included.ts'], ConfigurationTarget.Workspace) + release?.() + assert.deepEqual(await pending, []) + assert.deepEqual(harness.analyzer.getCachedFindings(document, harness.cancellation.token), []) + assert.deepEqual(await harness.analyzer.analyze(document, harness.cancellation.token), []) + assert.equal(harness.counts.documentSymbolCalls, 1) + assert.equal(harness.counts.referenceNames.length, 1) + } finally { + release?.() + lifecycle.dispose() + harness.dispose() + } + }) + + test('reports invalid settings while retaining valid entries', () => { + const warnings: string[] = [] + const reportWarning = (message: string): void => { warnings.push(message) } + + for (const invalid of [null, true, 12, '**/*.ts', {}]) { + assert.deepEqual(parseExcludePatterns(invalid, reportWarning), []) + } + + assert.equal(warnings.length, 5) + assert.ok(warnings.every(message => message.includes('zeroReference.exclude'))) + warnings.length = 0 + assert.deepEqual(parseExcludePatterns(['generated/**', 1, '', ' ', '**/*.generated.ts'], reportWarning), ['generated/**', '**/*.generated.ts']) + assert.equal(warnings.length, 3) + assert.ok(warnings[0]?.includes('[1]')) + assert.ok(warnings[1]?.includes('[2]')) + assert.ok(warnings[2]?.includes('[3]')) + }) + + test('logs invalid runtime configuration and keeps analysis operational', async () => { + const folder = workspace.workspaceFolders?.[0] + + assert.ok(folder !== undefined) + + const document = await workspace.openTextDocument(Uri.joinPath(folder.uri, 'included.ts')) + const harness = createAnalysisHarness(document) + const lifecycle = registerAnalysisLifecycle(harness.analyzer) + const configuration = workspace.getConfiguration('zeroReference') + + try { + await configuration.update('exclude', ['included.ts', 42], ConfigurationTarget.Workspace) + assert.deepEqual(await harness.analyzer.analyze(document, harness.cancellation.token), []) + assert.equal(harness.counts.documentSymbolCalls, 0) + assert.equal(harness.warnings.length, 1) + assert.ok(harness.warnings[0]?.includes('zeroReference.exclude[1]')) + await configuration.update('exclude', 'invalid', ConfigurationTarget.Workspace) + + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(findings.map(finding => finding.name), ['entry']) + assert.equal(harness.counts.documentSymbolCalls, 1) + assert.equal(harness.warnings.length, 2) + assert.ok(harness.warnings[1]?.includes('must be an array')) + } finally { + lifecycle.dispose() + harness.dispose() + } + }) +}) diff --git a/test/extension.test.ts b/test/extension.test.ts index 1bc932d..87bb1c4 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert' -import { commands, extensions, Uri, workspace } from 'vscode' -import type { CodeLens } from 'vscode' +import { commands, ConfigurationTarget, extensions, Range, Uri, WorkspaceEdit, workspace } from 'vscode' +import type { CodeAction, CodeLens } from 'vscode' import { ZeroReferenceAnalyzer } from '../src/analysis.js' import type { CommandExecutor } from '../src/analysis.js' import { createRefreshHandler } from '../src/extension.js' @@ -80,4 +80,54 @@ suite('extension integration', () => { true ) }) + + test('registers the Quick Fix and refreshes actual CodeLens after applying and removing suppression', async () => { + const folder = workspace.workspaceFolders?.[0] + + assert.ok(folder !== undefined) + + const uri = Uri.joinPath(folder.uri, 'action.ts') + const document = await workspace.openTextDocument(uri) + const original = document.getText() + const nameRange = new Range(0, 16, 0, 21) + const configuration = workspace.getConfiguration('zeroReference') + const previousExclusions = configuration.inspect('exclude')?.workspaceValue + + try { + const lenses = await commands.executeCommand('vscode.executeCodeLensProvider', uri) + + assert.ok(lenses?.some(lens => lens.command?.title === '"entry" has zero references')) + + const actions = await commands.executeCommand('vscode.executeCodeActionProvider', uri, nameRange) + const ignore = actions?.find(action => action.title === 'Zero Reference: Ignore this symbol') + + assert.ok(ignore?.edit !== undefined) + assert.equal(await workspace.applyEdit(ignore.edit), true) + + const suppressedLenses = await commands.executeCommand('vscode.executeCodeLensProvider', uri) + + assert.equal(suppressedLenses?.some(lens => lens.command?.title === '"entry" has zero references'), false) + + const restore = new WorkspaceEdit() + + restore.replace(uri, new Range(0, 0, document.lineCount, 0), original) + assert.equal(await workspace.applyEdit(restore), true) + + const restoredLenses = await commands.executeCommand('vscode.executeCodeLensProvider', uri) + + assert.ok(restoredLenses?.some(lens => lens.command?.title === '"entry" has zero references')) + await configuration.update('exclude', ['action.ts'], ConfigurationTarget.Workspace) + + const excludedActions = await commands.executeCommand('vscode.executeCodeActionProvider', uri, nameRange) + + assert.equal(excludedActions?.some(action => action.title === 'Zero Reference: Ignore this symbol'), false) + } finally { + await configuration.update('exclude', previousExclusions, ConfigurationTarget.Workspace) + + const restore = new WorkspaceEdit() + + restore.replace(uri, new Range(0, 0, document.lineCount, 0), original) + await workspace.applyEdit(restore) + } + }) }) diff --git a/test/fixtures/multi-root/first/same.ts b/test/fixtures/multi-root/first/same.ts new file mode 100644 index 0000000..15163df --- /dev/null +++ b/test/fixtures/multi-root/first/same.ts @@ -0,0 +1 @@ +export const entry = 1 diff --git a/test/fixtures/multi-root/second/same.ts b/test/fixtures/multi-root/second/same.ts new file mode 100644 index 0000000..4be2565 --- /dev/null +++ b/test/fixtures/multi-root/second/same.ts @@ -0,0 +1 @@ +export const entry = 2 diff --git a/test/fixtures/multi-root/third/same.ts b/test/fixtures/multi-root/third/same.ts new file mode 100644 index 0000000..d31278a --- /dev/null +++ b/test/fixtures/multi-root/third/same.ts @@ -0,0 +1 @@ +export const entry = 3 diff --git a/test/fixtures/multi-root/workspace.code-workspace b/test/fixtures/multi-root/workspace.code-workspace new file mode 100644 index 0000000..05a5243 --- /dev/null +++ b/test/fixtures/multi-root/workspace.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { "path": "first" }, + { "path": "second" } + ], + "settings": {} +} diff --git a/test/fixtures/workspace/action.ts b/test/fixtures/workspace/action.ts new file mode 100644 index 0000000..401b253 --- /dev/null +++ b/test/fixtures/workspace/action.ts @@ -0,0 +1 @@ +export function entry() {} diff --git a/test/fixtures/workspace/generated/excluded.ts b/test/fixtures/workspace/generated/excluded.ts new file mode 100644 index 0000000..b668662 --- /dev/null +++ b/test/fixtures/workspace/generated/excluded.ts @@ -0,0 +1,5 @@ +import { entry } from '../included' + +entry() + +export const ignored = 1 diff --git a/test/fixtures/workspace/included.ts b/test/fixtures/workspace/included.ts new file mode 100644 index 0000000..401b253 --- /dev/null +++ b/test/fixtures/workspace/included.ts @@ -0,0 +1 @@ +export function entry() {} diff --git a/test/fixtures/workspace/tsconfig.json b/test/fixtures/workspace/tsconfig.json new file mode 100644 index 0000000..b4967c7 --- /dev/null +++ b/test/fixtures/workspace/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/test/multiRoot/exclusions.test.ts b/test/multiRoot/exclusions.test.ts new file mode 100644 index 0000000..8437948 --- /dev/null +++ b/test/multiRoot/exclusions.test.ts @@ -0,0 +1,99 @@ +import { strict as assert } from 'node:assert' +import { ConfigurationTarget, Uri, workspace } from 'vscode' +import { registerAnalysisLifecycle } from '../../src/analysisLifecycle.js' +import { ZeroReferenceCodeLensProvider } from '../../src/codeLensProvider.js' +import { createAnalysisHarness } from '../analysisHarness.js' + +suite('multi-root exclusions', () => { + test('applies standard user, workspace, and folder precedence to identical relative paths', async () => { + const folders = workspace.workspaceFolders + + assert.equal(folders?.length, 2) + + const first = folders?.[0] + const second = folders?.[1] + + assert.ok(first !== undefined && second !== undefined) + + const firstDocument = await workspace.openTextDocument(Uri.joinPath(first.uri, 'same.ts')) + const secondDocument = await workspace.openTextDocument(Uri.joinPath(second.uri, 'same.ts')) + const firstHarness = createAnalysisHarness(firstDocument) + const secondHarness = createAnalysisHarness(secondDocument) + const lifecycle = registerAnalysisLifecycle(firstHarness.analyzer) + const secondLifecycle = registerAnalysisLifecycle(secondHarness.analyzer) + const firstConfiguration = workspace.getConfiguration('zeroReference', firstDocument.uri) + const secondConfiguration = workspace.getConfiguration('zeroReference', secondDocument.uri) + const globalValue = firstConfiguration.inspect('exclude')?.globalValue + + try { + await firstConfiguration.update('exclude', ['**/*.ts'], ConfigurationTarget.Global) + assert.deepEqual(await firstHarness.analyzer.analyze(firstDocument, firstHarness.cancellation.token), []) + assert.deepEqual(await secondHarness.analyzer.analyze(secondDocument, secondHarness.cancellation.token), []) + assert.equal(firstHarness.counts.documentSymbolCalls, 0) + assert.equal(secondHarness.counts.documentSymbolCalls, 0) + + await firstConfiguration.update('exclude', [], ConfigurationTarget.Workspace) + assert.equal((await firstHarness.analyzer.analyze(firstDocument, firstHarness.cancellation.token)).length, 1) + assert.equal((await secondHarness.analyzer.analyze(secondDocument, secondHarness.cancellation.token)).length, 1) + await firstConfiguration.update('exclude', ['same.ts'], ConfigurationTarget.WorkspaceFolder) + await secondConfiguration.update('exclude', ['*.js'], ConfigurationTarget.WorkspaceFolder) + + assert.deepEqual(await firstHarness.analyzer.analyze(firstDocument, firstHarness.cancellation.token), []) + assert.equal((await secondHarness.analyzer.analyze(secondDocument, secondHarness.cancellation.token)).length, 1) + assert.equal(firstHarness.counts.documentSymbolCalls, 1) + assert.equal(secondHarness.counts.documentSymbolCalls, 2) + await firstConfiguration.update('exclude', [], ConfigurationTarget.WorkspaceFolder) + assert.equal((await firstHarness.analyzer.analyze(firstDocument, firstHarness.cancellation.token)).length, 1) + assert.equal(firstHarness.counts.documentSymbolCalls, 2) + } finally { + await firstConfiguration.update('exclude', undefined, ConfigurationTarget.WorkspaceFolder) + await secondConfiguration.update('exclude', undefined, ConfigurationTarget.WorkspaceFolder) + await firstConfiguration.update('exclude', undefined, ConfigurationTarget.Workspace) + await firstConfiguration.update('exclude', globalValue, ConfigurationTarget.Global) + lifecycle.dispose() + secondLifecycle.dispose() + firstHarness.dispose() + secondHarness.dispose() + } + }) + + test('invalidates cached results and refreshes CodeLens when workspace folders change', async () => { + const first = workspace.workspaceFolders?.[0] + + assert.ok(first !== undefined) + + const thirdUri = Uri.joinPath(first.uri, '..', 'third') + const document = await workspace.openTextDocument(Uri.joinPath(first.uri, 'same.ts')) + const harness = createAnalysisHarness(document) + const lifecycle = registerAnalysisLifecycle(harness.analyzer) + const provider = new ZeroReferenceCodeLensProvider(harness.analyzer) + let refreshCount = 0 + const subscription = provider.onDidChangeCodeLenses(() => { refreshCount += 1 }) + + try { + const original = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.equal(original.length, 1) + + const changed = new Promise(resolve => { + const listener = workspace.onDidChangeWorkspaceFolders(() => { + listener.dispose() + resolve() + }) + }) + + assert.equal(workspace.updateWorkspaceFolders(2, 0, { uri: thirdUri }), true) + await changed + assert.ok(refreshCount > 0) + assert.deepEqual(harness.analyzer.getCachedFindings(document, harness.cancellation.token), []) + assert.equal((await harness.analyzer.analyze(document, harness.cancellation.token)).length, 1) + assert.equal(harness.counts.documentSymbolCalls, 2) + } finally { + subscription.dispose() + provider.dispose() + lifecycle.dispose() + harness.dispose() + workspace.updateWorkspaceFolders(2, 1) + } + }) +}) diff --git a/test/noWorkspace/exclusions.test.ts b/test/noWorkspace/exclusions.test.ts new file mode 100644 index 0000000..b47a23f --- /dev/null +++ b/test/noWorkspace/exclusions.test.ts @@ -0,0 +1,32 @@ +import { strict as assert } from 'node:assert' +import { ConfigurationTarget, workspace } from 'vscode' +import { createAnalysisHarness } from '../analysisHarness.js' + +suite('exclusions without a workspace', () => { + test('ignores file globs but still supports declaration suppression', async () => { + assert.equal(workspace.workspaceFolders, undefined) + + const configuration = workspace.getConfiguration('zeroReference') + const previousValue = configuration.inspect('exclude')?.globalValue + + const document = await workspace.openTextDocument({ + content: '// zero-reference-ignore-next-line\nfunction ignored() {}\nfunction entry() {}', + language: 'typescript' + }) + + const harness = createAnalysisHarness(document) + + try { + await configuration.update('exclude', ['**'], ConfigurationTarget.Global) + + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(findings.map(finding => finding.name), ['entry']) + assert.equal(harness.counts.documentSymbolCalls, 1) + assert.deepEqual(harness.counts.referenceNames, ['entry']) + } finally { + await configuration.update('exclude', previousValue, ConfigurationTarget.Global) + harness.dispose() + } + }) +}) diff --git a/test/suppression.test.ts b/test/suppression.test.ts new file mode 100644 index 0000000..6d3b2a8 --- /dev/null +++ b/test/suppression.test.ts @@ -0,0 +1,336 @@ +import { strict as assert } from 'node:assert' +import { Location, Range, WorkspaceEdit, workspace } from 'vscode' +import { createAnalysisHarness } from './analysisHarness.js' + +suite('declaration suppression', () => { + for (const language of ['typescript', 'typescriptreact', 'javascript', 'javascriptreact']) { + test(`skips only the suppressed declaration before reference lookup in ${language}`, async () => { + const document = await workspace.openTextDocument({ + content: '// zero-reference-ignore-next-line\nexport function entry() {}\nexport function neighbor() {}', + language + }) + + const harness = createAnalysisHarness(document) + + try { + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(findings.map(finding => finding.name), ['neighbor']) + assert.deepEqual(harness.counts.referenceNames, ['neighbor']) + + const edit = new WorkspaceEdit() + + edit.delete(document.uri, new Range(0, 0, 1, 0)) + assert.equal(await workspace.applyEdit(edit), true) + + const restored = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(restored.map(finding => finding.name).sort(), ['entry', 'neighbor']) + assert.equal(harness.counts.documentSymbolCalls, 2) + assert.equal(harness.counts.referenceNames.length, 3) + } finally { + harness.dispose() + } + }) + } + + test('supports variable spans beginning after declaration keywords', async () => { + const document = await workspace.openTextDocument({ + content: '// zero-reference-ignore-next-line\nexport const entry = 1;\nexport const neighbor = 2;', + language: 'typescript' + }) + + const harness = createAnalysisHarness(document) + + try { + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(findings.map(finding => finding.name), ['neighbor']) + assert.deepEqual(harness.counts.referenceNames, ['neighbor']) + } finally { + harness.dispose() + } + }) + + for (const language of ['typescript', 'typescriptreact', 'javascript', 'javascriptreact']) { + test(`binds a directive to split variable declaration keywords in ${language}`, async () => { + const document = await workspace.openTextDocument({ + content: '// zero-reference-ignore-next-line\nexport const\n entry = 1;\nconst neighbor = 2;', + language + }) + + const harness = createAnalysisHarness(document) + + try { + const suppressed = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(suppressed.map(finding => finding.name), ['neighbor']) + assert.deepEqual(harness.counts.referenceNames, ['neighbor']) + + const edit = new WorkspaceEdit() + + edit.delete(document.uri, new Range(0, 0, 1, 0)) + assert.equal(await workspace.applyEdit(edit), true) + + const restored = await harness.analyzer.analyze(document, harness.cancellation.token) + const entry = restored.find(finding => finding.name === 'entry') + + assert.ok(entry !== undefined) + assert.equal(entry.suppressionLine, 0) + assert.deepEqual(restored.map(finding => finding.name).sort(), ['entry', 'neighbor']) + assert.equal(harness.counts.referenceNames.length, 3) + + const misplaced = new WorkspaceEdit() + + misplaced.insert(document.uri, new Range(1, 0, 1, 0).start, '// zero-reference-ignore-next-line\n') + assert.equal(await workspace.applyEdit(misplaced), true) + + const stillVisible = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.ok(stillVisible.some(finding => finding.name === 'entry')) + assert.equal(harness.counts.referenceNames.length, 5) + } finally { + harness.dispose() + } + }) + + for (const declaration of ['function entry() {}', 'class entry {}']) { + test(`rejects a multiline ${declaration} expression in ${language}`, async () => { + const content = ['const outer = (', '// zero-reference-ignore-next-line', declaration, ');'].join('\n') + + const document = await workspace.openTextDocument({ + content, + language + }) + + const harness = createAnalysisHarness(document) + + try { + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + const entry = findings.find(finding => finding.name === 'entry') + + assert.ok(entry !== undefined) + assert.equal(entry.suppressionLine, undefined) + assert.ok(harness.counts.referenceNames.includes('entry')) + + const calls = harness.counts.referenceNames.length + const edit = new WorkspaceEdit() + + edit.delete(document.uri, new Range(1, 0, 2, 0)) + assert.equal(await workspace.applyEdit(edit), true) + + const withoutDirective = await harness.analyzer.analyze(document, harness.cancellation.token) + const visibleEntry = withoutDirective.find(finding => finding.name === 'entry') + + assert.ok(visibleEntry !== undefined) + assert.equal(visibleEntry.suppressionLine, undefined) + assert.equal(harness.counts.referenceNames.length, calls * 2) + } finally { + harness.dispose() + } + }) + } + } + + for (const opening of ['function outer() {', 'const outer = () => {', 'const outer = function () {']) { + test(`preserves nested statement suppression inside ${opening}`, async () => { + const content = [opening, ' // zero-reference-ignore-next-line', ' function entry() {}', '};'].join('\n') + + const document = await workspace.openTextDocument({ + content, + language: 'typescript' + }) + + const harness = createAnalysisHarness(document) + + try { + const suppressed = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(suppressed.map(finding => finding.name), ['outer']) + assert.deepEqual(harness.counts.referenceNames, ['outer']) + + const edit = new WorkspaceEdit() + + edit.delete(document.uri, new Range(1, 0, 2, 0)) + assert.equal(await workspace.applyEdit(edit), true) + + const restored = await harness.analyzer.analyze(document, harness.cancellation.token) + const entry = restored.find(finding => finding.name === 'entry') + + assert.equal(entry?.suppressionLine, 1) + assert.equal(harness.counts.referenceNames.length, 3) + } finally { + harness.dispose() + } + }) + } + + test('supports JSDoc followed by suppression before decorators', async () => { + const document = await workspace.openTextDocument({ + content: [ + 'class Owner {', + ' /** Entry point documentation. */', + ' // zero-reference-ignore-next-line', + ' @decorator', + ' entry() {}', + ' neighbor() {}', + '}' + ].join('\n'), + + language: 'typescript' + }) + + const harness = createAnalysisHarness(document) + + try { + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(findings.map(finding => finding.name).sort(), ['Owner', 'neighbor']) + assert.equal(harness.counts.referenceNames.includes('entry'), false) + + const edit = new WorkspaceEdit() + + edit.delete(document.uri, new Range(2, 0, 3, 0)) + assert.equal(await workspace.applyEdit(edit), true) + + const restored = await harness.analyzer.analyze(document, harness.cancellation.token) + const entry = restored.find(finding => finding.name === 'entry') + + assert.equal(entry?.suppressionLine, 2) + } finally { + harness.dispose() + } + }) + + for (const declaration of ['class Owner', 'namespace Owner']) { + test(`does not suppress members with their ${declaration}`, async () => { + const member = declaration.startsWith('class') ? ' member = 1;' : ' export const member = 1;' + const content = `// zero-reference-ignore-next-line\n${declaration} {\n${member}\n}` + + const document = await workspace.openTextDocument({ + content, + language: 'typescript' + }) + + const harness = createAnalysisHarness(document) + + try { + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(findings.map(finding => finding.name), ['member']) + assert.deepEqual(harness.counts.referenceNames, ['member']) + } finally { + harness.dispose() + } + }) + } + + for (const declarations of [ + ['function entry(value: string): string;', 'function entry(value: number): number;', 'function entry(value: unknown) { return value; }'], + ['interface entry {}', 'interface entry {}'] + ]) { + test(`suppresses the confirmed group for ${declarations[0]}`, async () => { + const lines = declarations.slice() + + lines.splice(1, 0, '// zero-reference-ignore-next-line') + + const document = await workspace.openTextDocument({ + content: lines.join('\n'), + language: 'typescript' + }) + + const harness = createAnalysisHarness(document, (symbol, symbols) => { + return symbols.filter(candidate => candidate.normalizedName === symbol.normalizedName) + .map(candidate => new Location(document.uri, candidate.declarationRange)) + }) + + try { + const suppressed = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(suppressed, []) + assert.equal(harness.counts.referenceNames.length, declarations.length - 1) + + const edit = new WorkspaceEdit() + + edit.delete(document.uri, new Range(1, 0, 2, 0)) + assert.equal(await workspace.applyEdit(edit), true) + + const restored = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(restored.map(finding => finding.name), ['entry']) + assert.equal(harness.counts.referenceNames.length, 2 * declarations.length - 1) + } finally { + harness.dispose() + } + }) + } + + for (const gap of ['', '// Another comment.', '/** Documentation. */']) { + test(`does not cross an intervening line: ${JSON.stringify(gap)}`, async () => { + const content = `// zero-reference-ignore-next-line\n${gap}\nfunction entry() {}` + + const document = await workspace.openTextDocument({ + content, + language: 'typescript' + }) + + const harness = createAnalysisHarness(document) + + try { + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(findings.map(finding => finding.name), ['entry']) + assert.deepEqual(harness.counts.referenceNames, ['entry']) + } finally { + harness.dispose() + } + }) + } + + for (const [label, content, language] of [ + ['template literal', 'const text = `\n// zero-reference-ignore-next-line\n${function entry() {}}\n`;', 'typescript'], + ['block comment', '/*\n// zero-reference-ignore-next-line\n*/ function entry() {}', 'typescript'], + ['JSX text', 'const markup =
\n// zero-reference-ignore-next-line\n{function entry() {}}\n
;', 'typescriptreact'] + ]) { + test(`does not interpret directive text inside ${label}`, async () => { + assert.ok(content !== undefined && language !== undefined) + + const document = await workspace.openTextDocument({ + content, + language + }) + + const harness = createAnalysisHarness(document) + + try { + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + const entry = findings.find(finding => finding.name === 'entry') + + assert.ok(entry !== undefined) + assert.equal(entry.suppressionLine, undefined) + assert.ok(harness.counts.referenceNames.includes('entry')) + } finally { + harness.dispose() + } + }) + } + + test('rejects suppression and insertion for multiple declarations on one line', async () => { + const document = await workspace.openTextDocument({ + content: '// zero-reference-ignore-next-line\nconst first = 1, second = 2;', + language: 'typescript' + }) + + const harness = createAnalysisHarness(document) + + try { + const findings = await harness.analyzer.analyze(document, harness.cancellation.token) + + assert.deepEqual(findings.map(finding => finding.name).sort(), ['first', 'second']) + assert.ok(findings.every(finding => finding.suppressionLine === undefined)) + assert.equal(harness.counts.referenceNames.length, 2) + } finally { + harness.dispose() + } + }) +})