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}