Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions .vscode-test.mjs
Original file line number Diff line number Diff line change
@@ -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'
}
])
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------

Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 56 additions & 2 deletions src/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -27,6 +29,7 @@ export interface ZeroReferenceFinding {
readonly declarationRange: Range;
readonly name: string;
readonly range: Range;
readonly suppressionLine?: number | undefined;
}

interface AnalysisIdentity {
Expand Down Expand Up @@ -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
Expand All @@ -83,6 +105,10 @@ export class ZeroReferenceAnalyzer implements Disposable {
return cachedAnalysis.findings
}

if (isDocumentExcluded(document, this.reportConfigurationWarning)) {
return []
}

let hasLoggedError = false
let hasProviderError = false

Expand Down Expand Up @@ -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<string>()

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
)
Expand Down Expand Up @@ -193,6 +233,8 @@ export class ZeroReferenceAnalyzer implements Disposable {
identity: AnalysisIdentity,
symbols: readonly SymbolData[],
declarationIdentitiesByRange: ReadonlyMap<string, ReadonlySet<string>>,
suppressionLines: ReadonlyMap<number, number>,
suppressedRanges: ReadonlySet<string>,
token: CancellationToken,
logErrorOnce: (error: unknown) => void
): Promise<readonly (FindingCandidate | null)[]> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -265,20 +313,24 @@ export class ZeroReferenceAnalyzer implements Disposable {
document.uri,
locations,
declarationIdentitiesByRange,
suppressedRanges,
fallbackDeclarationIdentity
)

if (declarationGroupKey === undefined) {
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
}
}
}
Expand Down Expand Up @@ -364,6 +416,7 @@ function getDeclarationGroupKey(
documentUri: Uri,
locations: readonly Location[] | undefined,
declarationIdentitiesByRange: ReadonlyMap<string, ReadonlySet<string>>,
suppressedRanges: ReadonlySet<string>,
declarationIdentityKey: string
): string | undefined {
if (locations === undefined || locations.length === 0) {
Expand All @@ -377,6 +430,7 @@ function getDeclarationGroupKey(
const declarationIdentities = declarationIdentitiesByRange.get(locationKey)

if (location.uri.toString() !== documentUri.toString()
|| suppressedRanges.has(locationKey)
|| !declarationIdentities?.has(declarationIdentityKey)) {
return
}
Expand Down
5 changes: 3 additions & 2 deletions src/analysisLifecycle.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions src/codeActionProvider.ts
Original file line number Diff line number Diff line change
@@ -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]
}
}
Loading