Filter panel results by project and hide test code

Roadmap item 3. One Filters button opens a menu holding both, rather than
two more controls in a toolbar that is already crowded in a docked panel:

- Hide test code, with the count of test results.
- A checkbox per project with its result count, plus All / None.

The button shows how many filters are active and the summary line reads
"12 of 43 references" while anything is filtered. Projects are persisted as
an *exclusion* list, so a project that only appears in a later search shows
up instead of being silently hidden. Test hiding seeds from
coloredReferences.hideTests on first results, then follows the panel.

What counts as test code is coloredReferences.testPattern, a regular
expression matched against the workspace-relative path and against the
containing project name. Matching on names turned out to need care: the
first pattern classified Latest.cs as a test because "Latest" contains
"test". The built-in pattern now requires the name to start at a boundary,
or an uppercase T for the CamelCase FooTests.cs form, and covers test/tests
directories, Tests.cs, foo_test.go, test_foo.py, foo.spec.ts and projects
named Something.Tests. An invalid configured regex is reported once and
ignored rather than throwing per search.

These filters narrow the panel only; the editor view still lists everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
max
2026-09-07 17:50:02 +02:00
co-authored by Claude Opus 5
parent 3e8ece8ed7
commit 3df78ba490
8 changed files with 468 additions and 6 deletions
+26 -2
View File
@@ -1,7 +1,8 @@
import * as vscode from 'vscode';
import * as path from 'path';
import {
RefKind, ReferenceResults, applyKinds, containingMembers, displayLine, referenceKinds, relativePath,
RefKind, ReferenceResults, applyKinds, containingMembers, displayLine, isTest, referenceKinds,
relativePath, testPattern,
} from './references';
import { SEMANTIC_FILE_LIMIT, sourceLegend, sourceTokens } from './semantic';
@@ -80,6 +81,8 @@ export interface PanelRow {
kind: RefKind;
/** Semantic token spans within `code`, empty when the server serves none. */
spans: CodeSpan[];
/** Whether this reference lives in test code. */
isTest: boolean;
}
/**
@@ -97,9 +100,12 @@ export function buildRows(
const rows: PanelRow[] = [];
const locations = new Map<number, vscode.Location>();
const pattern = testPattern();
let id = 0;
for (const file of results.files) {
const relative = relativePath(file.uri);
const test = isTest(file.uri, file.project, pattern);
const dir = path.dirname(relative);
for (const source of file.lines) {
const { text, symbolRanges } = displayLine(source);
@@ -119,6 +125,7 @@ export function buildRows(
member,
kind: symbolRanges[index].kind,
spans: spans.get(`${file.uri.toString()}|${source.line}`) ?? [],
isTest: test,
});
locations.set(id, new vscode.Location(file.uri, range.start));
id++;
@@ -198,12 +205,23 @@ export class ResultsView {
const { rows, locations } = buildRows(results, members, kinds, spans);
this.locations = locations;
// Projects in result order, with counts, so the filter list needs no work
// in the webview.
const projects = new Map<string, number>();
for (const row of rows) {
projects.set(row.project, (projects.get(row.project) ?? 0) + 1);
}
this.post({
type: 'results',
symbol: results.symbol,
languageId: results.languageId,
fileCount: results.files.length,
writeCount: rows.filter(r => r.kind === 'write').length,
testCount: rows.filter(r => r.isTest).length,
projects: [...projects].map(([name, count]) => ({ name, count })),
hideTestsDefault: vscode.workspace.getConfiguration('coloredReferences')
.get<boolean>('hideTests', false),
rows,
});
}
@@ -293,8 +311,14 @@ export class ResultsView {
<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>
<div class="popover-host">
<button id="filters" class="toolbar-button" aria-expanded="false"
aria-haspopup="true" title="Filter by project, or hide test code">Filters</button>
<div id="filters-menu" class="popover" role="dialog"
aria-label="Project and test filters" hidden></div>
</div>
<button id="toggle-group" class="toolbar-button" aria-pressed="true"
title="Group results by file">Group by file</button>
title="Group results by file">Group</button>
<button id="refresh" class="toolbar-button" title="Re-run the search (F5)">Refresh</button>
</div>
<div id="table">
+63
View File
@@ -402,6 +402,69 @@ export function invalidateSymbols(uri: vscode.Uri): void {
symbolCache.delete(uri.toString());
}
// ---------------------------------------------------------------------------
// Test code
// ---------------------------------------------------------------------------
/**
* Matches test projects and test files by the usual conventions: a `test`/`tests`
* directory, a project or file named `…Test`/`…Tests`, Go's `_test.go`, and
* `.spec.` / `.test.` files.
*/
const TEST_EXTENSIONS = '(?:cs|fs|vb|ts|tsx|js|jsx|py|go|rs|java|kt)';
export const DEFAULT_TEST_PATTERN = [
// a test / tests directory anywhere in the path
'(?:^|[\\\\/])[Tt]ests?[\\\\/]',
// Tests.cs, foo_test.go, foo.test.ts — the name must *start* at a boundary, so
// "Latest.cs" and "Attestation.cs" are not caught by the "test" inside them
`(?:^|[\\\\/_.\\-])[Tt]ests?\\.${TEST_EXTENSIONS}$`,
// FooTests.cs — CamelCase, so only an uppercase T counts here
`[A-Za-z0-9]Tests?\\.${TEST_EXTENSIONS}$`,
// test_foo.py — pytest's default prefix convention
'(?:^|[\\\\/])[Tt]est_',
// foo.spec.ts
'\\.spec\\.(?:ts|tsx|js|jsx)$',
// a project named Tests or Something.Tests
'(?:^|\\.)[Tt]ests?$',
].join('|');
let cachedPattern: { source: string; regex: RegExp | undefined } | undefined;
/** The configured test pattern, compiled once. Invalid regexes are reported and ignored. */
export function testPattern(): RegExp | undefined {
const configured = vscode.workspace.getConfiguration('coloredReferences')
.get<string>('testPattern', '') ?? '';
// An unset setting means "use the built-in pattern"; to treat everything as
// production code, configure a pattern that cannot match.
const source = configured.trim().length > 0 ? configured : DEFAULT_TEST_PATTERN;
if (cachedPattern?.source === source) {
return cachedPattern.regex;
}
let regex: RegExp | undefined;
try {
regex = new RegExp(source);
} catch (error) {
void vscode.window.showWarningMessage(
`Colored References: coloredReferences.testPattern is not a valid regular expression (${error}).`);
}
cachedPattern = { source, regex };
return regex;
}
/** Whether a reference lives in test code, by path or by containing project name. */
export function isTest(
uri: vscode.Uri, project: string | undefined, pattern = testPattern(),
): boolean {
if (!pattern) {
return false;
}
// Test both separators so one pattern works on either platform.
const relative = relativePath(uri);
return pattern.test(relative) || pattern.test(relative.replace(/\//g, '\\')) ||
(project !== undefined && pattern.test(project));
}
// ---------------------------------------------------------------------------
// Read / write kind
// ---------------------------------------------------------------------------
+105
View File
@@ -0,0 +1,105 @@
import * as assert from 'assert';
import * as path from 'path';
import * as vscode from 'vscode';
import { buildRows } from '../../panel';
import { DEFAULT_TEST_PATTERN, gather, isTest, testPattern } from '../../references';
const TARGET_FILE = path.join('Nerfed.Runtime', 'Profiler.cs');
const TARGET_SYMBOL = 'Profiler';
const TARGET_DECL = 'public static class Profiler';
const builtIn = new RegExp(DEFAULT_TEST_PATTERN);
/** isTest against the built-in pattern, without touching configuration. */
function classify(relativePath: string, project?: string): boolean {
// isTest() resolves the path through asRelativePath, so feed it an absolute path
// inside the workspace and let it come back out relative.
const folder = vscode.workspace.workspaceFolders?.[0];
assert.ok(folder, 'no workspace folder');
const uri = vscode.Uri.joinPath(folder.uri, ...relativePath.split('/'));
return isTest(uri, project, builtIn);
}
suite('Test-code detection', () => {
test('the built-in pattern matches the usual conventions', () => {
assert.ok(classify('tests/Foo.cs'), 'tests/ directory');
assert.ok(classify('Nerfed.Runtime/Tests/Foo.cs'), 'nested Tests/ directory');
assert.ok(classify('src/FooTests.cs'), 'FooTests.cs');
assert.ok(classify('src/FooTest.cs'), 'FooTest.cs');
assert.ok(classify('pkg/thing_test.go'), 'Go test file');
assert.ok(classify('src/thing.spec.ts'), 'spec.ts');
assert.ok(classify('src/thing.test.ts'), 'test.ts');
assert.ok(classify('src/test_thing.py'), 'pytest prefix');
});
test('a project named after tests counts even when the path does not', () => {
assert.ok(classify('src/Widget.cs', 'Nerfed.Tests'), 'project Nerfed.Tests');
assert.ok(classify('src/Widget.cs', 'Tests'), 'project Tests');
assert.ok(!classify('src/Widget.cs', 'Nerfed.Runtime'), 'production project');
assert.ok(!classify('src/Widget.cs', 'Latest'), 'project ending in "test"');
assert.ok(!classify('src/Widget.cs', 'Contest'), 'project containing "test"');
});
test('production code is not mistaken for test code', () => {
assert.ok(!classify('Nerfed.Runtime/Profiler.cs'));
assert.ok(!classify('Nerfed.Runtime/Systems/LocalToWorldSystem.cs'));
// These all contain "test" without being test code — the boundary rules matter.
assert.ok(!classify('src/Latest.cs'), 'Latest.cs');
assert.ok(!classify('src/Protest.cs'), 'Protest.cs');
assert.ok(!classify('src/greatest.ts'), 'greatest.ts');
assert.ok(!classify('src/contest/Entry.cs'), 'contest/ directory');
assert.ok(!classify('src/Attestation.cs'), 'Attestation.cs');
});
test('a configured pattern that cannot match disables detection', () => {
assert.ok(!isTest(vscode.Uri.file('/repo/tests/Foo.cs'), 'Tests', new RegExp('$^')));
});
test('the default configuration resolves to the built-in pattern', () => {
const pattern = testPattern();
assert.ok(pattern, 'no pattern compiled');
assert.strictEqual(pattern.source, builtIn.source);
});
});
suite('Project and test filtering data', () => {
test('rows expose the project and a test flag the panel can filter on', async () => {
const ext = vscode.extensions.getExtension('local.colored-references');
assert.ok(ext);
await ext.activate();
const folder = vscode.workspace.workspaceFolders?.[0];
assert.ok(folder);
const uri = vscode.Uri.joinPath(folder.uri, ...TARGET_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(TARGET_DECL);
if (at >= 0) {
position = new vscode.Position(i, doc.lineAt(i).text.indexOf(TARGET_SYMBOL, at) + 1);
break;
}
}
const locations = await vscode.commands.executeCommand<vscode.Location[]>(
'vscode.executeReferenceProvider', uri, position);
assert.ok(locations && locations.length > 5);
const results = await gather(
TARGET_SYMBOL, 'csharp', { uri, position }, locations, true);
const { rows } = buildRows(results, new Map());
assert.ok(rows.length > 0);
// The Nerfed solution has no test projects, so nothing should be flagged —
// this is the false-positive check against real paths.
const flagged = rows.filter(r => r.isTest).map(r => r.relPath);
assert.deepStrictEqual([...new Set(flagged)], [],
`flagged as test code: ${[...new Set(flagged)].join(', ')}`);
const projects = [...new Set(rows.map(r => r.project))].sort();
console.log(`[test] projects in results: ${projects.join(', ')}`);
assert.ok(projects.length > 1, 'expected references in more than one project');
assert.ok(projects.every(p => p.length > 0), 'every row should name a project');
});
});
+1
View File
@@ -14,6 +14,7 @@ export function run(): Promise<void> {
mocha.addFile(path.resolve(__dirname, 'panel.test.js'));
mocha.addFile(path.resolve(__dirname, 'kinds.test.js'));
mocha.addFile(path.resolve(__dirname, 'semantic.test.js'));
mocha.addFile(path.resolve(__dirname, 'filters.test.js'));
return new Promise((resolve, reject) => {
try {