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}