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}