Skip to main content

rustyfi_syntax/
stream.rs

1//! The parse source for the SATySFi surface grammar.
2//!
3//! The parse source is the eagerly lexed `Vec<Atom>`, wrapped by
4//! [`AtomStream`]: syan core has no `IntoParseStream for Vec<_>`, so the
5//! buffering lives here.
6//!
7//! Stream erasure (a `&mut dyn ParseStream` tower) does not belong here, and
8//! is obsoleted by syan: `Parse::parse_stream` takes `&mut S` and recursion
9//! reborrows, so `S` is a genuine fixed point and the instantiation set is
10//! finite without erasing anything, and no stream operation is a virtual call.
11//!
12//! # The high-water mark
13//!
14//! This module used to decline a failure high-water mark too, on the grounds
15//! that "`ParseError` is span-generic, every variant carrying the position it
16//! failed at, so the error reports itself". **That was false**, and this type
17//! now carries the mark because of it. `ParseError` does carry a position, but
18//! not a useful one for a failure inside a repetition: `Vec<TopBinding>` stops
19//! on the binding that would not parse and rolls the stream back, and its
20//! error is discarded rather than aggregated, so what surfaces is the
21//! enclosing rule's "expected end of input" at the binding's START. Measured,
22//! a 0.0.6 error sixty bytes into a top-level `let` reported at byte 3; a 0.1
23//! error anywhere in a file reported on the `module` keyword on line 1,
24//! because a 0.1 library IS one binding.
25//!
26//! The furthest-position-reached mark is the standard answer for a
27//! backtracking parser, and the stream is the only place it can be observed:
28//! it is a property of the *parse*, not of any one error value. `next()`
29//! records the furthest atom ever handed out and never forgets it, so
30//! backtracking cannot erase the evidence, and
31//! [`crate::parse_error::locate`] turns mark + error tree into one diagnostic.
32//!
33//! # The budget
34//!
35//! Both grammars backtrack exponentially on some incomplete inputs — see
36//! [`Budget`] for the measurements and for why a *compiler*, and not only a
37//! language server, wants a cap.
38
39use crate::span::Span;
40use crate::token::Atom;
41use std::convert::Infallible;
42use syan::parse::tape::Tape;
43use syan::parse::ParseStream;
44
45/// How much backtracking one parse may do before [`AtomStream`] declares the
46/// input unparseable and reports end of input.
47///
48/// The unit is a **serve**: one atom handed out by [`ParseStream::next`],
49/// counting every re-read a rollback causes. A count and not a clock, so the
50/// same source produces the same verdict on a fast machine, on a slow one, in
51/// a test and in a browser.
52///
53/// # Why a compiler has one at all
54///
55/// Because without it the compiler does not report anything. Measured on a
56/// release build, over chains of `let vN = N in` ending in a `let` with no
57/// right-hand side:
58///
59/// | file | error on | before |
60/// |---|---|---|
61/// | 9 lines | line 6 | exit 1, 7 ms |
62/// | 15 lines | line 12 | exit 1, 32 ms |
63/// | 35 lines | line 32 | **still running after 100 s** |
64///
65/// The cause is a plain unfactored common prefix, and it is worth naming
66/// precisely so that nobody hunts for it again: `Expr::LetIn` and
67/// `Expr::LetPatternIn` both begin `let ‹target› = ‹expr› in ‹body›`, so a
68/// failure in the innermost body is re-derived exactly twice per enclosing
69/// `let`. Measured, serves against chain length: 1,115 at 3, 9,459 at 6,
70/// 76,211 at 9, 610,227 at 12, 4,882,355 at 15 — ×2.000 each time. Deleting
71/// `LetPatternIn` (which is *shadowed* for a bare-variable target, as its own
72/// doc comment says) collapses the same measurements to 374, 677, 980, 1,283,
73/// 1,586: linear, 17 serves per atom.
74///
75/// Factoring the two into one variant is the real repair, and it is a CST
76/// change that reaches `elaborate.rs`, so it is deliberately not done here.
77/// Nor would it be the end of it — the 0.1 grammar blows up the same way on
78/// truncated prefixes of the bundled `std-ja.satyh`, ×5 per 200 bytes, from a
79/// different prefix. What a budget buys, and a grammar fix does not, is that
80/// the *next* such prefix is a slow error instead of a hang.
81///
82/// The give-up is reported as a give-up
83/// ([`crate::ParseFailureKind::GaveUp`]), never as a claim about the token
84/// the parse happened to stop at — and it still carries the high-water mark's
85/// position, which in every case measured is the line the author must look at.
86///
87/// # Why it scales with the input
88///
89/// A cap has to be unreachable by any honest parse of any honest file, and
90/// "honest" is a property per token, not per file: a fixed ceiling that a
91/// 300-line file cannot reach is one a generated 30,000-line file can. So the
92/// cap is a per-atom allowance, and only *superlinear* backtracking can
93/// outrun it.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub struct Budget(u64);
96
97impl Budget {
98    /// Serves per atom an honest parse is allowed.
99    ///
100    /// Calibrated from measurement, not guessed: a clean parse costs 14–17
101    /// serves per atom, and the worst of the 77 files in the bundled corpus
102    /// (`dist-v01/packages/tabular.satyh`) costs 34.7. This is roughly sixty
103    /// times that, and `parse_errors.rs`'s
104    /// `the_bundled_corpus_stays_far_under_the_per_atom_budget` re-measures
105    /// the corpus on every run rather than trusting the figure.
106    pub const PER_ATOM: u64 = 2_048;
107
108    /// Floor, so that a small file still gets the allowance a mid-sized one
109    /// would.
110    ///
111    /// Without it a ten-line file would be capped at a few thousand serves and
112    /// would give up on constructs a hundred-line file resolves. At roughly
113    /// 10M serves per second this is about a second of trying, and it is what
114    /// lets a broken chain of fifteen `let`s still reach a real verdict —
115    /// past which, the exponential above being what it is, each further
116    /// doubling of the floor buys exactly one more `let`. That is the argument
117    /// against simply raising it: the budget cannot buy diagnostic quality
118    /// here, only bound the damage.
119    pub const FLOOR: u64 = 8_000_000;
120
121    /// The allowance for a token vector of `atoms` atoms.
122    pub const fn for_atoms(atoms: usize) -> Self {
123        let scaled = (atoms as u64).saturating_mul(Self::PER_ATOM);
124        // `Ord::max` is not a `const fn`, hence the `if`.
125        Budget(if scaled > Self::FLOOR {
126            scaled
127        } else {
128            Self::FLOOR
129        })
130    }
131
132    /// An explicit allowance, for a caller with its own responsiveness
133    /// requirement — a language server spends less than a compiler, because a
134    /// human is waiting on every keystroke.
135    pub const fn exactly(serves: u64) -> Self {
136        Budget(serves)
137    }
138
139    /// No cap at all: the parse runs to a verdict or forever.
140    ///
141    /// For a caller that has bounded the work some other way, and for pinning
142    /// the unbounded behaviour in a test.
143    pub const fn unlimited() -> Self {
144        Budget(u64::MAX)
145    }
146
147    /// The allowance, in serves.
148    pub const fn serves(self) -> u64 {
149        self.0
150    }
151}
152
153/// A parse source over an eagerly lexed token vector, which remembers how far
154/// the parse ever got and stops it if it goes on too long.
155///
156/// Backtracking runs through syan's [`Tape`], which owns the pushback and the
157/// checkpoint scopes, so the forwarding half of this is a thin shim; the mark
158/// and the budget are the parts that are not.
159pub struct AtomStream {
160    tape: Tape<std::vec::IntoIter<Atom>>,
161    /// End byte of the furthest atom ever served; `0` if none was.
162    furthest: usize,
163    /// The span of that atom — kept as it is observed, rather than recovered
164    /// afterwards by scanning every token's span.
165    furthest_span: Option<Span>,
166    served: u64,
167    budget: u64,
168}
169
170impl AtomStream {
171    /// Wrap an eagerly lexed atom vector, with the budget [`Budget`]
172    /// calibrates for its size.
173    pub fn new(atoms: Vec<Atom>) -> Self {
174        let budget = Budget::for_atoms(atoms.len());
175        Self::with_budget(atoms, budget)
176    }
177
178    /// [`Self::new`] with the budget chosen by the caller.
179    pub fn with_budget(atoms: Vec<Atom>, budget: Budget) -> Self {
180        AtomStream {
181            tape: Tape::new(atoms.into_iter()),
182            furthest: 0,
183            furthest_span: None,
184            served: 0,
185            budget: budget.serves(),
186        }
187    }
188
189    /// End byte of the furthest atom the parser ever consumed; `0` if it
190    /// consumed nothing.
191    ///
192    /// Consumed, not peeked: a lookahead that rejects a token has not made
193    /// progress through it, and counting it would push every diagnostic one
194    /// token to the right.
195    pub fn furthest(&self) -> usize {
196        self.furthest
197    }
198
199    /// The span of the atom that set [`Self::furthest`] — the token the parse
200    /// stopped at.
201    ///
202    /// It is the token *ending* at the mark, not the one starting after it:
203    /// the generated leaf parsers are `next()` → match → `push()`-back-on-
204    /// mismatch (see [`crate::leaf`]), so the offending token has already been
205    /// pulled through the stream by the time the leaf rejects it. Reporting
206    /// the token after it would put every diagnostic one token to the right.
207    pub fn furthest_span(&self) -> Option<Span> {
208        self.furthest_span
209    }
210
211    /// Whether the parse hit its budget rather than reaching a real verdict.
212    ///
213    /// When this is true, the failure the parser reported means only "the
214    /// stream ended", which is this type's doing and not the source's — so the
215    /// caller must not dress it up as a claim about the source.
216    /// [`crate::parse_error::locate`] does not.
217    pub fn exhausted(&self) -> bool {
218        self.served >= self.budget
219    }
220
221    /// How many atoms have been served, counting every re-read. Exposed for
222    /// calibrating [`Budget`] against a real corpus.
223    pub fn served(&self) -> u64 {
224        self.served
225    }
226
227    fn observe(&mut self, span: Span) {
228        // Monotone by construction: a rollback re-serves atoms already seen,
229        // and the point of the mark is that backtracking does not lower it.
230        if span.end.byte > self.furthest || self.furthest_span.is_none() {
231            self.furthest = span.end.byte;
232            self.furthest_span = Some(span);
233        }
234    }
235}
236
237impl ParseStream for AtomStream {
238    type Atom = Atom;
239    type Error = Infallible;
240
241    fn next(&mut self) -> Option<Self::Atom> {
242        // Checked before the read, so an exhausted stream stays exhausted
243        // however many times the parser retries.
244        if self.served >= self.budget {
245            return None;
246        }
247        self.served += 1;
248        let atom = self.tape.next()?;
249        self.observe(atom.span);
250        Some(atom)
251    }
252
253    fn peek(&mut self) -> Option<&Self::Atom> {
254        self.tape.peek()
255    }
256
257    fn push(&mut self, atom: Self::Atom) {
258        self.tape.push(atom);
259    }
260
261    fn checkpoint_raw(&mut self) -> u64 {
262        self.tape.checkpoint()
263    }
264
265    fn rollback_raw(&mut self, raw: u64) {
266        self.tape.rollback(raw);
267    }
268
269    fn commit_raw(&mut self, raw: u64) {
270        self.tape.commit(raw);
271    }
272
273    fn get_error(&mut self) -> Result<(), Self::Error> {
274        Ok(())
275    }
276
277    fn skip_sep(&mut self) -> bool {
278        // Already lexed: there is no separator atom to skip.
279        false
280    }
281}