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