Skip to content

Directives (1/4): the seam — parsing, registry, and argument coercion - #120

Merged
luca-chen198 merged 4 commits into
nodes-app:mainfrom
wildthink:feat/directives-projection
Aug 17, 2026
Merged

Directives (1/4): the seam — parsing, registry, and argument coercion#120
luca-chen198 merged 4 commits into
nodes-app:mainfrom
wildthink:feat/directives-projection

Conversation

@wildthink

Copy link
Copy Markdown
Contributor

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), so cursorFollowsSpanInk, the ordered-list numbering, and the block-background work are all in. 356 tests green, demo builds.

What this is

MarkdownExtension handles delimiter-shaped constructs. It can't express a construct with a name and typed argumentsInlineSyntax is a pair of delimiter strings, so @font(size: 18){…} has nowhere to live. MarkdownDirective is 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.ext whose id carries a reserved directive. prefix:

return .ext(id: match.nodeID, range: match.range, contentRange: match.contentRange,
            markers: match.markers, parsesContent: match.parsesContent)

So InlineNode, buildTree, offsetNodes, InlineASTAdapter, MarkdownToken, and shrinkInlineMarkers are 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, with nodeID(for:) / directiveID(forNodeID:) as the only two places that know about it). An extension whose own id began directive. would collide; the prefix contains a ., which no bundled extension id uses. If you'd rather have a real InlineNode case 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

DirectiveRegistry is carried by ExtensionRegistry:

self.fingerprint = directives.isEmpty
    ? extensionFingerprint
    : extensionFingerprint + "~" + directives.fingerprint

Two consequences worth checking me on:

  • No second cache key exists. Every parse cache already keys on the grammar fingerprint, so registering a directive at runtime invalidates them with nothing new threaded through the pipeline.
  • A directive-free registry is byte-identical to before. The empty half contributes nothing, so no existing document re-parses and no existing cache entry is invalidated by this PR landing. There's a test asserting the fingerprint contains no ~ 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. @home in prose stays literal unless home is 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

File Lines What
InlineParser.swift 10 scanner hook in matchClaimedSpan, after every built-in
MarkdownExtension.swift 33 ExtensionRegistry carries the directive registry + folds its fingerprint
MarkdownEditorConfiguration.swift 12 directives + directiveSettings

46 lines across 3 existing files. Everything else is new files under Sources/MarkdownEngine/Directives/.

Deliberately not here

  • No directive ships. FontDirective / ColorDirective are 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.
  • Nothing is styled. A registered directive currently renders as literal text. DirectiveStyle, presentation, and glyphs are PR2/PR3.
  • No autocomplete. valueCompletions and the completion types are PR4.
  • Arguments coerce at styling time, not parse time, so a directive-free document pays nothing for the schema machinery.

Testing

swift build / swift test green — 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.

@luca-chen198

Copy link
Copy Markdown
Member

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 .ext under a reserved namespace instead of threading a new node kind through parser, styler and renderer is the right call, and the byte-identical fingerprint for a directive-free registry is the property that makes the seam safe to add at all.

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 feat/directives, holding PR1 until PR2 is open costs you nothing and means the API arrives together with the behaviour it exists for. Nothing to redo — open PR2 whenever suits you and I'll take them as a pair. The fixes below can land here in the meantime.

Two fixes I'd like in this PR, both confined to DirectiveArguments.swift, so neither touches anything #140 is rewriting:

  1. defaultValue on a positional parameter is never applied. applyingDefaults only fills labelled ones, so a positional parameter carrying a default silently yields nothing. Implement it or drop the field — it's public API, and once it's released I'm stuck with a parameter that does nothing.
  2. Escape handling is split across the feature. balanced() is escape-aware, splitArguments / splitLabel / coerce are not. @font(size: "a \" b"){x} matches, but the value arrives as a \" b, and an escaped quote flips inQuote in the comma split, so a multi-argument call splits wrong.

One known limitation to document rather than fix here. A code span or $…$ in the body rejects the whole directive: @font(size: 18){a `b` c} produces no directive node at all, while emphasis, links and nesting are all fine. That's the same shape #118 just granted an exemption for in link labels, so it deserves a doc line now and its own issue. Please don't fix it in this PR — it lives in scanLinkFamily's overlap rule, which is exactly what #140 rewrites, and #140's rebase has to build the "enumerate overlapping claims" machinery for #118 anyway. Cleanest as a small follow-up once that lands.

Later, not now:

  • MarkdownHTMLRenderer.html(from:extensions:) has no directives: parameter, so the clean-copy path can't see them and MarkdownDirective.html(arguments:bodyHTML:) is currently unreachable. Harmless while nothing is styled, but it has to land with PR2 or copied text diverges from what's on screen.
  • Worth a doc line that the hook sits before the extension loop, so directives beat extensions. Only "after every built-in" is written down.

On the bundled directives: agreed, Font and Color belong with PR2 — no point shipping presentation types before there's presentation. And I still want the directive-heavy restyle perf scenario from #108; PR2 is the right place for it.

@wildthink
wildthink force-pushed the feat/directives-projection branch from 9fb9dca to 68f1411 Compare August 10, 2026 13:17
wildthink-pub and others added 2 commits August 13, 2026 13:13
`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>
@wildthink

Copy link
Copy Markdown
Contributor Author

Thanks — and agreed on landing as a pair. PR2 is open as #155, based on this branch rather than on main, so it reads as the diff on top of this one. Rebased both onto current main too.

Fix 1 — positional defaultValue: implemented

You were right that it did nothing: applyingDefaults guarded on parameter.label, so positional parameters skipped the fill entirely. Implemented rather than dropped.

One semantic I had to pin down: 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 #2 missing — there is no syntax for "default here, but supply the next one", so the first positional without a default ends the fill. The missing-positional diagnostic also names its own index now instead of reporting once at the count. Four tests, all of which fail without the change.

Fix 2 — I think this one is unreachable, and the real question is different

I tried to reproduce your example and it doesn't behave as described:

@font(family: "a \" b"){x}   → no directive at all, not a directive with a wrong value

The \" is claimed by the escape pass (pass 2), the directive candidate overlaps a claimed span, and scanLinkFamily rejects it outright. So splitArguments never receives a backslash-escape — any backslash before ASCII punctuation kills the whole directive before argument parsing runs. Making the splitters escape-aware to match balanced() would be hardening a path nothing reaches.

The reachable case is a backslash before a non-punctuation character ("a \z b"), which isn't an escape at all and passes through raw — arguably correct, and certainly not what balanced()'s escape-awareness would change.

So I've left the splitters alone. The actual question is what an escape should mean inside a directive, and that's the same overlap rule as the limitation below — which is why I put both in one issue rather than patching half of it here. Say the word if you'd rather I harden the splitters defensively anyway; I just didn't want to add code whose only test would have to construct a state the parser can't produce.

The limitation — documented, and narrower than we both thought

Not fixed here, as you asked. But measuring it moved the boundary:

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

407 tests green here, 446 on #155, demo builds.

luca-chen198 and others added 2 commits August 17, 2026 15:09
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>
@luca-chen198
luca-chen198 merged commit 95b318b into nodes-app:main Aug 17, 2026
1 check passed
luca-chen198 added a commit to wildthink/swift-markdown-engine that referenced this pull request Aug 17, 2026
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.
luca-chen198 pushed a commit that referenced this pull request Aug 17, 2026
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.
wildthink pushed a commit to wildthink/swift-markdown-engine that referenced this pull request Aug 17, 2026
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>
wildthink pushed a commit to wildthink/swift-markdown-engine that referenced this pull request Aug 18, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants