1use ogeom_core::{OgeomResult, ogeom_bail};
11
12#[derive(Debug, Clone, PartialEq)]
14pub enum Arg {
15 Null,
17 Derived,
19 Ref(u64),
21 Int(i64),
23 Real(f64),
25 Str(String),
27 Enum(String),
29 List(Vec<Arg>),
31 Typed(Box<(String, Vec<Arg>)>),
35}
36
37impl Arg {
38 pub fn reference(&self) -> Option<u64> {
40 match self {
41 Self::Ref(n) => Some(*n),
42 _ => None,
43 }
44 }
45
46 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 pub fn list(&self) -> Option<&[Arg]> {
61 match self {
62 Self::List(items) => Some(items),
63 _ => None,
64 }
65 }
66
67 pub fn is_enum(&self, name: &str) -> bool {
69 matches!(self, Self::Enum(e) if e == name)
70 }
71}
72
73#[derive(Debug, Clone)]
76pub struct Instance {
77 pub parts: Vec<(String, Vec<Arg>)>,
79}
80
81impl Instance {
82 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 pub fn keyword(&self) -> &str {
92 self.parts.first().map_or("", |(k, _)| k.as_str())
93 }
94
95 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#[derive(Debug)]
106pub struct Exchange {
107 pub header: Vec<(String, Vec<Arg>)>,
109 #[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
117pub 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#[derive(Debug)]
139pub(crate) enum Instances {
140 Dense(Vec<Option<Instance>>),
142 Sparse(ogeom_core::FastMap<u64, Instance>),
144}
145
146impl Instances {
147 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 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 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 pub(crate) fn contains(&self, id: u64) -> bool {
192 self.get(id).is_some()
193 }
194
195 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 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
237pub(crate) type Header = Vec<(String, Vec<Arg>)>;
239
240pub(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 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 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 depth: u32,
321}
322
323const 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 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 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 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 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 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
607pub(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 #[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 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 #[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 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 #[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}