Skip to content

Design: directive seam — named inline commands with typed arguments (@font(size: 18){…}) #108

Description

@wildthink

Opening this per CONTRIBUTING ("for non-trivial features please open an issue first"). The work is already prototyped and green, but I'd rather have the design discussion before sending a PR — and I'm happy to reshape or drop it.

Branch: https://github.com/wildthink/swift-markdown-engine/tree/feat/directives

The gap

MarkdownExtension covers delimiter-shaped constructs beautifully. What it can't express is a construct with a name and typed argumentsInlineSyntax is open/close strings, so there's no way to write @font(size: 18){…} as an extension.

The proposal is a sibling seam, MarkdownDirective, deliberately built to the same isolation contract: it supplies syntax and presentation, never ranges.

config.directives = [FontDirective(), ColorDirective(), IconDirective()]
@font(size: 1.5em){half again}, @color(red){tinted}, @icon(star.fill, color: yellow)

The one design decision worth arguing about

Two forms, both tree-shaped: self-contained (@pagebreak, a leaf that draws a glyph) and container (@font(size: 18){text}, whose body is re-parsed).

There is deliberately no "applies to everything after me" form, even though that's the obvious reading of \font(size: 18). Two reasons, both from ARCHITECTURE.md:

  1. MarkdownASTStyler composes attributes on descent. A font comes from the enclosing node, not from document position. A stream-stateful directive would have to mutate state between siblings, which that model has no place for.
  2. Restyling and tokenization are block-scoped. A directive whose effect crossed block boundaries would invalidate scopedRanges and the BlockScopedTokenizer memo, taking per-keystroke cost from O(edit) to O(document-after-edit).

Scoping to a body keeps both intact, and composition then falls out of the existing walk rather than fighting it — @font(size: 18){**bold**} is bold and 18pt, and the same call inside a heading keeps the heading's weight, because the recursion just carries the transformed font down.

If you'd want the "rest of the block" form later, it's reachable as a bounded block-level directive without touching this — but I'd rather not build it speculatively.

How it fits the stated constraints

  • No new dependencies. Core target untouched on that front.
  • No new case threaded through parser, styler, and renderer. This was the constraint that shaped the implementation most. Directives project into the AST as extension-shaped nodes under a reserved directive. id namespace, so InlineNode, buildTree, offsetNodes, InlineASTAdapter, MarkdownToken, and shrinkInlineMarkers are all unchanged — and directives inherit marker shrink, caret reveal, token projection, incremental restyle, and rich copy for free.
  • Built-ins classify first. The scanner runs after every built-in in matchClaimedSpan, same as extension spans.
  • Markers shrink, they don't disappear. Self-contained glyphs reuse the inline-LaTeX collapse mechanism; source characters are never removed, so selection, find, copy, and undo still see them.
  • Caches. DirectiveRegistry is carried by ExtensionRegistry, so the directive fingerprint folds into the one grammar fingerprint every parse cache already keys on — no second key threaded anywhere. A directive-free registry produces a byte-identical fingerprint to today, so no existing document re-parses.
  • Public surface. Purely additive and opt-in; every new public symbol has a DocC comment. Nothing changes unless directives are registered.

Two safety rules make it enable-able over an existing corpus without migration: registered names only (@home in prose stays literal unless home is registered), and a left-boundary rule (name@example.com never opens a directive).

Upstream footprint

Everything else lives in new files under Sources/MarkdownEngine/Directives/, plus one styler and one coordinator extension file.

File Lines What
InlineParser.swift 10 scanner hook in matchClaimedSpan
MarkdownExtension.swift 33 ExtensionRegistry carries the directive registry + folds its fingerprint
MarkdownEditorConfiguration.swift 12 directives + directiveSettings
MarkdownASTStyler.swift 16 .ext branch for directive composition; Ctx widened to internal
MarkdownHTMLRenderer.swift 52 directive rendering for clean copy
MarkdownPasteboardWriter.swift / NativeTextView+Copy.swift 11 thread directives to the copy path
NativeTextViewWrapper.swift / NativeTextViewCoordinator*.swift 47 autocomplete hooks

~180 lines across 9 existing files.

Autocomplete

Names and argument values, riding the seam the [[wiki-link]] picker already established — onCaretRectChange, onInlinePreviewKey, plus a new onDirectiveCompletion / pendingDirectiveCompletion pair. No picker UI ships in the engine; the demo's is ~60 lines.

Value candidates come from MarkdownDirective.valueCompletions(for:prefix:), whose default already answers anything the schema declares (closed keyword sets, booleans) — a directive implements it only when its domain is dynamic. FlagDirective offers every ISO region that way, matching on code or localised country name, with no shipped dataset (codes from Locale.Region, names from the user's locale, flag computed from regional-indicator scalars).

Worth noting: completion can't use the AST. Mid-typing, @ico and @icon(sta are exactly what the parser rejects, so DirectiveCompletionScanner is a separate backwards scan bounded to 256 chars per caret move. It reuses the parser's boundary rule, so it can never offer something the parser would refuse.

Testing

132 new tests, 385 passing, no regressions. swift build and swift test green; demo builds and runs. Coverage includes boundary and rejection cases, argument coercion and every diagnostic kind, composition in both directions, glyph degradation (an unresolvable symbol leaves the source visible rather than collapsing to an invisible gap), scoped-vs-full restyle equivalence, and the places the picker must stay shut (code spans, selections, IME composition, raw source mode).

Known follow-ups, not done here

  • .latexImage / .latexBounds are now the generic glyph channel for four constructs (LaTeX, images, tables, directives) and the name is misleading. I reused rather than renamed to keep the diff small — a rename touches four files and is probably better as its own PR.
  • Argument coercion isn't memoised; it re-derives per directive node per restyle. Cheap at any realistic density, but it's the remaining un-cached path.
  • Directives are inline-only. Block-level (@layout(columns: 2) applying to the following block) would touch BlockParser and the restyle window — deliberately out of scope.

Questions for you

  1. Is a second seam the right shape at all, or would you rather see this generalised into MarkdownExtension (e.g. an optional argument grammar on InlineSyntax)?
  2. @ as the default marker — it avoids the double load \ already carries here (CommonMark escapes plus the LaTeX vocabulary behind $…$), and it's configurable per registry and per directive. Reasonable default for this project?
  3. Should the bundled directives (FontDirective et al.) ship in core at all, or would you prefer the seam alone with those as demo-only examples?
  4. Happy to split into smaller PRs along the phase boundaries (parsing → composition → glyphs → autocomplete) if that reviews better — the branch is already four coherent commits.

Also flagging: you have perf/typing-hotpath, perf/large-doc-switch, and fix/wikilink-uuid-loss-and-large-files-perf in flight touching the parse hot path and coordinator — the same files this touches. Nothing conflicts against main today, but I'll rebase if any of those land first.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions