ifc_lite_core/parser/keyword.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//! Case-insensitive comparison of a STEP keyword against an uppercase literal.
6//!
7//! [`EntityScanner::next_entity`](super::EntityScanner::next_entity) hands back
8//! the keyword bytes exactly as the file wrote them: `IFCWALL`, `IfcWall` and
9//! `ifcwall` all come back verbatim. ISO 10303-21 says keyword case is not
10//! significant, so a call site that compares that slice with `==`,
11//! `starts_with` or `ends_with` against an uppercase literal silently drops
12//! every record of a lowercase- or CamelCase-keyword file. #4497 fixed two
13//! such sites; a sweep afterwards found the same comparison hand-written at
14//! dozens more across five crates. These helpers are that rule in one place.
15//! The literal passed as `upper` is written in uppercase, and a debug build
16//! asserts it. That is a house convention, not a correctness need: the fold
17//! is symmetric, so a lowercase literal would still match in release.
18//!
19//! [`find_keyword`] is the whole-file twin, for the "does this file mention
20//! `IFCFOO` at all" probes that let the common no-such-entity file skip a
21//! full scan. It replaces `memchr::memmem::find(content, b"IFCFOO")`, which
22//! is case-sensitive for the same reason, and it is the general form of the
23//! `IFCPROJECT(` probe `ifc_lite_processing::prepass::find_ifcproject_keyword`
24//! used to hand-roll. `memmem` has no case-insensitive mode, so this
25//! prefilters ONE byte of the needle with `memchr2` (both cases, still SIMD)
26//! and verifies the rest with an ASCII case-insensitive compare. Which byte
27//! is the whole cost: every anchor hit is a ~10 ns loop exit and re-entry,
28//! so the anchor must be the letter that fires least. `I` is the worst
29//! choice in this alphabet (every IFC keyword starts with it and GUIDs are
30//! full of it): anchoring `IFCPROJECT(` on `I` cost 7.6-13.1 ms on a 20 MB
31//! project-less file against 0.33-0.36 ms on `J`. The ranking below was
32//! measured on five fixtures from 35 MB to 342 MB (upper+lower counts per
33//! letter, median rank); with it, an absent-needle probe runs 1.5-3x behind
34//! the case-sensitive `memmem` it replaces, and per-record `keyword_eq`
35//! chains cost about 1.3 ns per record more than a `match` on `==`.
36
37/// True when the raw keyword `raw` spells `upper` under STEP's
38/// case-insensitive keyword rule. `upper` must be uppercase ASCII.
39#[inline]
40pub fn keyword_eq(raw: &str, upper: &str) -> bool {
41 debug_assert_upper(upper.as_bytes());
42 raw.eq_ignore_ascii_case(upper)
43}
44
45/// [`keyword_eq`] for a prefix: true when `raw` begins with `upper`, case
46/// folded. `upper` must be uppercase ASCII.
47#[inline]
48pub fn keyword_starts_with(raw: &str, upper: &str) -> bool {
49 debug_assert_upper(upper.as_bytes());
50 raw.len() >= upper.len() && raw.as_bytes()[..upper.len()].eq_ignore_ascii_case(upper.as_bytes())
51}
52
53/// [`keyword_eq`] for a suffix: true when `raw` ends with `upper`, case
54/// folded. `upper` must be uppercase ASCII.
55#[inline]
56pub fn keyword_ends_with(raw: &str, upper: &str) -> bool {
57 debug_assert_upper(upper.as_bytes());
58 raw.len() >= upper.len()
59 && raw.as_bytes()[raw.len() - upper.len()..].eq_ignore_ascii_case(upper.as_bytes())
60}
61
62/// Byte offset of the first case-folded occurrence of `upper` in `content`,
63/// anywhere (not only at a record's keyword position): the whole-file
64/// "is this entity present at all" prefilter. `upper` must be uppercase ASCII
65/// and non-empty.
66///
67/// A hit inside a string literal is a false positive that costs the caller
68/// one scan it would otherwise have skipped, the same trade the
69/// case-sensitive `memmem` probe it replaces already made.
70pub fn find_keyword(content: &[u8], upper: &[u8]) -> Option<usize> {
71 debug_assert!(!upper.is_empty(), "find_keyword needs a non-empty needle");
72 debug_assert_upper(upper);
73 let anchor = rarest_letter_offset(upper);
74 // Folded here rather than trusted from the assert: a lowercase needle in
75 // release would otherwise search for the same byte twice and skip every
76 // uppercase occurrence.
77 let anchor_upper = upper[anchor].to_ascii_uppercase();
78 let anchor_lower = anchor_upper.to_ascii_lowercase();
79 let mut search_from = 0usize;
80 loop {
81 let rel = memchr::memchr2(anchor_upper, anchor_lower, content.get(search_from..)?)?;
82 let hit = search_from + rel;
83 search_from = hit + 1;
84 let Some(candidate) = hit.checked_sub(anchor) else {
85 continue;
86 };
87 let end = candidate + upper.len();
88 if end <= content.len() && content[candidate..end].eq_ignore_ascii_case(upper) {
89 return Some(candidate);
90 }
91 }
92}
93
94/// Uppercase letters ordered from rarest to commonest in IFC STEP content
95/// (keywords, enumerations, GUIDs and strings together), median rank over
96/// five fixtures (35 MB to 342 MB, `memchr2` count of both cases per letter).
97/// A heuristic for choosing the anchor byte only: correctness never depends
98/// on it. Punctuation is deliberately absent: `(` and `=` are commoner than
99/// every letter but `E I T A N L`.
100const LETTERS_RAREST_FIRST: &[u8; 26] = b"JZKQWXVHMBGDYUSPLRNOFTACIE";
101
102/// Offset in `upper` of the letter that should fire least often in a file;
103/// byte 0 when the needle has no ASCII letter at all.
104fn rarest_letter_offset(upper: &[u8]) -> usize {
105 let rank = |b: u8| LETTERS_RAREST_FIRST.iter().position(|&l| l == b);
106 upper
107 .iter()
108 .enumerate()
109 .filter_map(|(i, &b)| rank(b).map(|r| (r, i)))
110 .min()
111 .map_or(0, |(_, i)| i)
112}
113
114#[inline]
115fn debug_assert_upper(upper: &[u8]) {
116 debug_assert!(
117 !upper.iter().any(u8::is_ascii_lowercase),
118 "STEP keyword literal must be uppercase: {}",
119 String::from_utf8_lossy(upper)
120 );
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn eq_folds_only_the_raw_side() {
129 assert!(keyword_eq("IFCWALL", "IFCWALL"));
130 assert!(keyword_eq("IfcWall", "IFCWALL"));
131 assert!(keyword_eq("ifcwall", "IFCWALL"));
132 assert!(!keyword_eq("IFCWALLTYPE", "IFCWALL"));
133 assert!(!keyword_eq("IFCWAL", "IFCWALL"));
134 }
135
136 #[test]
137 fn prefix_and_suffix_fold_case() {
138 assert!(keyword_starts_with("IfcPropertySingleValue", "IFCPROPERTY"));
139 assert!(!keyword_starts_with("IfcProp", "IFCPROPERTY"));
140 assert!(keyword_ends_with("IfcWallType", "TYPE"));
141 assert!(keyword_ends_with("ifcdoorstyle", "STYLE"));
142 assert!(!keyword_ends_with("TYP", "TYPE"));
143 }
144
145 #[test]
146 fn find_keyword_hits_any_casing_and_misses_absent() {
147 let content = b"#1=IfcWall('a');\n#2=ifcindexedcolourmap($);\n#3=IFCDOOR('b');\n";
148 assert_eq!(find_keyword(content, b"IFCINDEXEDCOLOURMAP"), Some(20));
149 assert_eq!(find_keyword(content, b"IFCWALL"), Some(3));
150 assert_eq!(find_keyword(content, b"IFCDOOR"), Some(47));
151 assert_eq!(find_keyword(content, b"IFCWINDOW"), None);
152 assert_eq!(find_keyword(b"", b"IFCWALL"), None);
153 }
154
155 /// The anchor may sit past a hit whose candidate start would be negative,
156 /// or so late that the needle would run off the end; both must be skipped,
157 /// not panic or match.
158 #[test]
159 fn find_keyword_handles_anchor_near_the_edges() {
160 // 'J' at offset 0: candidate = 0 - 6 underflows, skip.
161 assert_eq!(find_keyword(b"J", b"IFCPROJECT"), None);
162 // Needle would run past the end of content.
163 assert_eq!(find_keyword(b"IFCPROJ", b"IFCPROJECT"), None);
164 assert_eq!(find_keyword(b"xxIFCPROJECT", b"IFCPROJECT"), Some(2));
165 }
166
167 #[test]
168 fn rarest_letter_prefers_j_over_i() {
169 assert_eq!(rarest_letter_offset(b"IFCPROJECT("), 6);
170 assert_eq!(rarest_letter_offset(b"123("), 0);
171 }
172}