brink_syntax_native/parser/mod.rs
1mod annotation;
2mod binding;
3mod block;
4mod choice;
5mod content;
6mod control_flow;
7mod decl;
8mod divert;
9mod doc_comment;
10mod element;
11mod expr;
12mod family;
13mod markup;
14mod source_file;
15mod stmt;
16#[cfg(test)]
17mod tests;
18mod types;
19
20use crate::SyntaxKind::{self, ERROR};
21use crate::lexer;
22use rowan::GreenNode;
23
24/// Result of parsing a `.brink` source file.
25///
26/// `PartialEq` compares the green tree structurally (rowan `GreenNode`
27/// equality is content-based) plus the error list.
28#[derive(Clone, PartialEq, Eq)]
29pub struct Parse {
30 green: GreenNode,
31 errors: Vec<ParseError>,
32}
33
34impl Parse {
35 /// The root green node of the lossless CST.
36 #[must_use]
37 pub fn green(&self) -> &GreenNode {
38 &self.green
39 }
40
41 /// The root syntax node (typed wrapper around the green tree).
42 #[must_use]
43 pub fn syntax(&self) -> crate::SyntaxNode {
44 crate::SyntaxNode::new_root(self.green.clone())
45 }
46
47 /// Parse errors encountered.
48 #[must_use]
49 pub fn errors(&self) -> &[ParseError] {
50 &self.errors
51 }
52}
53
54/// A parse diagnostic's severity — whether it blocks compilation.
55///
56/// This crate has no `brink-ir` dependency (peer-crate rule, `lib.rs`'s
57/// doc comment), so this stays a small local enum rather than reusing
58/// `brink_ir::Severity` — consumers (`brink-db`'s `lower_native_file`) map
59/// it onto the appropriate `DiagnosticCode` (`E037` for `Error`, a
60/// dedicated Warning-severity code for `Warning`) at the seam where the two
61/// diagnostic vocabularies meet.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ParseSeverity {
64 /// Malformed source — blocks compilation.
65 Error,
66 /// Advisory only — surfaced to the user but never blocks compilation
67 /// (issue #1263: `<-` outside a choice point *can* be literal dialogue,
68 /// so a hard error would be wrong).
69 Warning,
70}
71
72/// A parse error with a message and the source range it points at.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ParseError {
75 pub message: String,
76 /// Byte range in the source that the error points at.
77 pub range: rowan::TextRange,
78 /// Whether this diagnostic blocks compilation. Defaults to `Error` for
79 /// every existing diagnostic (`Parser::error`); only `Parser::warning`
80 /// produces `Warning`.
81 pub severity: ParseSeverity,
82}
83
84/// Parse a `.brink` source string into a lossless CST.
85#[must_use]
86pub fn parse(source: &str) -> Parse {
87 let raw_tokens = lexer::lex(source);
88 let mut p = Parser::new(&raw_tokens);
89 source_file::source_file(&mut p);
90 let green = p.builder.finish();
91 Parse {
92 green,
93 errors: p.errors,
94 }
95}
96
97/// Parse with a shared [`rowan::NodeCache`] for green-node interning.
98pub fn parse_with_cache(source: &str, cache: &mut rowan::NodeCache) -> Parse {
99 let raw_tokens = lexer::lex(source);
100 let mut p = Parser::with_cache(&raw_tokens, cache);
101 source_file::source_file(&mut p);
102 let green = p.builder.finish();
103 Parse {
104 green,
105 errors: p.errors,
106 }
107}
108
109// ── Parser internals ────────────────────────────────────────────────
110
111/// Maximum nesting depth for recursive grammar rules (blocks, expressions,
112/// parenthesized groups). Prevents stack overflow and superlinear parse
113/// time on pathological/adversarial input. 256 matches Rust's default
114/// `recursion_limit`.
115const MAX_DEPTH: u32 = 256;
116
117/// The parser. Holds a token stream and a `GreenNodeBuilder`.
118pub(crate) struct Parser<'t, 'c> {
119 tokens: &'t [(SyntaxKind, &'t str)],
120 pos: usize,
121 depth: u32,
122 /// Pre-computed non-trivia token indices. `non_trivia[k]` is the raw
123 /// token index of the k-th non-trivia token. Enables O(1) `nth(n)`
124 /// instead of an O(n) rescan per lookahead — this parser calls `nth`
125 /// in hot loops (block/content dispatch), so an un-indexed scan would
126 /// make parsing a large file superlinear.
127 non_trivia: Vec<usize>,
128 builder: rowan::GreenNodeBuilder<'c>,
129 errors: Vec<ParseError>,
130 /// When `true`, a `PATH` followed by `{` is **not** read as a
131 /// `TypeName { … }` construction literal (B5, issue #1464) — the brace
132 /// belongs to the enclosing construct's body instead.
133 ///
134 /// Set only in the four head positions where an expression is directly
135 /// followed by a block opener and the two readings are genuinely
136 /// ambiguous: `if`/`while`/`for … in` heads (`parser::control_flow`)
137 /// and the content-ground `{if …}`/`{match …}` heads
138 /// (`parser::family::conditional_body`). Rust's own
139 /// `no-struct-literal` restriction is the precedent; `(…)`, an
140 /// argument list and a construction literal's own entry list all clear
141 /// it again, so `if (Point { x: 1 }) == p { … }` still parses.
142 no_construct_literal: bool,
143 /// Whether a **cue chain** is currently live at body-item position —
144 /// the prose dialect's chain rule (`docs/prose-dialect-spec.md` §3.1:
145 /// "chain rules (dialogue is the line after a cue/parenthetical)", the
146 /// shipped `brink_ir::dialect` classifier's mechanism promoted to the
147 /// grammar). Set by a cue, carried across that cue's parentheticals
148 /// and dialogue lines, and cleared by a blank line or any other item
149 /// (`dialect.rs`: "blank lines always break a chain").
150 ///
151 /// Only [`element::at_parenthetical`] consults it, and only to decide
152 /// whether a whole-line `( … )` is a parenthetical or an ordinary
153 /// content line — so outside a cue chain the G-1 `(label)` spelling
154 /// (`content::at_content_label`) is reached exactly as before.
155 cue_chain: bool,
156}
157
158impl<'t> Parser<'t, 'static> {
159 fn new(tokens: &'t [(SyntaxKind, &'t str)]) -> Self {
160 let non_trivia = Self::build_non_trivia(tokens);
161 Self {
162 tokens,
163 pos: 0,
164 depth: 0,
165 non_trivia,
166 builder: rowan::GreenNodeBuilder::new(),
167 errors: Vec::new(),
168 no_construct_literal: false,
169 cue_chain: false,
170 }
171 }
172}
173
174impl<'t, 'c> Parser<'t, 'c> {
175 fn with_cache(tokens: &'t [(SyntaxKind, &'t str)], cache: &'c mut rowan::NodeCache) -> Self {
176 let non_trivia = Self::build_non_trivia(tokens);
177 Self {
178 tokens,
179 pos: 0,
180 depth: 0,
181 non_trivia,
182 builder: rowan::GreenNodeBuilder::with_cache(cache),
183 errors: Vec::new(),
184 no_construct_literal: false,
185 cue_chain: false,
186 }
187 }
188
189 /// O(n) pre-pass: collect the raw indices of all non-trivia tokens.
190 fn build_non_trivia(tokens: &[(SyntaxKind, &str)]) -> Vec<usize> {
191 tokens
192 .iter()
193 .enumerate()
194 .filter(|(_, (k, _))| !k.is_trivia())
195 .map(|(i, _)| i)
196 .collect()
197 }
198
199 /// Enter one level of recursive-grammar nesting. Returns `false` (and
200 /// records an error) if `MAX_DEPTH` would be exceeded — callers must
201 /// bail out without recursing further, still consuming forward
202 /// progress via `error_recover`. Every mutually-recursive entry point
203 /// (blocks, the annotated-brace family, expressions) pairs this with
204 /// `exit_depth` so pathological/adversarial nesting can never blow the
205 /// stack (CLAUDE.md: "guard against unbounded growth").
206 fn enter_depth(&mut self) -> bool {
207 if self.depth >= MAX_DEPTH {
208 self.error("maximum nesting depth exceeded".into());
209 false
210 } else {
211 self.depth += 1;
212 true
213 }
214 }
215
216 /// Leave one level entered by `enter_depth`.
217 fn exit_depth(&mut self) {
218 self.depth -= 1;
219 }
220
221 /// Set the [`Self::no_construct_literal`] restriction, returning the
222 /// previous value so the caller can restore it — a save/restore pair
223 /// rather than a plain `= false` reset, so nested heads (`if a { if b
224 /// { … } }`, `{if x: {match y { … }}}`) each unwind to whatever their
225 /// own enclosing context was.
226 fn set_no_construct_literal(&mut self, value: bool) -> bool {
227 std::mem::replace(&mut self.no_construct_literal, value)
228 }
229
230 /// Whether a `PATH` at the current position may be followed by a
231 /// `TypeName { … }` construction literal (see
232 /// [`Self::no_construct_literal`]).
233 fn construct_literals_allowed(&self) -> bool {
234 !self.no_construct_literal
235 }
236
237 /// Set the [`Self::cue_chain`] flag, returning the previous value — a
238 /// save/restore pair like [`Self::set_no_construct_literal`], so a
239 /// nested body can start a fresh chain and unwind to whatever its
240 /// enclosing body was in the middle of.
241 fn set_cue_chain(&mut self, value: bool) -> bool {
242 std::mem::replace(&mut self.cue_chain, value)
243 }
244
245 /// Whether a cue chain is live at the current body-item position (see
246 /// [`Self::cue_chain`]).
247 fn in_cue_chain(&self) -> bool {
248 self.cue_chain
249 }
250
251 // ── Lookahead ───────────────────────────────────────────────
252
253 /// The kind of the current token (or `EOF` if past the end).
254 fn current(&self) -> SyntaxKind {
255 self.nth(0)
256 }
257
258 /// Lookahead by `n` tokens, skipping trivia (WHITESPACE, comments).
259 /// `nth(0)` returns the current non-trivia token.
260 fn nth(&self, n: usize) -> SyntaxKind {
261 let start = self.non_trivia.partition_point(|&idx| idx < self.pos);
262 let target = start + n;
263 if target < self.non_trivia.len() {
264 self.tokens[self.non_trivia[target]].0
265 } else {
266 SyntaxKind::EOF
267 }
268 }
269
270 /// The source text of the `n`-th non-trivia token ahead (`""` past the
271 /// end). The text counterpart of [`Self::nth`], for the handful of
272 /// guards that recognize a *specific word* rather than a token kind —
273 /// today only the prose dialect's `INT.`/`EXT.` scene-heading prefix
274 /// (`parser::element::at_scene_heading`), which is a declared
275 /// line-shape pattern, not a reserved keyword.
276 fn nth_text(&self, n: usize) -> &'t str {
277 let start = self.non_trivia.partition_point(|&idx| idx < self.pos);
278 self.non_trivia
279 .get(start + n)
280 .map_or("", |&idx| self.tokens[idx].1)
281 }
282
283 /// True when the `n`-th and `n+1`-th non-trivia tokens ahead are
284 /// *directly adjacent* in the source — no whitespace or comment
285 /// between them. Used by sigil guards whose spelling is tight by
286 /// construction (`@NAME` — a lone `@` followed by a space stays plain
287 /// prose, per `SyntaxKind::AT`'s doc comment).
288 fn nth_adjacent(&self, n: usize) -> bool {
289 let start = self.non_trivia.partition_point(|&idx| idx < self.pos);
290 match (
291 self.non_trivia.get(start + n),
292 self.non_trivia.get(start + n + 1),
293 ) {
294 (Some(&a), Some(&b)) => b == a + 1,
295 _ => false,
296 }
297 }
298
299 /// Lookahead by `n` tokens WITHOUT skipping trivia.
300 fn nth_raw(&self, n: usize) -> SyntaxKind {
301 self.tokens
302 .get(self.pos + n)
303 .map_or(SyntaxKind::EOF, |&(k, _)| k)
304 }
305
306 /// Returns `true` if the current non-trivia token matches `kind`.
307 fn at(&self, kind: SyntaxKind) -> bool {
308 self.current() == kind
309 }
310
311 /// Returns `true` if we're at end-of-file.
312 fn at_eof(&self) -> bool {
313 self.current() == SyntaxKind::EOF
314 }
315
316 /// Current position in the raw token stream (for loop-progress checks).
317 fn pos(&self) -> usize {
318 self.pos
319 }
320
321 // ── Consumption ─────────────────────────────────────────────
322
323 /// Emit the current token to the builder and advance.
324 fn bump(&mut self) {
325 if self.pos < self.tokens.len() {
326 let (kind, text) = self.tokens[self.pos];
327 self.builder.token(rowan::SyntaxKind(kind as u16), text);
328 self.pos += 1;
329 }
330 }
331
332 /// If the current non-trivia token matches `kind`, eat trivia then bump it.
333 /// Returns `true` if consumed.
334 fn eat(&mut self, kind: SyntaxKind) -> bool {
335 // Flush leading trivia *unconditionally*, before the check — not
336 // only on a match. Two correctness properties depend on this:
337 // (1) trailing trivia with nothing meaningful after it (a final
338 // comment, trailing whitespace at EOF) would otherwise never get
339 // flushed into the tree at all, since every loop-continuation
340 // check (`at_eof`, `at(R_BRACE)`, …) trivia-skips to decide
341 // "nothing left to do" without ever having called `bump` on the
342 // trivia itself — found by `proptest_native`'s
343 // `arbitrary_garbage_never_panics` (`"#//"` lost its trailing
344 // `//`) and `truncated_input_never_panics_and_roundtrips` (a
345 // truncated `flow a_a_() ` lost its trailing space). (2) it makes
346 // every `eat`/`expect` call site safe to follow with a raw
347 // `bump()` for a *different* token regardless of whether pending
348 // trivia sat between them — the class of bug this crate's parser
349 // tests caught repeatedly during development (e.g. `annotation_arg`
350 // bumping a stray space instead of the next `IDENT`).
351 self.skip_ws();
352 if self.current() == kind {
353 self.bump();
354 true
355 } else {
356 false
357 }
358 }
359
360 /// Expect the current non-trivia token to be `kind`. If it is, eat
361 /// trivia and bump. Otherwise, emit an error (no token consumed —
362 /// callers that need forward progress on mismatch should follow up
363 /// with `error_recover`).
364 fn expect(&mut self, kind: SyntaxKind) {
365 if !self.eat(kind) {
366 self.error(format!("expected {kind:?}, found {:?}", self.current()));
367 }
368 }
369
370 /// Consume all trivia (`WHITESPACE`, `LINE_COMMENT`, `BLOCK_COMMENT`).
371 fn skip_ws(&mut self) {
372 while self.pos < self.tokens.len() && self.tokens[self.pos].0.is_trivia() {
373 self.bump();
374 }
375 }
376
377 /// Consume all trivia **and** `NEWLINE` tokens.
378 ///
379 /// `NEWLINE` is deliberately not trivia (it terminates content
380 /// lines/diverts/etc. at body-item position) — but inside an
381 /// explicitly bracket/brace-delimited list (param lists, struct
382 /// fields, annotation args, `use`-tree lists, match arms, …), a line
383 /// break is pure formatting, exactly the case the charter's "whitespace
384 /// never load-bearing" ground rule (§2) describes. Every such list
385 /// loop calls this instead of `skip_ws` so multi-line lists parse.
386 fn skip_ws_and_newlines(&mut self) {
387 while self.pos < self.tokens.len()
388 && (self.tokens[self.pos].0.is_trivia()
389 || self.tokens[self.pos].0 == SyntaxKind::NEWLINE)
390 {
391 self.bump();
392 }
393 }
394
395 /// Look at the next significant token, skipping trivia **and**
396 /// `NEWLINE` (read-only — does not move `pos`). The lookahead half of
397 /// [`Self::skip_ws_and_newlines`]'s policy, for list loops that need to
398 /// check a closing delimiter before deciding whether to recurse.
399 fn peek_skip_nl(&self) -> SyntaxKind {
400 let mut i = self.pos;
401 while i < self.tokens.len()
402 && (self.tokens[i].0.is_trivia() || self.tokens[i].0 == SyntaxKind::NEWLINE)
403 {
404 i += 1;
405 }
406 self.tokens.get(i).map_or(SyntaxKind::EOF, |&(k, _)| k)
407 }
408
409 // ── Nodes ───────────────────────────────────────────────────
410
411 /// Start a new CST node.
412 fn start_node(&mut self, kind: SyntaxKind) {
413 self.builder.start_node(rowan::SyntaxKind(kind as u16));
414 }
415
416 /// Start a new CST node at a previously saved checkpoint.
417 fn start_node_at(&mut self, checkpoint: rowan::Checkpoint, kind: SyntaxKind) {
418 self.builder
419 .start_node_at(checkpoint, rowan::SyntaxKind(kind as u16));
420 }
421
422 /// Finish the current CST node.
423 fn finish_node(&mut self) {
424 self.builder.finish_node();
425 }
426
427 /// Save the current position as a checkpoint for `start_node_at`.
428 fn checkpoint(&self) -> rowan::Checkpoint {
429 self.builder.checkpoint()
430 }
431
432 // ── Errors ──────────────────────────────────────────────────
433
434 /// Record a parse diagnostic at the current position with the given
435 /// severity. Shared implementation for [`Self::error`]/[`Self::warning`].
436 fn push_diagnostic(&mut self, message: String, severity: ParseSeverity) {
437 let upto = self.pos.min(self.tokens.len());
438 let start: usize = self.tokens[..upto].iter().map(|(_, t)| t.len()).sum();
439 let len: usize = self.tokens.get(self.pos).map_or(0, |(_, t)| t.len());
440 let start = rowan::TextSize::from(u32::try_from(start).unwrap_or(u32::MAX));
441 let len = rowan::TextSize::from(u32::try_from(len).unwrap_or(u32::MAX));
442 self.errors.push(ParseError {
443 message,
444 range: rowan::TextRange::at(start, len),
445 severity,
446 });
447 }
448
449 /// Record a parse error at the current position. Blocks compilation
450 /// (`ParseSeverity::Error`).
451 fn error(&mut self, message: String) {
452 self.push_diagnostic(message, ParseSeverity::Error);
453 }
454
455 /// Record a warning-severity diagnostic at the current position.
456 /// Advisory only — never blocks compilation (`ParseSeverity::Warning`).
457 fn warning(&mut self, message: String) {
458 self.push_diagnostic(message, ParseSeverity::Warning);
459 }
460
461 /// Wrap the current token in an `ERROR` node and advance.
462 ///
463 /// Used by grammar rules that need to recover from unexpected tokens
464 /// without losing the rest of the input. Guarantees forward progress
465 /// even at EOF-adjacent malformed input, as long as at least one raw
466 /// token remains — callers at the very top (`source_file`) additionally
467 /// guard against a zero-progress spin when even that isn't true.
468 fn error_recover(&mut self, message: &str) {
469 self.error(message.to_owned());
470 self.start_node(ERROR);
471 if self.pos < self.tokens.len() {
472 self.bump();
473 }
474 self.finish_node();
475 }
476}