1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
//! Inline markup — the XML-shaped span layer inside prose content
//! (`docs/prose-dialect-spec.md` §4, RULED 2026-07-25, issue #1716).
//!
//! ```text
//! He hands you <item id="lantern">the old lantern</item>. span, attrs
//! The bell tolls again. <pause/> point marker (§8b.11)
//! <center>LATER</center> markup, not an element (§8d.3)
//! Hello \<world\> escaped literals (§8d.6)
//! ```
//!
//! # Blunt lexing (§4.1), no new lexer tokens
//!
//! `GLUE` (`<>`) and `THREAD` (`<-`, splice) are already distinct compound
//! tokens at the lexer (`lexer/punctuation.rs`), so a bare `LT` reaching
//! this module can never be either. Recognition is a **parser**-level
//! adjacency check over already-existing tokens, the same discipline
//! `element::at_cue` uses for `@NAME`: `LT` immediately (no trivia) followed
//! by `IDENT` opens a span ([`at_span_open`]); `LT SLASH IDENT` immediately
//! closes one ([`at_span_close`]). A `<` that doesn't qualify either way —
//! `5 < 10`, a lone `<3` — falls through to ordinary `TEXT`, unchanged.
//!
//! # Hyphenated tag names (§4.1, RULED 2026-08-01, issue #1996)
//!
//! A tag name may contain `-`, but **only as an internal separator between
//! two `IDENT` segments** — `<fade-in>` is legal, a leading or trailing
//! hyphen is not (`<-x>`, `<x->`). This is a **parser**-level widening of
//! the tag-name shape only ([`tag_name_len`]/[`tag_name_text`]), scoped to
//! span-tag position — it does not touch `IDENT` lexing itself
//! (`lexer/ident.rs`), so identifiers everywhere else in the language are
//! unaffected.
//!
//! The leading-hyphen ban isn't just a style choice; it's partly a
//! consequence already forced by the lexer. An **open** tag's name can
//! never start with `-` in the first place: `<-` is already claimed by
//! `THREAD` (splice) at the lexer (`lexer/punctuation.rs`), so `<-x>` never
//! even reaches [`at_span_open`] as a `LT` — it lexes as `THREAD IDENT`.
//! A **close** tag's `</-x>` *is* lexically distinguishable (`LT SLASH
//! MINUS IDENT`, since `SLASH` breaks the `THREAD` pattern) — but
//! [`at_span_close`] requires its own first name token to be `IDENT`, not
//! `MINUS`, so it is rejected there for the same reason, keeping the rule
//! symmetric across open/close by construction rather than by a
//! special-cased check.
//!
//! A trailing hyphen is representable at the token level (a lone `-` not
//! immediately followed by `>`/`=` lexes as `MINUS`) but is deliberately
//! **not** folded into the name: [`tag_name_len`] only extends a name
//! across a `MINUS` that is itself followed by another adjacent `IDENT`.
//! `<x->` stops the name at `x`, leaving the `-` (or, when it sits directly
//! before `>`, the whole `->` — greedily lexed as one `DIVERT` token)
//! unconsumed; [`span`]'s own `p.expect(GT)` then reports a clear parse
//! error instead of silently accepting the dangling hyphen as part of the
//! name.
//!
//! A continuation segment (the word after a `-`) may also be a reserved
//! keyword ([`is_name_segment`]) — native keywords are reserved everywhere
//! in *code*, but a tag name is freeform prose vocabulary (§4.2), and
//! `fade-in` is this very ruling's own worked example even though `in` is
//! `KW_IN`. This leniency is deliberately narrow: only a segment reached
//! after an already-confirmed `-` gets it; the tag's opening segment still
//! goes through [`at_span_open`]/[`at_span_close`]'s existing `IDENT`-only
//! check unchanged, so a tag literally *named* a bare keyword (`<in>`,
//! hyphen or not) is still not representable — a pre-existing limitation
//! this issue doesn't ask to widen.
//!
//! # Nesting doctrine (§4.3) — enforced by sharing one scanning engine
//!
//! A tag must close in the same fragment scope it opened in. [`span`] does
//! not implement its own content scanner — it calls back into
//! `content::content_items_until_impl` with the **same** `stop` set its own
//! caller was given, plus an `expected_close` name. That single point is
//! what makes the doctrine unrepresentable-to-violate rather than merely
//! checked: if the enclosing fragment's own boundary (`NEWLINE`, an
//! enclosing `}`/`]`, EOF, or any other caller-supplied stop token) is
//! reached before the matching close tag, the recursive scan returns
//! without having consumed it, and [`span`] reports the tag unclosed — it
//! has no way to "reach out" and consume a close tag sitting past that
//! boundary, because the boundary is exactly what stops the shared scanner.
//! `<b>hello {name}</b>` closes inside the outer scope it opened in ✓.
//! `{tired: <i>yawn</i>|Ready.}` closes inside the *inner* scope (the
//! branch) it opened in ✓. `<b>hi {tired: there</b>|friend}` opens outside
//! the conditional and tries to close inside its branch — the branch's own
//! scan (stop set includes the branch boundary) reaches that boundary
//! first, `</b>` is never seen there, and `<b>` is reported unclosed ✗.
//!
//! Spans are also **line-scoped** (§4.3, mechanically forced): every
//! content-line-level caller's `stop` set already includes `NEWLINE`, so a
//! span can never survive past the end of its line either.
//!
//! # Escape set is final (§8d.6)
//!
//! `\<` `\{` `\#` `\\` — and only those four, **inline** (anywhere in a
//! line). A `BACKSLASH` before anything else is a compile error
//! ([`escape`]), never silently swallowed as a literal backslash.
//!
//! §8d.6 also rules a second, disjoint pair as **line-start** escapes —
//! `\!` `\@` ([`at_line_start_escape`]/[`line_start_escape`], issue #1744) —
//! protecting a literal leading `!`/`@` from the sigils those characters
//! carry there (`@NAME` cue dispatch, the `!name` annotation-element
//! dispatch sigil, §3.5b/issue #2004). "Line-start" means the first item
//! `content::content_line`/`content_line_else_boundary` is asked to scan —
//! which is the true start of a physical line for an ordinary content
//! line, but is also right after a compact cue's `@NAME:` prefix
//! (`element::cue_line`'s
//! `COMPACT_CUE` arm calls `content_line` directly for the fused dialogue
//! line) or a `!name` dispatch's own name (`element::bang_dispatch` calls
//! `content_line` directly for its remainder, the same way), since both
//! reuse the same entry point. A `\!`/`\@` anywhere else in a line is not
//! part of the inline four and hits the same compile error as any other
//! unrecognized backslash.
use crate;
use Parser;
// ── Recognition (blunt lexing, §4.1) ─────────────────────────────────
/// True at `<ident` with zero gap — a span's open tag.
pub
/// True at `</ident` with zero gap — a span's close tag. Does not require
/// the trailing `>` to be present; that is a normal `expect` at the actual
/// consumption site ([`span`]/[`consume_stray_close`]), not part of
/// recognition (mirrors [`at_span_open`], which likewise doesn't demand the
/// open tag's own `>`/attrs be well-formed to be *recognized* as an
/// attempt).
pub
/// [`at_span_close`], plus the close tag's name matches `name` exactly —
/// comparing the **full**, possibly hyphenated, tag name
/// ([`tag_name_text`]), never just its first token. Only meaningful where
/// [`at_span_close`] already holds.
///
/// `pub(crate)`, not private: `content::content_items_until_impl` also
/// calls this directly, to decide whether a close tag it reaches is the one
/// its own `expected_close` frame is looking for (see that function's
/// doc) — comparing only the close tag's leading `IDENT` segment there
/// would wrongly treat e.g. `</fade>` as matching a `<fade-in>` open tag
/// (both start with the `fade` segment).
pub
/// Length, in (non-trivia) lookahead tokens, of a tag name starting at
/// offset `start` — `p.nth(start)` must already be a confirmed `IDENT`
/// (every call site checks this before calling: [`at_span_open`]/
/// [`at_span_close`]'s own recognition, or [`span`]'s freshly-bumped `<`).
/// A name is `IDENT (MINUS IDENT)*`, all mutually adjacent (no whitespace/
/// comment gap anywhere) — see the module doc's "Hyphenated tag names"
/// section for why a leading hyphen can't reach here and a trailing one is
/// deliberately left unconsumed. Always returns an odd count (1, 3, 5, …).
/// True for a token kind that can stand as a hyphen-continuation segment of
/// a tag name (the word *after* a `-`) — `IDENT`, or any reserved keyword.
/// Native keywords are reserved everywhere in ordinary code (Rust-style,
/// per `SyntaxKind::is_keyword`'s doc), but a tag name is freeform prose
/// vocabulary (§4.2), not code: `<fade-in>` must work even though `in` is
/// `KW_IN` in expression position. Deliberately narrower than "any keyword
/// anywhere in a tag name" — only a *continuation* segment (reached after
/// an already-confirmed `-`) gets this leniency; the tag's own opening
/// segment still goes through `at_span_open`/`at_span_close`'s existing
/// `IDENT`-only check, unchanged.
/// The concatenated source text of a `len`-token tag name starting at
/// lookahead offset `start` (as computed by [`tag_name_len`]) —
/// `"fade"` + `"-"` + `"in"` = `"fade-in"`.
/// True at `/>` with zero gap — a self-closing tag (the point-marker shape,
/// §8b.11: `<pause/>`, `<sfx name="bell"/>`).
// ── Spans ──────────────────────────────────────────────────────────
/// Parse one `SPAN`, starting at a confirmed [`at_span_open`] position.
/// `stop`/`stop_at_else_arm` are forwarded unchanged into the recursive
/// content scan for the span's body — see the module doc's nesting-doctrine
/// section for why that single forwarding is the whole enforcement
/// mechanism.
pub
/// Zero or more `name="value"` attributes on a span's open tag. Guarded by
/// the same positive-lookahead discipline `content::at_content_label` uses
/// (`IDENT` *and* the following token is `EQ`) so a bare trailing word in a
/// malformed tag doesn't get misread as the start of an attribute.
/// `"value"` — static text only (§4.1's worked examples are all static;
/// see [`crate::SyntaxKind::SPAN_ATTR_VALUE`]'s doc for why this
/// deliberately does not reuse `expr::string_lit`'s `{expr}`-interpolation
/// support).
/// A close tag reached that does not match what the current (possibly
/// absent) span frame is looking for — either a genuinely stray close with
/// no live open anywhere, or one belonging to an ancestor that hasn't
/// finished yet (a nesting-doctrine violation in progress). Reported loudly
/// and consumed as a single `ERROR`-wrapped unit so scanning can keep
/// making forward progress; the ancestor (if any) still gets its own
/// "unclosed tag" diagnostic when its own scan runs out of input.
pub
// ── Escapes (§8d.6, the set is final) ────────────────────────────────
/// True when `k` is one of the four escapable tokens `\<` `\{` `\#` `\\`
/// produce literals from. **Do not extend this** — §8d.6 rules the set
/// final.
/// `BACKSLASH` plus exactly one of `< { # \`, immediately adjacent (no
/// trivia) — anything else is a compile error, per §8d.6 ("backslash before
/// anything else is a compile error"), recovered the same
/// single-token-`ERROR`-wrap way [`consume_stray_close`] does.
pub
// ── Line-start escapes `\!` `\@` (§8d.6, issue #1744) ─────────────────
//
// A second, disjoint escape set from [`is_escapable`]'s four inline
// escapes — **not** an extension of it (§8d.6 rules that set final; see
// its doc comment). `\!` and `\@` are legal as the first item
// `content::content_line`/`content_line_else_boundary` scans (right after
// the optional `(label)` prefix) — the true start of a physical line for
// an ordinary content line, but also right after a compact cue's `@NAME:`
// prefix, since `element::cue_line`'s `COMPACT_CUE` arm calls
// `content_line` directly for the fused dialogue line, and a `!name`
// dispatch's own name calls it for its remainder the same way
// (`element::bang_dispatch`) — both are this same entry point. A bare
// `!`/`@` there would otherwise carry sigil meaning: `@NAME` opens a `CUE`
// (`element::at_cue`), and a bare line-start `!` opens a `BANG_DISPATCH`
// (`element::at_bang_dispatch`, §3.5b/issue #2004) — an author must be
// able to write a literal leading `!`/`@` without colliding with either.
// Anywhere else in a line, `\!`/`\@` are not in the inline set and remain
// the ordinary "backslash before anything else" compile error [`escape`]
// already gives.
/// True at `\!` or `\@`, immediately adjacent (no trivia) — the two
/// line-start escapes. Only meaningful when checked at a content line's
/// own start; the caller is responsible for that positioning (mirrors
/// [`at_span_open`]'s "recognition only, not a claim about context").
pub
/// Consume a confirmed [`at_line_start_escape`] position into an `ESCAPE`
/// node holding the literal `!`/`@`. Reuses [`escape`]'s exact node shape
/// (`BACKSLASH` + the one escaped token) so lowering
/// (`hir::lower_native::body::push_escape`) needs no change — it already
/// takes any `ESCAPE` node's second token verbatim as the literal it
/// produces.
pub