Skip to content

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

Open
wildthink wants to merge 1 commit into
nodes-app:mainfrom
wildthink:feat/directives-projection
Open

Directives (1/4): the seam — parsing, registry, and argument coercion#120
wildthink wants to merge 1 commit 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.

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