Directives (1/4): the seam — parsing, registry, and argument coercion - #120
Conversation
|
Design approved — this is what I asked for in #108, and the description does exactly the job I wanted it to: the namespace choice and the fingerprint property are both spelled out, and the parsing stayed thin. Projecting onto On merging. I'd like to land PR1 and PR2 together rather than merge this on its own. It's a fair amount of new public API for a library that's semver-bound, and standalone a registered directive parses and then renders as plain text — which is a worse state than not registering it. Since the whole thing already exists on Two fixes I'd like in this PR, both confined to
One known limitation to document rather than fix here. A code span or Later, not now:
On the bundled directives: agreed, |
9fb9dca to
68f1411
Compare
`MarkdownExtension` covers delimiter-shaped constructs. What it cannot
express is a construct with a NAME and TYPED ARGUMENTS — `InlineSyntax` is a
pair of delimiter strings, so `@font(size: 18){…}` has no shape there.
This adds `MarkdownDirective` as a parallel seam built to the same isolation
contract: a directive supplies syntax and a parameter schema, never ranges.
Two forms, both tree-shaped, so a directive's effect never escapes its own
node: self-contained (`@pagebreak`) and container (`@font(size: 18){text}`,
whose body is re-parsed as markdown).
There is deliberately no "applies to everything after me" form, even though
that is the obvious reading. It would make styling depend on document
position rather than tree position, which breaks the styler's
compose-on-descent model, and its effect would outlive its own block, which
breaks the block-scoped incremental restyle.
Two decisions are the substance here, and both are about NOT adding surface:
Directives project into the AST as extension-shaped nodes (`InlineNode.ext`)
under a reserved `directive.` id namespace rather than as a new node kind.
`InlineNode`, `buildTree`, `offsetNodes`, `InlineASTAdapter`, `MarkdownToken`,
and `shrinkInlineMarkers` are therefore untouched, and directives inherit
marker shrink, caret reveal, token projection, incremental restyle, and rich
copy unchanged.
`DirectiveRegistry` is carried by `ExtensionRegistry` so its fingerprint folds
into the one grammar fingerprint every parse cache already keys on. There is
no second cache key threaded through the pipeline, and a directive-free
registry produces a byte-identical fingerprint to before, so no existing
document re-parses.
Two rules make the seam safe to enable over an existing corpus: registered
names only (`@home` stays literal unless `home` is registered), and a
left-boundary rule stated as a deny list — only letters and digits reject —
so `name@example.com` never opens a directive while markup delimiters
(`*@font(…){…}*`, `- @pagebreak`) do. An allow list of "opening punctuation"
was tried first and silently dropped every directive abutting markup.
Arguments are coerced against the schema at styling time, not parse time, so
the parser stays geometry-only and a directive-free document pays nothing.
Nothing is styled yet — no directive ships, and a registered one renders as
literal text. Presentation and autocomplete follow separately.
46 lines across 3 existing files; everything else is new.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review points from nodes-app#120, both confined to DirectiveArguments.swift plus comments, so neither touches what nodes-app#140 rewrote. `defaultValue` on a positional parameter did nothing: applyingDefaults guarded on parameter.label, so only labelled parameters were filled. Since it is public API, implementing it beats dropping it. Positional defaults fill by POSITION, which makes them a tail-only affair — given (a, b = 2, c), `@x(1)` yields 1, 2 and still reports nodes-app#2 missing, because there is no syntax for "default here, but supply the next one". The missing-positional diagnostic now names the parameter's own index instead of reporting once at the count. The body limitation is documented rather than fixed, as asked, in the scanner header, at the InlineParser hook, and in the changelog — and pinned by tests, so the follow-up that lifts it flips them rather than deleting them. The hook comment now also states that directives match before the extension loop. Note the limitation is narrower than the review described: only spans claimed by an EARLIER pass reject a directive, i.e. code spans and escapes. `$…$` is claimed in this pass and composes fine inside a body, as do links, emphasis and nesting. Tests cover both halves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
68f1411 to
502cacd
Compare
|
Thanks — and agreed on landing as a pair. PR2 is open as #155, based on this branch rather than on Fix 1 — positional
|
| body | result |
|---|---|
@font(size: 18){a `b` c} |
rejected |
@font(size: 18){a \* c} |
rejected |
@font(size: 18){a $x^2$ c} |
directive |
@font(size: 18){a [l](u) c} |
directive |
@font(size: 18){a *b* c} |
directive |
$…$ does not reject — it's claimed in pass 3 alongside directives, so it never overlaps a prior claim. Only genuinely pre-claimed spans bite: code spans (pass 1) and escapes (pass 2). That narrows the eventual fix to exempting two passes rather than "everything else in a body".
Documented in the DirectiveScanner header, at the InlineParser hook, and in the changelog — and pinned by tests asserting both halves, so whoever lifts it flips them deliberately instead of discovering a surprise. Filed as #154 with the analysis and the ClaimedIndex.overlapping-based fix sketch; happy to take it once this pair lands, since it touches the same rule.
Your other two
html(from:extensions:directives:)— done, it's in Directives (2/4): styling — font composition, colour, and rich copy #155, soMarkdownDirective.htmlis reachable and rich copy matches the screen.- Hook ordering — the comment now says directives match before the extension loop, not just after the built-ins, and why that ordering is safe.
- The Design: directive seam — named inline commands with typed arguments (
@font(size: 18){…}) #108 perf scenario is in Directives (2/4): styling — font composition, colour, and rich copy #155, with its timed assertions opt-in behindMDE_PERFand a structural test carrying the load on CI. After 350b2d3 I wasn't going to ship three more wall-clock ratios.
407 tests green here, 446 on #155, demo builds.
The identical guard inside `DirectiveScanner.match` never runs early
enough. `match` is too large to inline, so the call, the indirect return
buffer for a ~200-byte `DirectiveMatch?` and an outlined ARC helper all
execute per unclaimed character before the callee gets to test the
registry -- a cost paid in full by every document that registers no
directive at all.
Measured against `main` with an `-O` binary, minimum of three rounds of
nine runs each:
main before after
inline scan (200k) 3.910 ms 4.981 ms 4.022 ms +27.4% -> +2.9%
document parse (400k) 14.877 ms 16.313 ms 14.955 ms +9.7% -> +0.5%
That recovers 89.5% of the inline-scan cost and 94.6% of the
document-parse cost. The AST checksums are identical across all three
builds, so this drops a call and nothing else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ExtensionRegistry.fingerprint` folds in the directive registry, so a directive-only change already reaches this branch and drops the parse cache -- but the branch copied only `extensions`, leaving the restyle it triggers to run against the PREVIOUS directive list. Registering at construction was never affected (`makeNSView` assigns the whole configuration). Changing the list at runtime silently did nothing, which is exactly the shape an embedder's "directives on/off" setting would take. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nodes-app#120 landed as a squash, so its content reached main under a new sha while this branch still carries the original commits. The shared merge-base is therefore still f6137df and git saw the directive files as added on both sides. Resolution, all three mechanical: - MarkdownDirective.swift (add/add): took this branch's version, which is a strict superset of main's -- 78 lines added, none removed. - ARCHITECTURE.md, CHANGELOG.md: main contributed nothing at the conflict point (its directive text arrived with the squash, above the marker), so this branch's styling paragraphs stand as written. MarkdownASTStyler.swift auto-merged: main's link-target muting from nodes-app#156 sits in styleLink and the directive branch sits in styleInlines, on disjoint ranges.
A registered directive now styles its body instead of rendering as plain text.
`style` returns a `DirectiveStyle` — a font transform as DATA, not a closure — which the styler composes over whatever font the enclosing tree already established, so `@font(size: 18){**bold**}` is bold AND 18pt rather than one clobbering the other, and it stays inspectable and cheap on the per-keystroke path. The transform derives from the enclosing node rather than from document position, which is what lets it survive block-scoped restyle unchanged. Container styling lives in `MarkdownASTStyler+Directives.swift` as one more step in the existing compose-on-descent walk.
`MarkdownHTMLRenderer.html(from:extensions:directives:)` takes the registered set and recovers arguments from the same prefix geometry the styler uses, so rich copy and on-screen styling cannot disagree about what was passed.
`FontDirective` and `ColorDirective` ship off by default, the same posture as `HighlightExtension`. `DirectivePresentation` and `DirectiveCompletion` are deliberately not here — they land with the phases whose behaviour they exist for.
The directive-heavy restyle scenario is structural rather than timed: `scopedWorkIsIdenticalRegardlessOfDocumentSize` digests the styled output of the edited paragraph and requires it byte-identical in a 40-paragraph and a 400-paragraph document, which holds on every machine. The timed assertions are opt-in behind `MDE_PERF=1`.
Verified against the merge-base with two independent differential harnesses: with no directive registered, 180 corpus cases across 12 surfaces — AST, token projection, HTML, rich-copy body and styled attribute runs at every caret probe — are byte-identical. With directives registered, 10 registries x 66 samples are byte-identical before and after the emptiness-hoist fix. Release A/B: the hook cost that #120 introduced on the empty-registry path is fully recovered (document parse back to baseline within 0.2%).
Known and tracked: a directive body holding a span claimed by an earlier pass (code span, backslash escape) rejects the whole construct — that belongs with `scanLinkFamily`'s overlap rule. `FontDirective.html` reports relative sizes as pixels, and neither bundled directive escapes argument values into its `style` attribute; both are in the opt-in reference directives, not the seam.
nodes-app#120 and nodes-app#155 merged upstream, so the copies of those phases on this branch are superseded by the reviewed versions. Every directive file conflicted add/add — the two histories are disjoint for them — so each was arbitrated rather than taken from one side: upstream wins outright (review fixes; phases 3/4 never touched them): DirectiveArguments, DirectiveScanner, InlineParser, DirectiveArgumentTests, DirectiveParserTests, DirectivePerformanceTests ours (strict supersets of the merged versions): MarkdownDirective, BuiltinDirectives, MarkdownASTStyler+Directives, DirectiveStylingTests hand-merged: DirectiveTestFixtures — union; upstream's argument/parser tests need SizedDirective/TintDirective/SelfContainedPair, which this branch had dropped when the reference directives moved to Demo/ README, ARCHITECTURE, ContentView — upstream RELOCATED the extension and directive sections (d26b5a2, af8e637, d826f8a). Taking our side would have duplicated them, so upstream's placement stands and the phase 3/4 material was folded into it. CHANGELOG — upstream's two entries verbatim, phases 3 and 4 appended. DirectiveStylingTests.selfContainedStaysVisible asserted a self-contained call renders as literal text with nothing collapsing it. Phase 3's glyph pass is exactly that change, so it is replaced by the collapse/reveal pair rather than deleted, and the suite is no longer named "Phase 1 styling". 488 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rebase onto current main puts this branch on top of the directive seam (nodes-app#120), which added a claimed-span producer that did not exist when these tests were written. Two counted cases now cover it: 6.0x work for 6x the spans, on both the self-contained and container forms. Each shape pairs the directive with a code span deliberately. A paragraph of bare `@mk` claims nothing in passes 1-2, so `ClaimedIndex` is built EMPTY and a pairwise scan over it costs nothing — the assertion then passes no matter what the cursor does, and only `buildTree` is under test. That is exactly what the first version of these two tests did, and it looked fine: 6.0x, green. Verified the other way round, by restoring the pre-rewrite pairwise containment. With the code span both fail at 37.6x; without it both still pass. `expectLinearWork` takes a registry now, defaulted to the extensions one, so the existing call sites are unchanged. 455 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR 1 of 4 for the directive seam designed in #108. As you asked, this is the one to sign off the projection — the parsing is thin on purpose, and nothing is styled yet.
Rebased on current
main(0.11.0), socursorFollowsSpanInk, the ordered-list numbering, and the block-background work are all in. 356 tests green, demo builds.What this is
MarkdownExtensionhandles delimiter-shaped constructs. It can't express a construct with a name and typed arguments —InlineSyntaxis a pair of delimiter strings, so@font(size: 18){…}has nowhere to live.MarkdownDirectiveis the parallel seam, built to the same isolation contract: syntax and schema in, never ranges.Two forms, both tree-shaped: self-contained (
@pagebreak) and container (@font(size: 18){text}, body re-parsed as markdown). No "applies to everything after me" form — it would make styling depend on document position rather than tree position, and its effect would outlive its own block.The projection
Directives do not add a node kind. A match becomes an
InlineNode.extwhose id carries a reserveddirective.prefix:So
InlineNode,buildTree,offsetNodes,InlineASTAdapter,MarkdownToken, andshrinkInlineMarkersare all untouched, and directives inherit marker shrink, caret reveal, token projection, incremental restyle, and rich copy for free rather than reimplementing any of it.The cost is one namespace convention (
DirectiveRegistry.idPrefix, withnodeID(for:)/directiveID(forNodeID:)as the only two places that know about it). An extension whose own id begandirective.would collide; the prefix contains a., which no bundled extension id uses. If you'd rather have a realInlineNodecase and take the switch churn, say so — I went this way specifically because your CONTRIBUTING says new constructs shouldn't thread a case through parser, styler, and renderer.The fingerprint property
DirectiveRegistryis carried byExtensionRegistry:Two consequences worth checking me on:
~when no directives are registered.Only fields that change the PARSE participate (name, marker, form,
parsesBody) — presentation-only edits must not invalidate parse caches. Free-text fields are length-prefixed so the concatenation is injective.Safety over an existing corpus
Two rules, both tested:
Registered names only.
@homein prose stays literal unlesshomeis registered — same as unregistered extension syntax.Left boundary as a deny list. Only letters and digits reject. I wrote it as an allow list of "opening punctuation" first and it silently dropped every directive abutting markup —
*@font(size: 18){x}*,**…**,- @pagebreak— because the preceding character is a delimiter I hadn't listed. Deny-list is all the email rule (name@example.com) ever needed and can't fail that way.Rejection is always total: unregistered name, malformed call, wrong form, unbalanced delimiters, or a run crossing a line break all leave the candidate literal. Nothing here produces a partial construct.
One interaction found while testing: a directive containing a backslash escape (
@font(size: 18){a \} b}) stays literal, because the escape pass claims before the link-family pass and a candidate overlapping a claimed span is rejected. That's existing engine behaviour —[a \* b](url)and==a \* b==are rejected identically — so directives inherit it rather than special-casing. The scanner still measures the body correctly in isolation, so if escapes ever stop pre-claiming, directives need no change. Both facts are asserted.Footprint
InlineParser.swiftmatchClaimedSpan, after every built-inMarkdownExtension.swiftExtensionRegistrycarries the directive registry + folds its fingerprintMarkdownEditorConfiguration.swiftdirectives+directiveSettings46 lines across 3 existing files. Everything else is new files under
Sources/MarkdownEngine/Directives/.Deliberately not here
FontDirective/ColorDirectiveare pure presentation, so they arrive with styling in PR2. The parser tests declare their own shapes instead, which keeps them testing the seam rather than a bundled implementation.DirectiveStyle, presentation, and glyphs are PR2/PR3.valueCompletionsand the completion types are PR4.Testing
swift build/swift testgreen — 356 tests, including upstream's. New coverage: boundary and rejection cases, form enforcement, balanced/nested/escaped delimiters, single-line enforcement, precedence against code spans and inline LaTeX, body re-parsing, token projection, alternate and coexisting markers, multi-scalar marker rejection, and the fingerprint properties above.