Skip to content

Commit 0de5aa0

Browse files
committed
Add attributes signatures support; update docs about Inline plugin
1 parent e8ae92b commit 0de5aa0

21 files changed

Lines changed: 263 additions & 297 deletions

.vitepress/config.mts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,14 @@ gtag('config', 'G-VYGDN3X0PR');`],
8989
text: 'Plugins',
9090
link: '/docs/plugins.md',
9191
items: [
92-
{ text: 'Test Attribute', link: '/docs/plugins/test.md' },
9392
{ text: 'Assert & Expect', link: '/docs/plugins/assert.md' },
94-
{ text: 'Naming Conventions', link: '/docs/plugins/convention.md' },
9593
{ text: 'Inline Tests', link: '/docs/plugins/inline.md' },
9694
{ text: 'Data Providers', link: '/docs/plugins/data.md' },
97-
{ text: 'Lifecycle', link: '/docs/plugins/lifecycle.md' },
9895
{ text: 'Retry', link: '/docs/plugins/retry.md' },
99-
{ text: 'Benchmarks', link: '/docs/plugins/bench.md' },
96+
{ text: 'Bench', link: '/docs/plugins/bench.md' },
97+
{ text: '\#[Test]', link: '/docs/plugins/test.md' },
98+
{ text: 'Lifecycle', link: '/docs/plugins/lifecycle.md' },
99+
{ text: 'Convention', link: '/docs/plugins/convention.md' },
100100
],
101101
},
102102
{

.vitepress/func-block.ts

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
/**
2-
* Function signature block plugin for markdown-it.
2+
* Function/attribute signature block plugin for markdown-it.
33
*
44
* Tags:
5-
* <signature> — API reference card with Shiki-highlighted PHP signature
6-
* <func> — inline/block cross-reference to a <signature> block (tooltip + link)
5+
* <signature> — API reference card with Shiki-highlighted PHP signature (functions and attributes)
6+
* <func> — inline/block cross-reference to a function <signature> (tooltip + link)
7+
* <attr> — inline/block cross-reference to an attribute <signature> (tooltip + link)
78
* <class> — inline/block tag rendering short class name with FQN tooltip
89
* <plugin> — inline/block link to a plugin page by name (from plugin-block registry)
910
*/
1011
import type MarkdownIt from 'markdown-it'
1112
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs'
1213
import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs'
1314
import { getLocaleByPath, type LocaleConfig } from './locales'
14-
import { getEntry } from './func-registry'
15+
import { getEntry, getAttrEntry } from './func-registry'
1516
import { getPluginEntry } from './plugin-block'
1617

1718
interface Param {
@@ -84,9 +85,17 @@ function highlightSignature(md: MarkdownIt, signature: string): string {
8485

8586
// ─── Inline tag renderers (return HTML string) ──────────
8687

87-
function renderFuncRefHtml(md: MarkdownIt, rawFqn: string, locale?: LocaleConfig): string {
88-
const displayFqn = stripNamespace(rawFqn).display
89-
const entry = getEntry(locale?.code ?? 'en', rawFqn)
88+
function renderRefHtml(
89+
md: MarkdownIt,
90+
rawFqn: string,
91+
locale: LocaleConfig | undefined,
92+
lookupEntry: typeof getEntry,
93+
wrapDisplay?: (display: string) => string,
94+
extraClass?: string,
95+
): string {
96+
const baseDisplay = stripNamespace(rawFqn).display
97+
const displayFqn = wrapDisplay ? wrapDisplay(baseDisplay) : baseDisplay
98+
const entry = lookupEntry(locale?.code ?? 'en', rawFqn)
9099

91100
if (entry) {
92101
const sigHtml = highlightSignature(md, entry.signature)
@@ -98,17 +107,27 @@ function renderFuncRefHtml(md: MarkdownIt, rawFqn: string, locale?: LocaleConfig
98107
+ (shortHtml ? `<span class="func-ref-tooltip-short">${shortHtml}</span>` : '')
99108
+ `</span>`
100109

110+
const cls = extraClass ? `func-ref ${extraClass} vp-code` : 'func-ref vp-code'
111+
101112
if (entry.hasAnchor) {
102113
const href = entry.pagePath + '#' + entry.slug
103-
return `<a href="${href}" class="func-ref vp-code">${displayHtml}${tooltip}</a>`
114+
return `<a href="${href}" class="${cls}">${displayHtml}${tooltip}</a>`
104115
}
105116

106-
return `<span class="func-ref vp-code">${displayHtml}${tooltip}</span>`
117+
return `<span class="${cls}">${displayHtml}${tooltip}</span>`
107118
}
108119

109120
return `<code>${escapeHtml(displayFqn)}</code>`
110121
}
111122

123+
function renderFuncRefHtml(md: MarkdownIt, rawFqn: string, locale?: LocaleConfig): string {
124+
return renderRefHtml(md, rawFqn, locale, getEntry)
125+
}
126+
127+
function renderAttrRefHtml(md: MarkdownIt, rawFqn: string, locale?: LocaleConfig): string {
128+
return renderRefHtml(md, rawFqn, locale, getAttrEntry, (d) => '#[' + d + ']', 'attr-ref')
129+
}
130+
112131
function renderKlassRefHtml(fqn: string): string {
113132
const short = escapeHtml(stripNamespaceShort(fqn))
114133
const tooltip = `<span class="func-ref-tooltip"><code class="func-ref-tooltip-sig vp-code">${escapeHtml(fqn)}</code></span>`
@@ -231,6 +250,12 @@ export function funcBlockPlugin(md: MarkdownIt) {
231250
return renderPluginRefHtml(name, locale)
232251
})
233252

253+
registerBlockParagraphTag(md, 'attr_line', 'attr', (rawFqn, state) => {
254+
const relativePath = state.env?.relativePath || ''
255+
const locale = getLocaleByPath('/' + relativePath)
256+
return renderAttrRefHtml(md, rawFqn, locale)
257+
})
258+
234259
// ── <signature> block rule (multi-line) ──
235260

236261
md.block.ruler.before('html_block', 'func_block', (state, startLine, endLine, silent) => {
@@ -274,6 +299,7 @@ export function funcBlockPlugin(md: MarkdownIt) {
274299
registerInlineTag(md, 'func', 'func_ref', { before: 'html_inline' }, { withLocale: true })
275300
registerInlineTag(md, 'class', 'class_ref', { after: 'func_ref' })
276301
registerInlineTag(md, 'plugin', 'plugin_ref', { after: 'class_ref' }, { withLocale: true })
302+
registerInlineTag(md, 'attr', 'attr_ref', { after: 'plugin_ref' }, { withLocale: true })
277303

278304
// ── Inline renderers ──
279305

@@ -290,6 +316,11 @@ export function funcBlockPlugin(md: MarkdownIt) {
290316
const { content, meta } = tokens[idx]
291317
return renderPluginRefHtml(content, meta?.locale)
292318
}
319+
320+
md.renderer.rules['attr_ref'] = (tokens, idx) => {
321+
const { content, meta } = tokens[idx]
322+
return renderAttrRefHtml(md, content, meta?.locale)
323+
}
293324
}
294325

295326
// ─── <signature> block renderer ─────────────────────────
@@ -329,11 +360,15 @@ function renderFuncBlock(md: MarkdownIt, raw: string, locale?: LocaleConfig, env
329360
examples.push(em[1].trim())
330361
}
331362

332-
const { display } = stripNamespace(signature)
333-
const shortName = extractShortName(display)
363+
const isAttr = signature.startsWith('#[')
364+
const innerSig = isAttr ? signature.slice(2, -1) : signature
365+
const { display: innerDisplay } = stripNamespace(innerSig)
366+
const display = isAttr ? '#[' + innerDisplay + ']' : innerDisplay
367+
const rawShortName = extractShortName(innerDisplay)
368+
const shortName = isAttr ? '#[' + rawShortName + ']' : rawShortName
334369
const paramsLabel = locale?.signatureParamsLabel ?? 'Parameters:'
335370
const examplesLabel = locale?.signatureExamplesLabel ?? 'Examples:'
336-
const slug = buildSlug(signature)
371+
const slug = buildSlug(innerSig)
337372
const sigHtml = highlightSignature(md, display)
338373

339374
if (compact) {

.vitepress/func-registry.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
/**
2-
* Signature registry for cross-referencing `<func>` inline tags.
2+
* Signature registry for cross-referencing `<func>` and `<attr>` inline tags.
33
*
4-
* Pre-scans all .md files to collect `<signature>` blocks with FQN names and h > 0,
5-
* then provides lookup for inline `<func>` references to generate links and tooltips.
4+
* Pre-scans all .md files to collect `<signature>` blocks with FQN names,
5+
* 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.
68
*/
79
import { readFileSync, readdirSync, statSync } from 'fs'
810
import { join, relative } from 'path'
@@ -19,6 +21,7 @@ export interface RegistryEntry {
1921

2022
// locale code -> normalized FQN -> entry
2123
const registry = new Map<string, Map<string, RegistryEntry>>()
24+
const attrRegistry = new Map<string, Map<string, RegistryEntry>>()
2225

2326
/**
2427
* Normalize FQN by stripping arguments and return type.
@@ -63,6 +66,7 @@ function stripNamespaceForDisplay(signature: string): string {
6366
*/
6467
export function preScanSignatures(srcDir: string): void {
6568
registry.clear()
69+
attrRegistry.clear()
6670
const mdFiles = collectMdFiles(srcDir)
6771

6872
// Match all <signature> blocks with a name attribute
@@ -74,11 +78,6 @@ export function preScanSignatures(srcDir: string): void {
7478
const locale = getLocaleByPath('/' + relPath)
7579
const pagePath = '/' + relPath.replace(/\.md$/, '').replace(/\/index$/, '/')
7680

77-
if (!registry.has(locale.code)) {
78-
registry.set(locale.code, new Map())
79-
}
80-
const localeMap = registry.get(locale.code)!
81-
8281
sigRe.lastIndex = 0
8382
let match: RegExpExecArray | null
8483
while ((match = sigRe.exec(content)) !== null) {
@@ -90,10 +89,20 @@ export function preScanSignatures(srcDir: string): void {
9089
if (!nameMatch) continue
9190
const signature = nameMatch[1]
9291

92+
// Detect attribute signatures: #[\Namespace\Attr(...)]
93+
const isAttr = signature.startsWith('#[')
94+
const innerSig = isAttr ? signature.slice(2, -1) : signature
95+
9396
// Only collect FQN signatures (starting with \)
94-
if (!signature.startsWith('\\')) continue
97+
if (!innerSig.startsWith('\\')) continue
9598

96-
const fqn = normalizeFqn(signature)
99+
const fqn = normalizeFqn(innerSig)
100+
const targetRegistry = isAttr ? attrRegistry : registry
101+
102+
if (!targetRegistry.has(locale.code)) {
103+
targetRegistry.set(locale.code, new Map())
104+
}
105+
const localeMap = targetRegistry.get(locale.code)!
97106

98107
// Skip if already registered (first wins)
99108
if (localeMap.has(fqn)) continue
@@ -105,11 +114,13 @@ export function preScanSignatures(srcDir: string): void {
105114
const shortMatch = body.match(/<short>([\s\S]*?)<\/short>/)
106115
const short = shortMatch ? shortMatch[1].trim() : ''
107116

117+
const displaySig = stripNamespaceForDisplay(innerSig)
118+
108119
localeMap.set(fqn, {
109120
fqn,
110-
slug: buildSlugFromFqn(signature),
121+
slug: buildSlugFromFqn(innerSig),
111122
pagePath,
112-
signature: stripNamespaceForDisplay(signature),
123+
signature: isAttr ? '#[' + displaySig + ']' : displaySig,
113124
short,
114125
hasAnchor,
115126
})
@@ -118,13 +129,21 @@ export function preScanSignatures(srcDir: string): void {
118129
}
119130

120131
/**
121-
* Look up a signature entry by FQN and locale.
132+
* Look up a function signature entry by FQN and locale.
122133
*/
123134
export function getEntry(localeCode: string, rawFqn: string): RegistryEntry | undefined {
124135
const fqn = normalizeFqn(rawFqn)
125136
return registry.get(localeCode)?.get(fqn)
126137
}
127138

139+
/**
140+
* Look up an attribute signature entry by FQN and locale.
141+
*/
142+
export function getAttrEntry(localeCode: string, rawFqn: string): RegistryEntry | undefined {
143+
const fqn = normalizeFqn(rawFqn)
144+
return attrRegistry.get(localeCode)?.get(fqn)
145+
}
146+
128147
/**
129148
* Recursively collect all .md files in a directory.
130149
*/

blog/collider.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Here's my reasoning:
2929

3030
> The array solution should be slower than the `for` loop, since extra resources go into computing hashes for the hash table when creating the array, and more memory is needed for intermediate values.
3131
32-
Let's verify this: we'll write the functions and add the `#[Bench]` attribute to one of them.
32+
Let's verify this: we'll write the functions and add the <attr>\Testo\Bench</attr> attribute to one of them.
3333

3434
```php
3535
#[Bench(
@@ -56,7 +56,7 @@ public static function sumInArray(int $a, int $b): int
5656
}
5757
```
5858

59-
With the `#[Bench]` attribute, we're telling Testo that:
59+
With the <attr>\Testo\Bench</attr> attribute, we're telling Testo that:
6060
- we want to compare the performance of the current function (`sumInCycle`) with another function (`sumInArray`);
6161
- both functions will receive the same arguments: `1` and `5_000`;
6262
- to measure execution time, each function will be called 100 times in a row (`calls: 100`).
@@ -94,7 +94,7 @@ Statistics comes to the rescue with the [coefficient of variation](https://en.wi
9494
The smaller this coefficient, the more stable the results.
9595

9696
All we need to do is collect more data spread over time — that is, rerun the benchmarks multiple times.
97-
The `#[Bench]` attribute has an `iterations` parameter responsible for the number of benchmark reruns.
97+
The <attr>\Testo\Bench</attr> attribute has an `iterations` parameter responsible for the number of benchmark reruns.
9898

9999
Let's set `iterations: 10` and rerun:
100100

docs/cli-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ Filter tests by type. If specified, only tests of the matching type are run.
156156

157157
**Possible values:**
158158
- `test` — regular tests (methods in classes)
159-
- `inline`[inline tests](plugins/inline.md) (tests via `#[TestInline]`)
159+
- `inline`[inline tests](plugins/inline.md) (tests via <attr>\Testo\Inline\TestInline</attr>)
160160
- `bench`[benchmarks](plugins/bench.md)
161161

162162
**Examples:**

docs/getting-started.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ To learn more about configuration, visit the [Configuration](configuration.md) s
5555

5656
## Writing Your First Test
5757

58-
Create a test class in the configured directory (e.g., `tests/Unit/MyFirstTest.php`) and add a method with the `#[Test]` attribute:
58+
Create a test class in the configured directory (e.g., `tests/Unit/MyFirstTest.php`) and add a method with the <attr>\Testo\Test</attr> attribute:
5959

6060
```php
6161
final class MyFirstTest
@@ -71,7 +71,7 @@ final class MyFirstTest
7171
}
7272
```
7373

74-
The `#[Test]` attribute marks the method as a test, and the <class>\Testo\Assert</class> facade checks assertions. More about test approaches, attributes, and conventions — in [Writing Tests](writing-tests.md).
74+
The <attr>\Testo\Test</attr> attribute marks the method as a test, and the <class>\Testo\Assert</class> facade checks assertions. More about test approaches, attributes, and conventions — in [Writing Tests](writing-tests.md).
7575

7676
## Running Tests
7777

docs/plugins/assert.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,12 +305,12 @@ You can determine the type of the JSON value at the start, after which type-spec
305305
<short>Checks that the JSON object contains the given keys.</short>
306306
</signature>
307307
308-
<signature h="4" name="\Testo\Assert\Api\Json\JsonStructure::assertPath(string $path, callable $callback): static">
308+
<signature compact h="4" name="\Testo\Assert\Api\Json\JsonStructure::assertPath(string $path, callable $callback): static">
309309
<short>Checks a nested value at the given path.</short>
310310
<description>The callback receives a `JsonAbstract` for the value at the specified path, allowing you to build nested assertion chains.</description>
311311
</signature>
312312
313-
<signature h="4" name="\Testo\Assert\Api\Json\JsonCommon::matchesType(string $type): static">
313+
<signature compact h="4" name="\Testo\Assert\Api\Json\JsonCommon::matchesType(string $type): static">
314314
<short>Validates the JSON structure against a Psalm type.</short>
315315
<description>Accepts an extended Psalm type annotation — for example, `'array{foo: bool, bar?: non-empty-string}'` or `'list<array{id: positive-int}>'`.</description>
316316
</signature>

0 commit comments

Comments
 (0)