Skip to main content

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/// Fast entity scanner over raw IFC bytes without full parsing.
11/// O(n) performance for finding entities by type
12/// Uses memchr for SIMD-accelerated byte searching
13pub struct EntityScanner<'a> {
14    bytes: &'a [u8],
15    position: usize,
16}
17
18impl<'a> EntityScanner<'a> {
19    /// Create a new scanner.
20    ///
21    /// Positions past the STEP HEADER section when one is present so that a
22    /// stray `#` inside a header string (e.g. a CATIA `FILE_NAME` like
23    /// `'…\X0\2#.ifc'`) can't be mistaken for an entity start and corrupt
24    /// quote-parity for the rest of the file (issue #654).
25    pub fn new<T>(content: &'a T) -> Self
26    where
27        T: AsRef<[u8]> + ?Sized,
28    {
29        let bytes = content.as_ref();
30        Self {
31            bytes,
32            position: data_section_start(bytes),
33        }
34    }
35
36    /// Create a scanner positioned at a specific byte offset.
37    ///
38    /// Used by the sharded-scan pre-pass: each shard scans the full file
39    /// (so byte offsets returned are GLOBAL, not relative to the shard's
40    /// range) but starts walking at its assigned start offset. Callers are
41    /// expected to rewind `position` to a known entity boundary (typically
42    /// the byte after a `;\n` terminator) before calling `next_entity`.
43    ///
44    /// Does NOT auto-skip the HEADER section — that's the caller's
45    /// responsibility, since shards expect the exact offset they were given.
46    pub fn new_at<T>(content: &'a T, position: usize) -> Self
47    where
48        T: AsRef<[u8]> + ?Sized,
49    {
50        let bytes = content.as_ref();
51        let clamped = position.min(bytes.len());
52        Self {
53            bytes,
54            position: clamped,
55        }
56    }
57
58    /// Current byte offset of the scanner (start of the next entity to scan).
59    pub fn position(&self) -> usize {
60        self.position
61    }
62
63    /// Scan for the next entity
64    /// Returns (entity_id, type_name, line_start, line_end)
65    #[inline]
66    pub fn next_entity(&mut self) -> Option<(u32, &'a str, usize, usize)> {
67        // Find a '#' that actually starts an entity. A '#' is legal inside
68        // STEP-encoded quoted strings (e.g. CATIA writes filenames like
69        // `'…\X0\2#.ifc'` into the HEADER's FILE_NAME) AND inside STEP
70        // `/* … */` comments. Two layered guards:
71        //
72        //   1. Skip past `/* … */` comment regions entirely so an inner
73        //      `#N=…` token can't be mistaken for an entity (PR #865 follow-
74        //      up — `/* previous #12= IFCWALL */` was the canonical example
75        //      where the original `#N=` shape check still false-positived).
76        //   2. After comment-skipping locates a candidate '#', validate it
77        //      starts a real `#<digits>[ws]*=` pattern. Catches embedded
78        //      references inside STEP strings (CATIA `'…\X0\2#.ifc'`) AND
79        //      any comment-shaped tokens the comment skipper missed (mostly
80        //      a fallback now — true `/* */` regions never reach this check).
81        //
82        // Both checks together keep `next_entity` aligned with
83        // `build_entity_index` which is comment-blind today; if a stray
84        // comment-bound entity slips past the scanner, the index also
85        // ignores it, so the entity decoder + scanner stay consistent.
86        let bytes = self.bytes;
87        let len = bytes.len();
88        let (line_start, id_end_validated) = loop {
89            // Step (1): jump past any `/* … */` comment that starts at or
90            // before the next candidate '#'. Use memchr2 so we look for
91            // '#' and '/' in one SIMD pass — whichever comes first
92            // decides the next move.
93            let remaining = &bytes[self.position..];
94            let next = memchr::memchr2(b'#', b'/', remaining)?;
95            let candidate = self.position + next;
96            let candidate_byte = bytes[candidate];
97
98            if candidate_byte == b'/' {
99                // '/' might begin a STEP `/* … */` comment. If yes, jump
100                // past `*/`; if not, it's a STEP arithmetic '/' inside a
101                // value list (rare; just step past it).
102                if candidate + 1 < len && bytes[candidate + 1] == b'*' {
103                    let mut p = candidate + 2;
104                    while p + 1 < len {
105                        // Find next '*'; check if followed by '/'.
106                        let from = p;
107                        let star = match memchr::memchr(b'*', &bytes[from..]) {
108                            Some(off) => from + off,
109                            None => return None, // unterminated comment
110                        };
111                        if star + 1 < len && bytes[star + 1] == b'/' {
112                            self.position = star + 2;
113                            break;
114                        }
115                        p = star + 1;
116                    }
117                    if self.position <= candidate {
118                        // Comment never closed — refuse to scan further.
119                        return None;
120                    }
121                    continue;
122                }
123                // Lone '/' — not a comment. Skip past.
124                self.position = candidate + 1;
125                continue;
126            }
127
128            // candidate_byte == b'#'. Step (2): validate `#<digits>[ws]*=`.
129            let after = candidate + 1;
130            if after >= len || !bytes[after].is_ascii_digit() {
131                self.position = after;
132                continue;
133            }
134            // Walk the digit run.
135            let mut digit_end = after;
136            while digit_end < len && bytes[digit_end].is_ascii_digit() {
137                digit_end += 1;
138            }
139            // Skip optional whitespace and verify the next byte is '='.
140            let mut probe = digit_end;
141            while probe < len && bytes[probe].is_ascii_whitespace() {
142                probe += 1;
143            }
144            if probe < len && bytes[probe] == b'=' {
145                break (candidate, digit_end);
146            }
147            // '#<digits>' not followed by '=' — this is a comment or string
148            // reference, not an entity definition. Skip past the digits and
149            // keep searching.
150            self.position = digit_end;
151        };
152
153        // Find the end of the entity (semicolon) while respecting quoted strings
154        // IFC strings use single quotes and can contain semicolons
155        let line_content = &bytes[line_start..];
156        let end_offset = self.find_entity_end(line_content)?;
157        let line_end = line_start + end_offset + 1;
158
159        // Parse entity ID — digit range already validated in the candidate loop.
160        let id_start = line_start + 1;
161        let id_end = id_end_validated;
162        let id = self.parse_u32_fast(id_start, id_end)?;
163
164        // Find '=' after ID using SIMD
165        let eq_search = &self.bytes[id_end..line_end];
166        let eq_offset = memchr::memchr(b'=', eq_search)?;
167        let mut type_start = id_end + eq_offset + 1;
168
169        // Skip whitespace (inline)
170        while type_start < line_end && self.bytes[type_start].is_ascii_whitespace() {
171            type_start += 1;
172        }
173
174        // Find end of type name (at '(' or whitespace)
175        let mut type_end = type_start;
176        while type_end < line_end {
177            let b = self.bytes[type_end];
178            if b == b'(' || b.is_ascii_whitespace() {
179                break;
180            }
181            type_end += 1;
182        }
183
184        // Use safe UTF-8 conversion - malformed input should not cause UB
185        let type_name = std::str::from_utf8(&self.bytes[type_start..type_end]).unwrap_or("UNKNOWN");
186
187        // Move position past this entity
188        self.position = line_end;
189
190        Some((id, type_name, line_start, line_end))
191    }
192
193    /// Fast u32 parsing without string allocation
194    #[inline]
195    fn parse_u32_fast(&self, start: usize, end: usize) -> Option<u32> {
196        let mut result: u32 = 0;
197        for i in start..end {
198            let digit = self.bytes[i].wrapping_sub(b'0');
199            if digit > 9 {
200                return None;
201            }
202            result = result.wrapping_mul(10).wrapping_add(digit as u32);
203        }
204        Some(result)
205    }
206
207    /// Find the terminating semicolon of an entity, skipping over quoted strings.
208    /// IFC strings are enclosed in single quotes ('...') and can contain semicolons.
209    /// Returns the offset of the semicolon from the start of the slice.
210    ///
211    /// SIMD scan: instead of inspecting every byte, `memchr2` jumps straight to
212    /// the next quote or semicolon. The overwhelming majority of records are
213    /// string-free geometry primitives (`#7=IFCCARTESIANPOINT((1.,2.,3.));`), so
214    /// the common case resolves the terminator in a single vectorized hop rather
215    /// than a per-byte loop. Semantics are byte-identical to the scalar walk:
216    /// the first `;` outside a quoted string, with doubled `''` treated as an
217    /// escaped in-string quote per STEP (ISO 10303-21). This is the single
218    /// hottest structural-scan function and runs on every entity of every model
219    /// (native and wasm), through both `build_entity_index` and the processor
220    /// scan loop, which share this scanner.
221    #[inline]
222    fn find_entity_end(&self, content: &[u8]) -> Option<usize> {
223        let mut pos = 0;
224
225        loop {
226            // Outside a quoted string: jump to the next quote or the
227            // terminating semicolon in one SIMD pass.
228            pos += memchr::memchr2(b'\'', b';', &content[pos..])?;
229            if content[pos] == b';' {
230                return Some(pos);
231            }
232
233            // content[pos] == b'\'' : entered a quoted string. Scan to the
234            // closing quote, treating a doubled '' as an escaped quote.
235            pos += 1;
236            loop {
237                pos += memchr::memchr(b'\'', &content[pos..])?;
238                if content.get(pos + 1) == Some(&b'\'') {
239                    // Escaped quote ('') - skip both, stay in the string.
240                    pos += 2;
241                    continue;
242                }
243                // Closing quote.
244                pos += 1;
245                break;
246            }
247        }
248    }
249
250    /// Find all entities of a specific type
251    pub fn find_by_type(&mut self, target_type: &str) -> Vec<(u32, usize, usize)> {
252        let mut results = Vec::new();
253
254        while let Some((id, type_name, start, end)) = self.next_entity() {
255            if type_name.eq_ignore_ascii_case(target_type) {
256                results.push((id, start, end));
257            }
258        }
259
260        results
261    }
262
263    /// Count entities by type
264    pub fn count_by_type(&mut self) -> rustc_hash::FxHashMap<String, usize> {
265        let mut counts = rustc_hash::FxHashMap::default();
266
267        while let Some((_, type_name, _, _)) = self.next_entity() {
268            *counts.entry(type_name.to_string()).or_insert(0) += 1;
269        }
270
271        counts
272    }
273
274    /// Count the entities remaining from the scanner's current position, without
275    /// allocating anything per entity.
276    ///
277    /// Unlike [`count_by_type`](Self::count_by_type) (which builds a per-keyword
278    /// map) or [`build_entity_index`](crate::build_entity_index) (which retains a
279    /// span per entity, ~20 B each), this walks the byte stream and increments a
280    /// single counter: `O(scan)` time, `O(1)` memory. It is the cheap primitive
281    /// for a downstream entity-count DoS guard on a file too large to index
282    /// (issue #1517). Advances the scanner to the end of the data section.
283    pub fn count(&mut self) -> usize {
284        let mut n = 0usize;
285        while self.next_entity().is_some() {
286            n += 1;
287        }
288        n
289    }
290
291    /// Reset scanner to beginning (re-applies the HEADER skip).
292    pub fn reset(&mut self) {
293        self.position = data_section_start(self.bytes);
294    }
295
296    /// Fast check if attribute at given index is non-null (not '$')
297    /// This is used to filter building elements that don't have representation
298    /// without full entity decode. Index 0 is first attribute after '('.
299    ///
300    /// Returns true if attribute exists and is not '$', false otherwise.
301    #[inline]
302    pub fn has_non_null_attribute(&self, start: usize, end: usize, attr_index: usize) -> bool {
303        let content = &self.bytes[start..end];
304
305        // Find the opening parenthesis
306        let paren_pos = match memchr::memchr(b'(', content) {
307            Some(p) => p + 1,
308            None => return false,
309        };
310
311        let mut pos = paren_pos;
312        let mut current_attr = 0;
313        let mut depth = 0; // Track nested parentheses
314        let mut in_string = false;
315
316        // Helper to check if we're at target attribute and return result
317        let check_target = |pos: usize, current_attr: usize, depth: usize| -> Option<bool> {
318            if current_attr == attr_index && depth == 0 {
319                // Skip whitespace
320                let mut p = pos;
321                while p < content.len() && content[p].is_ascii_whitespace() {
322                    p += 1;
323                }
324                // Check if it's '$' (null)
325                if p < content.len() {
326                    return Some(content[p] != b'$');
327                }
328                return Some(false);
329            }
330            None
331        };
332
333        // Check if target is first attribute (index 0)
334        if let Some(result) = check_target(pos, current_attr, depth) {
335            return result;
336        }
337
338        while pos < content.len() {
339            let b = content[pos];
340
341            if in_string {
342                if b == b'\'' {
343                    // Check for escaped quote ('')
344                    if pos + 1 < content.len() && content[pos + 1] == b'\'' {
345                        pos += 2;
346                        continue;
347                    }
348                    in_string = false;
349                }
350                pos += 1;
351                continue;
352            }
353
354            match b {
355                b'\'' => {
356                    in_string = true;
357                    pos += 1;
358                }
359                b'(' => {
360                    depth += 1;
361                    pos += 1;
362                }
363                b')' => {
364                    if depth == 0 {
365                        // End of entity - attribute not found
366                        return false;
367                    }
368                    depth -= 1;
369                    pos += 1;
370                }
371                b',' if depth == 0 => {
372                    current_attr += 1;
373                    pos += 1;
374                    // Skip whitespace after comma
375                    while pos < content.len() && content[pos].is_ascii_whitespace() {
376                        pos += 1;
377                    }
378                    // Check if we're now at target attribute
379                    if let Some(result) = check_target(pos, current_attr, depth) {
380                        return result;
381                    }
382                }
383                _ => {
384                    pos += 1;
385                }
386            }
387        }
388
389        false
390    }
391}
392
393/// Count the entities in a STEP/IFC byte buffer in `O(scan)` time and `O(1)`
394/// memory — no entity index, no per-type map.
395///
396/// A thin wrapper over [`EntityScanner::count`]. This is the cheap primitive a
397/// downstream can use to reject a file with a pathologically large entity count
398/// that a byte-size cap would miss, WITHOUT paying the ~20 B/entity the full
399/// index costs (issue #1517). Header-aware and comment-/string-safe, exactly
400/// like the scanner (it IS the scanner), so the count matches what
401/// [`build_entity_index`](crate::build_entity_index) would find.
402pub fn entity_count<T>(content: &T) -> usize
403where
404    T: AsRef<[u8]> + ?Sized,
405{
406    EntityScanner::new(content).count()
407}
408
409/// Locate the byte offset of the first character after `DATA;` (skipping the
410/// STEP HEADER section). Returns 0 if the marker isn't found — partial files
411/// without a HEADER still scan from the top.
412///
413/// Scanning the HEADER for entities is unsafe: the HEADER is a free-form
414/// STEP record that legally contains arbitrary characters inside quoted
415/// strings (filenames, descriptions). CATIA emits `FILE_NAME('…\X0\2#.ifc'…)`,
416/// and a tokenizer that anchors on `#` will latch onto the in-string `#`,
417/// flip `find_entity_end`'s quote parity, and drop the rest of the file.
418/// See issue #654.
419///
420/// Quote-aware: the marker is only matched outside `'…'` strings, since a
421/// HEADER field could legally contain the literal text `DATA;` in a
422/// description or filename. Escaped single quotes (`''`) are treated as a
423/// pair of in-string characters per ISO 10303-21.
424fn data_section_start(bytes: &[u8]) -> usize {
425    const MARKER: &[u8] = b"DATA;";
426    let len = bytes.len();
427    if len < MARKER.len() {
428        return 0;
429    }
430    // Cap the header scan. Real-world headers are <2 KB; an unbounded scan
431    // here would defeat the point of an O(1)-up-front fix on giant files
432    // that legitimately lack a HEADER section.
433    let limit = len.min(1 << 18); // 256 KB
434    let mut pos = 0;
435    let mut in_string = false;
436    while pos < limit {
437        let b = bytes[pos];
438        if in_string {
439            if b == b'\'' {
440                if pos + 1 < limit && bytes[pos + 1] == b'\'' {
441                    pos += 2; // escaped quote
442                    continue;
443                }
444                in_string = false;
445            }
446            pos += 1;
447            continue;
448        }
449        if b == b'\'' {
450            in_string = true;
451            pos += 1;
452            continue;
453        }
454        if b == b'D' && pos + MARKER.len() <= len && &bytes[pos..pos + MARKER.len()] == MARKER {
455            return pos + MARKER.len();
456        }
457        pos += 1;
458    }
459    0
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn test_entity_scanner() {
468        let content = r#"
469#1=IFCPROJECT('guid',$,$,$,$,$,$,$,$);
470#2=IFCWALL('guid2',$,$,$,$,$,$,$);
471#3=IFCDOOR('guid3',$,$,$,$,$,$,$);
472#4=IFCWALL('guid4',$,$,$,$,$,$,$);
473"#;
474
475        let mut scanner = EntityScanner::new(content);
476
477        // Test next_entity
478        let (id, type_name, _, _) = scanner.next_entity().unwrap();
479        assert_eq!(id, 1);
480        assert_eq!(type_name, "IFCPROJECT");
481
482        // Test find_by_type
483        scanner.reset();
484        let walls = scanner.find_by_type("IFCWALL");
485        assert_eq!(walls.len(), 2);
486        assert_eq!(walls[0].0, 2);
487        assert_eq!(walls[1].0, 4);
488
489        // Test count_by_type
490        scanner.reset();
491        let counts = scanner.count_by_type();
492        assert_eq!(counts.get("IFCPROJECT"), Some(&1));
493        assert_eq!(counts.get("IFCWALL"), Some(&2));
494        assert_eq!(counts.get("IFCDOOR"), Some(&1));
495    }
496
497    /// Regression for issue #654: CATIA exports a FILE_NAME whose first
498    /// argument contains a literal `#` inside the quoted string (the encoded
499    /// filename `'…\X0\2#.ifc'`). The scanner used to latch onto that `#`,
500    /// flip `find_entity_end`'s quote parity at the closing `'`, and silently
501    /// drop every entity in the file.
502    #[test]
503    fn test_entity_scanner_hash_in_header_filename() {
504        let content = "ISO-10303-21;\nHEADER;\n\
505FILE_DESCRIPTION(('ViewDefinition [ReferenceView]'),'2;1');\n\
506FILE_NAME('26-IFC\\X2\\00B1\\X0\\2#.ifc','2026-04-29T18:21:27',$,$,'CATIA','CATIA',$);\n\
507FILE_SCHEMA(('IFC4'));\nENDSEC;\n\
508DATA;\n\
509#1=IFCPROJECT('guid',$,$,$,$,$,$,$,$);\n\
510#2=IFCWALL('guid2',$,$,$,$,$,$,$);\n\
511ENDSEC;\nEND-ISO-10303-21;\n";
512
513        let mut scanner = EntityScanner::new(content);
514        let counts = scanner.count_by_type();
515        assert_eq!(counts.get("IFCPROJECT"), Some(&1));
516        assert_eq!(counts.get("IFCWALL"), Some(&1));
517    }
518
519    /// Files without a DATA; marker (partial fragments, test fixtures) must
520    /// still scan from offset 0 — the HEADER-skip is best-effort.
521    #[test]
522    fn test_entity_scanner_no_header() {
523        let content = "#1=IFCWALL('guid',$,$,$,$,$,$,$);\n";
524        let mut scanner = EntityScanner::new(content);
525        let (id, type_name, _, _) = scanner.next_entity().unwrap();
526        assert_eq!(id, 1);
527        assert_eq!(type_name, "IFCWALL");
528    }
529
530    /// HEADER fields are free-form strings — a description, comment, or
531    /// embedded filename could legally contain the literal text `DATA;`.
532    /// The seek must ignore matches inside quoted strings and land on the
533    /// real section marker.
534    #[test]
535    fn test_entity_scanner_data_marker_inside_header_string() {
536        let content = "ISO-10303-21;\nHEADER;\n\
537FILE_DESCRIPTION(('section DATA; in description'),'2;1');\n\
538FILE_NAME('weird DATA; name.ifc','2026-04-29T18:21:27',$,$,'a','b',$);\n\
539FILE_SCHEMA(('IFC4'));\nENDSEC;\n\
540DATA;\n\
541#1=IFCWALL('guid',$,$,$,$,$,$,$);\n\
542ENDSEC;\nEND-ISO-10303-21;\n";
543
544        let mut scanner = EntityScanner::new(content);
545        let counts = scanner.count_by_type();
546        assert_eq!(counts.get("IFCWALL"), Some(&1));
547        // Confirm we landed at the real DATA;, not the one in the description.
548        let pos = scanner.position();
549        assert!(pos == content.len() || pos > content.find("ENDSEC;").unwrap());
550    }
551
552    /// `count` / `entity_count` must agree with the number of entities the
553    /// scanner walks (and with the entity index), while allocating nothing per
554    /// entity. It shares `next_entity`, so it inherits the header-skip and the
555    /// quote/comment guards for free.
556    #[test]
557    fn test_entity_count_matches_scan() {
558        let content = "ISO-10303-21;\nHEADER;\n\
559FILE_DESCRIPTION(('has a #99 and DATA; inside'),'2;1');\n\
560FILE_NAME('26-IFC\\X2\\00B1\\X0\\2#.ifc','2026-04-29T18:21:27',$,$,'a','b',$);\n\
561FILE_SCHEMA(('IFC4'));\nENDSEC;\n\
562DATA;\n\
563#1=IFCPROJECT('guid',$,$,$,$,$,$,$,$);\n\
564/* a comment with #77= IFCWALL inside */\n\
565#2=IFCWALL('guid2',$,$,$,'name with ; semicolon',$,$,$);\n\
566#3=IFCDOOR('guid3',$,$,$,$,$,$,$);\n\
567ENDSEC;\nEND-ISO-10303-21;\n";
568
569        // Free function.
570        assert_eq!(entity_count(content), 3);
571        // Method, from a fresh scanner.
572        assert_eq!(EntityScanner::new(content).count(), 3);
573        // Agrees with the per-type tally (which walks the same entities).
574        let total: usize = EntityScanner::new(content).count_by_type().values().sum();
575        assert_eq!(total, 3);
576    }
577
578    /// An empty / header-only buffer counts zero, never panics.
579    #[test]
580    fn test_entity_count_empty() {
581        assert_eq!(entity_count(""), 0);
582        assert_eq!(entity_count("ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\nENDSEC;\n"), 0);
583    }
584
585    /// Escaped single quotes (`''`) keep the string open per ISO 10303-21.
586    #[test]
587    fn test_entity_scanner_escaped_quote_in_header() {
588        let content = "ISO-10303-21;\nHEADER;\n\
589FILE_DESCRIPTION(('it''s fine: DATA; inside'),'2;1');\n\
590FILE_NAME('a','b',$,$,'c','d',$);\n\
591FILE_SCHEMA(('IFC4'));\nENDSEC;\n\
592DATA;\n\
593#7=IFCDOOR('guid',$,$,$,$,$,$,$);\n\
594ENDSEC;\n";
595
596        let mut scanner = EntityScanner::new(content);
597        let counts = scanner.count_by_type();
598        assert_eq!(counts.get("IFCDOOR"), Some(&1));
599    }
600}