Skip to main content

ifc_lite_core/parser/
scanner_attributes.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The one per-ATTRIBUTE read over a raw STEP record: [`StepListItems`],
6//! the top-level items of a record's argument list or of one list value, and
7//! [`nth_attribute_is_present`] on top of it.
8//!
9//! A sibling file for the same reason `scanner_header.rs` and
10//! `scanner_tests.rs` are: `scanner.rs` is at its `module_size_ratchet`
11//! budget, and this read shares no state or control flow with the record
12//! scan it sat next to. It walks one already-located record, comma by comma,
13//! rather than hunting the next record.
14//!
15//! The split used to exist several times: as `EntityScanner::has_non_null_attribute`
16//! and a comment-blind copy in `schema_helpers` (core review behind #4577,
17//! finding 8), then as `georef_parse::nth_top_level_attribute` and the
18//! processing crate's `parse_step_arguments`, which read a comma in a comment
19//! as a separator and an apostrophe in one as a string (#4687). The rule
20//! lives here.
21
22use crate::parser::lexical::skip_step_trivia;
23
24/// The top-level items of one parenthesized STEP list, each trimmed of
25/// trivia (STEP whitespace and `/* ... */` comments) at both ends. Nested
26/// parentheses and quoted strings are respected, so a `,` inside `('a,b')`
27/// or `(#1,#2)` does not separate items, and a comment is trivia wherever
28/// ISO 10303-21 allows one: a `,`, `(`, `)` or `'` inside it is comment text.
29///
30/// An empty slot (`,,`) is an empty item; `()` has no items. Iteration stops
31/// at the list's own `)`, which sets [`Self::closed`], or at a list, string
32/// or comment that never closes, which does not, and the unterminated item
33/// is not yielded.
34pub struct StepListItems<'a> {
35    bytes: &'a [u8],
36    pos: usize,
37    first: bool,
38    state: ListState,
39}
40
41#[derive(Clone, Copy, PartialEq, Eq)]
42enum ListState {
43    Open,
44    Closed,
45    Unreadable,
46}
47
48impl<'a> StepListItems<'a> {
49    /// The attributes of a record (`#id = TYPE(...)`). `None` when the
50    /// argument list never opens.
51    pub fn of_record(record: &'a [u8]) -> Option<Self> {
52        argument_list_start(record).map(|pos| Self::after_open(record, pos))
53    }
54
55    /// The items of a list value whose `(` is its first non-trivia byte, such
56    /// as one item of [`Self::of_record`]. `None` when it is not a list.
57    pub fn of_list(value: &'a [u8]) -> Option<Self> {
58        let open = skip_step_trivia(value, 0)?;
59        (value.get(open) == Some(&b'(')).then(|| Self::after_open(value, open + 1))
60    }
61
62    fn after_open(bytes: &'a [u8], pos: usize) -> Self {
63        Self { bytes, pos, first: true, state: ListState::Open }
64    }
65
66    /// Whether iteration has reached the list's own `)`.
67    pub fn closed(&self) -> bool {
68        self.state == ListState::Closed
69    }
70}
71
72impl<'a> Iterator for StepListItems<'a> {
73    type Item = &'a [u8];
74
75    fn next(&mut self) -> Option<&'a [u8]> {
76        if self.state != ListState::Open {
77            return None;
78        }
79        let bytes = self.bytes;
80        let mut pos = self.pos;
81        // The item's non-trivia span, once a value byte has been seen.
82        let (mut start, mut end) = (None, pos);
83        let mut depth = 0usize;
84        loop {
85            let Some(next) = skip_step_trivia(bytes, pos) else {
86                break;
87            };
88            pos = next;
89            let Some(&b) = bytes.get(pos) else {
90                break;
91            };
92            let value_start = pos;
93            match b {
94                b',' | b')' if depth == 0 => {
95                    let first = std::mem::replace(&mut self.first, false);
96                    self.pos = pos + 1;
97                    if b == b')' {
98                        self.state = ListState::Closed;
99                        if first && start.is_none() {
100                            return None; // `()`
101                        }
102                    }
103                    return Some(start.map_or(&bytes[pos..pos], |s| &bytes[s..end]));
104                }
105                b'\'' => {
106                    pos += 1;
107                    loop {
108                        match bytes.get(pos) {
109                            // An escaped quote ('') stays inside the string.
110                            Some(b'\'') if bytes.get(pos + 1) == Some(&b'\'') => pos += 2,
111                            Some(b'\'') => break,
112                            Some(_) => pos += 1,
113                            None => {
114                                self.state = ListState::Unreadable;
115                                return None;
116                            }
117                        }
118                    }
119                }
120                b'(' => depth += 1,
121                b')' => depth -= 1,
122                _ => {}
123            }
124            pos += 1;
125            start.get_or_insert(value_start);
126            end = pos;
127        }
128        self.state = ListState::Unreadable;
129        None
130    }
131}
132
133/// Whether the top-level attribute at `index` (0-based, first attribute
134/// after the `(`) of a STEP record holds a value: it exists, it is not `$`,
135/// and it is not an empty slot (`,,`). Attributes are split by
136/// [`StepListItems`], so nesting, strings and comments follow its rule, and
137/// whitespace is the STEP set (`is_step_space`), including the vertical tab
138/// and form feed that `u8::is_ascii_whitespace` leaves out.
139///
140/// `false` for anything the record does not settle: an argument list that
141/// never opens or never closes, a comment or string literal that never
142/// closes, an index past the last attribute. `false` is the "no geometry"
143/// side for every caller, so an unreadable record is skipped rather than
144/// meshed.
145///
146/// Callers use it to check attribute 6 (`Representation`, stable across
147/// every `IfcProduct` subtype) before deciding an otherwise-excluded spatial
148/// container exceptionally carries geometry (#1910), companion to
149/// `is_representationless_spatial_container_by_name`.
150pub fn nth_attribute_is_present(record: &[u8], index: usize) -> bool {
151    let Some(mut attributes) = StepListItems::of_record(record) else {
152        return false;
153    };
154    let holds_a_value = attributes
155        .nth(index)
156        .is_some_and(|value| !matches!(value.first(), None | Some(b'$')));
157    // A value still needs the list to close: an unclosed record settles nothing.
158    holds_a_value && {
159        attributes.by_ref().for_each(drop);
160        attributes.closed()
161    }
162}
163
164/// The index just past the `(` that opens a record's argument list. Only the
165/// `#id = TYPE` head and trivia precede it, so each non-trivia byte is stepped
166/// over and a comment is consumed as a region: a `(` inside one does not open
167/// the list. `None` when the list never opens or a comment never closes.
168pub(crate) fn argument_list_start(record: &[u8]) -> Option<usize> {
169    let mut pos = 0;
170    loop {
171        pos = skip_step_trivia(record, pos)?;
172        match record.get(pos)? {
173            b'(' => return Some(pos + 1),
174            _ => pos += 1,
175        }
176    }
177}
178
179#[cfg(test)]
180#[path = "scanner_attributes_tests.rs"]
181mod tests;