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:
@@ -11,8 +11,8 @@ highlighted like search matches; **writes are bold** on the theme's stronger wri
|
||||
same colour the editor itself uses for a write.
|
||||
|
||||
**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**
|
||||
Project and Containing member — grouped by file, with a text filter plus filters for reads/writes, project and
|
||||
test code. 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.
|
||||
|
||||
@@ -36,6 +36,19 @@ The **All / Reads / Writes** buttons filter by kind, and the summary line counts
|
||||
the text filter, so typing `write` narrows to writes as well. See *Known limitations* for where the kind comes
|
||||
from.
|
||||
|
||||
**Filters** opens a menu to hide test code or to include only some projects, each with its result count. The
|
||||
button shows how many filters are active, and the summary line reads *"12 of 43 references"* while anything is
|
||||
filtered. Choices persist per panel, and projects are remembered as an *exclusion* list — a project that only
|
||||
turns up in a later search shows up rather than being silently hidden.
|
||||
|
||||
What counts as test code is a regular expression, `coloredReferences.testPattern`, matched against each
|
||||
result's workspace-relative path and against its containing project name. The built-in pattern covers
|
||||
`test`/`tests` directories, `FooTests.cs`, `Tests.cs`, `foo_test.go`, `test_foo.py`, `foo.spec.ts` and projects
|
||||
named `Something.Tests` — without catching names that merely contain the word, like `Latest.cs` or a `contest`
|
||||
directory. `coloredReferences.hideTests` sets whether the filter starts on.
|
||||
|
||||
These filters narrow what the panel shows; the editor view always lists every reference.
|
||||
|
||||
## Semantic tokens
|
||||
|
||||
VS Code colors code twice: a TextMate grammar handles keywords, strings and comments from the text alone, then
|
||||
@@ -63,6 +76,9 @@ grammar-only colouring; results spanning more than 40 files skip it.
|
||||
- `coloredReferences.openBeside` — open results beside the current editor (default `true`)
|
||||
- `coloredReferences.showProject` — show the containing project in file headers (default `true`)
|
||||
- `coloredReferences.reuseTab` — reuse one results tab/panel instead of opening a new one per search (default `true`)
|
||||
- `coloredReferences.hideTests` — start the panel with test code hidden (default `false`)
|
||||
- `coloredReferences.testPattern` — regular expression deciding what counts as test code; empty uses the
|
||||
built-in pattern
|
||||
|
||||
## Install
|
||||
|
||||
@@ -107,6 +123,6 @@ the expected results stay deterministic.
|
||||
1. ~~Webview panel with resizable, sortable columns~~ — done; missing: virtualized rendering for very large
|
||||
result sets, and remembering column layout per workspace rather than per panel
|
||||
2. ~~Semantic token overlay~~ — done in both views
|
||||
3. Filter by project / exclude tests
|
||||
3. ~~Filter by project / exclude tests~~ — done in the panel
|
||||
4. ~~Read/write kind~~ — done in both views
|
||||
|
||||
|
||||
@@ -345,3 +345,98 @@ body.resizing {
|
||||
background: var(--vscode-inputValidation-warningBackground);
|
||||
border-color: var(--vscode-inputValidation-warningBorder, transparent);
|
||||
}
|
||||
|
||||
/* --- filters popover ---------------------------------------------------- */
|
||||
|
||||
.popover-host {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
color: var(--vscode-menu-foreground, var(--vscode-foreground));
|
||||
background: var(--vscode-menu-background, var(--vscode-editorWidget-background));
|
||||
border: 1px solid var(--vscode-menu-border, var(--vscode-editorWidget-border));
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.36));
|
||||
}
|
||||
|
||||
.popover[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popover-heading {
|
||||
padding: 6px 6px 2px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 0.9em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.popover-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.popover-row:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.popover-row.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.popover-row input {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
accent-color: var(--vscode-checkbox-background, var(--vscode-button-background));
|
||||
}
|
||||
|
||||
.popover-label {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.popover-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.popover-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 6px 2px;
|
||||
border-top: 1px solid var(--vscode-menu-separatorBackground, var(--vscode-panel-border));
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
padding: 0;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
+149
-1
@@ -28,7 +28,9 @@
|
||||
{ value: 'write', label: 'Writes', title: 'Show only references that write the symbol' },
|
||||
];
|
||||
|
||||
/** @type {{symbol: string, languageId: string, rows: any[], fileCount: number}} */
|
||||
/** @type {{symbol: string, languageId: string, rows: any[], fileCount: number,
|
||||
* projects?: {name: string, count: number}[], testCount?: number,
|
||||
* hideTestsDefault?: boolean}} */
|
||||
let data = { symbol: '', languageId: '', rows: [], fileCount: 0 };
|
||||
|
||||
const restored = vscode.getState() || {};
|
||||
@@ -38,6 +40,11 @@
|
||||
group: restored.group !== false,
|
||||
filter: restored.filter || '',
|
||||
kind: KIND_FILTERS.some(k => k.value === restored.kind) ? restored.kind : 'all',
|
||||
// Projects are stored as an *exclusion* list, so a project that only turns up in
|
||||
// a later search is visible by default rather than silently filtered out.
|
||||
hiddenProjects: Array.isArray(restored.hiddenProjects) ? restored.hiddenProjects : [],
|
||||
// undefined until the first results arrive, then seeded from the setting.
|
||||
hideTests: typeof restored.hideTests === 'boolean' ? restored.hideTests : undefined,
|
||||
widths: Object.assign({}, restored.widths),
|
||||
collapsed: Object.assign({}, restored.collapsed),
|
||||
selectedId: restored.selectedId,
|
||||
@@ -47,6 +54,8 @@
|
||||
summary: /** @type {HTMLElement} */ (document.getElementById('summary')),
|
||||
filter: /** @type {HTMLInputElement} */ (document.getElementById('filter')),
|
||||
kindFilter: /** @type {HTMLElement} */ (document.getElementById('kind-filter')),
|
||||
filters: /** @type {HTMLButtonElement} */ (document.getElementById('filters')),
|
||||
filtersMenu: /** @type {HTMLElement} */ (document.getElementById('filters-menu')),
|
||||
group: /** @type {HTMLButtonElement} */ (document.getElementById('toggle-group')),
|
||||
refresh: /** @type {HTMLButtonElement} */ (document.getElementById('refresh')),
|
||||
table: /** @type {HTMLElement} */ (document.getElementById('table')),
|
||||
@@ -404,6 +413,12 @@
|
||||
if (state.kind !== 'all' && row.kind !== state.kind) {
|
||||
return false;
|
||||
}
|
||||
if (state.hideTests && row.isTest) {
|
||||
return false;
|
||||
}
|
||||
if (state.hiddenProjects.indexOf(row.project) >= 0) {
|
||||
return false;
|
||||
}
|
||||
if (!needle) {
|
||||
return true;
|
||||
}
|
||||
@@ -638,6 +653,128 @@
|
||||
renderRows();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Project / test filters
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function activeFilterCount() {
|
||||
return state.hiddenProjects.filter(
|
||||
p => (data.projects || []).some(entry => entry.name === p)).length +
|
||||
(state.hideTests ? 1 : 0);
|
||||
}
|
||||
|
||||
function refreshFiltersButton() {
|
||||
const active = activeFilterCount();
|
||||
el.filters.textContent = active > 0 ? `Filters (${active})` : 'Filters';
|
||||
el.filters.setAttribute('aria-pressed', String(active > 0));
|
||||
}
|
||||
|
||||
function checkboxRow(label, checked, onToggle, count) {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'popover-row';
|
||||
|
||||
const box = document.createElement('input');
|
||||
box.type = 'checkbox';
|
||||
box.checked = checked;
|
||||
box.addEventListener('change', () => onToggle(box.checked));
|
||||
row.appendChild(box);
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'popover-label';
|
||||
text.textContent = label;
|
||||
row.appendChild(text);
|
||||
|
||||
if (count !== undefined) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'popover-count';
|
||||
badge.textContent = String(count);
|
||||
row.appendChild(badge);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildFiltersMenu() {
|
||||
const menu = el.filtersMenu;
|
||||
menu.textContent = '';
|
||||
|
||||
const tests = data.testCount || 0;
|
||||
const testRow = checkboxRow('Hide test code', !!state.hideTests, checked => {
|
||||
state.hideTests = checked;
|
||||
saveState();
|
||||
buildFiltersMenu();
|
||||
refreshFiltersButton();
|
||||
renderRows();
|
||||
}, tests);
|
||||
if (tests === 0) {
|
||||
testRow.classList.add('disabled');
|
||||
testRow.title = 'No results are in test code';
|
||||
}
|
||||
menu.appendChild(testRow);
|
||||
|
||||
const projects = data.projects || [];
|
||||
if (projects.length > 1) {
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'popover-heading';
|
||||
heading.textContent = 'Projects';
|
||||
menu.appendChild(heading);
|
||||
|
||||
for (const project of projects) {
|
||||
const shown = state.hiddenProjects.indexOf(project.name) < 0;
|
||||
menu.appendChild(checkboxRow(project.name || '(no project)', shown, checked => {
|
||||
state.hiddenProjects = state.hiddenProjects.filter(p => p !== project.name);
|
||||
if (!checked) {
|
||||
state.hiddenProjects.push(project.name);
|
||||
}
|
||||
saveState();
|
||||
buildFiltersMenu();
|
||||
refreshFiltersButton();
|
||||
renderRows();
|
||||
}, project.count));
|
||||
}
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'popover-actions';
|
||||
for (const [label, hidden] of [['All', []], ['None', projects.map(p => p.name)]]) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'link-button';
|
||||
button.textContent = /** @type {string} */ (label);
|
||||
button.addEventListener('click', () => {
|
||||
state.hiddenProjects = /** @type {string[]} */ (hidden).slice();
|
||||
saveState();
|
||||
buildFiltersMenu();
|
||||
refreshFiltersButton();
|
||||
renderRows();
|
||||
});
|
||||
actions.appendChild(button);
|
||||
}
|
||||
menu.appendChild(actions);
|
||||
}
|
||||
}
|
||||
|
||||
function openFilters(open) {
|
||||
el.filtersMenu.hidden = !open;
|
||||
el.filters.setAttribute('aria-expanded', String(open));
|
||||
if (open) {
|
||||
buildFiltersMenu();
|
||||
const first = el.filtersMenu.querySelector('input');
|
||||
if (first) {
|
||||
/** @type {HTMLElement} */ (first).focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
el.filters.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
openFilters(el.filtersMenu.hidden);
|
||||
});
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
const target = /** @type {HTMLElement} */ (event.target);
|
||||
if (!el.filtersMenu.hidden && !el.filtersMenu.contains(target) && target !== el.filters) {
|
||||
openFilters(false);
|
||||
}
|
||||
});
|
||||
|
||||
function buildKindFilter() {
|
||||
el.kindFilter.textContent = '';
|
||||
for (const option of KIND_FILTERS) {
|
||||
@@ -671,6 +808,9 @@
|
||||
event.preventDefault();
|
||||
el.filter.focus();
|
||||
el.filter.select();
|
||||
} else if (event.key === 'Escape' && !el.filtersMenu.hidden) {
|
||||
openFilters(false);
|
||||
el.filters.focus();
|
||||
} else if (event.key === 'Escape' && document.activeElement === el.filter) {
|
||||
el.filter.value = '';
|
||||
state.filter = '';
|
||||
@@ -692,6 +832,13 @@
|
||||
if (message.type === 'results') {
|
||||
const newSymbol = message.symbol !== data.symbol;
|
||||
data = message;
|
||||
if (typeof state.hideTests !== 'boolean') {
|
||||
state.hideTests = !!data.hideTestsDefault;
|
||||
}
|
||||
refreshFiltersButton();
|
||||
if (!el.filtersMenu.hidden) {
|
||||
buildFiltersMenu();
|
||||
}
|
||||
// Row ids are positional, so a selection carried over from another symbol
|
||||
// would point at an unrelated reference.
|
||||
if (newSymbol || !data.rows.some(r => r.id === state.selectedId)) {
|
||||
@@ -716,6 +863,7 @@
|
||||
applyPalette();
|
||||
el.filter.value = state.filter;
|
||||
buildKindFilter();
|
||||
refreshFiltersButton();
|
||||
el.group.setAttribute('aria-pressed', String(state.group));
|
||||
el.body.tabIndex = 0;
|
||||
computeAutoWidths();
|
||||
|
||||
@@ -165,6 +165,16 @@
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Use semantic tokens from the language server so identifiers are coloured by what they are. The editor view gets the theme-exact colouring; the panel gets the classification with an approximated palette. Turn off for grammar-only colouring."
|
||||
},
|
||||
"coloredReferences.hideTests": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Hide references in test code by default. The panel's Filters button toggles it per search."
|
||||
},
|
||||
"coloredReferences.testPattern": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "Regular expression deciding what counts as test code, matched against each result's workspace-relative path and against its containing project name. Leave empty for the built-in pattern, which covers `test`/`tests` directories, `*Tests.cs`, `*_test.go`, `*.spec.ts` and projects named `*.Tests`. Set it to a pattern that matches nothing (for example `$^`) to treat everything as production code."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+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