Symbol Escape
Note
Status: implemented. The language's literal-punctuation rule: a backslash
escapes the next punctuation character, so a DialogueDown sigil (#tag, =>)
can be written as ordinary prose.
Table of contents
- Goal and scope
- Ubiquitous language
- Writer-facing behavior
- Grammar
- Prior art
- Architecture
- Key design decisions
- Markdown interaction
- Diagnostics
- Error and boundary cases
- Testability
- Alternatives not chosen
- Open questions and deferred work
Goal and scope
DialogueDown recognizes its sigils in text Markdown has already unescaped, so
a writer cannot express a literal #word (it becomes a tag) or a literal =>
(it becomes a jump indicator and, with no link, warns as a dangling arrow).
This note gives the language one symbol escape: a backslash before an ASCII punctuation character writes what it begins literally.
In scope: literal # / ## tags and => jumps in prose, through one rule
that covers every sigil.
Out of scope: changing how an unescaped dangling arrow behaves (DLG1113
still warns); a diagnostic-suppression knob; a region escape
([nomarkup]-style); and code spans, which stay game calls rather than a way to
write literal prose.
Ubiquitous language
| Term | Meaning |
|---|---|
| Sigil | A punctuation sequence the language reads as syntax: # / ## (tag), => (jump). |
| Symbol escape | A backslash before an ASCII punctuation character, writing what it begins literally. |
| Escaped character | The character after the backslash; a sigil beginning there is text too. |
| Escape provenance | The front end's record that a run's first character was escaped (TextInline.IsFirstCharacterEscaped). |
Writer-facing behavior
Alice: The tag is \#main, and the rule is x \=> y.
Reads as The tag is #main, and the rule is x => y. — no tag, no jump.
| To write | Escape it as | Otherwise it becomes |
|---|---|---|
#word |
\#word |
a tag |
##default |
\##default |
a reserved tag |
=> |
\=> |
a jump |
\ |
\\ |
the start of an escape |
* _ ~ |
\* \_ \~ |
Markdown styling |
The escape follows Markdown's lexical rules: only ASCII punctuation is escapable, before anything else the backslash stays literal, a trailing backslash is a hard break rather than an escape, and inside code spans a backslash is an ordinary character of a game call.
Escape the leading character of the sigil (D5). An unescaped => with no link
still warns — escape it when the characters are deliberate.
The same rule reaches the speaker prefix, whose elements are sigils too: a name,
an @id, #tags, and the closing :. Escaping one of them breaks the prefix
and the line plays in the default voice — Alice\: Hello reads "Alice: Hello",
\@alice: Hi reads "@alice: Hi", and Alice \@alice: Hi reads
"Alice @alice: Hi". An escape demotes syntax to text and never promotes text
to syntax: a name that is not a plain word is quoted ("@alice": Hi), and
\Alice is not an escape at all. To keep an escape inside speech, put the
speaker's colon first (Alice: \@alice: Hi).
Grammar
EscapedCharacter = "\" , AsciiPunctuation ;
CommonMark's lexical rule, adopted unchanged; the language adds only its meaning for DialogueDown's sigils (D2).
Prior art
| Language | Escape | Lesson |
|---|---|---|
| CommonMark | \ before any ASCII punctuation (plus entities and code spans) |
The escape is a one-character prefix, not a new grammar — the baseline this note adopts. |
| MDX | \{ and \< extend Markdown's backslash to its own sigils |
A Markdown-embedded language that gains sigils extends the host's escape, exactly as here. |
| Yarn Spinner | \[ / \], \\, and a [nomarkup]…[/nomarkup] region |
Single-character escapes for the sigils; the region form answers how often brackets appear in prose, not how escapes work. |
| Ren'Py | Doubling the opening sigil: [[, {{, 【【 |
Doubling works only while the doubled form is unclaimed; ## is already the reserved-tag prefix. |
| Twine Harlowe | Verbatim: wrap symbols in backticks | Quoting-as-escape is valid, but code spans are already game calls here. |
| Ink | Almost none; a backslash before a space only, to force text where { would open markup |
Contextual parsing covers Ink; it cannot cover a #word that is genuinely ambiguous. |
Architecture
Markdig resolves the escape before DialogueDown sees the text, but its AST keeps one exact fact: whether a literal's first character was escaped. That flag is the whole mechanism.
flowchart LR
A["Markdig literal\n(first character escaped)"] --> B["TextInline\nIsFirstCharacterEscaped"]
B --> C["InlineLeafTokenizer\nescaped sigil becomes text"]
B --> E["LineBuilder\nspeaker prefix skipped"]
C --> F["Dialogue AST\nText — never a Tag or JumpIndicator"]
E --> F
F --> G["Desugar and later stages\nsee plain Text"]
| Type | Responsibility | Change |
|---|---|---|
TextInline |
Markdown text plus its content span | Carries IsFirstCharacterEscaped, copied from Markdig. |
MarkdigToMarkdownAstConverter |
Markdig tree → Markdown AST | Copies LiteralInline.IsFirstCharacterEscaped; no span heuristic. |
InlineLeafTokenizer |
Text → TextLeaf / TagLeaf / JumpLeaf |
An escaped leading character takes the sigil that begins there — or itself — as text. |
LineBuilder |
Peels a line's speaker prefix | Skips the prefix parse when the leading text starts escaped; PrecedesAJump asks the tokenizer's StartsWithJumpIndicator, which owns the arrow's spelling and escape rule. |
| Desugar, semantic analysis, graph, playbook | — | Unchanged. |
Two Markdig details make the flag exact:
- Every escape opens a new literal.
EscapeInlineParseralways creates a newLiteralInline, and the plain-text parser only extends it forward, so only the first character of a literal can ever be escaped. One boolean perTextInlinesuffices — no position list. - The flag is the signal; the span delta is a symptom. The converter already
computes
ContentSpanfromSpan.Length − Content.Length(the stripped backslash). Flag and delta agree, but the flag is explicit provenance while the delta is a length coincidence, so the design reads the flag.
The tokenizer's entry point takes the flag. On an escaped leading character it
tries the sigil parser at that position: a match becomes one literal TextLeaf
spanning the whole match, and a miss emits the single character; the remainder
tokenizes normally. The flag defaults to false; only the converter sets it,
and the other TextInline construction sites (the unmodeled handler's Keep,
MarkdownInlineExtensions.TrimLeadingWhitespace, LineBuilder.RemoveSpeakerPrefix)
stay exact with the default.
Key design decisions
D1 — Markdown's backslash is the one escape character
Markdown's own punctuation (\*, \_, \~) and DialogueDown's sigils (\#,
\=>) share one escape character and one mental model: writers already know it,
and a Markdown preview shows exactly the text the script will speak. The
alternatives below each break that coherence.
D2 — An escape literals the sigil that begins at it
A sigil is recognized only where its characters are unescaped, and escaping the
leading character writes the whole sigil that begins there as text — or the lone
character, when no sigil begins there. So \##default writes ##default (the
reserved sigil is taken whole) rather than # plus a custom tag. Escaping a
non-leading character still prevents the sigil, without literalizing the rest.
The speaker prefix follows from the same sentence, because its elements are sigils. Escaping one breaks the prefix, and a half-literal prefix is never half-recognized: the whole line stays speech rather than consuming the escaped element as syntax. Escapes demote; they never promote text into a name.
D3 — Keep escape provenance from the front end
The front end records that a character was escaped; it does not know what a sigil is, so the flag is DSL-agnostic. Re-deriving escape state downstream is rejected: scanning for backslashes would re-parse raw source after Markdown has reinterpreted it, and reconstructing from span deltas would turn an explicit fact into a coincidence. Markdig's flag rides on the literal it belongs to.
D4 — Resolve at tokenization; no new AST node
An escape could be carried to desugar and turned back into text there. Resolving
it at tokenization is simpler and stricter: the tokenizer emits ordinary Text
and no Tag or JumpIndicator, so desugar, semantic analysis, the graph, and
the playbook need no change, and the desugared tree's no-JumpIndicator
invariant holds by construction.
D5 — Canonical spelling escapes the sigil's leading character
The taught spellings are \#word, \##default, and \=> — the leading
character, per D2. \#\#default works too for writers who prefer Markdown
symmetry. Escaping a non-leading character is valid but untaught: =\> yields
the literal arrow without becoming the documented form.
Markdown interaction
- Previews agree with the compiler.
\#wordrenders as#wordin any CommonMark preview, and\=>as=>— the exact text the script speaks. - One escape disarms both layers.
\# Headingat line start is neither a heading nor a tag; the escape protects the character itself. - Escapes do not reach code spans. Per CommonMark, a backslash inside backticks is ordinary text, so game calls are unaffected.
- The hard break is unchanged. A trailing backslash still starts a new
speech;
\\writes a literal backslash at the end of a line. - The editor needs no new token. An escaped sigil is
Textin the Dialogue AST, and highlighting and completion project from that AST rather than from a source scan.
Diagnostics
No new diagnostic code, and no suppression knob. An escaped arrow emits no jump
indicator, so DLG1113 cannot fire; the message of the unescaped case gains the
deliberate spelling as a remedy — "if you meant the characters, write \=>" —
in the DiagnosticCatalog descriptor and the DiagnosticDocs.cs worked
example. The error-code reference is then regenerated.
Error and boundary cases
| Input | Result |
|---|---|
\#word |
Text #word; no Tag. |
\##default (or \#\#default) |
Text ##default; no reserved Tag. |
\##tag |
Text ##tag — the escaped # takes the whole reserved-style sigil. |
\# #tag |
Text # followed by a real tag tag — the space keeps a sigil from beginning at the escaped #. |
\=> |
Text =>; no jump, no DLG1113. |
=\> |
Text =>; valid but untaught (D5). |
\=, \\ |
Text =, text \. |
\A |
Text \A — not an escape (CommonMark escapes ASCII punctuation only). |
\ at end of line |
Hard break, unchanged. |
\# Heading at line start |
Text # Heading; neither a heading nor a tag. |
\=#tag |
Text = followed by a real tag tag. |
Alice: \#notatag |
Speech text #notatag, no tag, no tag diagnostics. |
Alice\: Hello |
Default speaker; speech Alice: Hello — the escaped colon declines the prefix. |
\Alice: Hello |
Default speaker; speech \Alice: Hello — \A is not an escape, so the backslash stays. |
\@alice: Hi |
No speaker prefix; the line speaks @alice: Hi. |
Alice \@alice: Hi |
Default speaker; speech Alice @alice: Hi — the escaped id breaks the prefix. |
Alice: \@alice: Hi |
Speaker Alice; speech @alice: Hi — the prefix colon comes before the escape. |
"@alice": Hi |
Speaker named @alice — quoting, not escaping, names unusual speakers. |
`Ready?` \=> and `Ready?`\=> |
The condition still guards the line; PrecedesAJump asks StartsWithJumpIndicator, with or without a space. |
`\#word` (code span) |
Still a game call or DLG1102; backslashes are literal inside code spans. |
*\#word* |
Styled literal #word inside emphasis. |
[\#tag](#x) in a link label |
Label text #tag; no tag, matching speech. |
#word, => unescaped |
Unchanged: a tag, and a jump or a dangling-arrow warning. |
Integration
The writer guide states the rule once, under
Literal punctuation, with
an Escaping a speaker prefix subsection in Speakers and lines. DLG1113 and the
error-code reference offer \=> as the deliberate spelling.
Testability
- Front end: the flag is true for
\#and\*, false for plain text, andTextandContentSpanare unchanged; it stays false at every non-converter construction site. - Tokenizer: an escaped leading character literalizes the sigil it begins
(
\##default→##default) and never yields aTagLeaforJumpLeaf; a character that begins no sigil is literal alone (\=#tagkeeps#taga tag);\=>xand=\>yield text; adjacent text leaves still coalesce within a run. - Line builder: leading escaped text never parses as a speaker prefix, and a condition before an escaped arrow peels as the line's condition.
- Integration: compiling
\#notatag,\##default,\=>, and=\>yields the expectedTextfragments and noDLG1113; the escaped cases join the transpiler tests' boundary set. - Coverage: the changed front-end, tokenizer, and line-builder paths stay at 100% line and branch coverage.
Alternatives not chosen
| Alternative | Why not |
|---|---|
| A DialogueDown-specific escape marker | A second convention for the same job; Markdown already owns the backslash. |
| Code span as the literal escape (Harlowe-style verbatim) | Code spans are game calls; making a bad call silently literal would silence DLG1102 and render prose as code. |
| Doubling the sigil (Ren'Py-style) | ## is already the reserved-tag prefix; ==> is ambiguous. |
HTML entities (#, =) |
Not writer-facing, and the front end keeps entities as source text rather than decoding them. |
| Carrying the escape to desugar | More model state for no benefit once the tokenizer writes the sigil as text. |
Demoting DLG1113 by configuration |
Silences the evidence instead of expressing intent; the requirement is a writer spelling, not a quieter report. |
Open questions and deferred work
- Editor affordance — highlighting follows from the token model: an escaped sigil is text, so no tag or jump token is projected. A literalize transform and typing suggestions are not part of the design, with the reasoning in the Diagnostic Quick Fixes note.
- Escaped-prefix warning (not built) — a diagnostic for the ambiguous shapes:
the leading text would parse as a speaker prefix if the escape were absent, and
the escaped run starts a prefix element (
@,#, or a quoted name) rather than the colon. It would point at the better spelling ("@alice": Hi, orAlice: \@alice: Hi).Alice\: Hello,\Alice: Hello,Alice: \@alice: Hi, and"@alice": Hiare deliberate and stay quiet.