Show read/write kind and let the panel filter on it

The reference request carries no read/write information, so derive it:

- Classify every reference syntactically first — assignment and compound
  assignment, ++/--, and ref/out arguments are writes, everything else is a
  read. The suffix test only looks past the end of the reference, so the `=`
  in `previous = x` does not make the read of `x` look like a write.
- Then ask textDocument/documentHighlight per file, whose Read/Write kinds
  override the syntactic answer. Plain Text highlights carry no kind and
  leave it standing, so servers without the feature still get a sensible
  column. Files have to be opened as text documents for the server to
  answer, so results spanning more than 60 files skip this step.

In the panel: a sortable Kind column with read/write badges, All / Reads /
Writes buttons, the kind included in the text filter, and a write count in
the summary line. The kind filter is persisted with the rest of the view
state. Line and Kind columns carry minimum widths that fit their own
headers, which Line previously did not.

Verified against DotRush on a field that is both read and written: the
assignment and the `ref` argument classify as writes, the subscript, the
comparison and the right-hand-side use as reads.

The editor view does not mark writes yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
max
2026-09-07 16:49:42 +02:00
co-authored by Claude Opus 5
parent 92ff02f7ff
commit 9b2dbc4522
7 changed files with 379 additions and 14 deletions
+14 -4
View File
@@ -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
+49 -1
View File
@@ -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);
}
+48 -5
View File
@@ -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();
+14 -4
View File
@@ -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<string, string>,
results: ReferenceResults,
members: Map<string, string>,
kinds: Map<string, RefKind> = new Map(),
): { rows: PanelRow[]; locations: Map<number, vscode.Location> } {
const rows: PanelRow[] = [];
const locations = new Map<number, vscode.Location>();
@@ -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 {
<div id="toolbar">
<span id="summary"></span>
<input id="filter" type="text" placeholder="Filter results (Ctrl+F)" spellcheck="false">
<div id="kind-filter" class="segmented" role="group" aria-label="Filter by read or write"></div>
<button id="toggle-group" class="toolbar-button" aria-pressed="true"
title="Group results by file">Group by file</button>
<button id="refresh" class="toolbar-button" title="Re-run the search (F5)">Refresh</button>
+101
View File
@@ -369,3 +369,104 @@ export async function containingMembers(results: ReferenceResults): Promise<Map<
export function invalidateSymbols(uri: vscode.Uri): void {
symbolCache.delete(uri.toString());
}
// ---------------------------------------------------------------------------
// Read / write kind
// ---------------------------------------------------------------------------
export type RefKind = 'read' | 'write';
/** Key for the per-reference maps: file, 0-based line, 0-based start column. */
export function refKey(uri: vscode.Uri, line: number, character: number): string {
return `${uri.toString()}|${line}|${character}`;
}
/** Assignment operators, and `++`/`--`, immediately after the reference. */
const ASSIGNED_AFTER = /^\s*(=(?!=)|[-+*/%&|^]=|<<=|>>>?=|\?\?=|\|\|=|&&=|\+\+|--)/;
/** `++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<vscode.DocumentHighlight[]> {
try {
await vscode.workspace.openTextDocument(uri);
} catch {
return [];
}
const highlights = await timeout(
vscode.commands.executeCommand<vscode.DocumentHighlight[]>(
'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<Map<string, RefKind>> {
const kinds = new Map<string, RefKind>();
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;
}
+1
View File
@@ -12,6 +12,7 @@ export function run(): Promise<void> {
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 {
+152
View File
@@ -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.Location[]>(
'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.Location[]>(
'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'));
});
});