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;
23use crate::step_encoding::step_string_literal_end;
24
25/// The top-level items of one parenthesized STEP list, each trimmed of
26/// trivia (STEP whitespace and `/* ... */` comments) at both ends. Nested
27/// parentheses and quoted strings are respected, so a `,` inside `('a,b')`
28/// or `(#1,#2)` does not separate items, and a comment is trivia wherever
29/// ISO 10303-21 allows one: a `,`, `(`, `)` or `'` inside it is comment text.
30///
31/// An empty slot (`,,`) is an empty item; `()` has no items. Iteration stops
32/// at the list's own `)`, which sets [`Self::closed`], or at a list, string
33/// or comment that never closes, which does not, and the unterminated item
34/// is not yielded.
35pub struct StepListItems<'a> {
36    bytes: &'a [u8],
37    pos: usize,
38    first: bool,
39    state: ListState,
40}
41
42#[derive(Clone, Copy, PartialEq, Eq)]
43enum ListState {
44    Open,
45    Closed,
46    Unreadable,
47}
48
49impl<'a> StepListItems<'a> {
50    /// The attributes of a record (`#id = TYPE(...)`). `None` when the
51    /// argument list never opens.
52    pub fn of_record(record: &'a [u8]) -> Option<Self> {
53        argument_list_start(record).map(|pos| Self::after_open(record, pos))
54    }
55
56    /// The items of a list value whose `(` is its first non-trivia byte, such
57    /// as one item of [`Self::of_record`]. `None` when it is not a list.
58    pub fn of_list(value: &'a [u8]) -> Option<Self> {
59        let open = skip_step_trivia(value, 0)?;
60        (value.get(open) == Some(&b'(')).then(|| Self::after_open(value, open + 1))
61    }
62
63    fn after_open(bytes: &'a [u8], pos: usize) -> Self {
64        Self { bytes, pos, first: true, state: ListState::Open }
65    }
66
67    /// Whether iteration has reached the list's own `)`.
68    pub fn closed(&self) -> bool {
69        self.state == ListState::Closed
70    }
71}
72
73impl<'a> Iterator for StepListItems<'a> {
74    type Item = &'a [u8];
75
76    fn next(&mut self) -> Option<&'a [u8]> {
77        if self.state != ListState::Open {
78            return None;
79        }
80        let bytes = self.bytes;
81        let mut pos = self.pos;
82        // The item's non-trivia span, once a value byte has been seen.
83        let (mut start, mut end) = (None, pos);
84        let mut depth = 0usize;
85        loop {
86            let Some(next) = skip_step_trivia(bytes, pos) else {
87                break;
88            };
89            pos = next;
90            let Some(&b) = bytes.get(pos) else {
91                break;
92            };
93            let value_start = pos;
94            match b {
95                b',' | b')' if depth == 0 => {
96                    let first = std::mem::replace(&mut self.first, false);
97                    self.pos = pos + 1;
98                    if b == b')' {
99                        self.state = ListState::Closed;
100                        if first && start.is_none() {
101                            return None; // `()`
102                        }
103                    }
104                    return Some(start.map_or(&bytes[pos..pos], |s| &bytes[s..end]));
105                }
106                // The shared literal rule: `''` and a `\S\` operand are text,
107                // so a comma or paren after `'Para \S\' 5'` is not read as
108                // structure (#7589). `pos` lands on the closing quote.
109                b'\'' => match step_string_literal_end(bytes, pos) {
110                    Some(past_close) => pos = past_close - 1,
111                    None => {
112                        self.state = ListState::Unreadable;
113                        return None;
114                    }
115                },
116                b'(' => depth += 1,
117                b')' => depth -= 1,
118                _ => {}
119            }
120            pos += 1;
121            start.get_or_insert(value_start);
122            end = pos;
123        }
124        self.state = ListState::Unreadable;
125        None
126    }
127}
128
129/// Whether the top-level attribute at `index` (0-based, first attribute
130/// after the `(`) of a STEP record holds a value: it exists, it is not `$`,
131/// and it is not an empty slot (`,,`). Attributes are split by
132/// [`StepListItems`], so nesting, strings and comments follow its rule, and
133/// whitespace is the STEP set (`is_step_space`), including the vertical tab
134/// and form feed that `u8::is_ascii_whitespace` leaves out.
135///
136/// `false` for anything the record does not settle: an argument list that
137/// never opens or never closes, a comment or string literal that never
138/// closes, an index past the last attribute. `false` is the "no geometry"
139/// side for every caller, so an unreadable record is skipped rather than
140/// meshed.
141///
142/// Callers use it to check attribute 6 (`Representation`, stable across
143/// every `IfcProduct` subtype) before deciding an otherwise-excluded spatial
144/// container exceptionally carries geometry (#1910), companion to
145/// `is_representationless_spatial_container_by_name`.
146pub fn nth_attribute_is_present(record: &[u8], index: usize) -> bool {
147    let Some(mut attributes) = StepListItems::of_record(record) else {
148        return false;
149    };
150    let holds_a_value = attributes
151        .nth(index)
152        .is_some_and(|value| !matches!(value.first(), None | Some(b'$')));
153    // A value still needs the list to close: an unclosed record settles nothing.
154    holds_a_value && {
155        attributes.by_ref().for_each(drop);
156        attributes.closed()
157    }
158}
159
160/// The index just past the `(` that opens a record's argument list. Only the
161/// `#id = TYPE` head and trivia precede it, so each non-trivia byte is stepped
162/// over and a comment is consumed as a region: a `(` inside one does not open
163/// the list. `None` when the list never opens or a comment never closes.
164pub(crate) fn argument_list_start(record: &[u8]) -> Option<usize> {
165    let mut pos = 0;
166    loop {
167        pos = skip_step_trivia(record, pos)?;
168        match record.get(pos)? {
169            b'(' => return Some(pos + 1),
170            _ => pos += 1,
171        }
172    }
173}
174
175#[cfg(test)]
176#[path = "scanner_attributes_tests.rs"]
177mod tests;