Skip to content

Commit 9aa311a

Browse files
committed
Update Filter plugin docs
1 parent 45a757a commit 9aa311a

5 files changed

Lines changed: 143 additions & 117 deletions

File tree

.vitepress/config.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,13 @@ gtag('config', 'G-VYGDN3X0PR');`],
9797
{ text: '\#[Test]', link: '/docs/plugins/test.md' },
9898
{ text: 'Lifecycle', link: '/docs/plugins/lifecycle.md' },
9999
{ text: 'Convention', link: '/docs/plugins/convention.md' },
100+
{ text: 'Filter', link: '/docs/plugins/filter.md' },
100101
],
101102
},
102103
{
103104
text: 'Guide',
104105
items: [
105106
{ text: 'CLI Reference', link: '/docs/cli-reference.md' },
106-
{ text: 'Filtering', link: '/docs/plugins/filter.md' },
107107
],
108108
},
109109
{

.vitepress/func-block.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type MarkdownIt from 'markdown-it'
1212
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs'
1313
import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs'
1414
import { getLocaleByPath, type LocaleConfig } from './locales'
15-
import { getEntry, getAttrEntry } from './func-registry'
15+
import { getEntry, getAttrEntry, getClassEntry } from './func-registry'
1616
import { getPluginEntry } from './plugin-block'
1717

1818
interface Param {
@@ -128,10 +128,30 @@ function renderAttrRefHtml(md: MarkdownIt, rawFqn: string, locale?: LocaleConfig
128128
return renderRefHtml(md, rawFqn, locale, getAttrEntry, (d) => '#[' + d + ']', 'attr-ref')
129129
}
130130

131-
function renderKlassRefHtml(fqn: string): string {
132-
const short = escapeHtml(stripNamespaceShort(fqn))
131+
function renderKlassRefHtml(md: MarkdownIt, fqn: string, locale?: LocaleConfig): string {
132+
const short = stripNamespaceShort(fqn)
133+
const entry = getClassEntry(locale?.code ?? 'en', fqn)
134+
135+
if (entry) {
136+
const sigHtml = highlightSignature(md, entry.signature)
137+
const shortHtml = entry.short ? md.renderInline(entry.short) : ''
138+
139+
const tooltip = `<span class="func-ref-tooltip">`
140+
+ `<code class="func-ref-tooltip-sig vp-code">${sigHtml}</code>`
141+
+ (shortHtml ? `<span class="func-ref-tooltip-short">${shortHtml}</span>` : '')
142+
+ `</span>`
143+
144+
if (entry.hasAnchor) {
145+
const href = entry.pagePath + '#' + entry.slug
146+
return `<a href="${href}" class="class-ref func-ref vp-code">${escapeHtml(short)}${tooltip}</a>`
147+
}
148+
149+
return `<span class="class-ref func-ref vp-code">${escapeHtml(short)}${tooltip}</span>`
150+
}
151+
152+
// Fallback: FQN tooltip only
133153
const tooltip = `<span class="func-ref-tooltip"><code class="func-ref-tooltip-sig vp-code">${escapeHtml(fqn)}</code></span>`
134-
return `<span class="class-ref func-ref vp-code">${short}${tooltip}</span>`
154+
return `<span class="class-ref func-ref vp-code">${escapeHtml(short)}${tooltip}</span>`
135155
}
136156

137157
function renderPluginRefHtml(name: string, locale?: LocaleConfig): string {
@@ -236,7 +256,11 @@ export function funcBlockPlugin(md: MarkdownIt) {
236256

237257
// ── Block rules for inline tags at line start ──
238258

239-
registerBlockParagraphTag(md, 'class_block', 'class', (fqn) => renderKlassRefHtml(fqn))
259+
registerBlockParagraphTag(md, 'class_block', 'class', (fqn, state) => {
260+
const relativePath = state.env?.relativePath || ''
261+
const locale = getLocaleByPath('/' + relativePath)
262+
return renderKlassRefHtml(md, fqn, locale)
263+
})
240264

241265
registerBlockParagraphTag(md, 'func_line', 'func', (rawFqn, state) => {
242266
const relativePath = state.env?.relativePath || ''
@@ -297,7 +321,7 @@ export function funcBlockPlugin(md: MarkdownIt) {
297321
// ── Inline rules ──
298322

299323
registerInlineTag(md, 'func', 'func_ref', { before: 'html_inline' }, { withLocale: true })
300-
registerInlineTag(md, 'class', 'class_ref', { after: 'func_ref' })
324+
registerInlineTag(md, 'class', 'class_ref', { after: 'func_ref' }, { withLocale: true })
301325
registerInlineTag(md, 'plugin', 'plugin_ref', { after: 'class_ref' }, { withLocale: true })
302326
registerInlineTag(md, 'attr', 'attr_ref', { after: 'plugin_ref' }, { withLocale: true })
303327

@@ -309,7 +333,8 @@ export function funcBlockPlugin(md: MarkdownIt) {
309333
}
310334

311335
md.renderer.rules['class_ref'] = (tokens, idx) => {
312-
return renderKlassRefHtml(tokens[idx].content)
336+
const { content, meta } = tokens[idx]
337+
return renderKlassRefHtml(md, content, meta?.locale)
313338
}
314339

315340
md.renderer.rules['plugin_ref'] = (tokens, idx) => {
@@ -361,9 +386,10 @@ function renderFuncBlock(md: MarkdownIt, raw: string, locale?: LocaleConfig, env
361386
}
362387

363388
const isAttr = signature.startsWith('#[')
364-
const innerSig = isAttr ? signature.slice(2, -1) : signature
389+
const isClass = !isAttr && signature.startsWith('new ')
390+
const innerSig = isAttr ? signature.slice(2, -1) : isClass ? signature.slice(4) : signature
365391
const { display: innerDisplay } = stripNamespace(innerSig)
366-
const display = isAttr ? '#[' + innerDisplay + ']' : innerDisplay
392+
const display = isAttr ? '#[' + innerDisplay + ']' : isClass ? 'new ' + innerDisplay : innerDisplay
367393
const rawShortName = extractShortName(innerDisplay)
368394
const shortName = isAttr ? '#[' + rawShortName + ']' : rawShortName
369395
const paramsLabel = locale?.signatureParamsLabel ?? 'Parameters:'

.vitepress/func-registry.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/**
2-
* Signature registry for cross-referencing `<func>` and `<attr>` inline tags.
2+
* Signature registry for cross-referencing `<func>`, `<attr>`, and `<class>` inline tags.
33
*
44
* Pre-scans all .md files to collect `<signature>` blocks with FQN names,
55
* then provides lookup for inline references to generate links and tooltips.
6-
* Function signatures (e.g. `\Testo\Assert::same(...)`) and attribute signatures
7-
* (e.g. `#[\Testo\MyAttr(...)]`) are stored in separate registries.
6+
* Three separate registries:
7+
* - functions/methods: signatures with `::` or `->` (e.g. `\Testo\Assert::same(...)`)
8+
* - attributes: signatures starting with `#[` (e.g. `#[\Testo\MyAttr(...)]`)
9+
* - classes: plain FQN signatures (e.g. `\Testo\Filter(...)`) — covers classes, enums, traits, interfaces
810
*/
911
import { readFileSync, readdirSync, statSync } from 'fs'
1012
import { join, relative } from 'path'
@@ -22,6 +24,7 @@ export interface RegistryEntry {
2224
// locale code -> normalized FQN -> entry
2325
const registry = new Map<string, Map<string, RegistryEntry>>()
2426
const attrRegistry = new Map<string, Map<string, RegistryEntry>>()
27+
const classRegistry = new Map<string, Map<string, RegistryEntry>>()
2528

2629
/**
2730
* Normalize FQN by stripping arguments and return type.
@@ -67,6 +70,7 @@ function stripNamespaceForDisplay(signature: string): string {
6770
export function preScanSignatures(srcDir: string): void {
6871
registry.clear()
6972
attrRegistry.clear()
73+
classRegistry.clear()
7074
const mdFiles = collectMdFiles(srcDir)
7175

7276
// Match all <signature> blocks with a name attribute
@@ -89,15 +93,24 @@ export function preScanSignatures(srcDir: string): void {
8993
if (!nameMatch) continue
9094
const signature = nameMatch[1]
9195

92-
// Detect attribute signatures: #[\Namespace\Attr(...)]
96+
// Detect signature type by prefix:
97+
// - #[...] → attribute
98+
// - new ... → class/enum/trait/interface
99+
// - otherwise → function/method
93100
const isAttr = signature.startsWith('#[')
94-
const innerSig = isAttr ? signature.slice(2, -1) : signature
101+
const isClass = !isAttr && signature.startsWith('new ')
102+
const innerSig = isAttr ? signature.slice(2, -1) : isClass ? signature.slice(4) : signature
95103

96104
// Only collect FQN signatures (starting with \)
97105
if (!innerSig.startsWith('\\')) continue
98106

99107
const fqn = normalizeFqn(innerSig)
100-
const targetRegistry = isAttr ? attrRegistry : registry
108+
109+
// Route to appropriate registry:
110+
// - attributes (#[...]) → attrRegistry
111+
// - functions/methods (:: or ->) → registry
112+
// - classes/enums/traits/interfaces (plain FQN) → classRegistry
113+
const targetRegistry = isAttr ? attrRegistry : isClass ? classRegistry : registry
101114

102115
if (!targetRegistry.has(locale.code)) {
103116
targetRegistry.set(locale.code, new Map())
@@ -120,7 +133,8 @@ export function preScanSignatures(srcDir: string): void {
120133
fqn,
121134
slug: buildSlugFromFqn(innerSig),
122135
pagePath,
123-
signature: isAttr ? '#[' + displaySig + ']' : displaySig,
136+
signature: isAttr ? '#[' + displaySig + ']' : isClass ? 'new ' + displaySig : displaySig,
137+
124138
short,
125139
hasAnchor,
126140
})
@@ -144,6 +158,14 @@ export function getAttrEntry(localeCode: string, rawFqn: string): RegistryEntry
144158
return attrRegistry.get(localeCode)?.get(fqn)
145159
}
146160

161+
/**
162+
* Look up a class/enum/trait/interface signature entry by FQN and locale.
163+
*/
164+
export function getClassEntry(localeCode: string, rawFqn: string): RegistryEntry | undefined {
165+
const fqn = normalizeFqn(rawFqn)
166+
return classRegistry.get(localeCode)?.get(fqn)
167+
}
168+
147169
/**
148170
* Recursively collect all .md files in a directory.
149171
*/

docs/plugins/filter.md

Lines changed: 37 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@ llms_description: "Filter class API, name/path/suite filters, OR/AND combination
44

55
# Test Filtering
66

7-
This document describes the business logic of test filtering in Testo.
7+
This document describes the internal logic of the filtering plugin: the algorithm, pipeline stages, and criteria combination. If you just need to filter tests when running — see the [CLI reference](../cli-reference.md).
88

99
<plugin-info name="Filter" class="\Testo\Filter\FilterPlugin" included="\Testo\Application\Config\Plugin\ApplicationPlugins" />
1010

1111
## Overview
1212

13-
Testo provides a flexible filtering system that operates in multiple stages to progressively narrow down the test set. Filtering can be controlled programmatically via the <class>\Testo\Common\Filter</class> class or automatically from CLI arguments.
14-
15-
## Filter Class
16-
17-
The <class>\Testo\Common\Filter</class> class is an immutable DTO containing test filtering criteria:
13+
Testo provides a flexible filtering system that operates in multiple stages to progressively narrow down the test set. Filtering can be controlled programmatically via the <class>\Testo\Filter</class> class or automatically from CLI arguments.
1814

15+
<signature h="2" name="new \Testo\Filter(array $suites = [], array $names = [], array $paths = [], ?string $type = null)">
16+
<short>Immutable DTO containing test filtering criteria.</short>
17+
<param name="$suites">Test suite names to filter by.</param>
18+
<param name="$names">Class, method, or function names. Formats: `ClassName::methodName`, `Namespace\ClassName`, fragment `methodName`. Optional DataProvider indices via colon: `name:providerIndex:datasetIndex`.</param>
19+
<param name="$paths">File or directory paths. Supports glob patterns: `*`, `?`, `[abc]`.</param>
20+
<param name="$type">Test type: `test`, `inline`, `bench`, or other custom type. If not specified — all types are run.</param>
21+
<example>
1922
```php
2023
$filter = new Filter(
2124
suites: ['Unit', 'Integration'],
@@ -24,48 +27,31 @@ $filter = new Filter(
2427
type: 'test',
2528
);
2629
```
30+
</example>
31+
</signature>
2732

28-
### Properties
29-
30-
**`testSuites`**: `list<non-empty-string>`
31-
- Test suite names to filter by
32-
- Used in Stage 1 to determine which configuration scopes to load
33-
34-
**`names`**: `list<non-empty-string>`
35-
- Class, method, or function names to filter by
36-
- Supports three formats:
37-
- Method: `ClassName::methodName` or `Namespace\ClassName::methodName`
38-
- FQN: `Namespace\ClassName` or `Namespace\functionName`
39-
- Fragment: `methodName`, `functionName`, or `ShortClassName`
40-
- Optional DataProvider indices: `name:providerIndex:datasetIndex`
41-
- Provides indices for data provider module
42-
- Indices are 0-based and independent of dataset labels
43-
- `datasetIndex` is optional (omit to pass only provider index)
44-
- Examples: `UserTest::testLogin:0`, `testAuth:1:3`, `UserTest:0`
45-
46-
**`paths`**: `list<non-empty-string>`
47-
- File or directory paths to filter by
48-
- Supports glob patterns: `*`, `?`, `[abc]`
33+
### Usage
4934

50-
**`type`**: `?non-empty-string`
51-
- Test type to filter by
52-
- Possible values: `test` (regular tests), `inline` (inline tests), `bench` (benchmarks), or other custom types
53-
- If not specified — all test types are run
54-
- Middleware bound to a specific type won't enter the pipeline if the type doesn't match
35+
**Via CLI options** — when creating via `Application::createFromInput()`, the plugin automatically creates <class>\Testo\Filter</class> from command options: `--filter`, `--path`, `--suite`, `--type`:
5536

56-
### Usage with Application
37+
```php
38+
$app = Application::createFromInput(
39+
inputOptions: ['filter' => ['UserTest'], 'suite' => ['Unit']],
40+
);
41+
$result = $app->run();
42+
```
5743

58-
The <class>\Testo\Common\Filter</class> object can be passed to `Application::run()`:
44+
**Via container** — register the <class>\Testo\Filter</class> object directly:
5945

6046
```php
61-
$app = Application::createFromInput(/* ... */);
47+
$app = Application::createFromConfig($config);
6248

63-
$filter = new Filter(
49+
$app->getContainer()->set(Filter::class, new Filter(
6450
suites: ['Unit'],
6551
names: ['UserTest'],
66-
);
52+
));
6753

68-
$result = $app->run($filter);
54+
$result = $app->run();
6955
```
7056

7157
When running from CLI, the <class>\Testo\Common\Filter</class> is populated automatically from command arguments via `Filter::fromScope()`.
@@ -103,7 +89,7 @@ $filter = new Filter(
10389

10490
## Name Filter Behavior
10591

106-
The behavior of name filtering is implemented in `FilterInterceptor` and depends on the name format:
92+
The behavior of name filtering is implemented in <class>\Testo\Filter\Internal\FilterInterceptor</class> and depends on the name format:
10793

10894
### Method Format (`ClassName::methodName`)
10995

@@ -145,28 +131,30 @@ $filter = new Filter(names: ['testLogin']);
145131
// Result: All classes with testLogin method, each with only that method
146132
```
147133

148-
### DataProvider Indices
134+
### Narrowing by DataProvider and DataSet
149135

150-
When tests use data providers, names can include provider and dataset indices using colon separator. These indices become available to the data provider module.
136+
After the name, you can narrow down to a specific DataProvider via colon, and further to a specific DataSet within it via another colon.
151137

152138
**Format:** `name:providerIndex:datasetIndex`
153139

154-
- Indices are 0-based integers, independent of dataset labels
155-
- `datasetIndex` is optional - omit to pass only provider index
156-
- Works with all name formats (Method, FQN, Fragment)
140+
- The format maps to <class>\Testo\Filter\DataPointer</class> and is passed to the data provider module.
141+
- "Provider" here means any attribute that spawns a separate test: <attr>\Testo\Data\DataProvider</attr>, <attr>\Testo\Data\DataSet</attr>, <attr>\Testo\Inline\TestInline</attr>, <attr>\Testo\Bench\Bench</attr>, etc.
142+
- Indices are 0-based, independent of dataset labels.
143+
- `datasetIndex` is optional — you can specify only the provider.
144+
- Works with all name formats (method, FQN, fragment).
157145

158146
**Examples:**
159147
```php
160-
// Pass provider #0 index
148+
// First provider
161149
$filter = new Filter(names: ['UserTest::testLogin:0']);
162150

163-
// Pass provider #0 and dataset #1 indices
151+
// First provider, second dataset
164152
$filter = new Filter(names: ['UserTest::testLogin:0:1']);
165153

166-
// Pass provider #1 and dataset #3 indices, matching any test named 'testAuth'
154+
// Second provider, fourth dataset — for any test named testAuth
167155
$filter = new Filter(names: ['testAuth:1:3']);
168156

169-
// Pass provider #0 index for entire UserTest class
157+
// First provider for entire UserTest class
170158
$filter = new Filter(names: ['UserTest:0']);
171159
```
172160

@@ -227,7 +215,7 @@ Filtering operates in five stages:
227215

228216
## Pattern Matching
229217

230-
`FilterInterceptor` uses whole-word boundary matching with regex:
218+
<class>\Testo\Filter\Internal\FilterInterceptor</class> uses whole-word boundary matching with regex:
231219

232220
```php
233221
private static function has(string $needle, string $haystack): bool

0 commit comments

Comments
 (0)