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 are ordered-choice backtrackers, so an unfactored common
36//! prefix costs a *factor* per nesting level rather than a constant. The one
37//! such prefix that had been measured is gone (see [`Budget`]); the cap
38//! remains, because it is what makes the next one a slow error instead of a
39//! hang — see [`Budget`] for why a *compiler*, and not only a language
40//! server, wants one.
41
42use crate::span::Span;
43use crate::token::Atom;
44use std::convert::Infallible;
45use syan::parse::tape::Tape;
46use syan::parse::ParseStream;
47
48/// How much backtracking one parse may do before [`AtomStream`] declares the
49/// input unparseable and reports end of input.
50///
51/// The unit is a **serve**: one atom handed out by [`ParseStream::next`],
52/// counting every re-read a rollback causes. A count and not a clock, so the
53/// same source produces the same verdict on a fast machine, on a slow one, in
54/// a test and in a browser.
55///
56/// # Why a compiler has one at all
57///
58/// Because without it the compiler does not report anything. Measured on a
59/// release build, over chains of `let vN = N in` ending in a `let` with no
60/// right-hand side:
61///
62/// | file | error on | before |
63/// |---|---|---|
64/// | 9 lines | line 6 | exit 1, 7 ms |
65/// | 15 lines | line 12 | exit 1, 32 ms |
66/// | 35 lines | line 32 | **still running after 100 s** |
67///
68/// The cause was a plain unfactored common prefix: `Expr::LetIn` and
69/// `Expr::LetPatternIn` both began `let ‹target› = ‹expr› in ‹body›`, so a
70/// failure in the innermost body was re-derived exactly twice per enclosing
71/// `let`. Measured, serves against chain length: 1,115 at 3, 9,459 at 6,
72/// 76,211 at 9, 610,227 at 12, 4,882,355 at 15 — ×2.000 each time.
73///
74/// **That prefix is now factored** and the table above is history:
75/// `cst::PatNonVarErased` refuses a bare-variable destructuring target — the
76/// restriction upstream 0.1 spells `pattern_non_var` — which makes the two
77/// alternatives disjoint at the token after `let`. The same chain costs 411,
78/// 750, 1,089, 1,428, 1,767: an arithmetic progression, +113 per `let`,
79/// pinned as an exact equality by `parse_errors.rs`'s
80/// `one_more_let_costs_a_constant_number_of_serves`. No input is known that
81/// reaches this cap any more.
82///
83/// The cap stays anyway, and the row above is why it was worth having: the
84/// 0.1 grammar was separately observed to blow up ×5 per 200 bytes on
85/// truncated prefixes of the bundled `std-ja.satyh`, from a *different*
86/// prefix that has not been chased down. What a budget buys, and a grammar
87/// fix does not, is that the next such prefix is a slow error instead of a
88/// hang.
89///
90/// The give-up is reported as a give-up
91/// ([`crate::ParseFailureKind::GaveUp`]), never as a claim about the token
92/// the parse happened to stop at — and it still carries the high-water mark's
93/// position, which in every case measured is the line the author must look at.
94///
95/// # Why it scales with the input
96///
97/// A cap has to be unreachable by any honest parse of any honest file, and
98/// "honest" is a property per token, not per file: a fixed ceiling that a
99/// 300-line file cannot reach is one a generated 30,000-line file can. So the
100/// cap is a per-atom allowance, and only *superlinear* backtracking can
101/// outrun it.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub struct Budget(u64);
104
105impl Budget {
106    /// Serves per atom an honest parse is allowed.
107    ///
108    /// Calibrated from measurement, not guessed: a clean parse costs 14–17
109    /// serves per atom, and the worst of the 77 files in the bundled corpus
110    /// (`dist-v01/packages/tabular.satyh`) costs 34.7. This is roughly sixty
111    /// times that, and `parse_errors.rs`'s
112    /// `the_bundled_corpus_stays_far_under_the_per_atom_budget` re-measures
113    /// the corpus on every run rather than trusting the figure.
114    pub const PER_ATOM: u64 = 2_048;
115
116    /// Floor, so that a small file still gets the allowance a mid-sized one
117    /// would.
118    ///
119    /// Without it a ten-line file would be capped at a few thousand serves and
120    /// would give up on constructs a hundred-line file resolves. At roughly
121    /// 10M serves per second this is about a second of trying.
122    ///
123    /// **Do not raise this to fix a give-up.** While the `let` prefix above
124    /// was unfactored, the floor bought a broken chain of fifteen `let`s a
125    /// real verdict and each further doubling bought exactly one more `let` —
126    /// which is the shape of the argument in general: against a superlinear
127    /// grammar the budget cannot buy diagnostic quality, only bound the
128    /// damage. A give-up means a production needs left-factoring; the number
129    /// to change is in `cst.rs`, not here.
130    pub const FLOOR: u64 = 8_000_000;
131
132    /// The allowance for a token vector of `atoms` atoms.
133    pub const fn for_atoms(atoms: usize) -> Self {
134        let scaled = (atoms as u64).saturating_mul(Self::PER_ATOM);
135        // `Ord::max` is not a `const fn`, hence the `if`.
136        Budget(if scaled > Self::FLOOR {
137            scaled
138        } else {
139            Self::FLOOR
140        })
141    }
142
143    /// An explicit allowance, for a caller with its own responsiveness
144    /// requirement — a language server spends less than a compiler, because a
145    /// human is waiting on every keystroke.
146    pub const fn exactly(serves: u64) -> Self {
147        Budget(serves)
148    }
149
150    /// No cap at all: the parse runs to a verdict or forever.
151    ///
152    /// For a caller that has bounded the work some other way, and for pinning
153    /// the unbounded behaviour in a test.
154    pub const fn unlimited() -> Self {
155        Budget(u64::MAX)
156    }
157
158    /// The allowance, in serves.
159    pub const fn serves(self) -> u64 {
160        self.0
161    }
162}
163
164/// A parse source over an eagerly lexed token vector, which remembers how far
165/// the parse ever got and stops it if it goes on too long.
166///
167/// Backtracking runs through syan's [`Tape`], which owns the pushback and the
168/// checkpoint scopes, so the forwarding half of this is a thin shim; the mark
169/// and the budget are the parts that are not.
170pub struct AtomStream {
171    tape: Tape<std::vec::IntoIter<Atom>>,
172    /// End byte of the furthest atom ever served; `0` if none was.
173    furthest: usize,
174    /// The span of that atom — kept as it is observed, rather than recovered
175    /// afterwards by scanning every token's span.
176    furthest_span: Option<Span>,
177    served: u64,
178    budget: u64,
179}
180
181impl AtomStream {
182    /// Wrap an eagerly lexed atom vector, with the budget [`Budget`]
183    /// calibrates for its size.
184    pub fn new(atoms: Vec<Atom>) -> Self {
185        let budget = Budget::for_atoms(atoms.len());
186        Self::with_budget(atoms, budget)
187    }
188
189    /// [`Self::new`] with the budget chosen by the caller.
190    pub fn with_budget(atoms: Vec<Atom>, budget: Budget) -> Self {
191        AtomStream {
192            tape: Tape::new(atoms.into_iter()),
193            furthest: 0,
194            furthest_span: None,
195            served: 0,
196            budget: budget.serves(),
197        }
198    }
199
200    /// End byte of the furthest atom the parser ever consumed; `0` if it
201    /// consumed nothing.
202    ///
203    /// Consumed, not peeked: a lookahead that rejects a token has not made
204    /// progress through it, and counting it would push every diagnostic one
205    /// token to the right.
206    pub fn furthest(&self) -> usize {
207        self.furthest
208    }
209
210    /// The span of the atom that set [`Self::furthest`] — the token the parse
211    /// stopped at.
212    ///
213    /// It is the token *ending* at the mark, not the one starting after it:
214    /// the generated leaf parsers are `next()` → match → `push()`-back-on-
215    /// mismatch (see [`crate::leaf`]), so the offending token has already been
216    /// pulled through the stream by the time the leaf rejects it. Reporting
217    /// the token after it would put every diagnostic one token to the right.
218    pub fn furthest_span(&self) -> Option<Span> {
219        self.furthest_span
220    }
221
222    /// Whether the parse hit its budget rather than reaching a real verdict.
223    ///
224    /// When this is true, the failure the parser reported means only "the
225    /// stream ended", which is this type's doing and not the source's — so the
226    /// caller must not dress it up as a claim about the source.
227    /// [`crate::parse_error::locate`] does not.
228    pub fn exhausted(&self) -> bool {
229        self.served >= self.budget
230    }
231
232    /// How many atoms have been served, counting every re-read. Exposed for
233    /// calibrating [`Budget`] against a real corpus.
234    pub fn served(&self) -> u64 {
235        self.served
236    }
237
238    fn observe(&mut self, span: Span) {
239        // Monotone by construction: a rollback re-serves atoms already seen,
240        // and the point of the mark is that backtracking does not lower it.
241        if span.end.byte > self.furthest || self.furthest_span.is_none() {
242            self.furthest = span.end.byte;
243            self.furthest_span = Some(span);
244        }
245    }
246}
247
248impl ParseStream for AtomStream {
249    type Atom = Atom;
250    type Error = Infallible;
251
252    fn next(&mut self) -> Option<Self::Atom> {
253        // Checked before the read, so an exhausted stream stays exhausted
254        // however many times the parser retries.
255        if self.served >= self.budget {
256            return None;
257        }
258        self.served += 1;
259        let atom = self.tape.next()?;
260        self.observe(atom.span);
261        Some(atom)
262    }
263
264    fn peek(&mut self) -> Option<&Self::Atom> {
265        self.tape.peek()
266    }
267
268    fn push(&mut self, atom: Self::Atom) {
269        self.tape.push(atom);
270    }
271
272    fn checkpoint_raw(&mut self) -> u64 {
273        self.tape.checkpoint()
274    }
275
276    fn rollback_raw(&mut self, raw: u64) {
277        self.tape.rollback(raw);
278    }
279
280    fn commit_raw(&mut self, raw: u64) {
281        self.tape.commit(raw);
282    }
283
284    fn get_error(&mut self) -> Result<(), Self::Error> {
285        Ok(())
286    }
287
288    fn skip_sep(&mut self) -> bool {
289        // Already lexed: there is no separator atom to skip.
290        false
291    }
292}