Skip to main content

ogeom_io/step/
parse.rs

1//! The ISO 10303-21 exchange structure, parsed but not yet interpreted.
2//!
3//! Part 21 is a syntax, not a schema: `#3 = CIRCLE('', #2, 5.0);` says an
4//! instance exists with a keyword and arguments, and what a `CIRCLE` *means*
5//! is the reader's business, not the parser's. This module turns the text
6//! into a map from instance number to typed argument trees and nothing more,
7//! which is what lets the reader say precisely which entities it understood
8//! and which it deliberately walked past.
9
10use ogeom_core::{OgeomResult, ogeom_bail};
11use std::collections::HashMap;
12
13/// One argument of an entity instance.
14#[derive(Debug, Clone, PartialEq)]
15pub enum Arg {
16    /// `$`: no value.
17    Null,
18    /// `*`: value derivable from the schema, not stated.
19    Derived,
20    /// `#n`: a reference to another instance.
21    Ref(u64),
22    /// An integer literal.
23    Int(i64),
24    /// A real literal.
25    Real(f64),
26    /// A string literal, with Part 21's quote doubling undone.
27    Str(String),
28    /// `.NAME.`: an enumeration value, without its dots.
29    Enum(String),
30    /// A parenthesised list.
31    List(Vec<Arg>),
32    /// `KEYWORD(...)` in argument position: a typed (select) value.
33    Typed(String, Vec<Arg>),
34}
35
36impl Arg {
37    /// The reference this argument carries, if it is one.
38    pub fn reference(&self) -> Option<u64> {
39        match self {
40            Self::Ref(n) => Some(*n),
41            _ => None,
42        }
43    }
44
45    /// The number this argument carries, integer or real.
46    pub fn number(&self) -> Option<f64> {
47        match self {
48            Self::Int(n) =>
49            {
50                #[allow(clippy::cast_precision_loss)]
51                Some(*n as f64)
52            }
53            Self::Real(x) => Some(*x),
54            _ => None,
55        }
56    }
57
58    /// The list this argument carries, if it is one.
59    pub fn list(&self) -> Option<&[Arg]> {
60        match self {
61            Self::List(items) => Some(items),
62            _ => None,
63        }
64    }
65
66    /// Whether this is the enumeration value `name`.
67    pub fn is_enum(&self, name: &str) -> bool {
68        matches!(self, Self::Enum(e) if e == name)
69    }
70}
71
72/// One instance: usually one keyword with arguments, several for a complex
73/// (multi-leaf) instance like `#1 = (A(...) B(...));`.
74#[derive(Debug, Clone)]
75pub struct Instance {
76    /// The parts, in file order.
77    pub parts: Vec<(String, Vec<Arg>)>,
78}
79
80impl Instance {
81    /// The arguments of the part with this keyword, if present.
82    pub fn part(&self, keyword: &str) -> Option<&[Arg]> {
83        self.parts
84            .iter()
85            .find(|(k, _)| k == keyword)
86            .map(|(_, a)| a.as_slice())
87    }
88
89    /// The single keyword of a simple instance.
90    pub fn keyword(&self) -> &str {
91        self.parts.first().map_or("", |(k, _)| k.as_str())
92    }
93
94    /// Every part of the instance: one for a simple instance, several for a
95    /// complex one, in file order.
96    pub fn parts(&self) -> impl Iterator<Item = (&str, &[Arg])> {
97        self.parts.iter().map(|(k, a)| (k.as_str(), a.as_slice()))
98    }
99}
100
101/// A parsed exchange file: the data section as a graph, the header kept as
102/// raw instances for whoever wants the schema name or the file's own record
103/// of itself.
104#[derive(Debug)]
105pub struct Exchange {
106    /// Header entries, in order.
107    pub header: Vec<(String, Vec<Arg>)>,
108    /// The data section, by instance number.
109    pub data: HashMap<u64, Instance>,
110}
111
112/// Parse a Part 21 exchange file.
113///
114/// # Errors
115///
116/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) on malformed
117/// syntax, with the byte offset where reading stopped making sense.
118pub fn parse(text: &str) -> OgeomResult<Exchange> {
119    let mut p = Parser {
120        bytes: text.as_bytes(),
121        at: 0,
122    };
123    p.skip_noise();
124    p.expect_keyword("ISO-10303-21")?;
125    p.expect(b';')?;
126
127    p.expect_keyword("HEADER")?;
128    p.expect(b';')?;
129    let mut header = Vec::new();
130    loop {
131        p.skip_noise();
132        if p.peek_keyword("ENDSEC") {
133            p.expect_keyword("ENDSEC")?;
134            p.expect(b';')?;
135            break;
136        }
137        let keyword = p.keyword()?;
138        let args = p.arguments()?;
139        p.expect(b';')?;
140        header.push((keyword, args));
141    }
142
143    p.expect_keyword("DATA")?;
144    p.expect(b';')?;
145    // Sized up front: an instance averages well under a hundred bytes, so
146    // this over-reserves a little rather than rehashing a half-million-entry
147    // map several times on the way up.
148    let mut data = HashMap::with_capacity(text.len() / 96);
149    loop {
150        p.skip_noise();
151        if p.peek_keyword("ENDSEC") {
152            p.expect_keyword("ENDSEC")?;
153            p.expect(b';')?;
154            break;
155        }
156        p.expect(b'#')?;
157        let id = p.integer()?;
158        p.expect(b'=')?;
159        p.skip_noise();
160        let parts = if p.peek(b'(') {
161            // A complex instance: parenthesised sequence of parts.
162            p.expect(b'(')?;
163            let mut parts = Vec::new();
164            loop {
165                p.skip_noise();
166                if p.peek(b')') {
167                    p.expect(b')')?;
168                    break;
169                }
170                let keyword = p.keyword()?;
171                let args = p.arguments()?;
172                parts.push((keyword, args));
173            }
174            parts
175        } else {
176            let keyword = p.keyword()?;
177            let args = p.arguments()?;
178            vec![(keyword, args)]
179        };
180        p.expect(b';')?;
181        #[allow(clippy::cast_sign_loss)]
182        data.insert(id as u64, Instance { parts });
183    }
184
185    p.expect_keyword("END-ISO-10303-21")?;
186    Ok(Exchange { header, data })
187}
188
189struct Parser<'a> {
190    bytes: &'a [u8],
191    at: usize,
192}
193
194impl Parser<'_> {
195    fn skip_noise(&mut self) {
196        loop {
197            while self.at < self.bytes.len() && self.bytes[self.at].is_ascii_whitespace() {
198                self.at += 1;
199            }
200            if self.at + 1 < self.bytes.len() && &self.bytes[self.at..self.at + 2] == b"/*" {
201                self.at += 2;
202                while self.at + 1 < self.bytes.len() && &self.bytes[self.at..self.at + 2] != b"*/" {
203                    self.at += 1;
204                }
205                self.at = (self.at + 2).min(self.bytes.len());
206                continue;
207            }
208            break;
209        }
210    }
211
212    fn peek(&mut self, byte: u8) -> bool {
213        self.skip_noise();
214        self.bytes.get(self.at) == Some(&byte)
215    }
216
217    fn expect(&mut self, byte: u8) -> OgeomResult<()> {
218        self.skip_noise();
219        if self.bytes.get(self.at) == Some(&byte) {
220            self.at += 1;
221            return Ok(());
222        }
223        ogeom_bail!(
224            Construction,
225            "expected '{}' at byte {} of the exchange file",
226            char::from(byte),
227            self.at
228        );
229    }
230
231    fn peek_keyword(&mut self, word: &str) -> bool {
232        self.skip_noise();
233        let end = self.at + word.len();
234        end <= self.bytes.len()
235            && &self.bytes[self.at..end] == word.as_bytes()
236            && self
237                .bytes
238                .get(end)
239                .is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_' && *b != b'-')
240    }
241
242    fn expect_keyword(&mut self, word: &str) -> OgeomResult<()> {
243        if self.peek_keyword(word) {
244            self.at += word.len();
245            return Ok(());
246        }
247        ogeom_bail!(
248            Construction,
249            "expected '{word}' at byte {} of the exchange file",
250            self.at
251        );
252    }
253
254    fn keyword(&mut self) -> OgeomResult<String> {
255        self.skip_noise();
256        let start = self.at;
257        while self
258            .bytes
259            .get(self.at)
260            .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_')
261        {
262            self.at += 1;
263        }
264        if self.at == start {
265            ogeom_bail!(
266                Construction,
267                "expected a keyword at byte {} of the exchange file",
268                start
269            );
270        }
271        Ok(String::from_utf8_lossy(&self.bytes[start..self.at]).into_owned())
272    }
273
274    fn integer(&mut self) -> OgeomResult<i64> {
275        self.skip_noise();
276        let start = self.at;
277        if self.bytes.get(self.at) == Some(&b'-') || self.bytes.get(self.at) == Some(&b'+') {
278            self.at += 1;
279        }
280        while self.bytes.get(self.at).is_some_and(u8::is_ascii_digit) {
281            self.at += 1;
282        }
283        let text = std::str::from_utf8(&self.bytes[start..self.at]).unwrap_or("");
284        text.parse().map_err(|_| {
285            ogeom_core::ogeom_err!(
286                Construction,
287                "expected an integer at byte {start} of the exchange file"
288            )
289        })
290    }
291
292    fn arguments(&mut self) -> OgeomResult<Vec<Arg>> {
293        self.expect(b'(')?;
294        let mut out = Vec::with_capacity(4);
295        loop {
296            self.skip_noise();
297            if self.peek(b')') {
298                self.expect(b')')?;
299                break;
300            }
301            out.push(self.argument()?);
302            self.skip_noise();
303            if self.peek(b',') {
304                self.expect(b',')?;
305            }
306        }
307        Ok(out)
308    }
309
310    fn argument(&mut self) -> OgeomResult<Arg> {
311        self.skip_noise();
312        let Some(&byte) = self.bytes.get(self.at) else {
313            ogeom_bail!(Construction, "the exchange file ends inside an argument");
314        };
315        match byte {
316            b'$' => {
317                self.at += 1;
318                Ok(Arg::Null)
319            }
320            b'*' => {
321                self.at += 1;
322                Ok(Arg::Derived)
323            }
324            b'#' => {
325                self.at += 1;
326                let id = self.integer()?;
327                #[allow(clippy::cast_sign_loss)]
328                Ok(Arg::Ref(id as u64))
329            }
330            b'(' => Ok(Arg::List(self.arguments()?)),
331            b'\'' => self.string(),
332            b'.' => {
333                self.at += 1;
334                let word = self.keyword()?;
335                self.expect(b'.')?;
336                Ok(Arg::Enum(word))
337            }
338            b'-' | b'+' | b'0'..=b'9' => self.number(),
339            _ if byte.is_ascii_alphabetic() || byte == b'_' => {
340                let keyword = self.keyword()?;
341                let args = self.arguments()?;
342                Ok(Arg::Typed(keyword, args))
343            }
344            _ => ogeom_bail!(
345                Construction,
346                "unexpected '{}' at byte {} of the exchange file",
347                char::from(byte),
348                self.at
349            ),
350        }
351    }
352
353    fn string(&mut self) -> OgeomResult<Arg> {
354        self.expect(b'\'')?;
355        let mut out = String::new();
356        loop {
357            // The common run (everything up to the next quote or non-ASCII
358            // byte) lands in one push, not a byte at a time. Bytes above
359            // ASCII keep their historical Latin-1 reading, one by one.
360            let start = self.at;
361            while self
362                .bytes
363                .get(self.at)
364                .is_some_and(|b| *b != b'\'' && b.is_ascii())
365            {
366                self.at += 1;
367            }
368            if self.at > start
369                && let Ok(run) = std::str::from_utf8(&self.bytes[start..self.at])
370            {
371                out.push_str(run);
372            }
373            match self.bytes.get(self.at) {
374                None => ogeom_bail!(Construction, "the exchange file ends inside a string"),
375                Some(b'\'') => {
376                    if self.bytes.get(self.at + 1) == Some(&b'\'') {
377                        out.push('\'');
378                        self.at += 2;
379                    } else {
380                        self.at += 1;
381                        break;
382                    }
383                }
384                Some(&b) => {
385                    out.push(char::from(b));
386                    self.at += 1;
387                }
388            }
389        }
390        Ok(Arg::Str(out))
391    }
392
393    fn number(&mut self) -> OgeomResult<Arg> {
394        let start = self.at;
395        if matches!(self.bytes.get(self.at), Some(b'-' | b'+')) {
396            self.at += 1;
397        }
398        let mut real = false;
399        while let Some(&b) = self.bytes.get(self.at) {
400            match b {
401                b'0'..=b'9' => self.at += 1,
402                b'.' => {
403                    // A dot starts a real, unless it starts an enumeration
404                    // hard against the number, which no real file does.
405                    real = true;
406                    self.at += 1;
407                }
408                b'E' | b'e' => {
409                    real = true;
410                    self.at += 1;
411                    if matches!(self.bytes.get(self.at), Some(b'-' | b'+')) {
412                        self.at += 1;
413                    }
414                }
415                _ => break,
416            }
417        }
418        let text = std::str::from_utf8(&self.bytes[start..self.at]).unwrap_or("");
419        if real {
420            text.parse().map(Arg::Real).map_err(|_| {
421                ogeom_core::ogeom_err!(
422                    Construction,
423                    "unreadable real at byte {start} of the exchange file"
424                )
425            })
426        } else {
427            text.parse().map(Arg::Int).map_err(|_| {
428                ogeom_core::ogeom_err!(
429                    Construction,
430                    "unreadable integer at byte {start} of the exchange file"
431                )
432            })
433        }
434    }
435}
436
437#[cfg(test)]
438#[allow(clippy::unwrap_used)]
439mod tests {
440    use super::*;
441
442    const SMALL: &str = "ISO-10303-21;
443HEADER;
444FILE_DESCRIPTION(('a part'),'2;1');
445FILE_NAME('p.stp','2020-01-01',(''),(''),'','','');
446FILE_SCHEMA(('AP203'));
447ENDSEC;
448DATA;
449#1=CARTESIAN_POINT('',(0.,1.5,-2.E-3));
450#2=DIRECTION('',(0.,0.,1.));
451#3=AXIS2_PLACEMENT_3D('',#1,#2,$);
452#4=(GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNIT_ASSIGNED_CONTEXT((#5)));
453#5=SI_UNIT(.MILLI.,.METRE.);
454ENDSEC;
455END-ISO-10303-21;
456";
457
458    #[test]
459    fn a_small_file_parses_into_its_instances() {
460        let file = parse(SMALL).unwrap();
461        assert_eq!(file.header.len(), 3);
462        assert_eq!(file.data.len(), 5);
463
464        let point = &file.data[&1];
465        assert_eq!(point.keyword(), "CARTESIAN_POINT");
466        let coords = point.parts[0].1[1].list().unwrap();
467        assert_eq!(coords[0].number(), Some(0.0));
468        assert_eq!(coords[1].number(), Some(1.5));
469        assert_eq!(coords[2].number(), Some(-2e-3));
470
471        let placement = &file.data[&3];
472        assert_eq!(placement.parts[0].1[1].reference(), Some(1));
473        assert_eq!(placement.parts[0].1[3], Arg::Null);
474
475        // The complex instance keeps both parts, each with its own arguments.
476        let context = &file.data[&4];
477        assert_eq!(context.parts.len(), 2);
478        assert!(context.part("GLOBAL_UNIT_ASSIGNED_CONTEXT").is_some());
479
480        let unit = &file.data[&5];
481        assert!(unit.parts[0].1[0].is_enum("MILLI"));
482    }
483
484    #[test]
485    fn strings_undouble_their_quotes() {
486        let file = parse("ISO-10303-21;HEADER;ENDSEC;DATA;#1=X('it''s');ENDSEC;END-ISO-10303-21;")
487            .unwrap();
488        assert_eq!(file.data[&1].parts[0].1[0], Arg::Str("it's".into()));
489    }
490
491    #[test]
492    fn malformed_files_are_refused_with_a_place() {
493        assert!(parse("ISO-10303-21;HEADER;DATA;").is_err());
494        assert!(parse("not a step file").is_err());
495    }
496}