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;