brink_syntax/parser/mod.rs
1mod choice;
2mod content;
3mod declaration;
4mod divert;
5mod expression;
6mod gather;
7mod inline;
8mod knot;
9mod logic;
10mod story;
11mod tag;
12mod types;
13
14use crate::SyntaxKind::{
15 self, BLOCK_COMMENT, COLON, EOF, ERROR, IDENT, L_BRACE, LINE_COMMENT, NEWLINE, PIPE, R_BRACE,
16};
17// `IDENT` above is also used by `at_kw_text`/`nth_text` (soft-keyword lookup).
18use crate::lexer;
19use rowan::GreenNode;
20
21/// Result of parsing an Ink source file.
22///
23/// `PartialEq` compares the green tree structurally (rowan `GreenNode`
24/// equality is content-based) plus the error list — used by the salsa
25/// `parse` query in `brink-db` for early-cutoff backdating.
26#[derive(Clone, PartialEq, Eq)]
27pub struct Parse {
28 green: GreenNode,
29 errors: Vec<ParseError>,
30}
31
32impl Parse {
33 /// The root green node of the lossless CST.
34 #[must_use]
35 pub fn green(&self) -> &GreenNode {
36 &self.green
37 }
38
39 /// The root syntax node (typed wrapper around the green tree).
40 #[must_use]
41 pub fn syntax(&self) -> crate::SyntaxNode {
42 crate::SyntaxNode::new_root(self.green.clone())
43 }
44
45 /// Parse errors encountered.
46 #[must_use]
47 pub fn errors(&self) -> &[ParseError] {
48 &self.errors
49 }
50}
51
52/// A parse error with a message and the source range it points at.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ParseError {
55 pub message: String,
56 /// Byte range in the source that the error points at.
57 pub range: rowan::TextRange,
58}
59
60/// Parse an Ink source string into a lossless CST.
61#[must_use]
62pub fn parse(source: &str) -> Parse {
63 let raw_tokens = lexer::lex(source);
64 let mut p = Parser::new(&raw_tokens);
65 story::source_file(&mut p);
66 let green = p.builder.finish();
67 Parse {
68 green,
69 errors: p.errors,
70 }
71}
72
73/// Parse with a shared [`rowan::NodeCache`] for green-node interning.
74///
75/// Re-parsing the same source through the same cache produces structurally
76/// identical subtrees that share the same `Arc` allocation, enabling O(1)
77/// pointer-equality checks via `GreenNode::eq`.
78pub fn parse_with_cache(source: &str, cache: &mut rowan::NodeCache) -> Parse {
79 let raw_tokens = lexer::lex(source);
80 let mut p = Parser::with_cache(&raw_tokens, cache);
81 story::source_file(&mut p);
82 let green = p.builder.finish();
83 Parse {
84 green,
85 errors: p.errors,
86 }
87}
88
89// ── Parser internals ────────────────────────────────────────────────
90
91/// Maximum nesting depth for recursive grammar rules (inline logic, expressions,
92/// parenthesized groups). Prevents stack overflow and superlinear parse time on
93/// pathological input. 256 matches Rust's default `recursion_limit`.
94const MAX_DEPTH: u32 = 256;
95
96/// The parser. Holds a token stream and a `GreenNodeBuilder`.
97pub(crate) struct Parser<'t, 'c> {
98 tokens: &'t [(SyntaxKind, &'t str)],
99 pos: usize,
100 depth: u32,
101 /// Pre-computed scan results for each `{` token. Indexed by raw token
102 /// position. For positions that are not `L_BRACE`, the value is meaningless.
103 /// For `L_BRACE` positions, stores `PIPE`, `COLON`, or `EOF` indicating
104 /// which delimiter appears first at depth-0 inside that brace pair.
105 brace_scan: Vec<SyntaxKind>,
106 /// Pre-computed non-trivia token indices. `non_trivia[k]` is the raw
107 /// token index of the k-th non-trivia token. Enables O(1) `nth(n)`.
108 non_trivia: Vec<usize>,
109 builder: rowan::GreenNodeBuilder<'c>,
110 errors: Vec<ParseError>,
111}
112
113impl<'t> Parser<'t, 'static> {
114 fn new(tokens: &'t [(SyntaxKind, &'t str)]) -> Self {
115 let brace_scan = Self::build_brace_scan(tokens);
116 let non_trivia = Self::build_non_trivia(tokens);
117 Self {
118 tokens,
119 pos: 0,
120 depth: 0,
121 brace_scan,
122 non_trivia,
123 builder: rowan::GreenNodeBuilder::new(),
124 errors: Vec::new(),
125 }
126 }
127}
128
129impl<'t, 'c> Parser<'t, 'c> {
130 fn with_cache(tokens: &'t [(SyntaxKind, &'t str)], cache: &'c mut rowan::NodeCache) -> Self {
131 let brace_scan = Self::build_brace_scan(tokens);
132 let non_trivia = Self::build_non_trivia(tokens);
133 Self {
134 tokens,
135 pos: 0,
136 depth: 0,
137 brace_scan,
138 non_trivia,
139 builder: rowan::GreenNodeBuilder::with_cache(cache),
140 errors: Vec::new(),
141 }
142 }
143
144 /// O(n) pre-pass: collect the raw indices of all non-trivia tokens.
145 /// Enables O(1) `nth(n)` lookup during parsing.
146 fn build_non_trivia(tokens: &[(SyntaxKind, &str)]) -> Vec<usize> {
147 tokens
148 .iter()
149 .enumerate()
150 .filter(|(_, (k, _))| !k.is_trivia())
151 .map(|(i, _)| i)
152 .collect()
153 }
154
155 /// O(n) pre-pass: for each `L_BRACE`, classify the brace pair as `COLON`
156 /// (conditional), `PIPE` (sequence), or `EOF` (bare expression).
157 ///
158 /// Classification rules (`||`-aware):
159 /// 1. If a **single** `|` (not part of `||`) appears at depth-0 →
160 /// sequence (`PIPE`), regardless of any COLON.
161 /// 2. Else if `COLON` appears at depth-0 → conditional (`COLON`).
162 /// 3. Else if `||` appears (no single `|`, no COLON) → sequence (`PIPE`),
163 /// since `||` without a conditional colon means two separators.
164 /// 4. Neither → bare expression (`EOF`).
165 ///
166 /// Examples:
167 /// - `{a|b:c}` — single `|` → sequence (rule 1)
168 /// - `{x || y: body}` — no single `|`, has COLON → conditional (rule 2)
169 /// - `{a||b}` — `||` only, no COLON → sequence (rule 3)
170 /// - `{x}` — neither → bare expression (rule 4)
171 fn build_brace_scan(tokens: &[(SyntaxKind, &str)]) -> Vec<SyntaxKind> {
172 // Stack entries track what we've seen at depth-0 inside each brace pair.
173 // `single_pipe_before_colon` is the key signal: a lone `|` that appears
174 // before any `:` means this brace pair is a sequence, not a conditional
175 // (the `|` is a separator, not part of a conditional body like `{x: a|b}`).
176 struct Entry {
177 brace_pos: usize,
178 has_colon: bool,
179 has_pipe: bool,
180 single_pipe_before_colon: bool,
181 }
182
183 fn classify(e: &Entry) -> SyntaxKind {
184 if e.single_pipe_before_colon {
185 PIPE // rule 1: single `|` before `:` → sequence
186 } else if e.has_colon {
187 COLON // rule 2: colon (with only `||` or no pipe before it) → conditional
188 } else if e.has_pipe {
189 PIPE // rule 3: `||` without colon → sequence separators
190 } else {
191 EOF // rule 4: bare expression
192 }
193 }
194
195 let n = tokens.len();
196 let mut result = vec![EOF; n];
197
198 // Precompute: for each token position, the next non-trivia token index.
199 let next_nt = {
200 let mut v = vec![n; n];
201 let mut last = n;
202 for i in (0..n).rev() {
203 v[i] = last;
204 if !tokens[i].0.is_trivia() {
205 last = i;
206 }
207 }
208 v
209 };
210
211 let mut stack: Vec<Entry> = Vec::new();
212 let mut prev_nt = EOF;
213
214 for (i, &(kind, _)) in tokens.iter().enumerate() {
215 if kind.is_trivia() {
216 continue;
217 }
218 match kind {
219 L_BRACE => {
220 stack.push(Entry {
221 brace_pos: i,
222 has_colon: false,
223 has_pipe: false,
224 single_pipe_before_colon: false,
225 });
226 prev_nt = L_BRACE;
227 }
228 R_BRACE => {
229 if let Some(entry) = stack.pop() {
230 result[entry.brace_pos] = classify(&entry);
231 }
232 prev_nt = R_BRACE;
233 }
234 COLON => {
235 if let Some(e) = stack.last_mut() {
236 e.has_colon = true;
237 }
238 prev_nt = COLON;
239 }
240 PIPE => {
241 if let Some(e) = stack.last_mut() {
242 e.has_pipe = true;
243 // Determine if this is a single `|` (not part of `||`).
244 let next_is_pipe = next_nt[i] < n && tokens[next_nt[i]].0 == PIPE;
245 let prev_is_pipe = prev_nt == PIPE;
246 let is_single = !next_is_pipe && !prev_is_pipe;
247 // Only matters if we haven't seen COLON yet.
248 if is_single && !e.has_colon {
249 e.single_pipe_before_colon = true;
250 }
251 }
252 prev_nt = PIPE;
253 }
254 NEWLINE => {
255 while let Some(entry) = stack.pop() {
256 result[entry.brace_pos] = classify(&entry);
257 }
258 prev_nt = NEWLINE;
259 }
260 _ => {
261 prev_nt = kind;
262 }
263 }
264 }
265
266 for entry in stack {
267 result[entry.brace_pos] = classify(&entry);
268 }
269
270 result
271 }
272
273 /// Returns `true` if the nesting depth limit has been reached.
274 fn at_depth_limit(&self) -> bool {
275 self.depth >= MAX_DEPTH
276 }
277
278 /// Look up the pre-computed scan result for a `{` token at the given raw
279 /// position. Returns `PIPE`, `COLON`, or `EOF`.
280 fn brace_scan_at(&self, raw_pos: usize) -> SyntaxKind {
281 self.brace_scan.get(raw_pos).copied().unwrap_or(EOF)
282 }
283
284 // ── Lookahead ───────────────────────────────────────────────
285
286 /// The kind of the current token (or `EOF` if past the end).
287 fn current(&self) -> SyntaxKind {
288 self.nth(0)
289 }
290
291 /// Lookahead by `n` tokens, skipping trivia (WHITESPACE, comments).
292 /// `nth(0)` returns the current non-trivia token.
293 ///
294 /// Uses the pre-computed `non_trivia` index for O(log n + 1) lookup
295 /// (binary search to find our position, then constant-time indexing).
296 fn nth(&self, n: usize) -> SyntaxKind {
297 // Find the first non-trivia index >= self.pos via binary search.
298 let start = self.non_trivia.partition_point(|&idx| idx < self.pos);
299 let target = start + n;
300 if target < self.non_trivia.len() {
301 self.tokens[self.non_trivia[target]].0
302 } else {
303 EOF
304 }
305 }
306
307 /// Lookahead by `n` tokens WITHOUT skipping trivia.
308 fn nth_raw(&self, n: usize) -> SyntaxKind {
309 self.tokens.get(self.pos + n).map_or(EOF, |&(k, _)| k)
310 }
311
312 /// The source text of the `n`-th non-trivia token ahead (skipping
313 /// trivia), or `""` past end-of-file. Used to recognize T1b's
314 /// contextual block keywords (`if`, `while`, `for`, `in`, `break`,
315 /// `continue`) without reserving them globally — they stay plain
316 /// `IDENT` tokens everywhere outside a `~ { … }` block, so existing ink
317 /// content using those words as identifiers is byte-for-byte unaffected.
318 fn nth_text(&self, n: usize) -> &'t str {
319 let start = self.non_trivia.partition_point(|&idx| idx < self.pos);
320 let target = start + n;
321 if target < self.non_trivia.len() {
322 self.tokens[self.non_trivia[target]].1
323 } else {
324 ""
325 }
326 }
327
328 /// Returns `true` if the current token is `IDENT` with exactly this text
329 /// — a contextual (soft) keyword check. See [`Parser::nth_text`].
330 fn at_kw_text(&self, text: &str) -> bool {
331 self.current() == IDENT && self.nth_text(0) == text
332 }
333
334 /// Returns `true` if the current non-trivia token matches `kind`.
335 fn at(&self, kind: SyntaxKind) -> bool {
336 self.current() == kind
337 }
338
339 /// Returns `true` if we're at end-of-file.
340 fn at_eof(&self) -> bool {
341 self.pos >= self.tokens.len()
342 }
343
344 /// Current position in the token stream (for loop-progress checks).
345 fn pos(&self) -> usize {
346 self.pos
347 }
348
349 // ── Consumption ─────────────────────────────────────────────
350
351 /// Emit the current token to the builder and advance.
352 fn bump(&mut self) {
353 if self.pos < self.tokens.len() {
354 let (kind, text) = self.tokens[self.pos];
355 self.builder.token(rowan::SyntaxKind(kind as u16), text);
356 self.pos += 1;
357 }
358 }
359
360 /// Bump the current token, which every call site's own dispatch has
361 /// already checked (via `nth`) to be `kind`.
362 ///
363 /// That check can be invalidated by trivia a caller forgot to flush
364 /// with `skip_ws()` before dispatching: `nth`/`current` skip trivia for
365 /// lookahead, but `self.pos` — and so this raw `bump()` — does not move
366 /// until something actually consumes it. A mismatch here is therefore a
367 /// parser bug, not a property of the input, but malformed/adversarial
368 /// input must never turn a parser bug into a panic. Degrade to a parse
369 /// error and still consume one token, preserving the forward-progress
370 /// invariant every caller relies on (no `bump_assert` call site may
371 /// become a stuck point that only the top-level `source_file` recovery
372 /// loop can un-stick).
373 fn bump_assert(&mut self, kind: SyntaxKind) {
374 if self.nth_raw(0) != kind {
375 self.error(format!("expected {kind:?}"));
376 }
377 self.bump();
378 }
379
380 /// If the current non-trivia token matches `kind`, eat trivia then bump it.
381 /// Returns `true` if consumed.
382 fn eat(&mut self, kind: SyntaxKind) -> bool {
383 if self.current() == kind {
384 self.skip_ws();
385 self.bump();
386 true
387 } else {
388 false
389 }
390 }
391
392 /// Expect the current non-trivia token to be `kind`. If it is, eat trivia
393 /// and bump. Otherwise, emit an error.
394 fn expect(&mut self, kind: SyntaxKind) {
395 if !self.eat(kind) {
396 self.error(format!("expected {kind:?}"));
397 }
398 }
399
400 /// Returns `true` if the current non-trivia token is `IDENT` or a keyword.
401 ///
402 /// Ink keywords are contextual — they may appear as identifiers in some
403 /// positions (e.g. list member names like `or`, `and`, `not`).
404 fn at_ident_or_keyword(&self) -> bool {
405 self.current() == IDENT || self.current().is_keyword()
406 }
407
408 /// If the current non-trivia token is `IDENT` or a keyword, eat trivia
409 /// then bump it. Returns `true` if consumed.
410 fn eat_ident_or_keyword(&mut self) -> bool {
411 if self.at_ident_or_keyword() {
412 self.skip_ws();
413 self.bump();
414 true
415 } else {
416 false
417 }
418 }
419
420 /// Expect the current non-trivia token to be `IDENT` or a keyword.
421 /// If not, emit an error.
422 fn expect_ident_or_keyword(&mut self) {
423 if !self.eat_ident_or_keyword() {
424 self.error("expected IDENT".into());
425 }
426 }
427
428 /// Consume all trivia (`WHITESPACE`, `LINE_COMMENT`, `BLOCK_COMMENT`).
429 fn skip_ws(&mut self) {
430 while self.pos < self.tokens.len() && self.tokens[self.pos].0.is_trivia() {
431 self.bump();
432 }
433 }
434
435 /// Consume a run of comment tokens (`LINE_COMMENT`, `BLOCK_COMMENT`) at
436 /// the raw position, WITHOUT consuming adjacent `WHITESPACE`.
437 ///
438 /// Used by `content::mixed_content`'s zero-progress recovery: a mid-line
439 /// comment is elided from the output, but the whitespace touching it on
440 /// either side is real content that must survive so the surrounding
441 /// `TEXT` runs fold back together correctly. Unlike `skip_ws` (which
442 /// treats `WHITESPACE` and comments as one contiguous trivia blob and
443 /// would swallow that whitespace too), this stops the instant it sees
444 /// anything that isn't a comment token — including `WHITESPACE` — so a
445 /// caller can retry `text_content` and pick that whitespace back up as
446 /// ordinary text. Confirmed against inklecate's own output: the
447 /// `astrochili__narrator` corpus's `Before comment ... /* A comment */
448 /// ... and after.` compiles to `Before comment ... ... and after.`
449 /// (the double space from both sides' whitespace surviving, only the
450 /// comment span itself removed) — see
451 /// `tests/tests_github/astrochili__narrator/test/units/comments.ink.json`.
452 fn skip_comment_tokens(&mut self) {
453 while matches!(self.nth_raw(0), LINE_COMMENT | BLOCK_COMMENT) {
454 self.bump();
455 }
456 }
457
458 // ── Nodes ───────────────────────────────────────────────────
459
460 /// Start a new CST node.
461 fn start_node(&mut self, kind: SyntaxKind) {
462 self.builder.start_node(rowan::SyntaxKind(kind as u16));
463 }
464
465 /// Start a new CST node at a previously saved checkpoint.
466 fn start_node_at(&mut self, checkpoint: rowan::Checkpoint, kind: SyntaxKind) {
467 self.builder
468 .start_node_at(checkpoint, rowan::SyntaxKind(kind as u16));
469 }
470
471 /// Finish the current CST node.
472 fn finish_node(&mut self) {
473 self.builder.finish_node();
474 }
475
476 /// Save the current position as a checkpoint for `start_node_at`.
477 fn checkpoint(&self) -> rowan::Checkpoint {
478 self.builder.checkpoint()
479 }
480
481 // ── Errors ──────────────────────────────────────────────────
482
483 /// Record a parse error at the current position.
484 fn error(&mut self, message: String) {
485 // Byte offset of the current token = total length of all preceding
486 // tokens (the lexer emits contiguous tokens covering the whole source).
487 let upto = self.pos.min(self.tokens.len());
488 let start: usize = self.tokens[..upto].iter().map(|(_, t)| t.len()).sum();
489 let len: usize = self.tokens.get(self.pos).map_or(0, |(_, t)| t.len());
490 let start = rowan::TextSize::from(u32::try_from(start).unwrap_or(u32::MAX));
491 let len = rowan::TextSize::from(u32::try_from(len).unwrap_or(u32::MAX));
492 self.errors.push(ParseError {
493 message,
494 range: rowan::TextRange::at(start, len),
495 });
496 }
497
498 /// Wrap the current token in an `ERROR` node and advance.
499 ///
500 /// Used by grammar rules that need to recover from unexpected tokens
501 /// without losing the rest of the input.
502 fn error_recover(&mut self, message: &str) {
503 self.error(message.to_owned());
504 self.start_node(ERROR);
505 self.bump();
506 self.finish_node();
507 }
508}
509
510#[cfg(test)]
511mod tests;