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
+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'));
});
});