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;