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}