Skip to main content

rscad_openscad/eval/
diag.rs

1//! Diagnostics for the OpenSCAD evaluator.
2//!
3//! Warnings/infos accumulate in a [`DiagSink`] during evaluation and are
4//! resolved to human-readable [`ScadDiagnostic`]s (with `file:line:col`
5//! locations) once evaluation finishes. Fatal problems (assert failures,
6//! runaway recursion, exhausted work budget) abort evaluation via [`Fatal`].
7
8use openscad_parser::prelude::{Ast, Span};
9
10/// Diagnostic severity.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd)]
12pub enum Severity {
13    Info,
14    Warning,
15    Error,
16}
17
18impl core::fmt::Display for Severity {
19    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
20        match self {
21            Severity::Info => f.write_str("info"),
22            Severity::Warning => f.write_str("warning"),
23            Severity::Error => f.write_str("error"),
24        }
25    }
26}
27
28/// A resolved evaluator diagnostic.
29#[derive(Clone, Debug, PartialEq)]
30pub struct ScadDiagnostic {
31    pub severity: Severity,
32    pub message: String,
33    /// `file:line:col` of the offending construct, when known.
34    pub location: Option<String>,
35}
36
37impl core::fmt::Display for ScadDiagnostic {
38    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
39        match &self.location {
40            Some(loc) => write!(f, "{}: {} ({})", self.severity, self.message, loc),
41            None => write!(f, "{}: {}", self.severity, self.message),
42        }
43    }
44}
45
46/// A fatal evaluation error: aborts the whole load.
47#[derive(Clone, Debug)]
48pub struct Fatal {
49    pub message: String,
50    pub span: Option<Span>,
51}
52
53impl Fatal {
54    pub fn new(message: impl Into<String>, span: Option<Span>) -> Self {
55        Fatal {
56            message: message.into(),
57            span,
58        }
59    }
60}
61
62/// Result alias used throughout the evaluator.
63pub type EvalResult<T> = core::result::Result<T, Fatal>;
64
65/// Dedup key for [`DiagSink::warn_once`]: message + flattened span.
66type WarnKey = (String, Option<(usize, usize, usize)>);
67
68/// Collects diagnostics during evaluation.
69#[derive(Debug, Default)]
70pub(crate) struct DiagSink {
71    raw: Vec<(Severity, String, Option<Span>)>,
72    seen: std::collections::HashSet<WarnKey>,
73}
74
75impl DiagSink {
76    pub fn info(&mut self, message: impl Into<String>, span: Option<Span>) {
77        let message = message.into();
78        tracing::info!(target: "scad", "{message}");
79        self.raw.push((Severity::Info, message, span));
80    }
81
82    pub fn warn(&mut self, message: impl Into<String>, span: Option<Span>) {
83        let message = message.into();
84        tracing::warn!(target: "scad", "{message}");
85        self.raw.push((Severity::Warning, message, span));
86    }
87
88    /// Warn, deduplicating identical (message, span) pairs — for diagnostics
89    /// that would otherwise repeat per loop iteration or module call.
90    pub fn warn_once(&mut self, message: impl Into<String>, span: Option<Span>) {
91        let message = message.into();
92        let key = (message.clone(), span.map(|s| (s.start, s.end, s.context)));
93        if self.seen.insert(key) {
94            self.warn(message, span);
95        }
96    }
97
98    /// Resolve spans to `file:line:col` locations using the parsed AST cache.
99    pub fn resolve(self, ast: &Ast) -> Vec<ScadDiagnostic> {
100        self.raw
101            .into_iter()
102            .map(|(severity, message, span)| ScadDiagnostic {
103                severity,
104                message,
105                location: span.and_then(|s| locate(ast, s)),
106            })
107            .collect()
108    }
109}
110
111/// Format a span as `file:line:col` (1-based).
112pub(crate) fn locate(ast: &Ast, span: Span) -> Option<String> {
113    let path = ast.path_of(span.context)?;
114    let source = ast.unparsed.get(path)?.text();
115    let prefix = source.get(..span.start.min(source.len()))?;
116    let line = prefix.matches('\n').count() + 1;
117    let col = prefix
118        .rsplit_once('\n')
119        .map_or(prefix.chars().count(), |(_, tail)| tail.chars().count())
120        + 1;
121    Some(format!("{}:{line}:{col}", path.display()))
122}
123
124/// Render a fatal error with its resolved location.
125pub(crate) fn render_fatal(ast: &Ast, fatal: &Fatal) -> String {
126    match fatal.span.and_then(|s| locate(ast, s)) {
127        Some(loc) => format!("{} ({loc})", fatal.message),
128        None => fatal.message.clone(),
129    }
130}