1use ogeom_core::{OgeomResult, ogeom_bail};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, PartialEq)]
15pub enum Arg {
16 Null,
18 Derived,
20 Ref(u64),
22 Int(i64),
24 Real(f64),
26 Str(String),
28 Enum(String),
30 List(Vec<Arg>),
32 Typed(String, Vec<Arg>),
34}
35
36impl Arg {
37 pub fn reference(&self) -> Option<u64> {
39 match self {
40 Self::Ref(n) => Some(*n),
41 _ => None,
42 }
43 }
44
45 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 pub fn list(&self) -> Option<&[Arg]> {
60 match self {
61 Self::List(items) => Some(items),
62 _ => None,
63 }
64 }
65
66 pub fn is_enum(&self, name: &str) -> bool {
68 matches!(self, Self::Enum(e) if e == name)
69 }
70}
71
72#[derive(Debug, Clone)]
75pub struct Instance {
76 pub parts: Vec<(String, Vec<Arg>)>,
78}
79
80impl Instance {
81 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 pub fn keyword(&self) -> &str {
91 self.parts.first().map_or("", |(k, _)| k.as_str())
92 }
93
94 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#[derive(Debug)]
105pub struct Exchange {
106 pub header: Vec<(String, Vec<Arg>)>,
108 pub data: HashMap<u64, Instance>,
110}
111
112pub 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 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 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 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 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 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}