openbim_step/recovery.rs
1//! Opt-in malformed-record recovery policy and non-fatal diagnostics.
2//!
3//! Strict parsing is the default because an authoring tool that silently drops
4//! data corrupts the model it is editing. A consumer (viewer, importer,
5//! reporter) has the opposite need: real exporter output contains occasional
6//! damaged records, and refusing an entire file over one of them is not useful.
7//!
8//! Recovery is therefore explicit, bounded, and reported: the caller opts in,
9//! only data records are recoverable, and every skipped byte range comes back
10//! as a [`Diagnostic`] so a consumer can show what was lost instead of
11//! pretending the file was clean.
12
13use crate::{Exchange, InstanceId, Span};
14use std::fmt;
15
16/// What to do when a data record cannot be parsed.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
18pub enum OnMalformed {
19 /// Fail the parse. The default: an unreadable record is a hard error.
20 #[default]
21 Abort,
22 /// Report the record as a diagnostic, resynchronize, and keep parsing.
23 Skip,
24}
25
26/// Parse behavior toggles.
27///
28/// Constructed with [`ParseOptions::default`] (strict) and adjusted through
29/// [`ParseOptions::on_malformed_record`], so later options cannot break
30/// existing call sites.
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
32#[non_exhaustive]
33pub struct ParseOptions {
34 /// Policy for unparsable data records.
35 pub on_malformed_record: OnMalformed,
36 /// Whether to report duplicate instance ids and references to ids that
37 /// are never defined. Off by default: strict parsing means syntax only.
38 pub check_references: bool,
39 /// Whether a data-section real written without its decimal point
40 /// (`1E-05`) is read as a real. Off by default; see
41 /// [`Self::accept_real_without_point`].
42 pub accept_real_without_point: bool,
43}
44
45impl ParseOptions {
46 /// Strict options: any malformed record aborts the parse.
47 #[must_use]
48 pub const fn strict() -> Self {
49 Self {
50 on_malformed_record: OnMalformed::Abort,
51 check_references: false,
52 accept_real_without_point: false,
53 }
54 }
55
56 /// Options that skip and report malformed data records, and read reals
57 /// written without their decimal point (see
58 /// [`Self::accept_real_without_point`]).
59 #[must_use]
60 pub const fn lenient() -> Self {
61 Self {
62 on_malformed_record: OnMalformed::Skip,
63 check_references: false,
64 accept_real_without_point: true,
65 }
66 }
67
68 /// Sets the malformed-record policy.
69 #[must_use]
70 pub const fn on_malformed_record(mut self, policy: OnMalformed) -> Self {
71 self.on_malformed_record = policy;
72 self
73 }
74
75 /// Enables or disables reference-integrity diagnostics.
76 ///
77 /// When enabled, every data record whose instance id was already defined
78 /// yields a [`DiagnosticKind::DuplicateId`], and every record referencing
79 /// an id that no record in the `DATA` section defines yields one
80 /// [`DiagnosticKind::DanglingReference`] per distinct missing id. Forward
81 /// references are legal (ISO 10303-21:2016 §11.2) and are only reported
82 /// if the target is still undefined at `ENDSEC`. Ids compare numerically,
83 /// so `#07` and `#7` name the same instance.
84 ///
85 /// Nothing is dropped or rewritten, so these diagnostics never make an
86 /// outcome lossy. The check keeps one entry per defined id, so memory is
87 /// linear in the number of records, including for [`crate::parse_events_with`].
88 #[must_use]
89 pub const fn check_references(mut self, enabled: bool) -> Self {
90 self.check_references = enabled;
91 self
92 }
93
94 /// Enables or disables reading reals written without a decimal point.
95 ///
96 /// ISO 10303-21 writes a real as `[sign] digits "." [digits]
97 /// [exponent]`, but some exporters omit the point before an exponent
98 /// (`1E-05`, `-2E3`, `3e+2`), including buildingSMART's own IFC4.x
99 /// alignment test files. Strict parsing refuses such a number with an
100 /// error for which [`StepError::is_real_without_point`](crate::StepError::is_real_without_point)
101 /// holds. When enabled, a data record keeps it as the real it means,
102 /// with the point inserted (`1.E-05`), and yields one
103 /// [`DiagnosticKind::RealWithoutPoint`] over the number's bytes. The
104 /// stored value carries the point, so writing the model back produces
105 /// valid Part 21.
106 ///
107 /// Only complete numbers are read: `1E`, `1E-` and `1EE2` stay errors,
108 /// as do numbers without leading digits such as `.5E2`. Text inside
109 /// strings is never touched. The header section stays strict, as it does
110 /// for every other recovery.
111 #[must_use]
112 pub const fn accept_real_without_point(mut self, enabled: bool) -> Self {
113 self.accept_real_without_point = enabled;
114 self
115 }
116}
117
118/// Severity of a non-fatal parse diagnostic.
119///
120/// Only [`Severity::Warning`] exists today: every diagnostic describes input
121/// that was accepted. Fatal problems are returned as
122/// [`StepError`](crate::StepError) instead of being reported here.
123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
124#[non_exhaustive]
125pub enum Severity {
126 /// Input was accepted, but is damaged or inconsistent.
127 #[default]
128 Warning,
129}
130
131/// What a [`Diagnostic`] reports.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
133#[non_exhaustive]
134pub enum DiagnosticKind {
135 /// A malformed data record was skipped under [`OnMalformed::Skip`]. The
136 /// only kind that loses input.
137 SkippedRecord,
138 /// A data record reuses an instance id defined earlier in the section.
139 /// ISO 10303-21:2016 §11.2 requires instance names to be unique. Both
140 /// records are kept; the diagnostic points at the later one.
141 DuplicateId,
142 /// A data record references an instance id that no record defines.
143 DanglingReference,
144 /// A real written without the decimal point ISO 10303-21 requires
145 /// before its exponent was read as a real, under
146 /// [`ParseOptions::accept_real_without_point`]. The record is kept, with
147 /// the point inserted; the span covers the number as written.
148 RealWithoutPoint,
149}
150
151/// A non-fatal problem found while parsing.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct Diagnostic {
154 severity: Severity,
155 kind: DiagnosticKind,
156 span: Span,
157 instance: Option<InstanceId>,
158 detail: String,
159}
160
161impl Diagnostic {
162 pub(crate) fn skipped_record(span: Span, detail: impl Into<String>) -> Self {
163 Self {
164 severity: Severity::Warning,
165 kind: DiagnosticKind::SkippedRecord,
166 span,
167 instance: None,
168 detail: detail.into(),
169 }
170 }
171
172 pub(crate) fn real_without_point(span: Span, token: &str) -> Self {
173 Self {
174 severity: Severity::Warning,
175 kind: DiagnosticKind::RealWithoutPoint,
176 span,
177 instance: None,
178 detail: format!(
179 "real {token} has no decimal point before its exponent; read as a real"
180 ),
181 }
182 }
183
184 pub(crate) fn duplicate_id(span: Span, id: InstanceId) -> Self {
185 Self {
186 severity: Severity::Warning,
187 kind: DiagnosticKind::DuplicateId,
188 span,
189 detail: format!("duplicate instance id {id}"),
190 instance: Some(id),
191 }
192 }
193
194 pub(crate) fn dangling_reference(span: Span, id: InstanceId) -> Self {
195 Self {
196 severity: Severity::Warning,
197 kind: DiagnosticKind::DanglingReference,
198 span,
199 detail: format!("reference to undefined instance {id}"),
200 instance: Some(id),
201 }
202 }
203
204 /// Severity of the diagnostic.
205 #[must_use]
206 pub const fn severity(&self) -> Severity {
207 self.severity
208 }
209
210 /// What the diagnostic reports.
211 #[must_use]
212 pub const fn kind(&self) -> DiagnosticKind {
213 self.kind
214 }
215
216 /// The instance id at fault. For a duplicate it is the later record's id
217 /// as written; for a dangling reference it is the missing id in canonical
218 /// form (leading zeros removed, since `#07` and `#7` are the same
219 /// instance). `None` for a skipped record and a real without a point.
220 #[must_use]
221 pub const fn instance(&self) -> Option<&InstanceId> {
222 self.instance.as_ref()
223 }
224
225 /// Byte range of the original input that the diagnostic covers.
226 ///
227 /// For a skipped record this is the whole discarded range, so a consumer
228 /// can quote the exact bytes that were dropped. For a reference defect it
229 /// is the offending record: the later duplicate, or the record holding the
230 /// dangling reference.
231 #[must_use]
232 pub const fn span(&self) -> Span {
233 self.span
234 }
235
236 /// Human-readable description without a location prefix.
237 #[must_use]
238 pub fn detail(&self) -> &str {
239 &self.detail
240 }
241}
242
243impl fmt::Display for Diagnostic {
244 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245 write!(
246 formatter,
247 "STEP warning at bytes {}..{}: {}",
248 self.span.start, self.span.end, self.detail
249 )
250 }
251}
252
253/// A parsed exchange together with everything that was recovered with loss.
254#[derive(Debug, Clone, PartialEq)]
255pub struct ParseOutcome {
256 /// Records that were read successfully.
257 pub exchange: Exchange,
258 /// Non-fatal problems, in source order. Empty for a clean file.
259 pub diagnostics: Vec<Diagnostic>,
260}
261
262impl ParseOutcome {
263 /// Whether nothing was dropped while reading.
264 ///
265 /// Only [`DiagnosticKind::SkippedRecord`] loses input. Reference
266 /// defects are reported but keep every record, so they do not count.
267 #[must_use]
268 pub fn is_lossless(&self) -> bool {
269 !self
270 .diagnostics
271 .iter()
272 .any(|diagnostic| diagnostic.kind == DiagnosticKind::SkippedRecord)
273 }
274}