ifc_lite_core/parser/scanner.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//! Byte-level SIMD fast scanner over raw IFC bytes.
6//!
7//! Independent of the nom [`tokenizer`](super::tokenizer): does its own
8//! hand-rolled, quote- and comment-aware parsing without building [`Token`]s.
9
10#[path = "scanner_header.rs"]
11mod scanner_header;
12use scanner_header::data_section_start;
13
14// The per-attribute read lives next door, per this file's own split pattern
15// (see `scanner_attributes.rs`'s doc comment).
16#[path = "scanner_attributes.rs"]
17mod scanner_attributes;
18pub use scanner_attributes::{nth_attribute_is_present, StepListItems};
19pub(crate) use scanner_attributes::argument_list_start;
20
21// The refusal/drop reporting surface, likewise (see its doc comment).
22#[path = "scanner_diagnostics.rs"]
23mod scanner_diagnostics;
24
25// `find_entity_end` and the #4179 rules it carries, likewise.
26#[path = "scanner_record_end.rs"]
27mod scanner_record_end;
28
29/// Fast entity scanner over raw IFC bytes without full parsing.
30/// O(n) performance for finding entities by type
31/// Uses memchr for SIMD-accelerated byte searching
32pub struct EntityScanner<'a> {
33 bytes: &'a [u8],
34 position: usize,
35 /// `line_start` of every record refused for an oversized instance name,
36 /// in scan order (so: strictly increasing). A `Vec` rather than a counter
37 /// because a SHARDED caller has to know WHERE a refusal happened before it
38 /// can tell a real one from one its speculative prefix invented — see
39 /// [`Self::skipped_oversized_id_starts`]. It never allocates on a file
40 /// with nothing to refuse, which is every real file.
41 skipped_oversized_id_starts: Vec<usize>,
42 /// See [`Self::malformed_record_starts`] for what these point at.
43 malformed_record_starts: Vec<usize>,
44 /// No `/` in `bytes[slash_from..next_slash]`, and `next_slash` is a `/` or
45 /// `bytes.len()`: the cache behind `find_entity_end`'s comment probe.
46 slash_from: usize,
47 next_slash: usize,
48}
49
50impl<'a> EntityScanner<'a> {
51 /// Create a new scanner.
52 ///
53 /// Positions past the STEP HEADER section when one is present so that a
54 /// stray `#` inside a header string (e.g. a CATIA `FILE_NAME` like
55 /// `'…\X0\2#.ifc'`) can't be mistaken for an entity start and corrupt
56 /// quote-parity for the rest of the file (issue #654).
57 pub fn new<T>(content: &'a T) -> Self
58 where
59 T: AsRef<[u8]> + ?Sized,
60 {
61 let bytes = content.as_ref();
62 Self {
63 bytes,
64 position: data_section_start(bytes),
65 skipped_oversized_id_starts: Vec::new(),
66 malformed_record_starts: Vec::new(),
67 slash_from: usize::MAX,
68 next_slash: 0,
69 }
70 }
71
72 /// Create a scanner positioned at a specific byte offset.
73 ///
74 /// Used by the sharded-scan pre-pass: each shard scans the full file
75 /// (so byte offsets returned are GLOBAL, not relative to the shard's
76 /// range) but starts walking at its assigned start offset. Callers are
77 /// expected to rewind `position` to a known entity boundary (typically
78 /// the byte after a `;\n` terminator) before calling `next_entity`.
79 ///
80 /// Does NOT auto-skip the HEADER section — that's the caller's
81 /// responsibility, since shards expect the exact offset they were given.
82 pub fn new_at<T>(content: &'a T, position: usize) -> Self
83 where
84 T: AsRef<[u8]> + ?Sized,
85 {
86 let bytes = content.as_ref();
87 let clamped = position.min(bytes.len());
88 Self {
89 bytes,
90 position: clamped,
91 skipped_oversized_id_starts: Vec::new(),
92 malformed_record_starts: Vec::new(),
93 slash_from: usize::MAX,
94 next_slash: 0,
95 }
96 }
97
98 /// Current byte offset of the scanner (start of the next entity to scan).
99 pub fn position(&self) -> usize {
100 self.position
101 }
102
103 /// Record `at` as a dropped record. Offsets only advance, so the list
104 /// stays strictly increasing.
105 fn mark_malformed(&mut self, at: usize) {
106 if self.malformed_record_starts.last() != Some(&at) {
107 self.malformed_record_starts.push(at);
108 }
109 }
110
111 /// Next `(entity_id, keyword, line_start, line_end)`. The keyword is the RAW slice as the file wrote it
112 /// (`IfcWall` stays `IfcWall`); STEP keyword case is not significant, so compare it with [`super::keyword_eq`], never `==`.
113 #[inline]
114 pub fn next_entity(&mut self) -> Option<(u32, &'a str, usize, usize)> {
115 // Find a '#' that actually starts an entity. A '#' is legal inside
116 // STEP-encoded quoted strings (e.g. CATIA writes filenames like
117 // `'…\X0\2#.ifc'` into the HEADER's FILE_NAME) AND inside STEP
118 // `/* … */` comments. Two layered guards:
119 //
120 // 1. Skip past `/* … */` comment regions entirely so an inner
121 // `#N=…` token can't be mistaken for an entity (PR #865 follow-
122 // up — `/* previous #12= IFCWALL */` was the canonical example
123 // where the original `#N=` shape check still false-positived).
124 // 2. After comment-skipping locates a candidate '#', validate it
125 // starts a real `#<trivia>=` pattern. Catches embedded
126 // references inside STEP strings (CATIA `'…\X0\2#.ifc'`) AND
127 // any comment-shaped tokens the comment skipper missed (mostly
128 // a fallback now — true `/* */` regions never reach this check).
129 //
130 // "Trivia", not whitespace: 10303-21 allows a comment wherever
131 // whitespace is allowed, INCLUDING inside a record, so
132 // `#1 /* was #7 */ = IFCWALL(…);` is a legal declaration and used to
133 // produce no record at all. The same rule governs the gap between the
134 // '=' and the type name, and `find_entity_end` skips a comment for it
135 // in the record body — otherwise a ';' written inside one ends the
136 // record early and the span handed to the decoder is truncated. The
137 // matched TypeScript half is `skipTrivia` in
138 // `packages/parser/src/step-lexing.ts`; change the two together.
139 //
140 // Both checks together are what `build_entity_index` (`decoder.rs`)
141 // relies on for its own comment-awareness: it is a bare loop over
142 // `next_entity`, not a separate scan, so it inherits this method's
143 // comment handling directly rather than needing to duplicate it.
144 let bytes = self.bytes;
145 let len = bytes.len();
146 // Outer loop so a record this scanner refuses (an oversized
147 // instance name, below) is SKIPPED rather than ending the scan.
148 loop {
149 let (line_start, parsed_id, eq_pos) = loop {
150 // Step (1): jump past any `/* … */` comment that starts at or
151 // before the next candidate '#'. Use memchr2 so we look for
152 // '#' and '/' in one SIMD pass — whichever comes first
153 // decides the next move.
154 let remaining = &bytes[self.position..];
155 let next = memchr::memchr2(b'#', b'/', remaining)?;
156 let candidate = self.position + next;
157 let candidate_byte = bytes[candidate];
158
159 if candidate_byte == b'/' {
160 // '/' might begin a STEP `/* … */` comment. If yes, jump
161 // past `*/`; if not, it's a STEP arithmetic '/' inside a
162 // value list (rare; just step past it).
163 if candidate + 1 < len && bytes[candidate + 1] == b'*' {
164 // An unterminated `/*` means corrupt input (#3303) —
165 // same "no resume point" shape as `find_entity_end`.
166 match super::lexical::skip_step_comment(bytes, candidate) {
167 Some(next_pos) => {
168 self.position = next_pos;
169 continue;
170 }
171 None => {
172 self.mark_malformed(candidate);
173 return None;
174 }
175 }
176 }
177 // Lone '/' — not a comment. Skip past.
178 self.position = candidate + 1;
179 continue;
180 }
181
182 // candidate_byte == b'#'. Step (2): validate `#<digits>[ws]*=`.
183 let after = candidate + 1;
184 let (digit_count, parsed_id) =
185 crate::express_id::parse_express_id_prefix(&bytes[after..]);
186 if digit_count == 0 {
187 self.position = after;
188 continue;
189 }
190 let digit_end = after + digit_count;
191 // Skip optional trivia and verify the next byte is '='.
192 // `None` means a comment opened here and never closes, which
193 // is not a declaration either — fall through to the rescan
194 // below, where the outer memchr2 finds the same '/*' and
195 // `skip_step_comment` ends the scan on it.
196 let probe = super::lexical::skip_step_trivia(bytes, digit_end).unwrap_or(len);
197 if probe < len && bytes[probe] == b'=' {
198 // A declaration continues `= keyword(` or `= (` (complex
199 // record); `!` opens a user-defined keyword, and a
200 // non-ASCII lead byte is tolerated because the type-name
201 // read below already maps it to `UNKNOWN` rather than
202 // refusing. An ASCII non-letter after the `=` is not a
203 // record, and accepting it would mint a phantom entity:
204 // `#1=IFCWALL(#5 = 3);` is refused at its `=` and
205 // re-hunted from past its `#`, and without this test
206 // `#5 = 3);` scans as entity #5 with body `3)`, which the
207 // last-wins index then writes over the REAL #5.
208 let after_eq = super::lexical::skip_step_trivia(bytes, probe + 1).unwrap_or(len);
209 if after_eq < len
210 && matches!(bytes[after_eq], b'A'..=b'Z' | b'a'..=b'z' | b'!' | b'(' | 0x80..=0xFF)
211 {
212 break (candidate, parsed_id, probe);
213 }
214 }
215 // '#<digits>' not followed by '=' and a record: a comment or
216 // string reference, or a stray '=' inside a refused body. Skip
217 // past the digits and keep searching.
218 self.position = digit_end;
219 };
220
221 // Find the end of the entity (semicolon) while respecting quoted strings
222 // IFC strings use single quotes and can contain semicolons
223 // The prefix contains only digits and already-closed trivia (#3987).
224 let body_start = eq_pos + 1;
225 let line_content = &bytes[body_start..];
226 let semicolon = match self.find_entity_end(body_start) {
227 Some(scanner_record_end::RecordEnd::At(o)) => o,
228 // An unescaped apostrophe (#7541): drop this record and resume
229 // at the next anchored declaration, never at its own `)`.
230 Some(scanner_record_end::RecordEnd::StrayQuote(quote)) => {
231 self.mark_malformed(line_start);
232 self.position = super::record_strings::resync_at_record_start(bytes, quote + 1);
233 continue;
234 }
235 None => {
236 // Report it, then drop just THIS record rather than the
237 // rest of the file: the `)` balancing its `(` is a real
238 // 10303-21 boundary (#4179), the same per-record skip the
239 // oversized id below takes. An unterminated string or
240 // comment has no balancing `)` either, so #3695's
241 // "nothing to resume from" stop is unchanged.
242 self.mark_malformed(line_start);
243 self.position = match super::lexical::close_step_record(line_content) {
244 super::lexical::RecordClose::At(o) => body_start + o,
245 // No balancing ')', but the bytes after are readable:
246 // re-hunt from past this record's '#' so the NEXT
247 // declaration is still found. Stopping here cost the
248 // whole tail for a missing ')' (#4179 review).
249 super::lexical::RecordClose::Unbalanced => line_start + 1,
250 super::lexical::RecordClose::Unreadable => return None,
251 };
252 continue;
253 }
254 };
255 let line_end = semicolon + 1;
256
257 // Refusal is still reported AFTER malformed-body detection (#3987).
258 let Some(id) = parsed_id else {
259 // The instance name does not fit `u32` (issue #3395). SKIP
260 // the record and keep scanning: returning `None` here would
261 // end the whole scan at the first oversized id, silently
262 // truncating the model from that byte on. Per-record skip is
263 // what the rest of this scanner already does with malformed
264 // input, and the counter above is how the caller finds out.
265 self.skipped_oversized_id_starts.push(line_start);
266 self.position = line_end;
267 continue;
268 };
269
270 // Use the validated '=', not one inside `#1 /* a=b */ = IFCWALL`.
271 // Skip trivia before the type; find_entity_end already verified
272 // closed comments, with line_end as a conservative fallback.
273 let type_start = super::lexical::skip_step_trivia(&self.bytes[..line_end], eq_pos + 1)
274 .unwrap_or(line_end);
275
276 // Find end of type name (at '(', whitespace, or a comment opener:
277 // `IFCWALL/* n */(…)` is legal and its type name is IFCWALL).
278 //
279 // STEP whitespace includes vertical tab, unlike is_ascii_whitespace.
280 let mut type_end = type_start;
281 let mut type_bytes_or = 0u8;
282 while type_end < line_end {
283 let b = self.bytes[type_end];
284 if b == b'(' || super::lexical::is_step_space(b) {
285 break;
286 }
287 if b == b'/' && self.bytes.get(type_end + 1) == Some(&b'*') {
288 break;
289 }
290 type_bytes_or |= b;
291 type_end += 1;
292 }
293
294 let type_bytes = &self.bytes[type_start..type_end];
295 let type_name = if type_bytes_or.is_ascii() {
296 // SAFETY: the loop ORs every byte in this exact slice; a clear
297 // high bit proves every byte is ASCII and therefore valid UTF-8.
298 unsafe { std::str::from_utf8_unchecked(type_bytes) }
299 } else {
300 std::str::from_utf8(type_bytes).unwrap_or("UNKNOWN")
301 };
302
303 // Move position past this entity
304 self.position = line_end;
305
306 return Some((id, type_name, line_start, line_end));
307 }
308 }
309
310
311 /// Find all entities of a specific type
312 pub fn find_by_type(&mut self, target_type: &str) -> Vec<(u32, usize, usize)> {
313 let mut results = Vec::new();
314
315 while let Some((id, type_name, start, end)) = self.next_entity() {
316 if type_name.eq_ignore_ascii_case(target_type) {
317 results.push((id, start, end));
318 }
319 }
320
321 results
322 }
323
324 /// Count entities by type
325 pub fn count_by_type(&mut self) -> rustc_hash::FxHashMap<String, usize> {
326 let mut counts = rustc_hash::FxHashMap::default();
327
328 while let Some((_, type_name, _, _)) = self.next_entity() {
329 *counts.entry(type_name.to_string()).or_insert(0) += 1;
330 }
331
332 counts
333 }
334
335 /// Count the entities remaining from the scanner's current position, without
336 /// allocating anything per entity.
337 ///
338 /// Unlike [`count_by_type`](Self::count_by_type) (which builds a per-keyword
339 /// map) or [`build_entity_index`](crate::build_entity_index) (which retains a
340 /// span per entity, ~20 B each), this walks the byte stream and increments a
341 /// single counter: `O(scan)` time, `O(1)` memory. It is the cheap primitive
342 /// for a downstream entity-count DoS guard on a file too large to index
343 /// (issue #1517). Advances the scanner to the end of the data section.
344 pub fn count(&mut self) -> usize {
345 let mut n = 0usize;
346 while self.next_entity().is_some() {
347 n += 1;
348 }
349 n
350 }
351
352 /// Reset scanner to beginning (re-applies the HEADER skip).
353 pub fn reset(&mut self) {
354 self.position = data_section_start(self.bytes);
355 self.skipped_oversized_id_starts.clear();
356 self.malformed_record_starts.clear();
357 }
358}
359
360/// Count the entities in a STEP/IFC byte buffer in `O(scan)` time and `O(1)`
361/// memory — no entity index, no per-type map.
362///
363/// A thin wrapper over [`EntityScanner::count`]. This is the cheap primitive a
364/// downstream can use to reject a file with a pathologically large entity count
365/// that a byte-size cap would miss, WITHOUT paying the ~20 B/entity the full
366/// index costs (issue #1517). Header-aware and comment-/string-safe, exactly
367/// like the scanner (it IS the scanner), so the count matches what
368/// [`build_entity_index`](crate::build_entity_index) would find.
369pub fn entity_count<T>(content: &T) -> usize
370where
371 T: AsRef<[u8]> + ?Sized,
372{
373 EntityScanner::new(content).count()
374}
375
376#[cfg(test)]
377#[path = "scanner_tests.rs"]
378mod scanner_tests;