ifc_lite_core/parser/
header.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//! Shared, quote-aware ISO 10303-21 header record and schema reading.
6//! The export writer and analytic processing use the same lexical rules.
7
8#[path = "header_scan.rs"]
9mod scan;
10
11pub use scan::{find_record_open, find_section_marker, Lex};
12
13/// The bounded source-header reader's maximum scan window.
14pub const MAX_HEADER_BYTES: usize = 64 * 1024;
15
16/// Split STEP record arguments at top-level commas, respecting paren/bracket
17/// nesting and single-quoted strings (with `''` escapes). Returns the raw,
18/// still-escaped argument substrings, trimmed.
19pub fn split_top_level(inner: &str) -> Vec<String> {
20    let bytes = inner.as_bytes();
21    let mut lex = Lex::new(bytes);
22    let mut args: Vec<String> = Vec::new();
23    let mut depth: i32 = 0;
24    let mut current = String::new();
25    // Everything except a comment is part of the argument's text, so the scan
26    // copies contiguous runs and cuts only where something is dropped (a
27    // comment) or where an argument ends (a top-level comma). Nothing is
28    // inspected per character, so multi-byte UTF-8 needs no special handling.
29    let mut run = 0;
30    let mut i = 0;
31    while i < bytes.len() {
32        if let Some(end) = lex.skip_lexical_at(i) {
33            if bytes[i] != b'\'' {
34                current.push_str(&inner[run..i]); // a comment is not part of the text
35                run = end;
36            }
37            i = end;
38            continue;
39        }
40        if bytes[i] == b',' && depth == 0 {
41            current.push_str(&inner[run..i]);
42            args.push(current.trim().to_string());
43            current.clear();
44            run = i + 1;
45        } else {
46            match bytes[i] {
47                b'(' | b'[' => depth += 1,
48                b')' | b']' => depth -= 1,
49                _ => {}
50            }
51        }
52        i += 1;
53    }
54    current.push_str(&inner[run..]);
55    if !current.trim().is_empty() || !args.is_empty() {
56        args.push(current.trim().to_string());
57    }
58    args
59}
60
61/// Decode a header string literal's inner text (outer quotes already stripped).
62///
63/// Both escape layers in the order the TS twin uses: un-double `''` FIRST, then
64/// resolve the ISO 10303-21 backslash directives (`\X2\HHHH\X0\`, `\X\HH`,
65/// `\S\`, `\Px\`). The order is load-bearing — decoding first would let two
66/// separately-escaped apostrophes (`\X\27\X\27`) become `''` and then collapse
67/// into one, losing a character.
68fn decode_literal(inner: &str) -> String {
69    let undoubled = if inner.contains("''") {
70        inner.replace("''", "'")
71    } else {
72        inner.to_string()
73    };
74    crate::decode_ifc_string(&undoubled).into_owned()
75}
76
77/// Decode one argument to a string, or `None` for `$` (unset), `*` (derived)
78/// or empty.
79pub fn decode_opt_string(arg: &str) -> Option<String> {
80    let t = arg.trim();
81    if t.is_empty() || t == "$" || t == "*" {
82        return None;
83    }
84    if t.len() >= 2 && t.starts_with('\'') && t.ends_with('\'') {
85        return Some(decode_literal(&t[1..t.len() - 1]));
86    }
87    Some(t.to_string())
88}
89
90/// Decode a list argument (`('a','b',...)`). `$`/empty yield an empty list, and
91/// unset entries are dropped. A bare single value where a list was expected is
92/// tolerated, as on the TS side.
93pub fn decode_string_list(arg: &str) -> Vec<String> {
94    decode_opt_string_list(arg).unwrap_or_default()
95}
96
97/// [`decode_string_list`], but an unset (`$`, `*`) or missing argument is
98/// `None` rather than an empty list, so a writer can tell "never stated" from a
99/// literal `()` (#5470).
100pub fn decode_opt_string_list(arg: &str) -> Option<Vec<String>> {
101    let t = arg.trim();
102    if t.is_empty() || t == "$" || t == "*" {
103        return None;
104    }
105    if !(t.starts_with('(') && t.ends_with(')')) {
106        return Some(decode_opt_string(t).into_iter().collect());
107    }
108    let entries = split_top_level(&t[1..t.len() - 1]);
109    let values: Vec<String> = entries.iter().filter_map(|a| decode_opt_string(a)).collect();
110    // A list whose every entry is unset (`($)`) states nothing either; only a
111    // literal `()` is an empty list.
112    if !entries.is_empty() && values.is_empty() {
113        return None;
114    }
115    Some(values)
116}
117
118/// Read all declared schema identifiers from the complete header.
119///
120/// This is the uncapped schema-detection entry point. It shares this module's
121/// lexical and record parser, but does not widen the export writer's
122/// public 64 KiB allocation contract. If a malformed header omits its own
123/// `ENDSEC;`, `DATA;` still stops the window before the model body. With no
124/// recognisable section boundary at all, the allocation remains capped.
125pub fn declared_schemas(content: &[u8]) -> Vec<String> {
126    schema_values_before_section(content, content.len().min(MAX_HEADER_BYTES))
127}
128
129fn schema_values_before_section(content: &[u8], fallback: usize) -> Vec<String> {
130    let boundary = [
131        find_section_marker(content, b"ENDSEC"),
132        find_section_marker(content, b"DATA"),
133    ]
134    .into_iter()
135    .flatten()
136    .min()
137    .unwrap_or(fallback);
138    let text = String::from_utf8_lossy(&content[..boundary]);
139    extract_record_args(&text, "FILE_SCHEMA")
140        .map(|record| decode_string_list(&record))
141        .unwrap_or_default()
142}
143
144/// Read schema identifiers from a bounded header window.
145///
146/// Processing only needs source identity and must not scan arbitrary model
147/// bodies. The export writer uses [`declared_schemas`] to preserve unusually
148/// long source headers for round-trip fidelity.
149pub fn declared_schemas_bounded(content: &[u8]) -> Vec<String> {
150    let window = &content[..content.len().min(MAX_HEADER_BYTES)];
151    schema_values_before_section(window, window.len())
152}
153
154/// Read the first declared schema identifier from a bounded header window.
155pub fn declared_schema_bounded(content: &[u8]) -> Option<String> {
156    declared_schemas_bounded(content).into_iter().next()
157}
158
159/// Read the first declared schema identifier from the complete header.
160///
161/// STEP re-export preserves the declaration's first identifier. Consumers
162/// which need a supported schema (rather than exact header preservation) must
163/// inspect [`declared_schemas`] instead.
164pub fn declared_schema(content: &[u8]) -> Option<String> {
165    declared_schemas(content).into_iter().next()
166}
167
168/// Extract the argument substring inside the parentheses of `KEYWORD( ... )`.
169/// Quote- and nesting-aware, so a quoted `)` never closes the record early.
170///
171/// The keyword search itself skips quoted text: a `FILE_DESCRIPTION` item that
172/// mentions `FILE_NAME` in prose is not the `FILE_NAME` record, and matching it
173/// drops the real one (the character after it is not `(`).
174pub fn extract_record_args(text: &str, keyword: &str) -> Option<String> {
175    let bytes = text.as_bytes();
176    let mut i = find_record_open(bytes, keyword.as_bytes())?;
177    let mut lex = Lex::new(bytes);
178    let start = i;
179    let mut depth: i32 = 0;
180    while i < bytes.len() {
181        if let Some(end) = lex.skip_lexical_at(i) {
182            i = end;
183            continue;
184        }
185        match bytes[i] {
186            b'(' => depth += 1,
187            b')' => {
188                depth -= 1;
189                if depth == 0 {
190                    return Some(text[start + 1..i].to_string());
191                }
192            }
193            _ => {}
194        }
195        i += 1;
196    }
197    None
198}
199
200#[cfg(test)]
201mod tests {
202    use super::{declared_schema, declared_schema_bounded, MAX_HEADER_BYTES};
203
204    #[test]
205    fn page_quote_operands_keep_header_decoys_inside_literals_7540() {
206        let source = r"'\S\' FILE_SCHEMA((fake)); DATA;' FILE_SCHEMA(('IFC4')); DATA;";
207        assert_eq!(super::find_record_open(source.as_bytes(), b"FILE_SCHEMA"),
208            Some(source.rfind("FILE_SCHEMA").unwrap() + "FILE_SCHEMA".len()));
209        assert_eq!(super::find_section_marker(source.as_bytes(), b"DATA"),
210            source.rfind("DATA"));
211        let vectors: serde_json::Value = serde_json::from_str(include_str!(
212            "../../tests/fixtures/step-page-directive.json"
213        )).unwrap();
214        for vector in vectors.as_array().unwrap() {
215            let literal = vector["literal"].as_str().unwrap();
216            let text = format!("{literal},NEXT");
217            assert_eq!(super::Lex::new(text.as_bytes()).skip_lexical_at(0), Some(literal.len()),
218                "{}", vector["name"]);
219        }
220    }
221
222    #[test]
223    fn header_schema_reader_skips_decoys_and_respects_window() {
224        let source = b"ISO-10303-21;\nHEADER;\n\
225            FILE_DESCRIPTION(('mentions FILE_SCHEMA((''IFC4X3''));'),'2;1');\n\
226            /* FILE_SCHEMA(('IFC4')); */\n\
227            file_schema /* actual */ (('IFC2X3'));\nENDSEC;\nDATA;\n\
228            #1=IFCPROJECT('g',$,$,$,$,$,$,$,$);\nENDSEC;";
229        assert_eq!(declared_schema_bounded(source).as_deref(), Some("IFC2X3"));
230
231        let mut long_header = b"ISO-10303-21;\nHEADER;\n".to_vec();
232        long_header.extend(std::iter::repeat_n(b' ', MAX_HEADER_BYTES));
233        long_header.extend_from_slice(b"FILE_SCHEMA(('IFC4'));\nENDSEC;\nDATA;");
234        assert_eq!(declared_schema_bounded(&long_header), None);
235        assert_eq!(declared_schema(&long_header).as_deref(), Some("IFC4"));
236    }
237}