ifc_lite_core/parser/tokenizer.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//! nom-combinator tokenizer for STEP/IFC entity lines.
6//!
7//! Zero-copy tokenization: string-like tokens borrow their original bytes.
8
9use nom::{
10 branch::alt,
11 bytes::complete::take_while1,
12 character::complete::{char, digit1, one_of},
13 combinator::{map, map_res, opt, recognize},
14 multi::separated_list0,
15 sequence::{delimited, pair, preceded, tuple},
16 IResult,
17};
18
19use crate::error::{Error, Result};
20use crate::generated::IfcType;
21use crate::step_encoding::step_string_literal_end;
22
23/// STEP/IFC token.
24///
25/// String-like tokens borrow their original bytes. Decode them only at a
26/// user-facing boundary so malformed real-world encodings cannot invalidate
27/// the structural parser.
28#[derive(Debug, Clone, PartialEq)]
29pub enum Token<'a> {
30 /// Entity reference: #123
31 EntityRef(u32),
32 /// String literal: 'text'
33 String(&'a [u8]),
34 /// Integer: 42
35 Integer(i64),
36 /// Float: 3.14
37 Float(f64),
38 /// Enum: .TRUE., .FALSE., .UNKNOWN.
39 Enum(&'a [u8]),
40 /// List: (1, 2, 3)
41 List(Vec<Token<'a>>),
42 /// Typed value: IFCPARAMETERVALUE(0.), IFCBOOLEAN(.T.)
43 TypedValue(&'a [u8], Vec<Token<'a>>),
44 /// Null value: $
45 Null,
46 /// Asterisk (derived value): *
47 Derived,
48}
49
50/// Parse entity reference: #123
51fn entity_ref(input: &[u8]) -> IResult<&[u8], Token<'_>> {
52 map(
53 preceded(char('#'), map_res(digit1, lexical_core::parse::<u32>)),
54 Token::EntityRef,
55 )(input)
56}
57
58/// Parse string literal: 'text' or "text"
59///
60/// An apostrophe literal ends where the shared rule says it does
61/// ([`step_string_literal_end`]): `''` is an escaped quote and the operand of
62/// a `\S\` PAGE directive is text. Ending at that operand left the rest of
63/// `'Para \S\' 5'` as unparseable input, so the whole entity failed to
64/// decode (#7589). The token borrows the content between the quotes, still
65/// escaped; consumers un-double `''` and decode backslash escapes.
66fn string_literal(input: &[u8]) -> IResult<&[u8], Token<'_>> {
67 match input.first() {
68 Some(b'\'') => match step_string_literal_end(input, 0) {
69 Some(past_close) => Ok((
70 &input[past_close..],
71 Token::String(&input[1..past_close - 1]),
72 )),
73 None => Err(nom::Err::Error(nom::error::Error::new(
74 input,
75 nom::error::ErrorKind::Char,
76 ))),
77 },
78 _ => map(
79 delimited(char('"'), double_quoted_content, char('"')),
80 Token::String,
81 )(input),
82 }
83}
84
85/// Content of a `"..."` literal up to its closing `"`; a doubled `""` stays
86/// inside. Uses memchr for SIMD-accelerated quote searching.
87#[inline]
88fn double_quoted_content(input: &[u8]) -> IResult<&[u8], &[u8]> {
89 let mut pos = 0;
90 while let Some(found) = memchr::memchr(b'"', &input[pos..]) {
91 let idx = pos + found;
92 if input.get(idx + 1) == Some(&b'"') {
93 pos = idx + 2;
94 continue;
95 }
96 return Ok((&input[idx..], &input[..idx]));
97 }
98 Err(nom::Err::Error(nom::error::Error::new(
99 input,
100 nom::error::ErrorKind::Char,
101 )))
102}
103
104/// Parse integer: 42, -42, +42
105///
106/// ISO 10303-21 spells `INTEGER` as `[ SIGN ] DIGIT { DIGIT }` with
107/// `SIGN = '+' | '-'`, so a leading `+` is as legal as a leading `-`. Accepting
108/// only `-` dropped the WHOLE entity, not just the attribute: every caller of
109/// `parse_entity` discards a record that fails to tokenize
110/// (`let Ok(..) = .. else { continue }`). The exponent sign below already
111/// accepted both, `fast_float2::parse_partial` (the fast reader in `decoder.rs`)
112/// accepts `+`, and the TypeScript half reads values with `parseFloat`, so the
113/// same file used to decode differently in the browser and in wasm.
114/// Uses lexical-core for 10x faster parsing
115#[inline]
116fn integer(input: &[u8]) -> IResult<&[u8], Token<'_>> {
117 map_res(recognize(tuple((opt(one_of("+-")), digit1))), |s: &[u8]| {
118 lexical_core::parse::<i64>(s)
119 .map(Token::Integer)
120 .map_err(|_| "parse error")
121 })(input)
122}
123
124/// Parse float: 3.14, -3.14, +3.14, 1.5E-10, 0., 1.
125/// IFC allows floats like "0." without decimal digits
126///
127/// `REAL` carries the same `[ SIGN ]` as `INTEGER`; see [`integer`] for why a
128/// rejected sign costs the whole entity.
129/// Uses lexical-core for 10x faster parsing
130#[inline]
131fn float(input: &[u8]) -> IResult<&[u8], Token<'_>> {
132 map_res(
133 recognize(tuple((
134 opt(one_of("+-")),
135 digit1,
136 char('.'),
137 opt(digit1), // Made optional to support "0." format
138 opt(tuple((one_of("eE"), opt(one_of("+-")), digit1))),
139 ))),
140 |s: &[u8]| {
141 lexical_core::parse::<f64>(s)
142 .map(Token::Float)
143 .map_err(|_| "parse error")
144 },
145 )(input)
146}
147
148/// Parse enum: .TRUE., .FALSE., .UNKNOWN., .ELEMENT.
149fn enum_value(input: &[u8]) -> IResult<&[u8], Token<'_>> {
150 map(
151 delimited(
152 char('.'),
153 take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'_'),
154 char('.'),
155 ),
156 Token::Enum,
157 )(input)
158}
159
160/// Parse null: $
161fn null(input: &[u8]) -> IResult<&[u8], Token<'_>> {
162 map(char('$'), |_| Token::Null)(input)
163}
164
165/// Parse derived: *
166fn derived(input: &[u8]) -> IResult<&[u8], Token<'_>> {
167 map(char('*'), |_| Token::Derived)(input)
168}
169
170/// Maximum nesting depth for token recursion (list and typed-value bodies).
171///
172/// Each `(` in the input bumps depth by one. Real-world IFC entities rarely
173/// nest beyond 5-10 levels; 256 leaves comfortable headroom while keeping
174/// the stack bounded against pathological inputs.
175const MAX_NESTING_DEPTH: u32 = 256;
176
177/// Parse typed value: IFCPARAMETERVALUE(0.), IFCBOOLEAN(.T.)
178///
179/// The type name and its `(` are usually adjacent, but a STEP writer's line
180/// wrap can land exactly between them - measured on a real file where 39 of
181/// 175 `IFCSURFACESTYLERENDERING` entities wrap as `…MEASURE\r\n(1.)`. Without
182/// `ws` here, `char('(')` sees `\r` and the whole entity fails to parse, which
183/// silently dropped it from every full-file walk (`decode_at_uncached` returns
184/// `Err`, and every caller's `let Ok(..) = .. else { continue }` skips it) -
185/// the entity was never malformed, just wrapped where the parser assumed it
186/// never would be.
187///
188/// An EMPTY argument list also needs `ws` on both sides of the parens
189/// (`IFCLABEL(\r\n)`), not just before `(`: `separated_list0` matches zero
190/// items without consuming anything, so with a bare `char(')')` any
191/// whitespace or comment before the `)` left it unconsumed and the whole
192/// typed value failed to parse (#3789).
193fn typed_value_at_depth(input: &[u8], depth: u32) -> IResult<&[u8], Token<'_>> {
194 map(
195 pair(
196 // Type name (all caps with optional numbers/underscores)
197 take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'_'),
198 // Arguments
199 delimited(
200 pair(ws, char('(')),
201 preceded(
202 ws,
203 separated_list0(delimited(ws, char(','), ws), move |i| {
204 token_at_depth(i, depth)
205 }),
206 ),
207 pair(ws, char(')')),
208 ),
209 ),
210 |(type_name, args)| Token::TypedValue(type_name, args),
211 )(input)
212}
213
214/// Skip whitespace AND `/* ... */` comments -- 10303-21 allows a comment
215/// anywhere whitespace is allowed, including between the attributes this
216/// tokenizer decodes. Without this, `/* rev; b */ $` was not trivia: no `alt`
217/// branch in `token_at_depth` starts with `/`, so a comment before a value
218/// made the whole entity fail to parse rather than reading the value clean.
219///
220/// Reuses `skip_step_trivia`, the matched pair of the scanners' trivia skip
221/// (`step-lexing.ts`'s `skipTrivia` on the TS side). An unterminated comment
222/// consumes to end of input rather than failing this always-succeeds
223/// combinator; the `alt` that follows then has nothing left to match and the
224/// entity fails to parse, which is the existing behaviour for corrupt input.
225fn ws(input: &[u8]) -> IResult<&[u8], ()> {
226 let end = super::lexical::skip_step_trivia(input, 0).unwrap_or(input.len());
227 Ok((&input[end..], ()))
228}
229
230/// Parse a token with optional surrounding whitespace
231/// Optimized ordering: test cheapest patterns first (single-char markers)
232fn token(input: &[u8]) -> IResult<&[u8], Token<'_>> {
233 token_at_depth(input, 0)
234}
235
236fn token_at_depth(input: &[u8], depth: u32) -> IResult<&[u8], Token<'_>> {
237 if depth > MAX_NESTING_DEPTH {
238 return Err(nom::Err::Failure(nom::error::Error::new(
239 input,
240 nom::error::ErrorKind::TooLarge,
241 )));
242 }
243 delimited(
244 ws,
245 alt((
246 // Single-char markers first (O(1) check)
247 null, // $
248 derived, // *
249 entity_ref, // # + digits
250 // Then by complexity
251 enum_value, // .XXX.
252 string_literal, // 'xxx'
253 move |i| list_at_depth(i, depth + 1), // (...)
254 // Numbers: float before integer since float includes '.'
255 float,
256 integer,
257 // IFCPARAMETERVALUE(0.) - most expensive, last
258 move |i| typed_value_at_depth(i, depth + 1),
259 )),
260 ws,
261 )(input)
262}
263
264/// Parse list: (1, 2, 3) or nested lists
265/// Test-only wrapper for the depth-0 entry into list.
266#[cfg(test)]
267fn list(input: &[u8]) -> IResult<&[u8], Token<'_>> {
268 list_at_depth(input, 0)
269}
270
271/// An EMPTY list (`( )`, `(\r\n)`, `(/* empty */)`) needs `ws` explicitly
272/// around the empty `separated_list0`: it matches zero items without
273/// consuming anything, so a bare `char(')')` failed to parse past any
274/// whitespace or comment left between the parens (#3789, same shape as
275/// `typed_value_at_depth`'s empty-args fix).
276fn list_at_depth(input: &[u8], depth: u32) -> IResult<&[u8], Token<'_>> {
277 map(
278 delimited(
279 pair(char('('), ws),
280 separated_list0(delimited(ws, char(','), ws), move |i| {
281 token_at_depth(i, depth)
282 }),
283 pair(ws, char(')')),
284 ),
285 Token::List,
286 )(input)
287}
288
289/// Parse a complete entity line from raw IFC bytes.
290/// Example: #123=IFCWALL('guid','owner',$,$,'name',$,$,$);
291// The nom `IResult` parser tuple type is intentionally explicit here; factoring
292// it into a `type` alias would obscure the parser combinator structure.
293#[allow(clippy::type_complexity)]
294pub fn parse_entity<'a, T>(input: &'a T) -> Result<(u32, IfcType, Vec<Token<'a>>)>
295where
296 T: AsRef<[u8]> + ?Sized,
297{
298 let input = input.as_ref();
299 let result: IResult<&[u8], (u32, &[u8], Vec<Token>)> = tuple((
300 // Entity ID: #123
301 delimited(
302 ws,
303 preceded(char('#'), map_res(digit1, lexical_core::parse::<u32>)),
304 ws,
305 ),
306 // Equals sign
307 preceded(
308 char('='),
309 // Entity type: IFCWALL
310 delimited(
311 ws,
312 take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'_'),
313 ws,
314 ),
315 ),
316 // Arguments: ('guid', 'owner', ...). `ws` around the empty
317 // `separated_list0` handles an EMPTY argument list separated from
318 // its `)` by whitespace or a comment (`#1=IFCX(\r\n);`), the same
319 // shape as `list_at_depth`'s and `typed_value_at_depth`'s fix
320 // (#3789): with zero items `separated_list0` consumes nothing, so
321 // a bare `char(')')` failed to parse past it.
322 delimited(
323 pair(char('('), ws),
324 separated_list0(delimited(ws, char(','), ws), token),
325 tuple((ws, char(')'), ws, char(';'))),
326 ),
327 ))(input);
328
329 match result {
330 Ok((_, (id, type_str, args))) => {
331 let type_str = std::str::from_utf8(type_str)
332 .map_err(|_| Error::parse(0, "Entity type is not ASCII/UTF-8"))?;
333 let ifc_type = IfcType::from_str(type_str);
334 Ok((id, ifc_type, args))
335 }
336 Err(e) => Err(Error::parse(0, format!("Failed to parse entity: {}", e))),
337 }
338}
339
340#[cfg(test)]
341#[path = "tokenizer_tests.rs"]
342mod tokenizer_tests;