diff --git a/README.md b/README.md index 1b1c14e..8084bae 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ source file*, so your theme's grammar colors every line for free. File headers s (`.csproj`/`.fsproj`/`.vbproj`) and reference count; the referenced symbol is highlighted; line numbers are shown in the gutter. -**Panel view** shows the same results as a table with resizable, sortable columns — Code, File, Line, Project -and Containing member — grouped by file, with a filter box. By default it docks in the **bottom panel** +**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 +docks in the **bottom panel** alongside Terminal and Problems, where a wide, short table reads best; you can drag it to either side bar, or set `coloredReferences.panelLocation` to put it in an editor group instead. @@ -28,6 +29,10 @@ In the panel, a single click previews a reference without leaving the panel, `En it, arrow keys walk the list, `Ctrl+F` focuses the filter, and clicking a column header sorts by it. Drag a column edge to resize it; double-click the edge to reset it. +The **All / Reads / Writes** buttons filter by kind, and the summary line counts the writes. Kind also works in +the text filter, so typing `write` narrows to writes as well. See *Known limitations* for where the kind comes +from. + ## Settings - `coloredReferences.view` — which view `Find All References (Colored)` opens: `document` (default) or `panel` @@ -66,7 +71,12 @@ the expected results stay deterministic. server has analyzed it. - The panel's code column cannot use your theme's token colors: webviews are not given them as CSS variables. It approximates the stock Dark+/Light+ hues instead. Semantic tokens (roadmap 2) would replace this. -- Read/write kind is not shown yet — no language server reports it through the standard reference request. +- Read/write kind is not part of the reference request, so it is derived two ways. Every reference is first + classified from the surrounding text (assignment and compound-assignment operators, `++`/`--`, `ref`/`out` + 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. - 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. @@ -76,4 +86,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. Optional DotRush fast path for containing member + read/write kind +4. ~~Read/write kind~~ — done for the panel; the editor view does not mark writes yet diff --git a/media/panel.css b/media/panel.css index d2574f3..81bfa14 100644 --- a/media/panel.css +++ b/media/panel.css @@ -3,7 +3,7 @@ :root { --row-height: 22px; --header-height: 24px; - --grid: var(--cr-grid, 40% 20% 56px 14% 20%); + --grid: var(--cr-grid, 36% 18% 48px 56px 14% 20%); } * { @@ -296,3 +296,51 @@ body.resizing { #empty[hidden] { display: none; } + +/* --- read / write ------------------------------------------------------- */ + +.segmented { + display: flex; + flex: 0 0 auto; +} + +.segmented .segment { + border-radius: 0; + border-right-width: 0; +} + +.segmented .segment:first-child { + border-radius: 2px 0 0 2px; +} + +.segmented .segment:last-child { + border-radius: 0 2px 2px 0; + border-right-width: 1px; +} + +.td.kind { + overflow: hidden; +} + +.kind-badge { + display: inline-block; + max-width: 100%; + padding: 0 5px; + border: 1px solid transparent; + border-radius: 3px; + font-size: 0.9em; + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; +} + +.kind-read { + color: var(--vscode-descriptionForeground); + background: var(--vscode-badge-background); +} + +.kind-write { + color: var(--vscode-inputValidation-warningForeground, var(--vscode-foreground)); + background: var(--vscode-inputValidation-warningBackground); + border-color: var(--vscode-inputValidation-warningBorder, transparent); +} diff --git a/media/panel.js b/media/panel.js index 5f62889..040f853 100644 --- a/media/panel.js +++ b/media/panel.js @@ -13,13 +13,21 @@ // `share` is the fraction of the panel a column gets before anyone drags it. const COLUMNS = [ - { key: 'code', label: 'Code', share: 0.40, min: 120, align: 'left' }, - { key: 'file', label: 'File', share: 0.20, min: 80, align: 'left' }, - { key: 'line', label: 'Line', share: 0.06, min: 44, align: 'right' }, + { key: 'code', label: 'Code', share: 0.36, min: 120, align: 'left' }, + { key: 'file', label: 'File', share: 0.18, min: 80, align: 'left' }, + // Minimums here are what the column's own header needs, not just its content. + { key: 'line', label: 'Line', share: 0.05, min: 58, align: 'right' }, + { key: 'kind', label: 'Kind', share: 0.07, min: 62, align: 'left' }, { key: 'project', label: 'Project', share: 0.14, min: 60, align: 'left' }, { key: 'member', label: 'Containing member', share: 0.20, min: 80, align: 'left' }, ]; + const KIND_FILTERS = [ + { value: 'all', label: 'All', title: 'Show reads and writes' }, + { value: 'read', label: 'Reads', title: 'Show only references that read the symbol' }, + { value: 'write', label: 'Writes', title: 'Show only references that write the symbol' }, + ]; + /** @type {{symbol: string, languageId: string, rows: any[], fileCount: number}} */ let data = { symbol: '', languageId: '', rows: [], fileCount: 0 }; @@ -29,6 +37,7 @@ sortDir: restored.sortDir === 'desc' ? 'desc' : 'asc', group: restored.group !== false, filter: restored.filter || '', + kind: KIND_FILTERS.some(k => k.value === restored.kind) ? restored.kind : 'all', widths: Object.assign({}, restored.widths), collapsed: Object.assign({}, restored.collapsed), selectedId: restored.selectedId, @@ -37,6 +46,7 @@ const el = { summary: /** @type {HTMLElement} */ (document.getElementById('summary')), filter: /** @type {HTMLInputElement} */ (document.getElementById('filter')), + kindFilter: /** @type {HTMLElement} */ (document.getElementById('kind-filter')), group: /** @type {HTMLButtonElement} */ (document.getElementById('toggle-group')), refresh: /** @type {HTMLButtonElement} */ (document.getElementById('refresh')), table: /** @type {HTMLElement} */ (document.getElementById('table')), @@ -341,10 +351,13 @@ } function matches(row, needle) { + if (state.kind !== 'all' && row.kind !== state.kind) { + return false; + } if (!needle) { return true; } - return (row.code + ' ' + row.relPath + ' ' + row.project + ' ' + row.member) + return (row.code + ' ' + row.relPath + ' ' + row.project + ' ' + row.member + ' ' + row.kind) .toLowerCase().indexOf(needle) >= 0; } @@ -385,6 +398,14 @@ div.appendChild(file); div.appendChild(cell('line', String(row.line))); + + const kind = cell('kind'); + const badge = document.createElement('span'); + badge.className = 'kind-badge kind-' + row.kind; + badge.textContent = row.kind; + kind.appendChild(badge); + div.appendChild(kind); + div.appendChild(cell('project', row.project)); const member = cell('member', row.member); @@ -463,6 +484,7 @@ const shown = rows.length; const total = data.rows.length; + const writes = data.writeCount || 0; el.summary.textContent = ''; const strong = document.createElement('b'); strong.textContent = data.symbol; @@ -470,7 +492,8 @@ el.summary.appendChild(document.createTextNode(`${total} reference${total === 1 ? '' : 's'} to `)); el.summary.appendChild(strong); el.summary.appendChild(document.createTextNode( - ` in ${data.fileCount} file${data.fileCount === 1 ? '' : 's'}`)); + ` in ${data.fileCount} file${data.fileCount === 1 ? '' : 's'}` + + (writes > 0 ? ` · ${writes} write${writes === 1 ? '' : 's'}` : ''))); } // ----------------------------------------------------------------------- @@ -565,6 +588,25 @@ renderRows(); }); + function buildKindFilter() { + el.kindFilter.textContent = ''; + for (const option of KIND_FILTERS) { + const button = document.createElement('button'); + button.className = 'toolbar-button segment'; + button.textContent = option.label; + button.title = option.title; + button.dataset.kind = option.value; + button.setAttribute('aria-pressed', String(state.kind === option.value)); + button.addEventListener('click', () => { + state.kind = option.value; + saveState(); + buildKindFilter(); + renderRows(); + }); + el.kindFilter.appendChild(button); + } + } + el.group.addEventListener('click', () => { state.group = !state.group; el.group.setAttribute('aria-pressed', String(state.group)); @@ -623,6 +665,7 @@ applyPalette(); el.filter.value = state.filter; + buildKindFilter(); el.group.setAttribute('aria-pressed', String(state.group)); el.body.tabIndex = 0; computeAutoWidths(); diff --git a/src/panel.ts b/src/panel.ts index caf2521..a407649 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { - ReferenceResults, containingMembers, displayLine, relativePath, + RefKind, ReferenceResults, containingMembers, displayLine, refKey, referenceKinds, relativePath, } from './references'; /** Where the results table is shown. */ @@ -24,6 +24,8 @@ export interface PanelRow { col: number; project: string; member: string; + /** Whether this reference reads or writes the symbol. */ + kind: RefKind; } /** @@ -33,7 +35,9 @@ export interface PanelRow { * Returns the rows plus the location each row navigates to, keyed by row id. */ export function buildRows( - results: ReferenceResults, members: Map, + results: ReferenceResults, + members: Map, + kinds: Map = new Map(), ): { rows: PanelRow[]; locations: Map } { const rows: PanelRow[] = []; const locations = new Map(); @@ -57,6 +61,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', }); locations.set(id, new vscode.Location(file.uri, range.start)); id++; @@ -128,8 +133,11 @@ export class ResultsView { this.results = results; this.host.setTitle(`References to ${results.symbol}`); - const members = await containingMembers(results); - const { rows, locations } = buildRows(results, members); + const [members, kinds] = await Promise.all([ + containingMembers(results), + referenceKinds(results), + ]); + const { rows, locations } = buildRows(results, members, kinds); this.locations = locations; this.post({ @@ -137,6 +145,7 @@ export class ResultsView { symbol: results.symbol, languageId: results.languageId, fileCount: results.files.length, + writeCount: rows.filter(r => r.kind === 'write').length, rows, }); } @@ -225,6 +234,7 @@ export class ResultsView {
+
diff --git a/src/references.ts b/src/references.ts index cd96e5b..61d3a53 100644 --- a/src/references.ts +++ b/src/references.ts @@ -369,3 +369,104 @@ export async function containingMembers(results: ReferenceResults): Promise>>?=|\?\?=|\|\|=|&&=|\+\+|--)/; +/** `++x` / `--x` immediately before the reference. */ +const INCDEC_BEFORE = /(\+\+|--)\s*$/; +/** C# `ref`/`out` arguments, and `&x` in C/C++, hand the callee a writable alias. */ +const BYREF_BEFORE = /(?:^|[^\w$])(ref|out)\s+$/; + +/** + * Classifies a reference from the surrounding text. Used for language servers that + * do not answer document highlights, and as the baseline the server can override. + */ +export function syntacticKind(lineText: string, range: vscode.Range): RefKind { + const end = range.end.line === range.start.line ? range.end.character : lineText.length; + const before = lineText.slice(0, Math.min(range.start.character, lineText.length)); + const after = lineText.slice(Math.min(end, lineText.length)); + + if (ASSIGNED_AFTER.test(after) || INCDEC_BEFORE.test(before) || BYREF_BEFORE.test(before)) { + return 'write'; + } + return 'read'; +} + +/** Asking the server for highlights in every file of a huge result set is not worth it. */ +const HIGHLIGHT_FILE_LIMIT = 60; + +/** + * Document highlights for the symbol at `position`, which carry a read/write kind. + * The file has to be open as a text document for the language server to answer. + */ +async function documentHighlights( + uri: vscode.Uri, position: vscode.Position, +): Promise { + try { + await vscode.workspace.openTextDocument(uri); + } catch { + return []; + } + const highlights = await timeout( + vscode.commands.executeCommand( + 'vscode.executeDocumentHighlights', uri, position), + 4000, undefined as unknown as vscode.DocumentHighlight[]); + return highlights ?? []; +} + +/** + * Read/write kind per reference, keyed by {@link refKey}. + * + * Every reference gets a syntactic classification first, then the language server's + * 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> { + const kinds = new Map(); + for (const file of results.files) { + for (const line of file.lines) { + for (const range of line.ranges) { + kinds.set(refKey(file.uri, line.line, range.start.character), + syntacticKind(line.text, range)); + } + } + } + + if (results.files.length > HIGHLIGHT_FILE_LIMIT) { + return kinds; + } + + await Promise.all(results.files.map(async file => { + const first = file.lines[0]; + if (!first) { + return; + } + const highlights = await documentHighlights(file.uri, first.ranges[0].start); + for (const highlight of highlights) { + const kind = highlight.kind === vscode.DocumentHighlightKind.Write ? 'write' + : highlight.kind === vscode.DocumentHighlightKind.Read ? 'read' + : undefined; + if (!kind) { + continue; + } + const key = refKey(file.uri, highlight.range.start.line, highlight.range.start.character); + // Only for ranges that are actually in the results. + if (kinds.has(key)) { + kinds.set(key, kind); + } + } + })); + + return kinds; +} diff --git a/src/test/suite/index.ts b/src/test/suite/index.ts index 21af43d..35df71d 100644 --- a/src/test/suite/index.ts +++ b/src/test/suite/index.ts @@ -12,6 +12,7 @@ export function run(): Promise { mocha.addFile(path.resolve(__dirname, 'references.test.js')); mocha.addFile(path.resolve(__dirname, 'panel.test.js')); + mocha.addFile(path.resolve(__dirname, 'kinds.test.js')); return new Promise((resolve, reject) => { try { diff --git a/src/test/suite/kinds.test.ts b/src/test/suite/kinds.test.ts new file mode 100644 index 0000000..c4e8bb1 --- /dev/null +++ b/src/test/suite/kinds.test.ts @@ -0,0 +1,152 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { buildRows } from '../../panel'; +import { gather, refKey, referenceKinds, syntacticKind } from '../../references'; + +const KIND_FILE = path.join('Nerfed.Editor', 'Systems', 'EditorProfilerWindow.cs'); +const KIND_SYMBOL = 'selectedFrame'; +const KIND_DECL = 'private int selectedFrame'; + +/** `syntacticKind` for the first occurrence of `symbol` in `text`. */ +function classify(text: string, symbol: string): string { + const start = text.indexOf(symbol); + assert.ok(start >= 0, `'${symbol}' not in '${text}'`); + return syntacticKind(text, new vscode.Range(0, start, 0, start + symbol.length)); +} + +suite('Read / write classification', () => { + test('plain assignment, compound assignment and increments are writes', () => { + assert.strictEqual(classify('x = 1;', 'x'), 'write'); + assert.strictEqual(classify(' x = 1;', 'x'), 'write'); + assert.strictEqual(classify('x += 1;', 'x'), 'write'); + assert.strictEqual(classify('x <<= 2;', 'x'), 'write'); + assert.strictEqual(classify('x ??= y;', 'x'), 'write'); + assert.strictEqual(classify('x++;', 'x'), 'write'); + assert.strictEqual(classify('++x;', 'x'), 'write'); + assert.strictEqual(classify('--x;', 'x'), 'write'); + }); + + test('by-reference arguments are writes', () => { + assert.strictEqual(classify('Foo(ref x);', 'x'), 'write'); + assert.strictEqual(classify('Foo(out x);', 'x'), 'write'); + assert.strictEqual(classify('if (Bar(string.Empty, ref x, 0))', 'x'), 'write'); + }); + + test('comparisons and uses are reads', () => { + assert.strictEqual(classify('if (x == 1)', 'x'), 'read'); + assert.strictEqual(classify('if (x != 1)', 'x'), 'read'); + assert.strictEqual(classify('if (x >= 1)', 'x'), 'read'); + assert.strictEqual(classify('Foo(x);', 'x'), 'read'); + assert.strictEqual(classify('return x;', 'x'), 'read'); + assert.strictEqual(classify('Foo(in x);', 'x'), 'read'); + }); + + test('the right-hand side of an assignment is a read', () => { + // `previous = x` must not be classified from the `=` that belongs to `previous`. + assert.strictEqual(classify('previous = x;', 'x'), 'read'); + assert.strictEqual(classify('int y = x + 1;', 'x'), 'read'); + }); + + test('a name that only looks like a prefix is not confused', () => { + // `refresh` starts with `ref` but is not a by-reference argument. + assert.strictEqual(classify('refresh(x);', 'x'), 'read'); + }); +}); + +suite('Read / write kind from the language server', () => { + test('kinds for a field that is both read and written', async () => { + const ext = vscode.extensions.getExtension('local.colored-references'); + assert.ok(ext); + await ext.activate(); + + const folder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(folder, 'no workspace folder'); + const uri = vscode.Uri.joinPath(folder.uri, ...KIND_FILE.split(path.sep)); + const doc = await vscode.workspace.openTextDocument(uri); + const editor = await vscode.window.showTextDocument(doc, { preview: false }); + + let position: vscode.Position | undefined; + 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; + } + } + assert.ok(position, `'${KIND_DECL}' not found in ${KIND_FILE}`); + editor.selection = new vscode.Selection(position, position); + + const locations = await vscode.commands.executeCommand( + 'vscode.executeReferenceProvider', uri, position); + assert.ok(locations && locations.length >= 4, + `expected several references to ${KIND_SYMBOL}, got ${locations?.length ?? 0}`); + + const results = await gather(KIND_SYMBOL, 'csharp', { uri, position }, locations, true); + const kinds = await referenceKinds(results); + + const file = results.files.find(f => f.uri.fsPath === uri.fsPath); + assert.ok(file, `${KIND_FILE} missing from the results`); + + const report: string[] = []; + const kindAt = (oneBasedLine: number) => { + const source = file.lines.find(l => l.line === oneBasedLine - 1); + if (!source) { + return undefined; + } + return kinds.get(refKey(file.uri, source.line, source.ranges[0].start.character)); + }; + for (const source of file.lines) { + report.push(` ${source.line + 1}: ` + + `${kinds.get(refKey(file.uri, source.line, source.ranges[0].start.character))}` + + ` ${source.text.trim()}`); + } + console.log(`[test] ${KIND_SYMBOL} kinds:\n${report.join('\n')}`); + + // 41: `selectedFrame = Profiler.Frames.Count - 1;` + assert.strictEqual(kindAt(41), 'write'); + // 43: `ImGui.SliderInt(string.Empty, ref selectedFrame, 0, ...)` + assert.strictEqual(kindAt(43), 'write'); + // 49: `Profiler.Frames.ElementAt(selectedFrame)` + assert.strictEqual(kindAt(49), 'read'); + // 56: `if (previousSelectedFrame != selectedFrame)` + assert.strictEqual(kindAt(56), 'read'); + // 58: `previousSelectedFrame = selectedFrame;` — a read of selectedFrame + assert.strictEqual(kindAt(58), 'read'); + + const writes = [...kinds.values()].filter(k => k === 'write').length; + const reads = [...kinds.values()].filter(k => k === 'read').length; + assert.ok(writes >= 2 && reads >= 3, `expected reads and writes, got ${reads}/${writes}`); + }); + + test('rows carry the kind, defaulting to read when unknown', 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); + + const kinds = await referenceKinds(results); + const { rows } = buildRows(results, new Map(), kinds); + assert.ok(rows.length > 0); + assert.ok(rows.every(r => r.kind === 'read' || r.kind === 'write'), + '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. + const { rows: bare } = buildRows(results, new Map()); + assert.ok(bare.every(r => r.kind === 'read')); + }); +});