ifc_lite_export/step_text.rs
1// SPDX-License-Identifier: MPL-2.0
2//! STEP text-level primitives shared by the STEP exporter (`step.rs`): string
3//! escaping, `#ref` scanning, and the by-index reads and writes of a record's
4//! root attributes.
5//!
6//! Header `FILE_SCHEMA` detection used to live here and is now `schema_detect`.
7//! It left because it is not a text EDIT: it reads one fact out of raw bytes
8//! before anything is parsed, and unlike everything here it runs on whole
9//! uncapped attacker-supplied files.
10//!
11//! The argument-list SPLIT left too, to `step_slot.rs`, when it grew a grammar
12//! check per slot (#4125). It is what the by-index writers here stand on rather
13//! than another line utility beside them, and its own header carries the rule
14//! that makes them safe.
15//!
16//! Split out of `step.rs` to keep that file under the module-size ratchet
17//! (`rust/processing/tests/module_size_ratchet.rs`). These are self-contained
18//! line/string utilities with no dependency on the DATA-section emission
19//! orchestration that stays in `step.rs`.
20
21use std::borrow::Cow;
22use std::collections::BTreeMap;
23
24use ifc_lite_core::express_id::parse_express_id;
25use ifc_lite_core::step_encoding::step_string_literal_end;
26
27use crate::step_slot::split_top_level_args;
28
29/// The edits that apply to one record, where a caller's attribute mutation and
30/// a copy-on-write repointing can both land on it. The repointing wins: it was
31/// computed from the caller's value rather than instead of it.
32pub(crate) fn merge_edits<'a>(
33 muts: Option<&'a BTreeMap<usize, String>>,
34 repointed: Option<&'a BTreeMap<usize, String>>,
35) -> Option<Cow<'a, BTreeMap<usize, String>>> {
36 match (muts, repointed) {
37 (None, None) => None,
38 (Some(edits), None) | (None, Some(edits)) => Some(Cow::Borrowed(edits)),
39 (Some(muts), Some(repointed)) => {
40 let mut merged = muts.clone();
41 merged.extend(repointed.iter().map(|(i, v)| (*i, v.clone())));
42 Some(Cow::Owned(merged))
43 }
44 }
45}
46
47/// Escape a STEP string literal body: double the apostrophe and reverse
48/// solidus, and encode any character outside the basic graphic range as its
49/// `\X2\`/`\X4\` control directive — never a raw byte — since ISO 10303-21
50/// 6.3.3.4 restricts a literal's plain-text bytes to 32-126. buildingSMART's
51/// IFC string-encoding guidance states the same for IFC2X3/IFC4/IFC4X3: a
52/// character outside decimal 32-126 "has to be encoded" (e.g. 'Ä' as
53/// `\X2\00C4\X0\`). A reader that treats the file's bytes as ISO-8859-1 — the
54/// byte encoding the base standard and most real consumers assume — turns a
55/// raw UTF-8 multi-byte sequence into mojibake or a broken parse; this exact
56/// writer shape is a reported, reproduced defect in real IFC tooling
57/// (IfcOpenShell#699/#1016; files rejected by Solibri).
58///
59/// ASCII control characters (the C0 range plus DEL) take the same directive:
60/// a newline goes out as `\X2\000A\X0\`, which `decode_ifc_string` reads
61/// back as a newline, and the record stays on one line.
62///
63/// `pub`, and re-exported from the crate root as `escape_step_string`, so the
64/// integration-test binary `tests/step_escape_parity.rs` can pin it to the
65/// shared vectors in `tests/fixtures/step_escape_vectors.json` — the same
66/// vectors the TypeScript `escapeStepString` is held to, since the two
67/// implementations cannot share code (#3300). Only this one function is
68/// re-exported, not the whole module, so the rest of `step_text` stays
69/// crate-private and the public surface widens by exactly one symbol —
70/// narrower than the `pub mod csv_cell` the CSV parity pin already relies on.
71pub fn escape(s: &str) -> String {
72 let mut out = String::with_capacity(s.len());
73 for c in s.chars() {
74 match c {
75 // ISO 10303-21 doubles both the apostrophe and the reverse
76 // solidus inside a string literal; each is independent of the
77 // other (order in the source string is preserved as-is).
78 '\'' => out.push_str("''"),
79 '\\' => out.push_str("\\\\"),
80 '\u{20}'..='\u{7E}' => out.push(c),
81 _ => {
82 let cp = c as u32;
83 if cp <= 0xFFFF {
84 out.push_str(&format!("\\X2\\{cp:04X}\\X0\\"));
85 } else {
86 out.push_str(&format!("\\X4\\{cp:08X}\\X0\\"));
87 }
88 }
89 }
90 }
91 out
92}
93
94/// Collect outgoing `#<digits>` references in a STEP entity line, skipping the
95/// contents of single-quoted strings (where a `#` is literal text).
96pub(crate) fn refs_in_line(line: &[u8], out: &mut Vec<u32>) {
97 let mut discarded = 0usize;
98 refs_in_line_counted(line, out, &mut discarded);
99}
100
101/// [`refs_in_line`], additionally counting every reference discarded because it
102/// exceeded `u32::MAX` (issue #3421/#3752) into `refused`. Used by the two
103/// reachability-closure callers ([`crate::step::export_step_with_stats`]'s
104/// included-set walk and [`crate::merged::plan::resolve_included`]) so a
105/// refusal there — which decides what a filtered/merged export DOES NOT emit —
106/// is at least counted, even though the referenced record, being unrepresentable,
107/// could never have been a real entity in this model either. Callers that only
108/// use the result to decide whether a line names an already-known id (the
109/// merged exporter's dropped-container rewrite in `merged/line_edit.rs`) keep
110/// calling plain `refs_in_line`: `dropped`/similar sets there only ever hold
111/// ids that were themselves scanned successfully (so at most `u32::MAX`), and
112/// a ref this function refuses can never equal one of them — refusing it there
113/// changes no decision, so counting it would be reporting a no-op.
114pub(crate) fn refs_in_line_counted(line: &[u8], out: &mut Vec<u32>, refused: &mut usize) {
115 let mut i = 0;
116 while i < line.len() {
117 let b = line[i];
118 if b == b'\'' {
119 // A `#` inside a literal is text. Where the literal ends is the
120 // shared rule: `''` and a `\S\'` operand stay inside it (#7589).
121 i = step_string_literal_end(line, i).unwrap_or(line.len());
122 continue;
123 }
124 if b == b'#' {
125 let mut j = i + 1;
126 while j < line.len() && line[j].is_ascii_digit() {
127 j += 1;
128 }
129 if j > i + 1 {
130 // A ref above `u32::MAX` refuses rather than wrapping onto a
131 // real low-numbered entity (issue #3421): it is dropped from
132 // the reference list instead of being followed to the wrong
133 // entity, the same "no third policy" refusal
134 // `parse_express_id` establishes everywhere else. Counted
135 // (issue #3752) so a refusal here at least leaves a trace.
136 match parse_express_id(&line[i + 1..j]) {
137 Some(n) => out.push(n),
138 None => *refused += 1,
139 }
140 i = j;
141 continue;
142 }
143 }
144 i += 1;
145 }
146}
147
148/// Apply root-attribute edits to a `#id=TYPE(attrs);` line, keeping the source
149/// line and COUNTING the refusal when they cannot be applied.
150///
151/// The edits are refused when the text is not a `#id=TYPE(...)` record at all,
152/// or when its argument list could not be scanned into slots
153/// ([`split_top_level_args`] refused it). Either way it is a refusal, not a
154/// no-op — the caller asked for an edit that is not in the output. Writing the
155/// edit anyway is the #2470 shape one level up: an undoubled apostrophe makes a
156/// nine-attribute record scan as seven slots, so writing `Description` by index
157/// lands on `ObjectPlacement`, deletes the reference that was there, and reports
158/// success (#4125).
159///
160/// An edit whose index is past the record's arity (caller-supplied JSON on the
161/// wasm bridge) is refused too: the other edits on the record still apply, the
162/// record counts once, and the out-of-range one is not in the output.
163///
164/// One function, and `refused` rather than an `Option` the caller interprets,
165/// because both emit sites need the same PAIR — leave the record as its author
166/// wrote it, and say that an edit is missing — and a site that did the first
167/// without the second would ship a file that looks like it carried the edit.
168pub(crate) fn apply_attr_mutations_counted(
169 line: &str,
170 muts: &BTreeMap<usize, String>,
171 refused: &mut usize,
172) -> String {
173 let trimmed = line.trim_end();
174 let body = trimmed.strip_suffix(';').unwrap_or(trimmed);
175 let edited = body.find('=').and_then(|eq| {
176 let after = &body[eq + 1..];
177 let popen = after.find('(')?;
178 let aclose = after.rfind(')').filter(|c| *c > popen)?;
179 let prefix = &body[..=eq];
180 let type_name = &after[..popen];
181 let mut args = split_top_level_args(&after[popen + 1..aclose])?;
182 for (idx, val) in muts.range(..args.len()) {
183 args[*idx] = val.clone();
184 }
185 if muts.range(args.len()..).next().is_some() {
186 *refused += 1;
187 }
188 Some(format!("{prefix}{type_name}({});", args.join(",")))
189 });
190 match edited {
191 Some(text) => text,
192 None => {
193 *refused += 1;
194 line.to_string()
195 }
196 }
197}
198
199#[cfg(test)]
200#[path = "step_text_tests.rs"]
201mod tests;
202
203/// Rewrite a record's own id, leaving everything after the `=` untouched.
204///
205/// For a copy-on-write copy: the body is the original's, byte for byte, with
206/// one attribute already replaced, and only the number in front changes.
207pub(crate) fn renumber(line: &str, new_id: u32) -> String {
208 let trimmed = line.trim_end();
209 match trimmed.find('=') {
210 Some(eq) => format!("#{new_id}{}", &trimmed[eq..]),
211 None => line.to_string(),
212 }
213}
214
215/// One attribute of a `#id=TYPE(args);` line, by position.
216///
217/// Split from the substitution below so a caller applying two edits to the
218/// same attribute can feed the first result into the second, rather than
219/// computing both from the original and losing one.
220///
221/// `None` covers both "that slot is past the end" and "this record's argument
222/// list could not be scanned into slots at all". The caller
223/// (`step_cow::candidate`) already treats `None` as "this mutation cannot be
224/// made" and counts it into `StepStats::copies_refused`, which is the right
225/// answer for either: a slot read out of a mis-scanned list is some other
226/// attribute's text, and a copy built from it would carry the wrong value.
227pub(crate) fn attribute_of(line: &str, index: usize) -> Option<String> {
228 let trimmed = line.trim_end();
229 let body = trimmed.strip_suffix(';').unwrap_or(trimmed);
230 let eq = body.find('=')?;
231 let after = &body[eq + 1..];
232 let popen = after.find('(')?;
233 let aclose = after.rfind(')').filter(|c| *c > popen)?;
234 split_top_level_args(&after[popen + 1..aclose])?
235 .into_iter()
236 .nth(index)
237}
238
239/// Replace references inside one attribute, leaving its neighbours alone.
240///
241/// Rewrites every unquoted `#from` in the attribute to `#to`. Returns the
242/// rewritten attribute, or `None` when the attribute does not hold that
243/// reference. The caller is expected to treat `None` as "this edit cannot be
244/// made" rather than proceeding, because a copy nothing points at is an orphan
245/// and a reference to a filtered record is a dangling one.
246///
247/// A list keeps its order and its other entries: repointing one element of a
248/// property set's `HasProperties` must not disturb the rest, or the diff
249/// against the source stops being small.
250///
251/// Text is left alone. A property value reading `'lot #41'` is a sentence, and
252/// rewriting inside it would change what the file says rather than what it
253/// points at.
254pub(crate) fn substitute_ref_in_attr(attr: &str, from: u32, to: u32) -> Option<String> {
255 let old = format!("#{from}");
256 let new = format!("#{to}");
257 let bytes = attr.as_bytes();
258 let mut out = String::with_capacity(attr.len());
259 let mut replaced = false;
260 // Every index this stops at is an ASCII byte or just past one, so each
261 // slice below sits on a char boundary.
262 let mut copied = 0usize;
263 let mut i = 0usize;
264 while i < bytes.len() {
265 if bytes[i] == b'\'' {
266 // The shared rule for where a literal ends (#7589).
267 i = step_string_literal_end(bytes, i).unwrap_or(bytes.len());
268 continue;
269 }
270 // `#4` must not match inside `#41`.
271 if bytes[i..].starts_with(old.as_bytes()) && !bytes.get(i + old.len()).is_some_and(u8::is_ascii_digit) {
272 out.push_str(&attr[copied..i]);
273 out.push_str(&new);
274 i += old.len();
275 copied = i;
276 replaced = true;
277 continue;
278 }
279 i += 1;
280 }
281 out.push_str(&attr[copied..]);
282 replaced.then_some(out)
283}