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};
11
12/// One argument of an entity instance.
13#[derive(Debug, Clone, PartialEq)]
14pub enum Arg {
15    /// `$`: no value.
16    Null,
17    /// `*`: value derivable from the schema, not stated.
18    Derived,
19    /// `#n`: a reference to another instance.
20    Ref(u64),
21    /// An integer literal.
22    Int(i64),
23    /// A real literal.
24    Real(f64),
25    /// A string literal, with Part 21's quote doubling undone.
26    Str(String),
27    /// `.NAME.`: an enumeration value, without its dots.
28    Enum(String),
29    /// A parenthesised list.
30    List(Vec<Arg>),
31    /// `KEYWORD(...)` in argument position: a typed (select) value: the
32    /// keyword and its arguments, boxed, so the rare typed value does not
33    /// size every argument of the file.
34    Typed(Box<(String, Vec<Arg>)>),
35}
36
37impl Arg {
38    /// The reference this argument carries, if it is one.
39    pub fn reference(&self) -> Option<u64> {
40        match self {
41            Self::Ref(n) => Some(*n),
42            _ => None,
43        }
44    }
45
46    /// The number this argument carries, integer or real.
47    pub fn number(&self) -> Option<f64> {
48        match self {
49            Self::Int(n) =>
50            {
51                #[allow(clippy::cast_precision_loss)]
52                Some(*n as f64)
53            }
54            Self::Real(x) => Some(*x),
55            _ => None,
56        }
57    }
58
59    /// The list this argument carries, if it is one.
60    pub fn list(&self) -> Option<&[Arg]> {
61        match self {
62            Self::List(items) => Some(items),
63            _ => None,
64        }
65    }
66
67    /// Whether this is the enumeration value `name`.
68    pub fn is_enum(&self, name: &str) -> bool {
69        matches!(self, Self::Enum(e) if e == name)
70    }
71}
72
73/// One instance: usually one keyword with arguments, several for a complex
74/// (multi-leaf) instance like `#1 = (A(...) B(...));`.
75#[derive(Debug, Clone)]
76pub struct Instance {
77    /// The parts, in file order.
78    pub parts: Vec<(String, Vec<Arg>)>,
79}
80
81impl Instance {
82    /// The arguments of the part with this keyword, if present.
83    pub fn part(&self, keyword: &str) -> Option<&[Arg]> {
84        self.parts
85            .iter()
86            .find(|(k, _)| k == keyword)
87            .map(|(_, a)| a.as_slice())
88    }
89
90    /// The single keyword of a simple instance.
91    pub fn keyword(&self) -> &str {
92        self.parts.first().map_or("", |(k, _)| k.as_str())
93    }
94
95    /// Every part of the instance: one for a simple instance, several for a
96    /// complex one, in file order.
97    pub fn parts(&self) -> impl Iterator<Item = (&str, &[Arg])> {
98        self.parts.iter().map(|(k, a)| (k.as_str(), a.as_slice()))
99    }
100}
101
102/// A parsed exchange file: the data section as a graph, the header kept as
103/// raw instances for whoever wants the schema name or the file's own record
104/// of itself.
105#[derive(Debug)]
106pub struct Exchange {
107    /// Header entries, in order.
108    pub header: Vec<(String, Vec<Arg>)>,
109    /// The data section, by instance number.
110    #[allow(
111        clippy::disallowed_types,
112        reason = "public field; a std map keeps the API"
113    )]
114    pub data: std::collections::HashMap<u64, Instance>,
115}
116
117/// Parse a Part 21 exchange file.
118///
119/// # Errors
120///
121/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) on malformed
122/// syntax, with the byte offset where reading stopped making sense, or on an
123/// instance number defined twice, naming it.
124pub fn parse(text: &str) -> OgeomResult<Exchange> {
125    let (header, instances) = parse_instances(text)?;
126    Ok(Exchange {
127        header,
128        data: instances.into_iter().collect(),
129    })
130}
131
132/// The data section by instance number, as the reader looks it up.
133///
134/// Instance numbers are nearly always dense, numbered from one in file
135/// order, so a slot per number answers a lookup with an index. A file whose
136/// numbers run far past its count (more than four per instance, plus a
137/// margin) gets a map instead.
138#[derive(Debug)]
139pub(crate) enum Instances {
140    /// A slot per instance number up to the largest.
141    Dense(Vec<Option<Instance>>),
142    /// The instances of a file whose numbers are sparse.
143    Sparse(ogeom_core::FastMap<u64, Instance>),
144}
145
146impl Instances {
147    /// The table for these instances, refusing a number defined twice:
148    /// Part 21 names each instance once within the exchange structure.
149    fn new(list: Vec<(u64, Instance)>) -> OgeomResult<Self> {
150        let top = list.iter().map(|(id, _)| *id).max().unwrap_or(0);
151        match usize::try_from(top) {
152            Ok(top) if top <= list.len().saturating_mul(4).saturating_add(1024) => {
153                let mut slots: Vec<Option<Instance>> = Vec::new();
154                slots.resize_with(top + 1, || None);
155                for (id, instance) in list {
156                    // In range: no number exceeds `top`.
157                    if let Some(slot) = usize::try_from(id).ok().and_then(|i| slots.get_mut(i)) {
158                        if slot.is_some() {
159                            defined_twice(id)?;
160                        }
161                        *slot = Some(instance);
162                    }
163                }
164                Ok(Self::Dense(slots))
165            }
166            _ => {
167                let mut map = ogeom_core::FastMap::default();
168                map.reserve(list.len());
169                for (id, instance) in list {
170                    if map.insert(id, instance).is_some() {
171                        defined_twice(id)?;
172                    }
173                }
174                Ok(Self::Sparse(map))
175            }
176        }
177    }
178
179    /// The instance numbered `id`.
180    pub(crate) fn get(&self, id: u64) -> Option<&Instance> {
181        match self {
182            Self::Dense(slots) => usize::try_from(id)
183                .ok()
184                .and_then(|i| slots.get(i))
185                .and_then(Option::as_ref),
186            Self::Sparse(map) => map.get(&id),
187        }
188    }
189
190    /// Whether an instance is numbered `id`.
191    pub(crate) fn contains(&self, id: u64) -> bool {
192        self.get(id).is_some()
193    }
194
195    /// Every instance with its number: in number order when dense.
196    pub(crate) fn iter(&self) -> impl Iterator<Item = (u64, &Instance)> {
197        let (dense, sparse) = match self {
198            Self::Dense(slots) => (Some(slots), None),
199            Self::Sparse(map) => (None, Some(map)),
200        };
201        let dense = dense.into_iter().flat_map(|slots| {
202            (0_u64..)
203                .zip(slots)
204                .filter_map(|(id, slot)| Some((id, slot.as_ref()?)))
205        });
206        let sparse = sparse
207            .into_iter()
208            .flat_map(|map| map.iter().map(|(id, instance)| (*id, instance)));
209        dense.chain(sparse)
210    }
211
212    /// Every instance.
213    pub(crate) fn values(&self) -> impl Iterator<Item = &Instance> {
214        self.iter().map(|(_, instance)| instance)
215    }
216
217    fn into_iter(self) -> Box<dyn Iterator<Item = (u64, Instance)>> {
218        match self {
219            Self::Dense(slots) => Box::new(
220                (0_u64..)
221                    .zip(slots)
222                    .filter_map(|(id, slot)| Some((id, slot?))),
223            ),
224            Self::Sparse(map) => Box::new(map.into_iter()),
225        }
226    }
227}
228
229fn defined_twice(id: u64) -> OgeomResult<()> {
230    ogeom_bail!(
231        Construction,
232        "the instance #{id} is defined twice in the exchange file; Part 21 names each \
233         instance once"
234    );
235}
236
237/// Header entries, each a keyword and its arguments, in order.
238pub(crate) type Header = Vec<(String, Vec<Arg>)>;
239
240/// The header entries and the data section of a Part 21 exchange file.
241///
242/// # Errors
243///
244/// As [`parse`].
245pub(crate) fn parse_instances(text: &str) -> OgeomResult<(Header, Instances)> {
246    let mut p = Parser {
247        bytes: text.as_bytes(),
248        at: 0,
249        depth: 0,
250    };
251    p.skip_noise();
252    p.expect_keyword("ISO-10303-21")?;
253    p.expect(b';')?;
254
255    p.expect_keyword("HEADER")?;
256    p.expect(b';')?;
257    let mut header = Vec::new();
258    loop {
259        p.skip_noise();
260        if p.peek_keyword("ENDSEC") {
261            p.expect_keyword("ENDSEC")?;
262            p.expect(b';')?;
263            break;
264        }
265        let keyword = p.keyword()?;
266        let args = p.arguments()?;
267        p.expect(b';')?;
268        header.push((keyword, args));
269    }
270
271    p.expect_keyword("DATA")?;
272    p.expect(b';')?;
273    // Sized up front: an instance averages well under a hundred bytes, so
274    // this over-reserves a little rather than growing a half-million-entry
275    // list several times on the way up.
276    let mut data = Vec::with_capacity(text.len() / 96);
277    loop {
278        p.skip_noise();
279        if p.peek_keyword("ENDSEC") {
280            p.expect_keyword("ENDSEC")?;
281            p.expect(b';')?;
282            break;
283        }
284        p.expect(b'#')?;
285        let id = p.id()?;
286        p.expect(b'=')?;
287        p.skip_noise();
288        let parts = if p.peek(b'(') {
289            // A complex instance: parenthesised sequence of parts.
290            p.expect(b'(')?;
291            let mut parts = Vec::new();
292            loop {
293                p.skip_noise();
294                if p.peek(b')') {
295                    p.expect(b')')?;
296                    break;
297                }
298                let keyword = p.keyword()?;
299                let args = p.arguments()?;
300                parts.push((keyword, args));
301            }
302            parts
303        } else {
304            let keyword = p.keyword()?;
305            let args = p.arguments()?;
306            vec![(keyword, args)]
307        };
308        p.expect(b';')?;
309        data.push((id, Instance { parts }));
310    }
311
312    p.expect_keyword("END-ISO-10303-21")?;
313    Ok((header, Instances::new(data)?))
314}
315
316struct Parser<'a> {
317    bytes: &'a [u8],
318    at: usize,
319    /// How many argument lists are open.
320    depth: u32,
321}
322
323/// How deep argument lists may nest. Real files nest a few levels (a list
324/// of lists of control points); past this the file is hostile, and the
325/// recursive descent would otherwise run out of stack.
326const MOST_NESTING: u32 = 256;
327
328impl Parser<'_> {
329    fn skip_noise(&mut self) {
330        loop {
331            while self.at < self.bytes.len() && self.bytes[self.at].is_ascii_whitespace() {
332                self.at += 1;
333            }
334            if self.at + 1 < self.bytes.len() && &self.bytes[self.at..self.at + 2] == b"/*" {
335                self.at += 2;
336                while self.at + 1 < self.bytes.len() && &self.bytes[self.at..self.at + 2] != b"*/" {
337                    self.at += 1;
338                }
339                self.at = (self.at + 2).min(self.bytes.len());
340                continue;
341            }
342            break;
343        }
344    }
345
346    fn peek(&mut self, byte: u8) -> bool {
347        self.skip_noise();
348        self.bytes.get(self.at) == Some(&byte)
349    }
350
351    fn expect(&mut self, byte: u8) -> OgeomResult<()> {
352        self.skip_noise();
353        if self.bytes.get(self.at) == Some(&byte) {
354            self.at += 1;
355            return Ok(());
356        }
357        ogeom_bail!(
358            Construction,
359            "expected '{}' at byte {} of the exchange file",
360            char::from(byte),
361            self.at
362        );
363    }
364
365    fn peek_keyword(&mut self, word: &str) -> bool {
366        self.skip_noise();
367        let end = self.at + word.len();
368        end <= self.bytes.len()
369            && &self.bytes[self.at..end] == word.as_bytes()
370            && self
371                .bytes
372                .get(end)
373                .is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_' && *b != b'-')
374    }
375
376    fn expect_keyword(&mut self, word: &str) -> OgeomResult<()> {
377        if self.peek_keyword(word) {
378            self.at += word.len();
379            return Ok(());
380        }
381        ogeom_bail!(
382            Construction,
383            "expected '{word}' at byte {} of the exchange file",
384            self.at
385        );
386    }
387
388    fn keyword(&mut self) -> OgeomResult<String> {
389        self.skip_noise();
390        let start = self.at;
391        while self
392            .bytes
393            .get(self.at)
394            .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_')
395        {
396            self.at += 1;
397        }
398        if self.at == start {
399            ogeom_bail!(
400                Construction,
401                "expected a keyword at byte {} of the exchange file",
402                start
403            );
404        }
405        Ok(String::from_utf8_lossy(&self.bytes[start..self.at]).into_owned())
406    }
407
408    fn integer(&mut self) -> OgeomResult<i64> {
409        self.skip_noise();
410        let start = self.at;
411        if self.bytes.get(self.at) == Some(&b'-') || self.bytes.get(self.at) == Some(&b'+') {
412            self.at += 1;
413        }
414        while self.bytes.get(self.at).is_some_and(u8::is_ascii_digit) {
415            self.at += 1;
416        }
417        let text = std::str::from_utf8(&self.bytes[start..self.at]).unwrap_or("");
418        text.parse().map_err(|_| {
419            ogeom_core::ogeom_err!(
420                Construction,
421                "expected an integer at byte {start} of the exchange file"
422            )
423        })
424    }
425
426    /// An instance id: a non-negative integer.
427    fn id(&mut self) -> OgeomResult<u64> {
428        let at = self.at;
429        let id = self.integer()?;
430        u64::try_from(id).map_err(|_| {
431            ogeom_core::ogeom_err!(
432                Construction,
433                "a negative instance id at byte {at} of the exchange file"
434            )
435        })
436    }
437
438    fn arguments(&mut self) -> OgeomResult<Vec<Arg>> {
439        if self.depth >= MOST_NESTING {
440            ogeom_bail!(
441                Construction,
442                "argument lists nest more than {MOST_NESTING} deep at byte {} of the \
443                 exchange file",
444                self.at
445            );
446        }
447        self.depth += 1;
448        let listed = self.argument_list();
449        self.depth -= 1;
450        listed
451    }
452
453    /// `( [ parameter { , parameter } ] )`: empty, or parameters each
454    /// followed by a comma or the closing parenthesis.
455    fn argument_list(&mut self) -> OgeomResult<Vec<Arg>> {
456        self.expect(b'(')?;
457        let mut out = Vec::with_capacity(4);
458        if self.peek(b')') {
459            self.at += 1;
460            return Ok(out);
461        }
462        loop {
463            self.skip_noise();
464            if matches!(self.bytes.get(self.at), Some(b',' | b')')) {
465                ogeom_bail!(
466                    Construction,
467                    "a list leaves a parameter empty at byte {} of the exchange file",
468                    self.at
469                );
470            }
471            out.push(self.argument()?);
472            self.skip_noise();
473            match self.bytes.get(self.at) {
474                Some(b',') => self.at += 1,
475                Some(b')') => {
476                    self.at += 1;
477                    break;
478                }
479                _ => ogeom_bail!(
480                    Construction,
481                    "expected ',' or ')' after a parameter at byte {} of the exchange file",
482                    self.at
483                ),
484            }
485        }
486        Ok(out)
487    }
488
489    fn argument(&mut self) -> OgeomResult<Arg> {
490        self.skip_noise();
491        let Some(&byte) = self.bytes.get(self.at) else {
492            ogeom_bail!(Construction, "the exchange file ends inside an argument");
493        };
494        match byte {
495            b'$' => {
496                self.at += 1;
497                Ok(Arg::Null)
498            }
499            b'*' => {
500                self.at += 1;
501                Ok(Arg::Derived)
502            }
503            b'#' => {
504                self.at += 1;
505                Ok(Arg::Ref(self.id()?))
506            }
507            b'(' => Ok(Arg::List(self.arguments()?)),
508            b'\'' => self.string(),
509            b'.' => {
510                self.at += 1;
511                let word = self.keyword()?;
512                self.expect(b'.')?;
513                Ok(Arg::Enum(word))
514            }
515            b'-' | b'+' | b'0'..=b'9' => self.number(),
516            _ if byte.is_ascii_alphabetic() || byte == b'_' => {
517                let keyword = self.keyword()?;
518                let args = self.arguments()?;
519                Ok(Arg::Typed(Box::new((keyword, args))))
520            }
521            _ => ogeom_bail!(
522                Construction,
523                "unexpected '{}' at byte {} of the exchange file",
524                char::from(byte),
525                self.at
526            ),
527        }
528    }
529
530    fn string(&mut self) -> OgeomResult<Arg> {
531        self.expect(b'\'')?;
532        let mut raw: Vec<u8> = Vec::new();
533        loop {
534            // The common run, everything up to the next quote, in one
535            // extend rather than a byte at a time.
536            let start = self.at;
537            while self.bytes.get(self.at).is_some_and(|b| *b != b'\'') {
538                self.at += 1;
539            }
540            raw.extend_from_slice(&self.bytes[start..self.at]);
541            match self.bytes.get(self.at) {
542                None => ogeom_bail!(Construction, "the exchange file ends inside a string"),
543                Some(_) => {
544                    if self.bytes.get(self.at + 1) == Some(&b'\'') {
545                        raw.push(b'\'');
546                        self.at += 2;
547                    } else {
548                        self.at += 1;
549                        break;
550                    }
551                }
552            }
553        }
554        // Bytes past ASCII are not the standard's, but exporters write
555        // them: UTF-8 where they are valid UTF-8, Latin-1 otherwise.
556        let text = match std::str::from_utf8(&raw) {
557            Ok(text) => text.to_owned(),
558            Err(_) => raw.iter().map(|b| char::from(*b)).collect(),
559        };
560        Ok(Arg::Str(decode_escapes(&text)))
561    }
562
563    fn number(&mut self) -> OgeomResult<Arg> {
564        let start = self.at;
565        if matches!(self.bytes.get(self.at), Some(b'-' | b'+')) {
566            self.at += 1;
567        }
568        let mut real = false;
569        while let Some(&b) = self.bytes.get(self.at) {
570            match b {
571                b'0'..=b'9' => self.at += 1,
572                b'.' => {
573                    // A dot starts a real, unless it starts an enumeration
574                    // hard against the number, which no real file does.
575                    real = true;
576                    self.at += 1;
577                }
578                b'E' | b'e' => {
579                    real = true;
580                    self.at += 1;
581                    if matches!(self.bytes.get(self.at), Some(b'-' | b'+')) {
582                        self.at += 1;
583                    }
584                }
585                _ => break,
586            }
587        }
588        let text = std::str::from_utf8(&self.bytes[start..self.at]).unwrap_or("");
589        if real {
590            text.parse().map(Arg::Real).map_err(|_| {
591                ogeom_core::ogeom_err!(
592                    Construction,
593                    "unreadable real at byte {start} of the exchange file"
594                )
595            })
596        } else {
597            text.parse().map(Arg::Int).map_err(|_| {
598                ogeom_core::ogeom_err!(
599                    Construction,
600                    "unreadable integer at byte {start} of the exchange file"
601                )
602            })
603        }
604    }
605}
606
607/// A STEP string's escapes read: `\\` a backslash, `\X\hh` the Latin-1
608/// character `hh`, `\X2\...\X0\` UTF-16 and `\X4\...\X0\` UTF-32 code
609/// units in hex, `\S\c` the character `c` in the upper half of the code
610/// page. A code-page directive (`\P?\`) is dropped. Anything that does not
611/// parse as an escape is kept as written.
612pub(crate) fn decode_escapes(text: &str) -> String {
613    if !text.contains('\\') {
614        return text.to_owned();
615    }
616    let chars: Vec<char> = text.chars().collect();
617    let mut out = String::with_capacity(text.len());
618    let mut i = 0;
619    let hex = |slice: &[char]| -> Option<u32> {
620        let s: String = slice.iter().collect();
621        u32::from_str_radix(&s, 16).ok()
622    };
623    while i < chars.len() {
624        let rest = &chars[i..];
625        let starts = |p: &str| {
626            let p: Vec<char> = p.chars().collect();
627            rest.len() >= p.len() && rest[..p.len()] == p[..]
628        };
629        if starts("\\\\") {
630            out.push('\\');
631            i += 2;
632        } else if starts("\\X2\\") || starts("\\X4\\") {
633            let width = if starts("\\X2\\") { 4 } else { 8 };
634            let body_start = i + 4;
635            let Some(end) = (body_start..chars.len().saturating_sub(3))
636                .find(|&k| chars[k..k + 4] == ['\\', 'X', '0', '\\'])
637            else {
638                out.push(chars[i]);
639                i += 1;
640                continue;
641            };
642            let body = &chars[body_start..end];
643            let units: Option<Vec<u32>> = body.chunks(width).map(hex).collect();
644            let decoded = units.and_then(|units| {
645                if width == 4 {
646                    let units: Vec<u16> = units
647                        .iter()
648                        .filter_map(|u| u16::try_from(*u).ok())
649                        .collect();
650                    String::from_utf16(&units).ok()
651                } else {
652                    units.iter().map(|u| char::from_u32(*u)).collect()
653                }
654            });
655            match decoded {
656                Some(decoded) if body.len().is_multiple_of(width) => {
657                    out.push_str(&decoded);
658                    i = end + 4;
659                }
660                _ => {
661                    out.push(chars[i]);
662                    i += 1;
663                }
664            }
665        } else if starts("\\X\\") && rest.len() >= 5 {
666            match hex(&rest[3..5]).and_then(char::from_u32) {
667                Some(c) => {
668                    out.push(c);
669                    i += 5;
670                }
671                None => {
672                    out.push(chars[i]);
673                    i += 1;
674                }
675            }
676        } else if starts("\\S\\") && rest.len() >= 4 {
677            match char::from_u32(u32::from(rest[3]) + 0x80) {
678                Some(c) if rest[3].is_ascii() => {
679                    out.push(c);
680                    i += 4;
681                }
682                _ => {
683                    out.push(chars[i]);
684                    i += 1;
685                }
686            }
687        } else if starts("\\P") && rest.len() >= 4 && rest[3] == '\\' {
688            i += 4;
689        } else {
690            out.push(chars[i]);
691            i += 1;
692        }
693    }
694    out
695}
696
697#[cfg(test)]
698#[allow(clippy::unwrap_used)]
699mod tests {
700
701    /// An argument costs a pointer's worth of payload and a tag: a file
702    /// holds millions of them.
703    #[test]
704    fn an_argument_is_small() {
705        assert!(
706            core::mem::size_of::<Arg>() <= 32,
707            "{}",
708            core::mem::size_of::<Arg>()
709        );
710    }
711
712    use super::*;
713
714    const SMALL: &str = "ISO-10303-21;
715HEADER;
716FILE_DESCRIPTION(('a part'),'2;1');
717FILE_NAME('p.stp','2020-01-01',(''),(''),'','','');
718FILE_SCHEMA(('AP203'));
719ENDSEC;
720DATA;
721#1=CARTESIAN_POINT('',(0.,1.5,-2.E-3));
722#2=DIRECTION('',(0.,0.,1.));
723#3=AXIS2_PLACEMENT_3D('',#1,#2,$);
724#4=(GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNIT_ASSIGNED_CONTEXT((#5)));
725#5=SI_UNIT(.MILLI.,.METRE.);
726ENDSEC;
727END-ISO-10303-21;
728";
729
730    #[test]
731    fn a_small_file_parses_into_its_instances() {
732        let file = parse(SMALL).unwrap();
733        assert_eq!(file.header.len(), 3);
734        assert_eq!(file.data.len(), 5);
735
736        let point = &file.data[&1];
737        assert_eq!(point.keyword(), "CARTESIAN_POINT");
738        let coords = point.parts[0].1[1].list().unwrap();
739        assert_eq!(coords[0].number(), Some(0.0));
740        assert_eq!(coords[1].number(), Some(1.5));
741        assert_eq!(coords[2].number(), Some(-2e-3));
742
743        let placement = &file.data[&3];
744        assert_eq!(placement.parts[0].1[1].reference(), Some(1));
745        assert_eq!(placement.parts[0].1[3], Arg::Null);
746
747        // The complex instance keeps both parts, each with its own arguments.
748        let context = &file.data[&4];
749        assert_eq!(context.parts.len(), 2);
750        assert!(context.part("GLOBAL_UNIT_ASSIGNED_CONTEXT").is_some());
751
752        let unit = &file.data[&5];
753        assert!(unit.parts[0].1[0].is_enum("MILLI"));
754    }
755
756    #[test]
757    fn strings_undouble_their_quotes() {
758        let file = parse("ISO-10303-21;HEADER;ENDSEC;DATA;#1=X('it''s');ENDSEC;END-ISO-10303-21;")
759            .unwrap();
760        assert_eq!(file.data[&1].parts[0].1[0], Arg::Str("it's".into()));
761    }
762
763    fn one_instance(instance: &str) -> String {
764        format!("ISO-10303-21;HEADER;ENDSEC;DATA;{instance}ENDSEC;END-ISO-10303-21;")
765    }
766
767    /// Part 21 separates a list's parameters by commas: `(a b)` drops one
768    /// and `(a,)` or `(,a)` leaves one empty. Each is refused at the place
769    /// it breaks, in argument lists, nested lists and typed values alike.
770    #[test]
771    fn a_list_needs_exactly_one_comma_between_its_parameters() {
772        for (instance, at) in [
773            ("#1=X(1 2);", "#1=X(1 "),
774            ("#1=X(1,);", "#1=X(1,"),
775            ("#1=X(,1);", "#1=X("),
776            ("#1=X(1,,2);", "#1=X(1,"),
777            ("#1=X((1.,2.)(3.,4.));", "#1=X((1.,2.)"),
778            ("#1=X('',(1.,2.,));", "#1=X('',(1.,2.,"),
779            ("#1=X(Y(1,));", "#1=X(Y(1,"),
780            ("#1=X(,);", "#1=X("),
781        ] {
782            let text = one_instance(instance);
783            let byte = text.find(instance).unwrap() + at.len();
784            let message = parse(&text).unwrap_err().to_string();
785            assert!(
786                message.contains(&format!("byte {byte} ")),
787                "{instance}: {message}"
788            );
789        }
790        // An empty list and a single parameter are both well formed.
791        for instance in ["#1=X();", "#1=X(());", "#1=X(1);", "#1=X( 1 , ( ) , $ );"] {
792            assert!(parse(&one_instance(instance)).is_ok(), "{instance}");
793        }
794    }
795
796    /// Part 21 gives each instance a name unique within the exchange
797    /// structure. A second `#2` is refused by name, whichever table the
798    /// numbers would fill, rather than overwriting the first.
799    #[test]
800    fn an_instance_named_twice_is_refused() {
801        for (first, second) in [(1, 2), (1, 1_000_000)] {
802            let text = format!(
803                "ISO-10303-21;HEADER;ENDSEC;DATA;#{first}=X(1);#{second}=Y(2);\
804                 #{second}=Z(3);ENDSEC;END-ISO-10303-21;"
805            );
806            let message = parse(&text).unwrap_err().to_string();
807            assert!(message.contains(&format!("#{second} ")), "{message}");
808            assert!(super::parse_instances(&text).is_err());
809        }
810    }
811
812    #[test]
813    fn malformed_files_are_refused_with_a_place() {
814        assert!(parse("ISO-10303-21;HEADER;DATA;").is_err());
815        assert!(parse("not a step file").is_err());
816    }
817}