Skip to main content

geop_ops_step/
part21.rs

1//! ISO 10303-21 ("Part 21"), the clear-text encoding every STEP file is
2//! written in: a header, then a data section of entity instances, each
3//! `#id = NAME(parameters);` — or, for an instance of several entity types
4//! at once, a *complex* instance `#id = (NAME1(...) NAME2(...));`.
5//!
6//! This module only knows the encoding, not what any entity means: it reads
7//! a file into an [`Exchange`] and writes one back. What the entities say
8//! about geometry is [`crate::import`]'s and [`crate::export`]'s business.
9
10use std::collections::BTreeMap;
11use std::fmt::Write;
12
13use geop_core_math::geop_error::{GeopError, GeopResult};
14
15/// One parameter of an entity instance.
16#[derive(Clone, Debug, PartialEq)]
17pub enum Value {
18    /// `$`: no value.
19    Null,
20    /// `*`: a value derived from others, not written.
21    Derived,
22    Integer(i64),
23    Real(f64),
24    String(String),
25    /// `.NAME.`, held without the dots: `.T.` is `Enum("T")`.
26    Enum(String),
27    /// `#id`: another instance.
28    Ref(u64),
29    /// `"..."`: a bit string, held as written.
30    Binary(String),
31    List(Vec<Value>),
32    /// `NAME(value)`: a value of a defined type, as `LENGTH_MEASURE(1.5)`.
33    Typed(String, Box<Value>),
34}
35
36/// One entity type's part of an instance: its name and parameters.
37#[derive(Clone, Debug, PartialEq)]
38pub struct Record {
39    pub name: String,
40    pub args: Vec<Value>,
41}
42
43/// An entity instance: of one type, or complex — of several at once, each
44/// with its own parameters, as `(B_SPLINE_CURVE(...) RATIONAL_B_SPLINE_CURVE(...))`.
45#[derive(Clone, Debug, PartialEq)]
46pub enum Instance {
47    Simple(Record),
48    Complex(Vec<Record>),
49}
50
51impl Instance {
52    /// The record of the type `name` this instance is of — itself, if
53    /// simple and of that type.
54    pub fn record(&self, name: &str) -> Option<&Record> {
55        match self {
56            Instance::Simple(r) => (r.name == name).then_some(r),
57            Instance::Complex(rs) => rs.iter().find(|r| r.name == name),
58        }
59    }
60
61    /// Whether this instance is of the type `name`.
62    pub fn is(&self, name: &str) -> bool {
63        self.record(name).is_some()
64    }
65
66    /// What the instance is, for messages: its type, or its types joined
67    /// by `+` for a complex one.
68    pub fn type_name(&self) -> String {
69        match self {
70            Instance::Simple(r) => r.name.clone(),
71            Instance::Complex(rs) => rs
72                .iter()
73                .map(|r| r.name.as_str())
74                .collect::<Vec<_>>()
75                .join("+"),
76        }
77    }
78}
79
80/// A whole exchange file: its header records and its instances by id.
81#[derive(Clone, Debug, Default, PartialEq)]
82pub struct Exchange {
83    pub header: Vec<Record>,
84    pub instances: BTreeMap<u64, Instance>,
85}
86
87impl Exchange {
88    /// The instance `#id`.
89    pub fn get(&self, id: u64) -> GeopResult<&Instance> {
90        self.instances.get(&id).ok_or_else(|| {
91            GeopError::new(format!("the file refers to #{id}, which it does not have"))
92        })
93    }
94
95    /// The ids of every instance of the type `name`, in id order.
96    pub fn all_of(&self, name: &str) -> Vec<u64> {
97        self.instances
98            .iter()
99            .filter(|(_, instance)| instance.is(name))
100            .map(|(&id, _)| id)
101            .collect()
102    }
103
104    /// Reads the text of an exchange file.
105    pub fn parse(text: &str) -> GeopResult<Self> {
106        Parser {
107            lexer: Lexer {
108                bytes: text.as_bytes(),
109                pos: 0,
110            },
111        }
112        .exchange()
113    }
114
115    /// The text of the exchange file, one instance per line.
116    pub fn write(&self) -> String {
117        let mut out = String::from("ISO-10303-21;\nHEADER;\n");
118        for record in &self.header {
119            write_record(&mut out, record);
120            out.push_str(";\n");
121        }
122        out.push_str("ENDSEC;\nDATA;\n");
123        for (id, instance) in &self.instances {
124            let _ = write!(out, "#{id}=");
125            match instance {
126                Instance::Simple(record) => write_record(&mut out, record),
127                Instance::Complex(records) => {
128                    out.push('(');
129                    for (k, record) in records.iter().enumerate() {
130                        if k > 0 {
131                            out.push(' ');
132                        }
133                        write_record(&mut out, record);
134                    }
135                    out.push(')');
136                }
137            }
138            out.push_str(";\n");
139        }
140        out.push_str("ENDSEC;\nEND-ISO-10303-21;\n");
141        out
142    }
143}
144
145fn write_record(out: &mut String, record: &Record) {
146    out.push_str(&record.name);
147    write_list(out, &record.args);
148}
149
150fn write_list(out: &mut String, values: &[Value]) {
151    out.push('(');
152    for (k, value) in values.iter().enumerate() {
153        if k > 0 {
154            out.push(',');
155        }
156        write_value(out, value);
157    }
158    out.push(')');
159}
160
161fn write_value(out: &mut String, value: &Value) {
162    match value {
163        Value::Null => out.push('$'),
164        Value::Derived => out.push('*'),
165        Value::Integer(i) => {
166            let _ = write!(out, "{i}");
167        }
168        Value::Real(r) => out.push_str(&real(*r)),
169        Value::String(s) => {
170            out.push('\'');
171            for c in s.chars() {
172                match c {
173                    '\'' => out.push_str("''"),
174                    '\\' => out.push_str("\\\\"),
175                    ' '..='~' => out.push(c),
176                    c => {
177                        let mut units = [0u16; 2];
178                        for unit in c.encode_utf16(&mut units) {
179                            let _ = write!(out, "\\X2\\{unit:04X}\\X0\\");
180                        }
181                    }
182                }
183            }
184            out.push('\'');
185        }
186        Value::Enum(e) => {
187            let _ = write!(out, ".{e}.");
188        }
189        Value::Ref(id) => {
190            let _ = write!(out, "#{id}");
191        }
192        Value::Binary(b) => {
193            let _ = write!(out, "\"{b}\"");
194        }
195        Value::List(values) => write_list(out, values),
196        Value::Typed(name, value) => {
197            out.push_str(name);
198            out.push('(');
199            write_value(out, value);
200            out.push(')');
201        }
202    }
203}
204
205/// `r` as a Part 21 real, which needs a decimal point and an upper-case
206/// exponent: `1.0`, `-0.25`, `1.5E-7`. Written as the shortest text that
207/// reads back to exactly `r`.
208pub fn real(r: f64) -> String {
209    let text = format!("{r:?}");
210    let (mantissa, exponent) = match text.split_once('e') {
211        Some((m, e)) => (m.to_string(), Some(e.to_string())),
212        None => (text, None),
213    };
214    let mantissa = if mantissa.contains('.') {
215        mantissa
216    } else {
217        format!("{mantissa}.")
218    };
219    match exponent {
220        Some(e) => format!("{mantissa}E{e}"),
221        None => mantissa,
222    }
223}
224
225#[derive(Clone, Debug, PartialEq)]
226enum Token {
227    /// A keyword: an entity or type name, or a section name like `DATA`
228    /// (which may contain `-`, as `END-ISO-10303-21`).
229    Keyword(String),
230    Ref(u64),
231    Integer(i64),
232    Real(f64),
233    String(String),
234    Enum(String),
235    Binary(String),
236    Null,
237    Derived,
238    Open,
239    Close,
240    Comma,
241    Equals,
242    Semicolon,
243    End,
244}
245
246struct Lexer<'a> {
247    bytes: &'a [u8],
248    pos: usize,
249}
250
251impl Lexer<'_> {
252    fn error(&self, what: impl std::fmt::Display) -> GeopError {
253        let line = 1 + self.bytes[..self.pos.min(self.bytes.len())]
254            .iter()
255            .filter(|&&b| b == b'\n')
256            .count();
257        GeopError::new(format!("reading the STEP file, line {line}: {what}"))
258    }
259
260    fn skip_space(&mut self) -> GeopResult<()> {
261        loop {
262            match self.bytes.get(self.pos) {
263                Some(b) if b.is_ascii_whitespace() => self.pos += 1,
264                Some(b'/') if self.bytes.get(self.pos + 1) == Some(&b'*') => {
265                    let start = self.pos;
266                    self.pos += 2;
267                    loop {
268                        match self.bytes.get(self.pos) {
269                            None => {
270                                self.pos = start;
271                                return Err(self.error("a comment that never ends"));
272                            }
273                            Some(b'*') if self.bytes.get(self.pos + 1) == Some(&b'/') => {
274                                self.pos += 2;
275                                break;
276                            }
277                            Some(_) => self.pos += 1,
278                        }
279                    }
280                }
281                _ => return Ok(()),
282            }
283        }
284    }
285
286    fn next(&mut self) -> GeopResult<Token> {
287        self.skip_space()?;
288        let Some(&b) = self.bytes.get(self.pos) else {
289            return Ok(Token::End);
290        };
291        let single = |lexer: &mut Self, token| {
292            lexer.pos += 1;
293            Ok(token)
294        };
295        match b {
296            b'(' => single(self, Token::Open),
297            b')' => single(self, Token::Close),
298            b',' => single(self, Token::Comma),
299            b'=' => single(self, Token::Equals),
300            b';' => single(self, Token::Semicolon),
301            b'$' => single(self, Token::Null),
302            b'*' => single(self, Token::Derived),
303            b'#' => {
304                self.pos += 1;
305                let start = self.pos;
306                while self.bytes.get(self.pos).is_some_and(u8::is_ascii_digit) {
307                    self.pos += 1;
308                }
309                let digits = std::str::from_utf8(&self.bytes[start..self.pos]).unwrap_or("");
310                digits
311                    .parse()
312                    .map(Token::Ref)
313                    .map_err(|_| self.error("a `#` that is not followed by an instance number"))
314            }
315            b'\'' => self.string(),
316            b'"' => {
317                self.pos += 1;
318                let start = self.pos;
319                while self.bytes.get(self.pos).is_some_and(|&c| c != b'"') {
320                    self.pos += 1;
321                }
322                let text = String::from_utf8_lossy(&self.bytes[start..self.pos]).into_owned();
323                self.pos += 1;
324                Ok(Token::Binary(text))
325            }
326            b'.' if self
327                .bytes
328                .get(self.pos + 1)
329                .is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_') =>
330            {
331                self.pos += 1;
332                let start = self.pos;
333                while self
334                    .bytes
335                    .get(self.pos)
336                    .is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_')
337                {
338                    self.pos += 1;
339                }
340                let name = String::from_utf8_lossy(&self.bytes[start..self.pos]).to_uppercase();
341                if self.bytes.get(self.pos) != Some(&b'.') {
342                    return Err(
343                        self.error(format!("the enumeration value .{name} has no closing `.`"))
344                    );
345                }
346                self.pos += 1;
347                Ok(Token::Enum(name))
348            }
349            b'+' | b'-' | b'.' | b'0'..=b'9' => self.number(),
350            b'!' | b'A'..=b'Z' | b'a'..=b'z' | b'_' => {
351                let start = self.pos;
352                self.pos += 1;
353                while self
354                    .bytes
355                    .get(self.pos)
356                    .is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'-')
357                {
358                    self.pos += 1;
359                }
360                Ok(Token::Keyword(
361                    String::from_utf8_lossy(&self.bytes[start..self.pos]).to_uppercase(),
362                ))
363            }
364            other => Err(self.error(format!("an unexpected character {:?}", other as char))),
365        }
366    }
367
368    fn number(&mut self) -> GeopResult<Token> {
369        let start = self.pos;
370        if matches!(self.bytes.get(self.pos), Some(b'+' | b'-')) {
371            self.pos += 1;
372        }
373        let mut real = false;
374        while let Some(&c) = self.bytes.get(self.pos) {
375            match c {
376                b'0'..=b'9' => self.pos += 1,
377                b'.' => {
378                    real = true;
379                    self.pos += 1;
380                }
381                b'E' | b'e' => {
382                    real = true;
383                    self.pos += 1;
384                    if matches!(self.bytes.get(self.pos), Some(b'+' | b'-')) {
385                        self.pos += 1;
386                    }
387                }
388                _ => break,
389            }
390        }
391        let text = std::str::from_utf8(&self.bytes[start..self.pos]).unwrap_or("");
392        if real {
393            // Rust does not read `1.E5` or `1.`: put a zero after a bare point.
394            let text = text.replace(".E", ".0E").replace(".e", ".0e");
395            let text = if text.ends_with('.') {
396                format!("{text}0")
397            } else {
398                text
399            };
400            text.parse()
401                .map(Token::Real)
402                .map_err(|_| self.error(format!("{text:?} is not a number")))
403        } else {
404            text.parse()
405                .map(Token::Integer)
406                .map_err(|_| self.error(format!("{text:?} is not a number")))
407        }
408    }
409
410    /// A string: `'...'`, a quote written twice, with the `\X2\...\X0\`
411    /// (UTF-16), `\X\hh` (Latin-1) and `\\` escapes decoded and any other
412    /// escape kept as written.
413    fn string(&mut self) -> GeopResult<Token> {
414        self.pos += 1;
415        let mut raw = Vec::new();
416        loop {
417            match self.bytes.get(self.pos) {
418                None => return Err(self.error("a string that never ends")),
419                Some(b'\'') if self.bytes.get(self.pos + 1) == Some(&b'\'') => {
420                    raw.push(b'\'');
421                    self.pos += 2;
422                }
423                Some(b'\'') => {
424                    self.pos += 1;
425                    break;
426                }
427                Some(&c) => {
428                    raw.push(c);
429                    self.pos += 1;
430                }
431            }
432        }
433        let raw = String::from_utf8_lossy(&raw).into_owned();
434        Ok(Token::String(decode(&raw)))
435    }
436}
437
438fn decode(raw: &str) -> String {
439    let mut out = String::new();
440    let mut rest = raw;
441    while let Some(k) = rest.find('\\') {
442        out.push_str(&rest[..k]);
443        rest = &rest[k..];
444        if let Some(after) = rest.strip_prefix("\\\\") {
445            out.push('\\');
446            rest = after;
447        } else if let Some(after) = rest.strip_prefix("\\X2\\") {
448            let end = after.find("\\X0\\").unwrap_or(after.len());
449            let hex = &after[..end];
450            let units: Vec<u16> = (0..hex.len() / 4)
451                .filter_map(|i| u16::from_str_radix(&hex[4 * i..4 * i + 4], 16).ok())
452                .collect();
453            out.push_str(&String::from_utf16_lossy(&units));
454            rest = after.get(end + 4..).unwrap_or("");
455        } else if let Some(after) = rest.strip_prefix("\\X\\") {
456            match after.get(..2).and_then(|h| u8::from_str_radix(h, 16).ok()) {
457                Some(byte) => {
458                    out.push(byte as char);
459                    rest = &after[2..];
460                }
461                None => {
462                    out.push_str("\\X\\");
463                    rest = after;
464                }
465            }
466        } else {
467            out.push('\\');
468            rest = &rest[1..];
469        }
470    }
471    out.push_str(rest);
472    out
473}
474
475struct Parser<'a> {
476    lexer: Lexer<'a>,
477}
478
479impl Parser<'_> {
480    fn expect(&mut self, expected: Token) -> GeopResult<()> {
481        let token = self.lexer.next()?;
482        if token == expected {
483            Ok(())
484        } else {
485            Err(self
486                .lexer
487                .error(format!("expected {expected:?}, found {token:?}")))
488        }
489    }
490
491    fn exchange(mut self) -> GeopResult<Exchange> {
492        let mut exchange = Exchange::default();
493        loop {
494            match self.lexer.next()? {
495                Token::End => break,
496                Token::Keyword(k) if k == "ISO-10303-21" => self.expect(Token::Semicolon)?,
497                Token::Keyword(k) if k == "END-ISO-10303-21" => {
498                    self.expect(Token::Semicolon)?;
499                    break;
500                }
501                Token::Keyword(k) if k == "HEADER" => {
502                    self.expect(Token::Semicolon)?;
503                    loop {
504                        match self.lexer.next()? {
505                            Token::Keyword(k) if k == "ENDSEC" => {
506                                self.expect(Token::Semicolon)?;
507                                break;
508                            }
509                            Token::Keyword(name) => {
510                                let record = self.record_after(name)?;
511                                exchange.header.push(record);
512                                self.expect(Token::Semicolon)?;
513                            }
514                            other => {
515                                return Err(self
516                                    .lexer
517                                    .error(format!("expected a header entry, found {other:?}")));
518                            }
519                        }
520                    }
521                }
522                Token::Keyword(k) if k == "DATA" => {
523                    // `DATA('name', ('schema'));` names a section in files of
524                    // several; there is no need to tell them apart here.
525                    match self.lexer.next()? {
526                        Token::Semicolon => {}
527                        Token::Open => {
528                            self.list_after_open()?;
529                            self.expect(Token::Semicolon)?;
530                        }
531                        other => {
532                            return Err(self
533                                .lexer
534                                .error(format!("expected `;` after DATA, found {other:?}")));
535                        }
536                    }
537                    self.data(&mut exchange)?;
538                }
539                other => {
540                    return Err(self
541                        .lexer
542                        .error(format!("expected a section, found {other:?}")));
543                }
544            }
545        }
546        Ok(exchange)
547    }
548
549    fn data(&mut self, exchange: &mut Exchange) -> GeopResult<()> {
550        loop {
551            match self.lexer.next()? {
552                Token::Keyword(k) if k == "ENDSEC" => {
553                    self.expect(Token::Semicolon)?;
554                    return Ok(());
555                }
556                Token::Ref(id) => {
557                    self.expect(Token::Equals)?;
558                    let instance = match self.lexer.next()? {
559                        Token::Keyword(name) => Instance::Simple(self.record_after(name)?),
560                        Token::Open => {
561                            let mut records = Vec::new();
562                            loop {
563                                match self.lexer.next()? {
564                                    Token::Close => break,
565                                    Token::Keyword(name) => records.push(self.record_after(name)?),
566                                    other => {
567                                        return Err(self.lexer.error(format!(
568                                            "#{id}: expected an entity of a complex instance, found {other:?}"
569                                        )));
570                                    }
571                                }
572                            }
573                            Instance::Complex(records)
574                        }
575                        other => {
576                            return Err(self
577                                .lexer
578                                .error(format!("#{id}: expected an entity, found {other:?}")));
579                        }
580                    };
581                    self.expect(Token::Semicolon)?;
582                    if exchange.instances.insert(id, instance).is_some() {
583                        return Err(self.lexer.error(format!("#{id} is defined twice")));
584                    }
585                }
586                other => {
587                    return Err(self
588                        .lexer
589                        .error(format!("expected an instance `#id = ...`, found {other:?}")));
590                }
591            }
592        }
593    }
594
595    /// The parameters of the entity `name`, whose name was just read.
596    fn record_after(&mut self, name: String) -> GeopResult<Record> {
597        self.expect(Token::Open)?;
598        Ok(Record {
599            name,
600            args: self.list_after_open()?,
601        })
602    }
603
604    /// The values of a list whose `(` was just read, up to its `)`.
605    fn list_after_open(&mut self) -> GeopResult<Vec<Value>> {
606        let mut values = Vec::new();
607        let mut token = self.lexer.next()?;
608        if token == Token::Close {
609            return Ok(values);
610        }
611        loop {
612            values.push(self.value(token)?);
613            match self.lexer.next()? {
614                Token::Comma => token = self.lexer.next()?,
615                Token::Close => return Ok(values),
616                other => {
617                    return Err(self
618                        .lexer
619                        .error(format!("expected `,` or `)` in a list, found {other:?}")));
620                }
621            }
622        }
623    }
624
625    fn value(&mut self, token: Token) -> GeopResult<Value> {
626        Ok(match token {
627            Token::Null => Value::Null,
628            Token::Derived => Value::Derived,
629            Token::Integer(i) => Value::Integer(i),
630            Token::Real(r) => Value::Real(r),
631            Token::String(s) => Value::String(s),
632            Token::Enum(e) => Value::Enum(e),
633            Token::Ref(id) => Value::Ref(id),
634            Token::Binary(b) => Value::Binary(b),
635            Token::Open => Value::List(self.list_after_open()?),
636            Token::Keyword(name) => {
637                self.expect(Token::Open)?;
638                let mut inner = self.list_after_open()?;
639                let value = if inner.len() == 1 {
640                    inner.pop().expect("one value")
641                } else {
642                    Value::List(inner)
643                };
644                Value::Typed(name, Box::new(value))
645            }
646            other => {
647                return Err(self
648                    .lexer
649                    .error(format!("expected a value, found {other:?}")));
650            }
651        })
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    const SAMPLE: &str = "ISO-10303-21;
660HEADER;
661/* a comment */
662FILE_DESCRIPTION(('a test'),'2;1');
663FILE_NAME('it''s.stp','2026-10-04T00:00:00',('me'),(''),'','','');
664FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));
665ENDSEC;
666DATA;
667#1=CARTESIAN_POINT('',(0.,1.5,-2.E-3));
668#2 = DIRECTION ( 'd' , ( 0.0 , 0.0 , 1.0 ) ) ;
669#3=(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.));
670#4=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-05),#3,'distance_accuracy_value','');
671#5=B_SPLINE_CURVE_WITH_KNOTS('',2,(#1,#1,#1),.UNSPECIFIED.,.F.,.F.,(3,3),(0.,1.),.UNSPECIFIED.);
672#6=PRODUCT('caf\\X2\\00E9\\X0\\','',$,(#7));
673ENDSEC;
674END-ISO-10303-21;
675";
676
677    #[test]
678    fn a_file_reads_with_complex_instances_and_typed_values() {
679        let exchange = Exchange::parse(SAMPLE).unwrap();
680        assert_eq!(exchange.header.len(), 3);
681        assert_eq!(exchange.header[1].args[0], Value::String("it's.stp".into()));
682        let point = exchange.get(1).unwrap().record("CARTESIAN_POINT").unwrap();
683        assert_eq!(
684            point.args[1],
685            Value::List(vec![Value::Real(0.0), Value::Real(1.5), Value::Real(-2e-3)])
686        );
687        let unit = exchange.get(3).unwrap();
688        assert!(unit.is("SI_UNIT") && unit.is("LENGTH_UNIT"));
689        assert_eq!(
690            unit.record("SI_UNIT").unwrap().args,
691            vec![Value::Enum("MILLI".into()), Value::Enum("METRE".into())]
692        );
693        assert_eq!(
694            exchange
695                .get(4)
696                .unwrap()
697                .record("UNCERTAINTY_MEASURE_WITH_UNIT")
698                .unwrap()
699                .args[0],
700            Value::Typed("LENGTH_MEASURE".into(), Box::new(Value::Real(1e-5)))
701        );
702        assert_eq!(
703            exchange.get(6).unwrap().record("PRODUCT").unwrap().args[0],
704            Value::String("café".into())
705        );
706        assert!(exchange.get(7).is_err());
707    }
708
709    #[test]
710    fn a_written_file_reads_back_the_same() {
711        let exchange = Exchange::parse(SAMPLE).unwrap();
712        let again = Exchange::parse(&exchange.write()).unwrap();
713        assert_eq!(exchange, again);
714    }
715
716    #[test]
717    fn reals_are_written_as_part_21_reads_them() {
718        assert_eq!(real(1.0), "1.0");
719        assert_eq!(real(-0.25), "-0.25");
720        assert_eq!(real(1.5e-7), "1.5E-7");
721        assert_eq!(real(1e20), "1.E20");
722        for r in [0.1, 1.0 / 3.0, -123456.789e-30, 2.5e300] {
723            let Value::Real(back) = Exchange::parse(&format!("DATA;#1=A({});ENDSEC;", real(r)))
724                .unwrap()
725                .get(1)
726                .unwrap()
727                .record("A")
728                .unwrap()
729                .args[0]
730            else {
731                panic!()
732            };
733            assert_eq!(back, r);
734        }
735    }
736
737    #[test]
738    fn errors_say_where() {
739        let error = Exchange::parse("ISO-10303-21;\nDATA;\n#1=A(1,;\nENDSEC;").unwrap_err();
740        assert!(error.to_string().contains("line 3"), "{error}");
741    }
742}