ifc_lite_core/parser/
lexical.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The STEP `/* ... */` comment-skip rule -- and, on top of it, the trivia
6//! rule that says a comment is legal wherever whitespace is -- shared by every
7//! scanner that has no reason to answer an unterminated comment differently
8//! from the others.
9//!
10//! [`skip_step_comment`] answers an unterminated `/*` by refusing it: it
11//! returns `None`, the same as "not a comment here" from the caller's point
12//! of view. That is correct for a scanner walking already-located,
13//! well-formed record bytes looking for structure — an unterminated comment
14//! there means the input is corrupt, and silently consuming the rest of it
15//! is worse than stopping. [`super::scanner::EntityScanner`] and
16//! `ifc-lite-geometry`'s `IfcTriangulatedFaceSet` CoordIndex walk both used
17//! to hand-roll this and disagreed with each other on exactly this case
18//! (issue #3303); both now call this one function.
19//!
20//! The shared STEP HEADER prescan (`header::Lex`) answers the
21//! same question differently, on purpose, and that is not the divergence
22//! #3303 is about: a header prescan that swallows every later record has
23//! lost the schema, so it treats an unterminated `/*` as ordinary text
24//! instead of refusing. See the doc comment on `Lex::skip_comment_at` for
25//! why that call site needs its own answer. It still calls this function to
26//! find where a *closed* comment ends — only the unterminated case is
27//! handled differently, and only at that one call site.
28
29pub(crate) use crate::step_encoding::is_page_quote_operand;
30
31/// If a STEP `/* ... */` comment starts at `bytes[i]`, the index just past
32/// its closing `*/`.
33///
34/// Returns `None` when `bytes[i]` doesn't begin a comment, and ALSO when it
35/// does but no closing `*/` exists anywhere in `bytes` after it — an
36/// unterminated comment is refused rather than silently run to end of input.
37/// A caller that needs to tell those two `None` cases apart already knows
38/// which one it's in, from having checked `bytes[i..i + 2] == b"/*"` itself
39/// before calling (as every call site in this repo does), so the single
40/// `Option` return doesn't lose information.
41#[inline]
42pub fn skip_step_comment(bytes: &[u8], i: usize) -> Option<usize> {
43    if bytes.get(i) != Some(&b'/') || bytes.get(i + 1) != Some(&b'*') {
44        return None;
45    }
46    let len = bytes.len();
47    let mut p = i + 2;
48    while p + 1 < len {
49        // Find the next '*'; check whether it's followed by '/'.
50        let star = match memchr::memchr(b'*', &bytes[p..]) {
51            Some(off) => p + off,
52            None => return None, // unterminated comment
53        };
54        if star + 1 < len && bytes[star + 1] == b'/' {
55            return Some(star + 2);
56        }
57        p = star + 1;
58    }
59    None // unterminated comment
60}
61
62/// Index of the first byte at or after `i` that is neither ASCII whitespace
63/// nor part of a `/* ... */` comment.
64///
65/// ISO 10303-21 allows a comment ANYWHERE whitespace is allowed, which
66/// includes inside a record: between an instance name and its `=`, between the
67/// `=` and the type name, and between the type name and its `(`. A scanner
68/// that skips only whitespace at those points reads
69/// `#1 /* was #7 */ = IFCWALL(…);` as no record at all.
70///
71/// `None` when a comment opens here and never closes — everything from there
72/// to the end of `bytes` is inside it, so there is nothing left to find. That
73/// is [`skip_step_comment`]'s answer, deliberately, and not a separate rule.
74///
75/// This is the matched pair of `skipTrivia` in
76/// `packages/parser/src/step-lexing.ts`; the two halves are changed together.
77#[inline]
78pub fn skip_step_trivia(bytes: &[u8], i: usize) -> Option<usize> {
79    let len = bytes.len();
80    let mut p = i;
81    loop {
82        while p < len && is_step_space(bytes[p]) {
83            p += 1;
84        }
85        if bytes.get(p) != Some(&b'/') || bytes.get(p + 1) != Some(&b'*') {
86            return Some(p);
87        }
88        p = skip_step_comment(bytes, p)?;
89    }
90}
91
92/// ASCII whitespace per ISO 10303-21: space, tab, LF, CR, form feed, vertical
93/// tab.
94///
95/// Spelled out rather than `u8::is_ascii_whitespace`, which follows the
96/// WhatWG Infra Standard's definition and EXCLUDES vertical tab (0x0B) — its
97/// docs say as much. The STEP header scanner now uses this same function;
98/// reaching for a stdlib predicate once mismatched the TypeScript half's
99/// `isSpaceByte`/`isAsciiSpace` on `\x0B` and, before that
100/// mismatch was found, TypeScript's `isSpaceByte` itself omitted both `\x0C`
101/// and `\x0B` — issue #3733, a form feed silently dropping an entity that
102/// this scanner parsed. Keep this set and `isSpaceByte` in
103/// `packages/parser/src/step-lexing.ts` identical.
104#[inline]
105pub fn is_step_space(b: u8) -> bool {
106    matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)
107}
108
109/// Where a refused record ends, and whether anything after it can be read.
110///
111/// The two failures are separate because they call for opposite answers. A
112/// record whose parens never balance (`#2=IFCB(2;`) still leaves the rest of
113/// the file readable, so a scan drops that ONE record and hunts on; one whose
114/// literal or comment never closes puts everything to end of input inside it,
115/// so there is nothing to resume from and the scan stops (#3695). Collapsing
116/// them cost the whole tail of the file for a missing `)`, which is the same
117/// amplification #4179 is about.
118#[derive(Debug, PartialEq, Eq)]
119pub enum RecordClose {
120    /// Offset just past the `)` balancing the record's `(`.
121    At(usize),
122    /// No balancing `)`, but every literal and comment closed.
123    Unbalanced,
124    /// A literal or comment opened and never closed.
125    Unreadable,
126}
127
128/// Whether the last significant byte of `plain` is `')'`, or `carried` when
129/// `plain` holds nothing but STEP whitespace (so the answer stands from before
130/// it). The cheap half of the record-boundary rule argued on
131/// [`close_step_record`]; walks backwards, stopping on the first byte for
132/// every real record.
133#[inline]
134pub fn closes_with_paren(plain: &[u8], carried: bool) -> bool {
135    match plain.iter().rev().find(|b| !is_step_space(**b)) {
136        Some(&b) => b == b')',
137        None => carried,
138    }
139}
140
141/// Offset just past the `)` that balances the first `(` in `bytes`, or `None`
142/// when the input runs out, a comment or literal never closes, or a `)`
143/// arrives before any `(`.
144///
145/// # Where a STEP record ends, and whether a `;` is its own (#4179)
146///
147/// This doc is the one argument for a rule four scans implement:
148/// [`EntityScanner::find_entity_end`](super::scanner) and this function in
149/// Rust, `StepTokenizer.scanEntitiesFast` (`packages/parser/src/tokenizer.ts`)
150/// with `packages/parser/src/step-record-boundary.ts` in TypeScript, and
151/// `WORKER_CODE` (`packages/parser/src/scan-worker-source.ts`), which
152/// hand-duplicates it because a Blob worker cannot import at runtime. Rust is
153/// the source of truth per AGENTS.md; the halves are changed together.
154///
155/// ## The two rules
156///
157/// A "skip to the next unquoted `;`" scan is bounded only by end-of-buffer, so
158/// a record missing its own `;` takes the first `;` it can reach — the NEXT
159/// record's, or the footer's — reporting success while swallowing whatever lay
160/// between. Two rules read off the ISO 10303-21 grammar bound it to the
161/// record's own body:
162///
163///   * `simple_record` is `keyword '(' [parameter_list] ')'`, so the last
164///     significant byte before the terminator is always `')'`. A `;` preceded
165///     by anything else, as in `#2=IFCWALL('b')\nENDSEC;`, is the footer's.
166///   * `'='` appears in the exchange structure only in
167///     `entity_instance_name '=' record`, never inside a parameter list, so
168///     one before the terminator, as in `#2=IFCB(2)\n#3=IFCC(3);`, means the
169///     scan walked into the NEXT declaration.
170///
171/// Both are judged only on bytes outside strings and comments: an `=` in
172/// either is text, and a comment between the `)` and the `;` is trivia.
173///
174/// The two are a cheap approximation of one general property — *the `;` is the
175/// first non-trivia byte after the `)` balancing the record's own `(`* — which
176/// is what THIS function computes exactly. The approximation is deliberate:
177/// the exact rule costs a full paren balance per record on the hottest
178/// structural path, so it runs only where a byte test already refused. It is
179/// strictly weaker, and admits shapes the exact rule rejects, `#2=IFCB(2) (3);`
180/// among them.
181///
182/// ## Why the scan stops at the next `=`
183///
184/// The second rule above binds HERE too, and for the same reason: a `)` found
185/// after a top-level `=` closes some LATER record's parameter list, never this
186/// one's. Reporting it would hand back a resume point past a record the scan
187/// has not read, which drops that record outright — `#1=A(2 #2=IFCWALL($));`
188/// balanced at #2's `)` and resumed past it, losing #2.
189///
190/// It is also what bounds the walk. Without it a record with no `)` of its own
191/// walks to end of input to answer [`RecordClose::Unbalanced`], the caller
192/// resumes one byte past the `#`, and the NEXT declaration repeats the whole
193/// walk: a file of `#1=A(2;` repeated is clean O(n^2), measured 16.2s at 80 000
194/// records (0.6 MB) and rising 4x per doubling. With the bound each refusal
195/// costs its own record's bytes, so the total is the file length however many
196/// records are malformed. `close_step_record` is on every untrusted-bytes entry
197/// point (`entity_count`, `build_entity_index`, `ColumnarEntityIndex::from_scan`,
198/// the wasm prepass, the server's parse routes), so that shape was reachable
199/// from a plain upload.
200///
201/// The TypeScript half (`findEntityLength`, `step-record-boundary.ts`, and the
202/// hand-duplicate in `scan-worker-source.ts`) is tracked as #4573: it carries
203/// neither bound, so the browser scan worker takes the same quadratic shape
204/// and still balances past a following declaration.
205///
206/// ## Why a refusal RECOVERS instead of stopping
207///
208/// 10303-21 closes every record's parameter list, so the `)` this function
209/// finds is a real grammar boundary even when the `;` after it is missing.
210/// That lets the scan drop the ONE broken record and carry on. The rest of the
211/// file is not the malformed record's to take: a shard whose scanner stops
212/// hands back no handoff, and both stitches — `stitchShards`
213/// (`packages/geometry/src/shard-stitch.ts`) and the native
214/// `parallel_scan::stitch` — read that as "no more real entities" and discard
215/// every LATER shard, including bytes those shards already scanned cleanly.
216/// Measured on a 40-record file whose #20 lost its `;`, at four shards: 40
217/// records in, 19 out. An unterminated string or comment has no such `)`,
218/// nothing to resume from, so that case still stops as #3695 set it.
219///
220/// A string literal is jumped whole and a `/* … */` comment after it, in that
221/// order, so a `(` or `)` inside either is text; the same rule and the same
222/// order the scanner's own body walk uses. The matched TypeScript half is
223/// `findEntityLength` in `packages/parser/src/step-lexing.ts`.
224pub fn close_step_record(bytes: &[u8]) -> RecordClose {
225    let mut pos = 0;
226    let mut depth = 0u32;
227    while pos < bytes.len() {
228        match bytes[pos] {
229            b'\'' => {
230                pos += 1;
231                loop {
232                    match memchr::memchr(b'\'', &bytes[pos..]) {
233                        Some(off) => pos += off + 1,
234                        None => return RecordClose::Unreadable,
235                    }
236                    if is_page_quote_operand(bytes, pos - 1) {
237                        continue;
238                    }
239                    // A doubled '' is an escaped quote: still inside.
240                    if bytes.get(pos) == Some(&b'\'') {
241                        pos += 1;
242                        continue;
243                    }
244                    // An unescaped apostrophe (#7541): every byte after it is
245                    // read inverted, so no ')' found from here is this record's.
246                    match super::record_strings::string_parameter_end(bytes, pos - 1) {
247                        super::record_strings::QuoteEnd::Closes => break,
248                        super::record_strings::QuoteEnd::Stray => return RecordClose::Unbalanced,
249                        super::record_strings::QuoteEnd::Unreadable => return RecordClose::Unreadable,
250                    }
251                }
252            }
253            b'/' if bytes.get(pos + 1) == Some(&b'*') => match skip_step_comment(bytes, pos) {
254                Some(next) => pos = next,
255                None => return RecordClose::Unreadable,
256            },
257            // An '=' out here is the NEXT declaration's, so this record's own
258            // ')' cannot lie past it: see "Why the scan stops at the next '='"
259            // above. Bounds the walk to the record's own reach, which is what
260            // keeps a file of unbalanced records linear rather than quadratic.
261            b'=' => return RecordClose::Unbalanced,
262            b'(' => {
263                depth += 1;
264                pos += 1;
265            }
266            b')' => {
267                match depth.checked_sub(1) {
268                    Some(d) => depth = d,
269                    // A ')' before any '(' is not this record's close, but it
270                    // closed nothing either, so the bytes after are readable.
271                    None => return RecordClose::Unbalanced,
272                }
273                pos += 1;
274                if depth == 0 {
275                    return RecordClose::At(pos);
276                }
277            }
278            _ => pos += 1,
279        }
280    }
281    RecordClose::Unbalanced
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn trivia_skips_a_run_of_whitespace_and_comments() {
290        let bytes = b"  /* a */\t/* b */\n = X";
291        let end = skip_step_trivia(bytes, 0).expect("every comment closes");
292        assert_eq!(&bytes[end..], b"= X");
293    }
294
295    #[test]
296    fn trivia_stops_on_the_first_non_trivia_byte() {
297        assert_eq!(skip_step_trivia(b"=X", 0), Some(0));
298        // A lone '/' is STEP division, not a comment opener, so trivia ends.
299        assert_eq!(skip_step_trivia(b" /2", 0), Some(1));
300    }
301
302    #[test]
303    fn trivia_refuses_an_unterminated_comment() {
304        assert_eq!(skip_step_trivia(b" /* never closes", 0), None);
305    }
306
307    /// Issue #3733: form feed (0x0C) and vertical tab (0x0B) are both trivia.
308    /// `u8::is_ascii_whitespace` (the WhatWG set) includes the former and
309    /// excludes the latter, which is exactly the shape of bug this guards --
310    /// see `is_step_space`'s doc comment.
311    #[test]
312    fn trivia_skips_form_feed_and_vertical_tab() {
313        assert_eq!(skip_step_trivia(b"\x0c=X", 0), Some(1));
314        assert_eq!(skip_step_trivia(b"\x0b=X", 0), Some(1));
315        assert_eq!(skip_step_trivia(b"\x0c\x0b =X", 0), Some(3));
316    }
317
318    #[test]
319    fn closed_comment_skips_to_after_close() {
320        let bytes = b"/* hello #77 */rest";
321        assert_eq!(skip_step_comment(bytes, 0), Some(15));
322        assert_eq!(&bytes[15..], b"rest");
323    }
324
325    #[test]
326    fn adjacent_asterisks_inside_a_comment_do_not_confuse_the_scan() {
327        let bytes = b"/* a ** weird *comment* */tail";
328        let end = skip_step_comment(bytes, 0).expect("comment is closed");
329        assert_eq!(&bytes[end..], b"tail");
330    }
331
332    #[test]
333    fn unterminated_comment_is_refused() {
334        assert_eq!(skip_step_comment(b"/* never closes", 0), None);
335        assert_eq!(skip_step_comment(b"/*", 0), None);
336        assert_eq!(skip_step_comment(b"/* trailing star *", 0), None);
337    }
338
339    #[test]
340    fn close_step_record_finds_the_balancing_paren() {
341        // Offset is just PAST the ')', which is where a scan resumes.
342        assert_eq!(close_step_record(b"IFCWALL($);rest"), RecordClose::At(10));
343        assert_eq!(close_step_record(b"IFCWALL(('a'),(1.,2.))\ntail"), RecordClose::At(22));
344        // A paren inside a literal or a comment is text, not depth.
345        assert_eq!(close_step_record(b"IFCWALL('a)b');"), RecordClose::At(14));
346        assert_eq!(close_step_record(b"IFCWALL('a''(b');"), RecordClose::At(16));
347        assert_eq!(close_step_record(b"IFCWALL(/* ) ( */$);"), RecordClose::At(19));
348        // Nothing to resume from: the stop the #3695 cluster relies on.
349        assert_eq!(close_step_record(b"IFCWALL('never closes"), RecordClose::Unreadable);
350        assert_eq!(close_step_record(b"IFCWALL(/* never closes"), RecordClose::Unreadable);
351        // Readable, just never balanced: a scan may drop the record and hunt on.
352        assert_eq!(close_step_record(b"IFCWALL($"), RecordClose::Unbalanced);
353        assert_eq!(close_step_record(b"IFCWALL(2;#3=IFCC(3);"), RecordClose::Unbalanced);
354        // A ')' before any '(' must not underflow.
355        assert_eq!(close_step_record(b")))"), RecordClose::Unbalanced);
356    }
357
358    /// A `)` after a top-level `=` closes a LATER record's parameter list, so
359    /// the walk stops at that `=` rather than balancing across a declaration
360    /// it has not read. Pre-fix `IFCA(2 #2=IFCWALL($));` answered `At`, and
361    /// the scanner resumed past #2 and lost it. The same bound is what keeps
362    /// a file of unbalanced records linear instead of O(n^2).
363    /// Regression for #4577.
364    #[test]
365    fn close_step_record_stops_at_the_next_declaration() {
366        assert_eq!(close_step_record(b"IFCA(2 #2=IFCWALL($));"), RecordClose::Unbalanced);
367        assert_eq!(close_step_record(b"IFCA(2;\n#2=IFCB(3);"), RecordClose::Unbalanced);
368        // An '=' inside a literal or a comment is text, not a declaration, so
369        // a record carrying one still closes where it really closes.
370        assert_eq!(close_step_record(b"IFCA('a=b');"), RecordClose::At(11));
371        assert_eq!(close_step_record(b"IFCA(/* a=b */$);"), RecordClose::At(16));
372        assert_eq!(
373            close_step_record(b"IFCDOCUMENTREFERENCE('http://h/q?a=b&c=d',$);"),
374            RecordClose::At(44)
375        );
376    }
377
378    #[test]
379    fn not_a_comment_at_all() {
380        assert_eq!(skip_step_comment(b"/x not a comment", 0), None);
381        assert_eq!(skip_step_comment(b"/", 0), None);
382        assert_eq!(skip_step_comment(b"", 0), None);
383    }
384}