Skip to main content

teamcraft/
stepbom.rs

1//! Product structure of STEP assemblies (ISO 10303-21 files of AP203, AP214
2//! and AP242) as a BOM table for the import (see
3//! [`crate::services::imports`]).
4//!
5//! The reader looks at the product structure only, never at the geometry:
6//!
7//! * `PRODUCT('id', 'name', 'description', …)` – a part or an assembly; the
8//!   id is its part number,
9//! * `PRODUCT_DEFINITION_FORMATION(_WITH_SPECIFIED_SOURCE)` and
10//!   `PRODUCT_DEFINITION` – the version and the definition the structure
11//!   links,
12//! * `NEXT_ASSEMBLY_USAGE_OCCURRENCE(…, #parent, #child, …)` – one placed
13//!   occurrence of a component; the quantity is the number of occurrences
14//!   below the same parent.
15//!
16//! The assembly is the product that no other product uses; with several of
17//! them, each becomes a component of the new assembly. A sub-assembly lists
18//! its components at its first occurrence only (the import reads the BOM of a
19//! component once). Complex instances (`#1 = (A() B());`) and the quantities
20//! of `QUANTIFIED_ASSEMBLY_COMPONENT_USAGE` are not read.
21//!
22//! An own minimal Part 21 reader: `ruststep` was rejected as a direct
23//! dependency (see `docs/OPEN_WORK.md`), and only five entity types are needed.
24#![deny(
25    clippy::unwrap_used,
26    clippy::expect_used,
27    clippy::indexing_slicing,
28    clippy::panic,
29    clippy::unreachable
30)]
31
32use std::collections::{HashMap, HashSet};
33
34use crate::bomtable::{field_label, BomRow, BomTable, Column, MAX_ROWS};
35
36/// Deeper structures are cut (protects the recursion).
37const MAX_DEPTH: usize = 64;
38
39/// Deeper nested parameter lists are skipped without recursion, so a hostile
40/// file cannot overflow the stack (real files nest a few levels at most).
41const MAX_NESTING: usize = 32;
42
43/// Sub-assembly occurrences expanded at most by [`external_instance_counts`]
44/// (a deep structure of many-times-placed sub-assemblies grows fast).
45const MAX_OCCURRENCES: usize = 1_000_000;
46
47/// Whether the file name is a STEP file (`.step`, `.stp`, `.p21`).
48pub fn is_step(filename: &str) -> bool {
49    filename
50        .rsplit_once('.')
51        .is_some_and(|(_, ext)| matches!(ext.to_ascii_lowercase().as_str(), "step" | "stp" | "p21"))
52}
53
54/// A parameter of an entity instance.
55#[derive(Debug, PartialEq)]
56enum Param {
57    Str(String),
58    Ref(u64),
59    List(Vec<Param>),
60    /// `$`, `*`, numbers, enumerations and typed values – not needed here.
61    Other,
62}
63
64struct Product {
65    id: String,
66    name: String,
67    description: String,
68}
69
70impl Product {
71    fn shown(&self) -> &str {
72        if self.id.trim().is_empty() {
73            self.name.trim()
74        } else {
75            self.id.trim()
76        }
77    }
78}
79
80/// Reads the product structure of a STEP file.
81pub fn read(bytes: &[u8]) -> Result<BomTable, String> {
82    let text = String::from_utf8_lossy(bytes);
83    if !text
84        .trim_start_matches('\u{feff}')
85        .trim_start()
86        .starts_with("ISO-10303-21")
87    {
88        return Err("Not a STEP file: it does not start with ISO-10303-21.".into());
89    }
90    let mut products: HashMap<u64, Product> = HashMap::new();
91    // formation → product, definition → formation
92    let mut formations: HashMap<u64, u64> = HashMap::new();
93    let mut definitions: HashMap<u64, u64> = HashMap::new();
94    // (parent definition, child definition) in file order
95    let mut usages: Vec<(u64, u64)> = Vec::new();
96    for_each_instance(&text, |id, keyword, body| {
97        let is = |name: &str| keyword.eq_ignore_ascii_case(name);
98        if is("PRODUCT") {
99            let p = params(body);
100            products.insert(
101                id,
102                Product {
103                    id: string(&p, 0),
104                    name: string(&p, 1),
105                    description: string(&p, 2),
106                },
107            );
108        } else if is("PRODUCT_DEFINITION_FORMATION")
109            || is("PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE")
110        {
111            if let Some(product) = reference(&params(body), 2) {
112                formations.insert(id, product);
113            }
114        } else if is("PRODUCT_DEFINITION") || is("PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS") {
115            if let Some(formation) = reference(&params(body), 2) {
116                definitions.insert(id, formation);
117            }
118        } else if is("NEXT_ASSEMBLY_USAGE_OCCURRENCE") {
119            let p = params(body);
120            if let (Some(parent), Some(child)) = (reference(&p, 3), reference(&p, 4)) {
121                usages.push((parent, child));
122            }
123        }
124    });
125
126    let product_of = |definition: u64| {
127        definitions
128            .get(&definition)
129            .and_then(|f| formations.get(f))
130            .filter(|p| products.contains_key(p))
131            .copied()
132    };
133    // parent product → (child product, occurrences), both in file order
134    let mut children: HashMap<u64, Vec<(u64, usize)>> = HashMap::new();
135    let mut parents: Vec<u64> = Vec::new();
136    let mut used = HashSet::new();
137    let mut unresolved = 0;
138    for (parent, child) in usages {
139        let (Some(p), Some(c)) = (product_of(parent), product_of(child)) else {
140            unresolved += 1;
141            continue;
142        };
143        let list = children.entry(p).or_insert_with(|| {
144            parents.push(p);
145            Vec::new()
146        });
147        match list.iter_mut().find(|(x, _)| *x == c) {
148            Some(entry) => entry.1 += 1,
149            None => list.push((c, 1)),
150        }
151        used.insert(c);
152    }
153    if children.is_empty() {
154        return Err(
155            "No assembly structure found: the STEP file has no NEXT_ASSEMBLY_USAGE_OCCURRENCE – \
156             it holds a single part or was exported without its product structure."
157                .into(),
158        );
159    }
160    let roots: Vec<u64> = parents
161        .iter()
162        .filter(|p| !used.contains(*p))
163        .copied()
164        .collect();
165    if roots.is_empty() {
166        return Err("The product structure of the STEP file is circular.".into());
167    }
168
169    let mut walk = Walk {
170        products: &products,
171        children: &children,
172        expanded: HashSet::new(),
173        path: Vec::new(),
174        rows: Vec::new(),
175        warnings: Vec::new(),
176    };
177    if unresolved > 0 {
178        walk.warnings.push(format!(
179            "{unresolved} assembly occurrences point to unknown product definitions and are \
180             ignored."
181        ));
182    }
183    let root = if let [root] = roots[..] {
184        let mut row = walk.row(root, 0, 1.0);
185        row.line = 1;
186        walk.expand(root, 0)?;
187        Some(row)
188    } else {
189        walk.warnings.push(format!(
190            "The STEP file holds {} top-level assemblies: each becomes a component of the new \
191             assembly.",
192            roots.len()
193        ));
194        for &root in &roots {
195            let row = walk.row(root, 1, 1.0);
196            walk.rows.push(row);
197            walk.expand(root, 1)?;
198        }
199        None
200    };
201    let columns = [
202        ("PRODUCT id", "number"),
203        ("PRODUCT name", "name"),
204        ("PRODUCT description", "description"),
205        ("NEXT_ASSEMBLY_USAGE_OCCURRENCE", "quantity"),
206    ]
207    .into_iter()
208    .map(|(header, field)| Column {
209        header: header.into(),
210        field,
211        label: field_label(field),
212    })
213    .collect();
214    Ok(BomTable {
215        root,
216        rows: walk.rows,
217        columns,
218        extra_columns: Vec::new(),
219        warnings: walk.warnings,
220    })
221}
222
223/// How often a STEP assembly with **external references** places each
224/// referenced file (#247): the parts live in their own files
225/// (`'bolt.stp'`), the assembly file holds the structure. Which file a
226/// product definition stands for is read from
227///
228/// * `DOCUMENT_FILE('bolt.stp', …)` – the file (its id, or its name; a path
229///   is reduced to the file name), or the id of an
230///   `APPLIED_EXTERNAL_IDENTIFICATION_ASSIGNMENT('bolt.stp', …, (#doc))`;
231/// * linked to the definition by `APPLIED_DOCUMENT_REFERENCE(#doc, …,
232///   (#definition))`, `PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS(…,
233///   (#doc))` or `DOCUMENT_PRODUCT_EQUIVALENCE` / `…_ASSOCIATION(…, #doc,
234///   #product|formation|definition)`.
235///
236/// Every `NEXT_ASSEMBLY_USAGE_OCCURRENCE` below the top assemblies counts
237/// once for the file of its child; a child without a file of its own is a
238/// sub-assembly inside this file, whose occurrences count with its own
239/// multiplicity (depth bounded). File names in their original spelling;
240/// `None` when no occurrence leads to an external file (a monolithic STEP
241/// file, a single part, no STEP file).
242pub fn external_instance_counts(bytes: &[u8]) -> Option<HashMap<String, u32>> {
243    let text = String::from_utf8_lossy(bytes);
244    if !text
245        .trim_start_matches('\u{feff}')
246        .trim_start()
247        .starts_with("ISO-10303-21")
248    {
249        return None;
250    }
251    // document → file name
252    let mut doc_names: HashMap<u64, String> = HashMap::new();
253    let mut doc_ids: HashMap<u64, String> = HashMap::new();
254    // (document, referenced definition / formation / product)
255    let mut doc_links: Vec<(u64, u64)> = Vec::new();
256    let mut formations: HashMap<u64, u64> = HashMap::new();
257    let mut definitions: HashMap<u64, u64> = HashMap::new();
258    let mut usages: Vec<(u64, u64)> = Vec::new();
259    let file_name = |s: &str| -> Option<String> {
260        let last = s.rsplit(['\\', '/']).next().unwrap_or(s).trim();
261        (last.contains('.') && !last.starts_with('.')).then(|| last.to_string())
262    };
263    for_each_instance(&text, |id, keyword, body| {
264        let is = |name: &str| keyword.eq_ignore_ascii_case(name);
265        if is("DOCUMENT_FILE") {
266            let p = params(body);
267            if let Some(n) = file_name(&string(&p, 0)).or_else(|| file_name(&string(&p, 1))) {
268                doc_ids.insert(id, n);
269            }
270        } else if is("APPLIED_EXTERNAL_IDENTIFICATION_ASSIGNMENT") {
271            let p = params(body);
272            if let (Some(n), Some(Param::List(items))) = (file_name(&string(&p, 0)), p.get(3)) {
273                for item in items {
274                    if let Param::Ref(doc) = item {
275                        doc_names.entry(*doc).or_insert_with(|| n.clone());
276                    }
277                }
278            }
279        } else if is("APPLIED_DOCUMENT_REFERENCE") {
280            let p = params(body);
281            if let (Some(doc), Some(Param::List(items))) = (reference(&p, 0), p.get(2)) {
282                for item in items {
283                    if let Param::Ref(target) = item {
284                        doc_links.push((doc, *target));
285                    }
286                }
287            }
288        } else if is("DOCUMENT_PRODUCT_EQUIVALENCE") || is("DOCUMENT_PRODUCT_ASSOCIATION") {
289            let p = params(body);
290            if let (Some(doc), Some(target)) = (reference(&p, 2), reference(&p, 3)) {
291                doc_links.push((doc, target));
292            }
293        } else if is("PRODUCT_DEFINITION_FORMATION")
294            || is("PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE")
295        {
296            if let Some(product) = reference(&params(body), 2) {
297                formations.insert(id, product);
298            }
299        } else if is("PRODUCT_DEFINITION") || is("PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS") {
300            let p = params(body);
301            if let Some(formation) = reference(&p, 2) {
302                definitions.insert(id, formation);
303            }
304            if let Some(Param::List(docs)) = p.get(4) {
305                for d in docs {
306                    if let Param::Ref(doc) = d {
307                        doc_links.push((*doc, id));
308                    }
309                }
310            }
311        } else if is("NEXT_ASSEMBLY_USAGE_OCCURRENCE") {
312            let p = params(body);
313            if let (Some(parent), Some(child)) = (reference(&p, 3), reference(&p, 4)) {
314                usages.push((parent, child));
315            }
316        }
317    });
318    // the DOCUMENT_FILE's own id wins over an assignment
319    for (doc, name) in doc_ids {
320        doc_names.insert(doc, name);
321    }
322    if usages.is_empty() || doc_names.is_empty() {
323        return None;
324    }
325    // file name per definition: linked directly, or through its formation
326    // or product
327    let mut by_target: HashMap<u64, &str> = HashMap::new();
328    for (doc, target) in &doc_links {
329        if let Some(name) = doc_names.get(doc) {
330            by_target.entry(*target).or_insert(name.as_str());
331        }
332    }
333    let file_of = |definition: u64| -> Option<&str> {
334        let formation = definitions.get(&definition).copied();
335        let product = formation.and_then(|f| formations.get(&f).copied());
336        [Some(definition), formation, product]
337            .into_iter()
338            .flatten()
339            .find_map(|t| by_target.get(&t).copied())
340    };
341    let mut children: HashMap<u64, Vec<u64>> = HashMap::new();
342    let mut used = HashSet::new();
343    let mut parents = Vec::new();
344    for (parent, child) in usages {
345        children
346            .entry(parent)
347            .or_insert_with(|| {
348                parents.push(parent);
349                Vec::new()
350            })
351            .push(child);
352        used.insert(child);
353    }
354    let mut counts: HashMap<String, u32> = HashMap::new();
355    // (definition, depth) – one entry per occurrence, so a sub-assembly
356    // placed twice inside this file counts its parts twice
357    let mut stack: Vec<(u64, usize)> = parents
358        .iter()
359        .filter(|p| !used.contains(*p))
360        .map(|&p| (p, 0))
361        .collect();
362    let mut steps = 0usize;
363    while let Some((parent, depth)) = stack.pop() {
364        steps += 1;
365        if depth >= MAX_DEPTH || steps > MAX_OCCURRENCES {
366            break;
367        }
368        for &child in children.get(&parent).into_iter().flatten() {
369            match file_of(child) {
370                Some(name) => {
371                    let n = counts.entry(name.to_string()).or_default();
372                    *n = n.saturating_add(1);
373                }
374                None => stack.push((child, depth + 1)),
375            }
376        }
377    }
378    (!counts.is_empty()).then_some(counts)
379}
380
381/// Depth-first walk through the structure, producing the rows in BOM order.
382struct Walk<'a> {
383    products: &'a HashMap<u64, Product>,
384    children: &'a HashMap<u64, Vec<(u64, usize)>>,
385    expanded: HashSet<u64>,
386    path: Vec<u64>,
387    rows: Vec<BomRow>,
388    warnings: Vec<String>,
389}
390
391impl Walk<'_> {
392    /// Line 1 is the assembly, the components follow in BOM order.
393    fn row(&self, product: u64, level: usize, quantity: f64) -> BomRow {
394        let p = &self.products[&product];
395        let number = p.id.trim().to_string();
396        let name = match p.name.trim() {
397            "" => number.clone(),
398            name => name.to_string(),
399        };
400        let description = match p.description.trim() {
401            d if d == name || d == number => String::new(),
402            d => d.to_string(),
403        };
404        BomRow {
405            line: self.rows.len() + 2,
406            level,
407            find_number: None,
408            number,
409            name,
410            description,
411            quantity,
412            unit: String::new(),
413            material: String::new(),
414            mass_kg: None,
415            structure: String::new(),
416            extra: Vec::new(),
417        }
418    }
419
420    /// Adds the components of `product` (on `level` + 1) – at its first
421    /// occurrence only.
422    fn expand(&mut self, product: u64, level: usize) -> Result<(), String> {
423        if !self.expanded.insert(product) {
424            return Ok(());
425        }
426        let Some(children) = self.children.get(&product) else {
427            return Ok(());
428        };
429        if level >= MAX_DEPTH {
430            self.warnings.push(format!(
431                "The structure is deeper than {MAX_DEPTH} levels – the components of {} are \
432                 skipped.",
433                self.products[&product].shown()
434            ));
435            return Ok(());
436        }
437        self.path.push(product);
438        for &(child, count) in children {
439            if self.path.contains(&child) {
440                self.warnings.push(format!(
441                    "{} would contain itself – the occurrence is skipped.",
442                    self.products[&child].shown()
443                ));
444                continue;
445            }
446            if self.rows.len() >= MAX_ROWS {
447                return Err(format!("The assembly has more than {MAX_ROWS} BOM rows."));
448            }
449            let row = self.row(child, level + 1, count as f64);
450            self.rows.push(row);
451            self.expand(child, level + 1)?;
452        }
453        self.path.pop();
454        Ok(())
455    }
456}
457
458/// The text parameter at `index`, decoded; empty if there is none.
459fn string(params: &[Param], index: usize) -> String {
460    match params.get(index) {
461        Some(Param::Str(s)) => s.clone(),
462        _ => String::new(),
463    }
464}
465
466fn reference(params: &[Param], index: usize) -> Option<u64> {
467    match params.get(index) {
468        Some(Param::Ref(r)) => Some(*r),
469        _ => None,
470    }
471}
472
473/// Calls `f(id, keyword, parameters)` for every simple entity instance
474/// `#id = KEYWORD(parameters);` of the file (header entries have no `#id`).
475fn for_each_instance(text: &str, mut f: impl FnMut(u64, &str, &str)) {
476    let b = text.as_bytes();
477    let (mut start, mut i) = (0, 0);
478    let mut in_string = false;
479    while let Some(&c) = b.get(i) {
480        if in_string {
481            if c == b'\'' {
482                if b.get(i + 1) == Some(&b'\'') {
483                    i += 1;
484                } else {
485                    in_string = false;
486                }
487            }
488        } else if c == b'\'' {
489            in_string = true;
490        } else if c == b'/' && b.get(i + 1) == Some(&b'*') {
491            i = text
492                .get(i + 2..)
493                .and_then(|t| t.find("*/"))
494                .map_or(b.len(), |e| i + 2 + e + 2);
495            continue;
496        } else if c == b';' {
497            if let Some((id, keyword, body)) = text.get(start..i).and_then(instance) {
498                f(id, keyword, body);
499            }
500            start = i + 1;
501        }
502        i += 1;
503    }
504}
505
506/// Splits `#12 = KEYWORD ( … )` into id, keyword and the text between the
507/// outer parentheses.
508fn instance(mut record: &str) -> Option<(u64, &str, &str)> {
509    // Comments before the instance.
510    while let Some(comment) = record.trim_start().strip_prefix("/*") {
511        record = comment.split_once("*/")?.1;
512    }
513    let rest = record.trim_start().strip_prefix('#')?;
514    let digits = rest.find(|c: char| !c.is_ascii_digit())?;
515    let id = rest.get(..digits)?.parse().ok()?;
516    let rest = rest
517        .get(digits..)?
518        .trim_start()
519        .strip_prefix('=')?
520        .trim_start();
521    let end = rest
522        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
523        .unwrap_or(rest.len());
524    let (keyword, rest) = rest.split_at(end);
525    if keyword.is_empty() {
526        return None; // complex instance
527    }
528    let body = rest.trim().strip_prefix('(')?.strip_suffix(')')?;
529    Some((id, keyword, body))
530}
531
532/// Parses a parameter list (without its outer parentheses).
533fn params(body: &str) -> Vec<Param> {
534    let mut parser = Parser {
535        text: body,
536        bytes: body.as_bytes(),
537        i: 0,
538        depth: 0,
539    };
540    parser.list()
541}
542
543struct Parser<'a> {
544    text: &'a str,
545    bytes: &'a [u8],
546    i: usize,
547    /// Nesting of the list being read.
548    depth: usize,
549}
550
551impl Parser<'_> {
552    fn peek(&self) -> Option<u8> {
553        self.bytes.get(self.i).copied()
554    }
555
556    /// Skips white space and comments.
557    fn space(&mut self) {
558        loop {
559            match self.peek() {
560                Some(c) if c.is_ascii_whitespace() => self.i += 1,
561                Some(b'/') if self.bytes.get(self.i + 1) == Some(&b'*') => {
562                    self.i = self
563                        .text
564                        .get(self.i + 2..)
565                        .unwrap_or_default()
566                        .find("*/")
567                        .map_or(self.bytes.len(), |e| self.i + 2 + e + 2);
568                }
569                _ => return,
570            }
571        }
572    }
573
574    /// Parameters up to the closing parenthesis (or the end).
575    fn list(&mut self) -> Vec<Param> {
576        let mut out = Vec::new();
577        loop {
578            self.space();
579            match self.peek() {
580                None => return out,
581                Some(b')') => {
582                    self.i += 1;
583                    return out;
584                }
585                Some(b',') => self.i += 1,
586                Some(b'\'') => out.push(Param::Str(self.string())),
587                Some(b'#') => {
588                    self.i += 1;
589                    let start = self.i;
590                    while self.peek().is_some_and(|c| c.is_ascii_digit()) {
591                        self.i += 1;
592                    }
593                    out.push(
594                        match self.text.get(start..self.i).unwrap_or_default().parse() {
595                            Ok(r) => Param::Ref(r),
596                            Err(_) => Param::Other,
597                        },
598                    );
599                }
600                Some(b'(') if self.depth >= MAX_NESTING => {
601                    // `other` skips the balanced list iteratively.
602                    self.other();
603                    out.push(Param::Other);
604                }
605                Some(b'(') => {
606                    self.i += 1;
607                    self.depth += 1;
608                    let list = self.list();
609                    self.depth -= 1;
610                    out.push(Param::List(list));
611                }
612                Some(_) => {
613                    self.other();
614                    out.push(Param::Other);
615                }
616            }
617        }
618    }
619
620    /// Skips one other parameter, e.g. `$`, `.T.`, `2.5` or `LENGTH_MEASURE(2.5)`.
621    fn other(&mut self) {
622        let mut depth = 0usize;
623        while let Some(c) = self.peek() {
624            match c {
625                b',' | b')' if depth == 0 => return,
626                b'(' => depth += 1,
627                b')' => depth -= 1,
628                b'\'' => {
629                    self.string();
630                    continue;
631                }
632                _ => {}
633            }
634            self.i += 1;
635        }
636    }
637
638    /// A quoted string starting at the current quote, decoded.
639    fn string(&mut self) -> String {
640        self.i += 1;
641        let mut raw = String::new();
642        let mut from = self.i;
643        while let Some(c) = self.peek() {
644            if c == b'\'' {
645                raw.push_str(self.text.get(from..self.i).unwrap_or_default());
646                if self.bytes.get(self.i + 1) == Some(&b'\'') {
647                    raw.push('\'');
648                    self.i += 2;
649                    from = self.i;
650                    continue;
651                }
652                self.i += 1;
653                return decode(&raw);
654            }
655            self.i += 1;
656        }
657        raw.push_str(self.text.get(from..).unwrap_or_default());
658        decode(&raw)
659    }
660}
661
662/// Decodes the escapes of Part 21 strings: `\\`, `\X2\…\X0\` (UTF-16),
663/// `\X4\…\X0\` (UTF-32), `\X\hh` and `\S\c` (ISO 8859-1), `\N\`, `\T\`;
664/// code page switches (`\PA\`) are dropped.
665fn decode(raw: &str) -> String {
666    let mut out = String::with_capacity(raw.len());
667    let mut rest = raw;
668    // every cut below follows checked ASCII bytes, so it is on a character
669    // boundary; `get` keeps it from panicking all the same
670    fn from(s: &str, n: usize) -> &str {
671        s.get(n..).unwrap_or_default()
672    }
673    while let Some(p) = rest.find('\\') {
674        out.push_str(rest.get(..p).unwrap_or_default());
675        rest = from(rest, p);
676        if let Some(r) = rest.strip_prefix("\\\\") {
677            out.push('\\');
678            rest = r;
679        } else if let Some((width, r)) = rest
680            .strip_prefix("\\X2\\")
681            .map(|r| (4, r))
682            .or_else(|| rest.strip_prefix("\\X4\\").map(|r| (8, r)))
683        {
684            let (hex, after) = r.split_once("\\X0\\").unwrap_or((r, ""));
685            let units = hex.as_bytes().chunks(width).filter_map(|chunk| {
686                std::str::from_utf8(chunk)
687                    .ok()
688                    .and_then(|h| u32::from_str_radix(h, 16).ok())
689            });
690            if width == 4 {
691                let units: Vec<u16> = units.map(|u| u as u16).collect();
692                out.extend(char::decode_utf16(units).map(|c| c.unwrap_or('\u{fffd}')));
693            } else {
694                out.extend(units.map(|u| char::from_u32(u).unwrap_or('\u{fffd}')));
695            }
696            rest = after;
697        } else if let Some(v) = rest
698            .strip_prefix("\\X\\")
699            .and_then(|r| r.get(..2))
700            .and_then(|h| u8::from_str_radix(h, 16).ok())
701        {
702            out.push(char::from(v));
703            rest = from(rest, 5);
704        } else if let Some(c) = rest
705            .strip_prefix("\\S\\")
706            .and_then(|r| r.bytes().next())
707            .filter(u8::is_ascii)
708        {
709            out.push(char::from(c + 128));
710            rest = from(rest, 4);
711        } else if let Some(r) = rest.strip_prefix("\\N\\") {
712            out.push('\n');
713            rest = r;
714        } else if let Some(r) = rest.strip_prefix("\\T\\") {
715            out.push('\t');
716            rest = r;
717        } else if let [b'\\', b'P', _, b'\\', ..] = rest.as_bytes() {
718            rest = from(rest, 4);
719        } else {
720            out.push('\\');
721            rest = from(rest, 1);
722        }
723    }
724    out.push_str(rest);
725    out
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    /// A STEP file with the given entity instances in its DATA section.
733    fn step(data: &str) -> Vec<u8> {
734        format!(
735            "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\n\
736             FILE_NAME('pump.step','2026-11-04T12:00:00',(''),(''),'','','');\n\
737             FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));\nENDSEC;\nDATA;\n{data}\nENDSEC;\n\
738             END-ISO-10303-21;\n"
739        )
740        .into_bytes()
741    }
742
743    /// PRODUCT, formation and definition of one product: definition `#<n>3`.
744    fn product(n: u32, id: &str, name: &str) -> String {
745        format!(
746            "#{n}1=PRODUCT('{id}','{name}','',(#9));\n\
747             #{n}2=PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('','',#{n}1,.NOT_KNOWN.);\n\
748             #{n}3=PRODUCT_DEFINITION('design','',#{n}2,#8);\n"
749        )
750    }
751
752    fn usage(id: u32, parent: u32, child: u32) -> String {
753        format!("#{id}=NEXT_ASSEMBLY_USAGE_OCCURRENCE('{id}','','',#{parent}3,#{child}3,$);\n")
754    }
755
756    fn flat(t: &BomTable) -> Vec<(usize, &str, f64)> {
757        t.rows
758            .iter()
759            .map(|r| (r.level, r.number.as_str(), r.quantity))
760            .collect()
761    }
762
763    #[test]
764    fn assembly_with_sub_assembly_and_repeated_parts() {
765        let data = [
766            product(1, "PUMP-100", "Pump"),
767            product(2, "HSG-1", "Housing"),
768            product(3, "ROT-1", "Rotor assembly"),
769            product(4, "M6x20", "Screw"),
770            product(5, "SHAFT-1", "Shaft"),
771            // the first occurrence of the rotor comes before the screws
772            usage(101, 1, 2),
773            usage(102, 1, 3),
774            usage(103, 1, 4),
775            usage(104, 1, 4),
776            usage(105, 3, 5),
777            usage(106, 3, 4),
778            usage(107, 1, 4),
779        ]
780        .concat();
781        let t = read(&step(&data)).unwrap();
782        let root = t.root.as_ref().unwrap();
783        assert_eq!((root.level, root.number.as_str()), (0, "PUMP-100"));
784        assert_eq!(root.name, "Pump");
785        assert_eq!(
786            flat(&t),
787            vec![
788                (1, "HSG-1", 1.0),
789                (1, "ROT-1", 1.0),
790                (2, "SHAFT-1", 1.0),
791                (2, "M6x20", 1.0),
792                (1, "M6x20", 3.0),
793            ]
794        );
795        assert_eq!(t.rows[1].name, "Rotor assembly");
796        assert_eq!(
797            t.rows.iter().map(|r| r.line).collect::<Vec<_>>(),
798            vec![2, 3, 4, 5, 6]
799        );
800        assert!(t.warnings.is_empty());
801        assert_eq!(t.columns.len(), 4);
802    }
803
804    #[test]
805    fn shared_sub_assembly_is_listed_once() {
806        let data = [
807            product(1, "TOP", "Top"),
808            product(2, "LEFT", "Left"),
809            product(3, "RIGHT", "Right"),
810            product(4, "WHEEL", "Wheel"),
811            product(5, "HUB", "Hub"),
812            usage(101, 1, 2),
813            usage(102, 1, 3),
814            usage(103, 2, 4),
815            usage(104, 3, 4),
816            usage(105, 4, 5),
817        ]
818        .concat();
819        let t = read(&step(&data)).unwrap();
820        assert_eq!(
821            flat(&t),
822            vec![
823                (1, "LEFT", 1.0),
824                (2, "WHEEL", 1.0),
825                (3, "HUB", 1.0),
826                (1, "RIGHT", 1.0),
827                (2, "WHEEL", 1.0),
828            ]
829        );
830    }
831
832    #[test]
833    fn several_top_level_assemblies() {
834        let data = [
835            product(1, "A", "A"),
836            product(2, "B", "B"),
837            product(3, "P", "P"),
838            usage(101, 1, 3),
839            usage(102, 2, 3),
840        ]
841        .concat();
842        let t = read(&step(&data)).unwrap();
843        assert!(t.root.is_none());
844        assert_eq!(
845            flat(&t),
846            vec![(1, "A", 1.0), (2, "P", 1.0), (1, "B", 1.0), (2, "P", 1.0)]
847        );
848        assert!(t.warnings[0].contains("2 top-level assemblies"));
849    }
850
851    #[test]
852    fn single_parts_and_other_files_are_refused() {
853        let part = read(&step(&product(1, "BRACKET", "Bracket"))).unwrap_err();
854        assert!(part.contains("No assembly structure"), "{part}");
855        let fixture = std::fs::read("tests/fixtures/cube.step").unwrap();
856        assert!(read(&fixture).is_err());
857        assert!(read(b"solid cube\nendsolid\n")
858            .unwrap_err()
859            .contains("Not a STEP"));
860        let circular = [
861            product(1, "A", "A"),
862            product(2, "B", "B"),
863            usage(101, 1, 2),
864            usage(102, 2, 1),
865        ]
866        .concat();
867        assert!(read(&step(&circular)).unwrap_err().contains("circular"));
868    }
869
870    #[test]
871    fn strings_comments_and_escapes() {
872        let data = "/* exported; with comments */\n\
873            #11=PRODUCT('M\\X2\\00DC\\X0\\LLER-1','Gr\\S\\|n''s part; \\X\\E4','',(#9));\n\
874            #12=PRODUCT_DEFINITION_FORMATION('','',#11);\n\
875            #13=PRODUCT_DEFINITION('design','' /* note */,#12,#8);\n\
876            #21=PRODUCT('X','Box','A box',(#9));\n\
877            #22=PRODUCT_DEFINITION_FORMATION('','',#21);\n\
878            #23=PRODUCT_DEFINITION('design','',#22,#8);\n\
879            #30=(GEOMETRIC_REPRESENTATION_CONTEXT(3)REPRESENTATION_CONTEXT('',''));\n\
880            #31=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#23,#13,$);\n";
881        let t = read(&step(data)).unwrap();
882        let root = t.root.unwrap();
883        assert_eq!(
884            (root.number.as_str(), root.description.as_str()),
885            ("X", "A box")
886        );
887        assert_eq!(t.rows[0].number, "MÜLLER-1");
888        assert_eq!(t.rows[0].name, "Grün's part; ä");
889    }
890
891    #[test]
892    fn decoding() {
893        assert_eq!(decode("a\\\\b"), "a\\b");
894        assert_eq!(decode("\\X2\\03B103B2\\X0\\!"), "αβ!");
895        assert_eq!(decode("\\X4\\0001F600\\X0\\"), "😀");
896        assert_eq!(decode("\\PA\\x\\N\\y"), "x\ny");
897        assert_eq!(decode("50\\%"), "50\\%");
898        assert!(is_step("Pump.STEP") && is_step("a.stp") && !is_step("a.stl"));
899        assert_eq!(
900            params("'a',#5,(#6,#7),.T.,LENGTH_MEASURE(2.5),$,'x(y'"),
901            vec![
902                Param::Str("a".into()),
903                Param::Ref(5),
904                Param::List(vec![Param::Ref(6), Param::Ref(7)]),
905                Param::Other,
906                Param::Other,
907                Param::Other,
908                Param::Str("x(y".into()),
909            ]
910        );
911    }
912
913    #[test]
914    fn unresolved_occurrences_are_reported_and_ignored() {
915        let data = [
916            product(1, "TOP", "Top"),
917            product(2, "P", "Part"),
918            usage(101, 1, 2),
919            usage(102, 1, 7), // no definition #73
920            usage(103, 8, 2), // no definition #83
921        ]
922        .concat();
923        let t = read(&step(&data)).unwrap();
924        assert_eq!(flat(&t), vec![(1, "P", 1.0)]);
925        assert_eq!(t.warnings.len(), 1);
926        assert!(
927            t.warnings[0].starts_with("2 assembly occurrences"),
928            "{:?}",
929            t.warnings
930        );
931    }
932
933    #[test]
934    fn byte_order_mark_lower_case_keywords_and_plain_formations() {
935        // A UTF-8 BOM and blank lines before the magic, lower-case keywords,
936        // PRODUCT_DEFINITION_FORMATION without source and definitions with
937        // associated documents.
938        let data = "#11=product('ASM','Assembly','',(#9));\n\
939            #12=PRODUCT_DEFINITION_FORMATION('','',#11);\n\
940            #13=PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS('d','',#12,#8,());\n\
941            #21=PRODUCT('PRT','Part','',(#9));\n\
942            #22=product_definition_formation_with_specified_source('','',#21,.MADE.);\n\
943            #23=product_definition('design','',#22,#8);\n\
944            #31=next_assembly_usage_occurrence('1','','',#13,#23,$);\n";
945        let mut bytes = "\u{feff}\n\n".as_bytes().to_vec();
946        bytes.extend(step(data));
947        let t = read(&bytes).unwrap();
948        assert_eq!(t.root.as_ref().unwrap().number, "ASM");
949        assert_eq!(flat(&t), vec![(1, "PRT", 1.0)]);
950    }
951
952    #[test]
953    fn names_and_descriptions() {
954        let data = "#11=PRODUCT('ASM','','ASM',(#9));\n\
955            #12=PRODUCT_DEFINITION_FORMATION('','',#11);\n\
956            #13=PRODUCT_DEFINITION('design','',#12,#8);\n\
957            #21=PRODUCT(' P-1 ',' Plate ',' Plate ',(#9));\n\
958            #22=PRODUCT_DEFINITION_FORMATION('','',#21);\n\
959            #23=PRODUCT_DEFINITION('design','',#22,#8);\n\
960            #31=NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#13,#23,$);\n";
961        let t = read(&step(data)).unwrap();
962        let root = t.root.unwrap();
963        // Without a name the id is the name; a description that only
964        // repeats the name or the number is dropped; values are trimmed.
965        assert_eq!((root.name.as_str(), root.description.as_str()), ("ASM", ""));
966        let p = &t.rows[0];
967        assert_eq!(
968            (p.number.as_str(), p.name.as_str(), p.description.as_str()),
969            ("P-1", "Plate", "")
970        );
971        assert_eq!(
972            (p.find_number, p.unit.as_str(), p.mass_kg),
973            (None, "", None)
974        );
975    }
976
977    #[test]
978    fn a_component_that_contains_itself_is_skipped() {
979        // TOP → A → B → A: the inner A would contain itself.
980        let data = [
981            product(1, "TOP", "Top"),
982            product(2, "A", "A"),
983            product(3, "B", "B"),
984            usage(101, 1, 2),
985            usage(102, 2, 3),
986            usage(103, 3, 2),
987        ]
988        .concat();
989        let t = read(&step(&data)).unwrap();
990        assert_eq!(flat(&t), vec![(1, "A", 1.0), (2, "B", 1.0)]);
991        assert!(
992            t.warnings
993                .iter()
994                .any(|w| w.contains("A would contain itself")),
995            "{:?}",
996            t.warnings
997        );
998    }
999
1000    #[test]
1001    fn very_deep_structures_are_cut() {
1002        // A chain of MAX_DEPTH + 5 nested assemblies.
1003        let n = (MAX_DEPTH + 5) as u32;
1004        let mut data = String::new();
1005        for i in 1..=n + 1 {
1006            data.push_str(&product(i, &format!("L{i}"), "Level"));
1007        }
1008        for i in 1..=n {
1009            data.push_str(&usage(100_000 + i, i, i + 1));
1010        }
1011        let t = read(&step(&data)).unwrap();
1012        assert_eq!(t.rows.len(), MAX_DEPTH);
1013        assert_eq!(t.rows.last().unwrap().level, MAX_DEPTH);
1014        assert!(t.warnings.iter().any(|w| w.contains("deeper than")));
1015    }
1016
1017    #[test]
1018    fn more_decoding() {
1019        assert_eq!(decode("Gr\\S\\|n"), "Grün", "\\S\\ adds 128");
1020        assert_eq!(decode("\\X\\C4rger"), "Ärger");
1021        assert_eq!(decode("tab\\T\\end"), "tab\tend");
1022        assert_eq!(decode("\\X2\\00E4"), "ä", "missing \\X0\\ terminator");
1023        assert_eq!(decode("plain"), "plain");
1024        assert_eq!(decode("trailing\\"), "trailing\\");
1025        assert!(is_step("asm.P21") && !is_step("step") && !is_step("a.step.zip"));
1026    }
1027
1028    #[test]
1029    fn deeply_nested_parameters_do_not_overflow_the_stack() {
1030        let n = 200_000;
1031        let body = format!("'a',{}{},#7", "(".repeat(n), ")".repeat(n));
1032        let p = params(&body);
1033        assert_eq!(p.len(), 3);
1034        assert_eq!(p[0], Param::Str("a".into()));
1035        assert_eq!(p[2], Param::Ref(7));
1036        // The outer levels are kept, the rest is skipped as one parameter.
1037        let mut level = &p[1];
1038        let mut depth = 0;
1039        while let Param::List(inner) = level {
1040            depth += 1;
1041            level = &inner[0];
1042        }
1043        assert_eq!((depth, level), (MAX_NESTING, &Param::Other));
1044
1045        let file = format!(
1046            "{}#1=PRODUCT({}{});\n",
1047            product(1, "A", "A"),
1048            "(".repeat(n),
1049            ")".repeat(n)
1050        );
1051        assert!(read(&step(&file)).is_err());
1052    }
1053
1054    /// Found by cargo mutants: a comment between parameters is skipped; a
1055    /// stray `/` (damaged file) costs one parameter, not the rest of the list.
1056    #[test]
1057    fn comments_and_stray_slashes_between_parameters() {
1058        assert_eq!(
1059            params("#1, /* the part */ #2,'x'"),
1060            [Param::Ref(1), Param::Ref(2), Param::Str("x".into())]
1061        );
1062        assert_eq!(params("/x,#5"), [Param::Other, Param::Ref(5)]);
1063        // a quoted `)` or `,` inside a skipped typed value ends nothing
1064        assert_eq!(
1065            params("NAME('x)y'),LABEL('a,b'),#7"),
1066            [Param::Other, Param::Other, Param::Ref(7)]
1067        );
1068    }
1069
1070    /// Found by cargo mutants: the nesting depth goes down again after a
1071    /// list, so many sibling lists are all read (nesting beyond
1072    /// `MAX_NESTING`: `deeply_nested_parameters_do_not_overflow_the_stack`).
1073    #[test]
1074    fn sibling_lists_do_not_count_as_nesting() {
1075        let siblings: Vec<String> = (1..=MAX_NESTING * 3).map(|i| format!("(#{i})")).collect();
1076        let parsed = params(&siblings.join(","));
1077        assert_eq!(parsed.len(), MAX_NESTING * 3);
1078        assert!(parsed
1079            .iter()
1080            .all(|p| matches!(p, Param::List(l) if l.len() == 1)));
1081    }
1082
1083    /// Found by cargo mutants: comments, unset parameters (`*`) and quoted
1084    /// semicolons when splitting into instances.
1085    #[test]
1086    fn instances_with_unset_parameters_comments_and_quotes() {
1087        let text = "#1=PRODUCT('a;b','',(#2),*);\n/* a comment; with #9=X(); */\
1088                    #2=PRODUCT_CONTEXT('it''s',*,'mechanical');#3=X(1.5/2.);";
1089        let mut seen = Vec::new();
1090        for_each_instance(text, |id, keyword, _| seen.push((id, keyword.to_string())));
1091        assert_eq!(
1092            seen,
1093            [
1094                (1, "PRODUCT".to_string()),
1095                (2, "PRODUCT_CONTEXT".to_string()),
1096                (3, "X".to_string())
1097            ]
1098        );
1099    }
1100
1101    /// The document of an external file and its link to a definition.
1102    fn external(doc: u32, name: &str, link: &str) -> String {
1103        format!("#{doc}=DOCUMENT_FILE('{name}','',#7,'','','');\n{link}\n")
1104    }
1105
1106    #[test]
1107    fn external_references_count_their_occurrences() {
1108        // assembly #13 places bolt #23 four times and nut #33 twice; the
1109        // bolt's file is linked to its definition, the nut's to its product
1110        let mut data =
1111            product(1, "PUMP", "Pump") + &product(2, "B", "Bolt") + &product(3, "N", "Nut");
1112        data += &external(
1113            900,
1114            "C:\\parts\\bolt.stp",
1115            "#901=APPLIED_DOCUMENT_REFERENCE(#900,'',(#23));",
1116        );
1117        data += &external(
1118            910,
1119            "nut.stp",
1120            "#911=DOCUMENT_PRODUCT_EQUIVALENCE('','',#910,#31);",
1121        );
1122        for (i, child) in [2, 2, 2, 2, 3, 3].into_iter().enumerate() {
1123            data += &usage(100 + i as u32, 1, child);
1124        }
1125        let counts = external_instance_counts(&step(&data)).unwrap();
1126        assert_eq!(counts.get("bolt.stp"), Some(&4));
1127        assert_eq!(counts.get("nut.stp"), Some(&2));
1128        assert_eq!(counts.len(), 2);
1129    }
1130
1131    #[test]
1132    fn inline_sub_assemblies_multiply_and_associated_documents_link() {
1133        // pump #13 → frame #23 (inline, no file) twice; frame → screw #33
1134        // three times; the screw's file through its definition's documents
1135        let mut data = product(1, "PUMP", "Pump") + &product(2, "F", "Frame");
1136        data += "#31=PRODUCT('S','Screw','',(#9));\n\
1137                 #32=PRODUCT_DEFINITION_FORMATION('','',#31);\n\
1138                 #33=PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS('design','',#32,#8,(#920));\n";
1139        data += "#920=DOCUMENT_FILE('','',#7,'','','');\n\
1140                 #921=APPLIED_EXTERNAL_IDENTIFICATION_ASSIGNMENT('screw.step',$,#6,(#920));\n";
1141        data += &usage(100, 1, 2);
1142        data += &usage(101, 1, 2);
1143        for i in 0..3 {
1144            data += &usage(110 + i, 2, 3);
1145        }
1146        let counts = external_instance_counts(&step(&data)).unwrap();
1147        assert_eq!(counts, HashMap::from([("screw.step".to_string(), 6)]));
1148    }
1149
1150    #[test]
1151    fn monolithic_files_and_other_bytes_have_no_counts() {
1152        let data = product(1, "PUMP", "Pump") + &product(2, "B", "Bolt") + &usage(100, 1, 2);
1153        assert_eq!(external_instance_counts(&step(&data)), None);
1154        assert_eq!(external_instance_counts(b"\x00bolt.stp\x00"), None);
1155        // a document without any occurrence
1156        let doc = product(2, "B", "Bolt")
1157            + &external(
1158                900,
1159                "bolt.stp",
1160                "#901=APPLIED_DOCUMENT_REFERENCE(#900,'',(#23));",
1161            );
1162        assert_eq!(external_instance_counts(&step(&doc)), None);
1163    }
1164}