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;