diff --git a/README.md b/README.md index 8084bae..03a55b3 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Find All References results shown the way Visual Studio does it — in two views **Editor view** (default) writes the results into a read-only virtual document in the *same language as your source file*, so your theme's grammar colors every line for free. File headers show the containing project -(`.csproj`/`.fsproj`/`.vbproj`) and reference count; the referenced symbol is highlighted; line numbers are -shown in the gutter. +(`.csproj`/`.fsproj`/`.vbproj`) and reference count; line numbers are shown in the gutter. Read references are +highlighted like search matches; **writes are bold** on the theme's stronger write-occurrence background, the +same colour the editor itself uses for a write. **Panel view** shows the same results as a table with resizable, sortable columns — Code, File, Line, Kind, Project and Containing member — grouped by file, with a filter box and a reads/writes filter. By default it @@ -76,7 +77,8 @@ the expected results stay deterministic. arguments), then `textDocument/documentHighlight` is asked per file and its `Read`/`Write` kinds override that wherever the server has an opinion. Servers that answer with plain `Text` highlights, or not at all, leave the syntactic answer standing — which is good for straightforward code and can be wrong for exotic - expressions. Results spanning more than 60 files skip the server round-trip entirely. + expressions. Results spanning more than 60 files skip the server round-trip entirely. The editor view paints + the syntactic answer immediately and re-paints if the server disagrees, so it never waits on highlights. - Some language servers try to attach to every document of their language, including the virtual one, and may log a harmless error about an unknown URI scheme. @@ -86,4 +88,4 @@ the expected results stay deterministic. result sets, and remembering column layout per workspace rather than per panel 2. Semantic token overlay via `vscode.provideDocumentSemanticTokens` 3. Filter by project / exclude tests -4. ~~Read/write kind~~ — done for the panel; the editor view does not mark writes yet +4. ~~Read/write kind~~ — done in both views diff --git a/src/extension.ts b/src/extension.ts index d3fc8c8..5cf01d9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,7 +3,8 @@ import * as path from 'path'; import { PanelManager, Placement, ResultsView } from './panel'; import { CODE_INDENT, Origin, ReferenceResults, RenderedDocument, ResultLine, SCHEME, - findReferences, gather, invalidateSymbols, relativePath, renderDocument, + applyKinds, findReferences, gather, invalidateSymbols, referenceKinds, relativePath, + renderDocument, syntacticKinds, } from './references'; type View = 'document' | 'panel'; @@ -93,6 +94,16 @@ const symbolDecoration = vscode.window.createTextEditorDecorationType({ borderRadius: '2px', }); +// Writes use the theme's "strong" word-highlight colour — the same one the editor uses +// for a write occurrence — plus bold, so the two are told apart in any theme. +const writeDecoration = vscode.window.createTextEditorDecorationType({ + backgroundColor: new vscode.ThemeColor('editor.wordHighlightStrongBackground'), + border: '1px solid', + borderColor: new vscode.ThemeColor('editor.wordHighlightStrongBorder'), + borderRadius: '2px', + fontWeight: 'bold', +}); + const headerDecoration = vscode.window.createTextEditorDecorationType({ isWholeLine: true, fontWeight: 'bold', @@ -114,6 +125,7 @@ function applyDecorations(editor: vscode.TextEditor): void { const numbers: vscode.DecorationOptions[] = []; const symbols: vscode.Range[] = []; + const writes: vscode.DecorationOptions[] = []; const headers: vscode.Range[] = []; const titles: vscode.Range[] = []; @@ -124,8 +136,13 @@ function applyDecorations(editor: vscode.TextEditor): void { range: new vscode.Range(i, 0, i, 0), renderOptions: { before: { contentText: String((line.sourceLine ?? 0) + 1) } }, }); - for (const [start, end] of line.symbolRanges ?? []) { - symbols.push(new vscode.Range(i, start, i, end)); + for (const range of line.symbolRanges ?? []) { + const at = new vscode.Range(i, range.start, i, range.end); + if (range.kind === 'write') { + writes.push({ range: at, hoverMessage: 'Writes to the symbol' }); + } else { + symbols.push(at); + } } break; } @@ -140,10 +157,37 @@ function applyDecorations(editor: vscode.TextEditor): void { editor.setDecorations(lineNumberDecoration, numbers); editor.setDecorations(symbolDecoration, symbols); + editor.setDecorations(writeDecoration, writes); editor.setDecorations(headerDecoration, headers); editor.setDecorations(titleDecoration, titles); } +function redecorate(uri: vscode.Uri): void { + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.toString() === uri.toString()) { + applyDecorations(editor); + } + } +} + +/** + * Upgrades the syntactic read/write kinds with the language server's document + * highlights. Only the decorations change, never the text, so this can land after the + * results are already on screen. + */ +async function refineKinds(uri: vscode.Uri, stored: StoredDocument): Promise { + const kinds = await referenceKinds(stored.results); + if (store.get(uri) !== stored) { + return; // a newer search took this tab over + } + for (const line of stored.lines) { + if (line.kind === 'code' && line.file && line.sourceLine !== undefined && line.symbolRanges) { + applyKinds(line.file, line.sourceLine, line.symbolRanges, kinds); + } + } + redecorate(uri); +} + // --------------------------------------------------------------------------- // Running a search // --------------------------------------------------------------------------- @@ -151,7 +195,9 @@ function applyDecorations(editor: vscode.TextEditor): void { /** Shows results in the virtual document view. Reuses `targetUri` when refreshing. */ async function showDocument(results: ReferenceResults, targetUri?: vscode.Uri): Promise { const cfg = config(); - const rendered = renderDocument(results); + // Syntactic kinds are free, so the write markers are right from the first paint; + // refineKinds() upgrades them from the language server a moment later. + const rendered = renderDocument(results, syntacticKinds(results)); const reuse = cfg.get('reuseTab', true); const uri = targetUri ?? store.createUri(results.symbol, results.origin.uri, reuse); @@ -169,7 +215,8 @@ async function showDocument(results: ReferenceResults, targetUri?: vscode.Uri): } } - store.set(uri, { ...rendered, results }); + const stored: StoredDocument = { ...rendered, results }; + store.set(uri, stored); const doc = await vscode.workspace.openTextDocument(uri); if (doc.languageId !== results.languageId) { @@ -197,6 +244,7 @@ async function showDocument(results: ReferenceResults, targetUri?: vscode.Uri): editor.revealRange(new vscode.Range(0, 0, firstCode, 0), vscode.TextEditorRevealType.AtTop); } applyDecorations(editor); + void refineKinds(uri, stored); } async function showPanel(results: ReferenceResults, target?: ResultsView): Promise { @@ -274,13 +322,9 @@ function targetLocation(document: vscode.TextDocument, position: vscode.Position } if (line.kind === 'code') { // Several references can share a line; if the cursor is inside one of them, jump to that one. - // Displayed offsets differ from source offsets by a constant, so shifting relative to the first works. - let col = line.sourceCol ?? 0; - const ranges = line.symbolRanges ?? []; - const hit = ranges.find(([s, e]) => position.character >= s && position.character <= e); - if (hit && ranges.length > 1) { - col += hit[0] - ranges[0][0]; - } + const hit = (line.symbolRanges ?? []).find( + r => position.character >= r.start && position.character <= r.end); + const col = hit?.sourceCol ?? line.sourceCol ?? 0; return new vscode.Location(line.file, new vscode.Position(line.sourceLine ?? 0, col)); } return undefined; @@ -432,6 +476,7 @@ export function activate(context: vscode.ExtensionContext): void { lineNumberDecoration, symbolDecoration, + writeDecoration, headerDecoration, titleDecoration, store.onDidChange, diff --git a/src/panel.ts b/src/panel.ts index a407649..c19047d 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { - RefKind, ReferenceResults, containingMembers, displayLine, refKey, referenceKinds, relativePath, + RefKind, ReferenceResults, applyKinds, containingMembers, displayLine, referenceKinds, relativePath, } from './references'; /** Where the results table is shown. */ @@ -48,12 +48,13 @@ export function buildRows( const dir = path.dirname(relative); for (const source of file.lines) { const { text, symbolRanges } = displayLine(source); + applyKinds(file.uri, source.line, symbolRanges, kinds); const member = members.get(`${file.uri.toString()}|${source.line}`) ?? ''; source.ranges.forEach((range, index) => { rows.push({ id, code: text, - hits: [symbolRanges[index]], + hits: [[symbolRanges[index].start, symbolRanges[index].end]], file: path.basename(relative), dir: dir === '.' ? '' : dir, relPath: relative, @@ -61,7 +62,7 @@ export function buildRows( col: range.start.character + 1, project: file.project ?? '', member, - kind: kinds.get(refKey(file.uri, source.line, range.start.character)) ?? 'read', + kind: symbolRanges[index].kind, }); locations.set(id, new vscode.Location(file.uri, range.start)); id++; diff --git a/src/references.ts b/src/references.ts index 61d3a53..592ddae 100644 --- a/src/references.ts +++ b/src/references.ts @@ -43,6 +43,16 @@ export interface ReferenceResults { total: number; } +/** One reference as it appears in displayed text. */ +export interface DisplayRange { + /** Column range within the displayed text. */ + start: number; + end: number; + /** 0-based column in the source line, for navigation. */ + sourceCol: number; + kind: RefKind; +} + /** One displayed line in the virtual results document. */ export interface ResultLine { kind: 'code' | 'header' | 'title' | 'blank'; @@ -52,8 +62,8 @@ export interface ResultLine { sourceLine?: number; /** 0-based source column of the first reference on this line (code) */ sourceCol?: number; - /** Column ranges of the referenced symbol(s) within the displayed text (code) */ - symbolRanges?: [number, number][]; + /** The referenced symbol(s) on this line (code) */ + symbolRanges?: DisplayRange[]; } export interface RenderedDocument { @@ -226,23 +236,44 @@ export async function gather( */ export function displayLine( source: ReferenceLine, indent = '', -): { text: string; symbolRanges: [number, number][] } { +): { text: string; symbolRanges: DisplayRange[] } { const trimmed = source.text.trimStart(); const removed = source.text.length - trimmed.length; const limit = indent.length + trimmed.length; - const symbolRanges: [number, number][] = []; + const symbolRanges: DisplayRange[] = []; for (const range of source.ranges) { const start = Math.max(0, range.start.character - removed) + indent.length; const endChar = range.end.line === source.line ? range.end.character : source.text.length; const end = Math.max(start + 1, endChar - removed + indent.length); - symbolRanges.push([Math.min(start, limit), Math.min(end, limit)]); + symbolRanges.push({ + start: Math.min(start, limit), + end: Math.min(end, limit), + sourceCol: range.start.character, + // Free, and correct for straightforward code; a language server's document + // highlights can override it later without re-rendering anything. + kind: syntacticKind(source.text, range), + }); } return { text: indent + trimmed, symbolRanges }; } -export function renderDocument(results: ReferenceResults): RenderedDocument { +/** Overrides the syntactic kinds on `ranges` wherever `kinds` has an entry. */ +export function applyKinds( + file: vscode.Uri, sourceLine: number, ranges: DisplayRange[], kinds: Map, +): void { + for (const range of ranges) { + const kind = kinds.get(refKey(file, sourceLine, range.sourceCol)); + if (kind) { + range.kind = kind; + } + } +} + +export function renderDocument( + results: ReferenceResults, kinds: Map = new Map(), +): RenderedDocument { const prefix = commentPrefix(results.languageId); const out: string[] = []; const lines: ResultLine[] = []; @@ -265,6 +296,7 @@ export function renderDocument(results: ReferenceResults): RenderedDocument { for (const source of file.lines) { const { text, symbolRanges } = displayLine(source, CODE_INDENT); + applyKinds(file.uri, source.line, symbolRanges, kinds); push(text, { kind: 'code', file: file.uri, @@ -432,7 +464,7 @@ async function documentHighlights( * document highlights override it wherever they express an opinion — `Text` highlights * carry no kind, so those keep the syntactic answer. */ -export async function referenceKinds(results: ReferenceResults): Promise> { +export function syntacticKinds(results: ReferenceResults): Map { const kinds = new Map(); for (const file of results.files) { for (const line of file.lines) { @@ -442,6 +474,11 @@ export async function referenceKinds(results: ReferenceResults): Promise> { + const kinds = syntacticKinds(results); if (results.files.length > HIGHLIGHT_FILE_LIMIT) { return kinds; diff --git a/src/test/suite/kinds.test.ts b/src/test/suite/kinds.test.ts index c4e8bb1..b0e88fb 100644 --- a/src/test/suite/kinds.test.ts +++ b/src/test/suite/kinds.test.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { buildRows } from '../../panel'; -import { gather, refKey, referenceKinds, syntacticKind } from '../../references'; +import { gather, refKey, referenceKinds, renderDocument, syntacticKind } from '../../references'; const KIND_FILE = path.join('Nerfed.Editor', 'Systems', 'EditorProfilerWindow.cs'); const KIND_SYMBOL = 'selectedFrame'; @@ -120,7 +120,54 @@ suite('Read / write kind from the language server', () => { assert.ok(writes >= 2 && reads >= 3, `expected reads and writes, got ${reads}/${writes}`); }); - test('rows carry the kind, defaulting to read when unknown', async () => { + test('the editor view marks writes on the right display ranges', async () => { + const folder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(folder); + const uri = vscode.Uri.joinPath(folder.uri, ...KIND_FILE.split(path.sep)); + const doc = await vscode.workspace.openTextDocument(uri); + + let position = new vscode.Position(0, 0); + for (let i = 0; i < doc.lineCount; i++) { + const at = doc.lineAt(i).text.indexOf(KIND_DECL); + if (at >= 0) { + position = new vscode.Position(i, doc.lineAt(i).text.indexOf(KIND_SYMBOL, at) + 1); + break; + } + } + const locations = await vscode.commands.executeCommand( + 'vscode.executeReferenceProvider', uri, position); + const results = await gather(KIND_SYMBOL, 'csharp', { uri, position }, locations ?? [], true); + + // Rendering with no kind map still classifies syntactically, so the markers are + // correct on the first paint; the server pass only refines them. + const bare = renderDocument(results); + const refined = renderDocument(results, await referenceKinds(results)); + + for (const rendered of [bare, refined]) { + const writes = new Set(); + for (const line of rendered.lines) { + if (line.kind !== 'code' || line.sourceLine === undefined) { + continue; + } + const sourceText: string = doc.lineAt(line.sourceLine).text; + const displayed: string = rendered.content.split('\n')[rendered.lines.indexOf(line)]; + for (const range of line.symbolRanges ?? []) { + // The recorded source column must actually be the symbol. + assert.ok(sourceText.startsWith(KIND_SYMBOL, range.sourceCol), + `line ${line.sourceLine + 1} col ${range.sourceCol} is not ${KIND_SYMBOL}`); + // And the display range must cover the symbol in the rendered text. + assert.strictEqual(displayed.slice(range.start, range.end), KIND_SYMBOL); + if (range.kind === 'write') { + writes.add(line.sourceLine + 1); + } + } + } + assert.deepStrictEqual([...writes].sort((a, b) => a - b), [41, 43], + 'expected the assignment and the ref argument to be the only writes'); + } + }); + + test('rows carry the kind, falling back to the syntactic one', async () => { const folder = vscode.workspace.workspaceFolders?.[0]; assert.ok(folder); const uri = vscode.Uri.joinPath(folder.uri, ...KIND_FILE.split(path.sep)); @@ -145,8 +192,12 @@ suite('Read / write kind from the language server', () => { 'every row needs a read/write kind'); assert.ok(rows.some(r => r.kind === 'write'), 'no write rows'); - // With no kind map at all, rows still render as reads rather than blank. + // With no kind map at all — a language server that serves no document + // highlights — rows still carry the syntactic classification, not a blank + // column or a uniform "read". const { rows: bare } = buildRows(results, new Map()); - assert.ok(bare.every(r => r.kind === 'read')); + assert.deepStrictEqual(bare.map(r => r.kind), rows.map(r => r.kind), + 'the syntactic fallback should already agree with the server here'); + assert.ok(bare.some(r => r.kind === 'write'), 'no write rows without a kind map'); }); }); diff --git a/src/test/suite/references.test.ts b/src/test/suite/references.test.ts index 55d2b91..014f67c 100644 --- a/src/test/suite/references.test.ts +++ b/src/test/suite/references.test.ts @@ -209,6 +209,39 @@ suite('Colored References - real C# solution', () => { assert.deepStrictEqual(failures, [], failures.join('\n')); }); + test('each occurrence on a shared line navigates to its own column', async () => { + const shared: number[] = []; + for (const line of codeLineNumbers(doc)) { + const text = doc.lineAt(line).text; + if (text.split(TARGET_SYMBOL).length - 1 > 1) { + shared.push(line); + } + } + assert.ok(shared.length > 0, + `no result line holds two references to ${TARGET_SYMBOL}; this test needs one`); + + const failures: string[] = []; + for (const line of shared) { + const text = doc.lineAt(line).text; + for (let at = text.indexOf(TARGET_SYMBOL); at >= 0; + at = text.indexOf(TARGET_SYMBOL, at + 1)) { + const target = await definitionAt(doc, line, at + 1); + if (!target) { + failures.push(`line ${line} col ${at}: no target`); + continue; + } + const source = await vscode.workspace.openTextDocument(target.uri); + const landed = source.lineAt(target.range.start.line).text + .slice(target.range.start.character); + if (!landed.startsWith(TARGET_SYMBOL)) { + failures.push(`line ${line} col ${at} landed on ` + + `${JSON.stringify(landed.slice(0, 20))}`); + } + } + } + assert.deepStrictEqual(failures, [], failures.join('\n')); + }); + test('file headers navigate to the top of the file and show the project', async () => { const headers = headerLineNumbers(doc); assert.ok(headers.length > 0);