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:
+26
-2
@@ -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">
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user