Skip to main content

oxdock_parser/
error.rs

1//! Typed parse errors for the OxDock DSL (issue #143).
2//!
3//! Beginners must always learn WHAT they did wrong: line and column,
4//! what was found, what was expected, and a concrete example. A syntax
5//! error must never surface as a bare `unknown command: X`.
6//!
7//! String and file parsers populate line, column span, and source line
8//! from pest spans. Token stream parsers (`macro_input.rs`) set
9//! `source_line` to `None` and forward the compiler span instead, since
10//! `proc_macro2::Span` carries coordinates but not source text.
11
12use std::fmt;
13
14/// Backwards compatible result alias for parser entrypoints.
15/// Public functions return `ParseResult` directly instead of erasing
16/// into `anyhow::Error`, so downstream crates match statically.
17pub type ParseResult<T> = std::result::Result<T, ParseError>;
18
19/// Machine readable classification of a parse failure.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ParseErrorKind {
22    /// The pest grammar could not tokenize the input at all.
23    PestParse,
24    /// First word is a known command or structural keyword, but the
25    /// rest of the line is malformed. Never report these as unknown.
26    InvalidSyntax { command: String },
27    /// First word matches no known command or keyword in any case.
28    /// Reserved strictly for truly unknown names.
29    UnknownCommand { name: String },
30    /// Guard, block, scope, or structural keyword failure
31    /// (`WITH_IO`, `LET`, `FOR`, `IF`, `{{`, `}}`, guards).
32    Structural { rule: String },
33    /// Arity, flag, or static argument type failure for a known command.
34    Validation { command: String },
35}
36
37/// Location and diagnostic context threaded through lowering.
38///
39/// String parsers fill `col_start`, `col_end`, and `source_line` from
40/// pest spans. Token stream parsers leave `source_line` as `None`;
41/// macro call sites keep their own `proc_macro2::Span` for
42/// `syn::Error` conversion (a span handle is not `Send`/`Sync`, so it
43/// never lives inside `ParseError`, which must stay convertible into
44/// `anyhow::Error`). Integer coordinates extracted from the span are
45/// attached here instead.
46#[derive(Debug, Clone, Default)]
47pub struct SpanContext<'a> {
48    /// 1-based line number. Always populated on the string path.
49    pub line: usize,
50    /// 1-based start column. `None` on token stream paths without locations.
51    pub col_start: Option<usize>,
52    /// 1-based end column (inclusive). `None` on token stream paths.
53    pub col_end: Option<usize>,
54    /// Raw source line text. `None` on token stream paths.
55    pub source_line: Option<String>,
56    /// Full script text (borrowed, zero copy). Lets span refinement
57    /// resolve the exact source line for any line number, including
58    /// multi-line bodies, without ever faking text. `None` on token
59    /// stream paths and bare line fallbacks.
60    pub source: Option<&'a str>,
61    /// Compiler span for macro contexts, kept at the call site for
62    /// `syn::Error` conversion. Gated on the same feature as
63    /// `macro_input.rs` to avoid new mandatory parser dependencies.
64    /// Never transferred into `ParseError`.
65    #[cfg(feature = "proc-macro-api")]
66    pub compiler_span: Option<proc_macro2::Span>,
67    /// Zero-based step index in `ScriptParser::parse`, when known.
68    pub step_index: Option<usize>,
69}
70
71impl<'a> SpanContext<'a> {
72    /// Bare line context. Used only where no span is available
73    /// (direct `lower_command` calls, end of script errors).
74    pub fn line_only(line: usize) -> Self {
75        Self {
76            line,
77            ..Self::default()
78        }
79    }
80
81    /// Full string path context with column span and source text.
82    pub fn full(line: usize, col_start: usize, col_end: usize, source_line: String) -> Self {
83        Self {
84            line,
85            col_start: Some(col_start),
86            col_end: Some(col_end),
87            source_line: Some(source_line),
88            source: None,
89            #[cfg(feature = "proc-macro-api")]
90            compiler_span: None,
91            step_index: None,
92        }
93    }
94
95    /// Attach the full script text for multi-line span resolution.
96    pub fn with_source(mut self, source: &'a str) -> Self {
97        if self.source_line.is_none() {
98            self.source_line = source
99                .lines()
100                .nth(self.line.saturating_sub(1))
101                .map(str::to_string);
102        }
103        self.source = Some(source);
104        self
105    }
106
107    /// Token stream context: coordinates without source text.
108    /// The caller keeps the span handle for `syn::Error` conversion;
109    /// only the integer coordinates travel in the context.
110    #[cfg(feature = "proc-macro-api")]
111    pub fn from_compiler_span(
112        line: usize,
113        col_start: Option<usize>,
114        col_end: Option<usize>,
115        span: proc_macro2::Span,
116    ) -> Self {
117        Self {
118            line,
119            col_start,
120            col_end,
121            source_line: None,
122            source: None,
123            compiler_span: Some(span),
124            step_index: None,
125        }
126    }
127
128    /// Borrowed compiler span for `syn::Error` conversion at the call site.
129    #[cfg(feature = "proc-macro-api")]
130    pub fn compiler_span(&self) -> Option<&proc_macro2::Span> {
131        self.compiler_span.as_ref()
132    }
133
134    /// Attach a step index (builder style).
135    pub fn with_step(mut self, step_index: usize) -> Self {
136        self.step_index = Some(step_index);
137        self
138    }
139}
140
141/// Typed parse error with structured diagnostics and a
142/// backwards compatible `Display` rendering.
143///
144/// Diagnostic detail lives behind a `Box` so `ParseResult` stays small
145/// in `Result` returns (cold path allocation only).
146#[derive(Debug, Clone)]
147pub struct ParseError {
148    kind: ParseErrorKind,
149    line: usize,
150    detail: Box<ErrorDetail>,
151}
152
153/// Heap stored diagnostic detail for [`ParseError`].
154#[derive(Debug, Clone)]
155struct ErrorDetail {
156    col_start: Option<usize>,
157    col_end: Option<usize>,
158    source_line: Option<String>,
159    step_index: Option<usize>,
160    found: Option<String>,
161    expected: Vec<String>,
162    hint: Option<String>,
163    /// Legacy message body. `Display` appends the location block,
164    /// so existing `expect_error_contains` assertions keep passing.
165    message: String,
166}
167
168impl ParseError {
169    fn new(
170        kind: ParseErrorKind,
171        line: usize,
172        ctx: &SpanContext,
173        found: Option<String>,
174        expected: Vec<String>,
175        hint: Option<String>,
176        message: String,
177    ) -> Self {
178        Self {
179            kind,
180            line,
181            detail: Box::new(ErrorDetail {
182                col_start: ctx.col_start,
183                col_end: ctx.col_end,
184                source_line: ctx.source_line.clone(),
185                step_index: ctx.step_index,
186                found,
187                expected,
188                hint,
189                message,
190            }),
191        }
192    }
193    /// Pest grammar failure. `message` must already contain the
194    /// `parse error (expected: ...)` header; the location block is
195    /// appended by `Display` unless already present.
196    pub fn pest(
197        message: String,
198        expected: Vec<String>,
199        hint: Option<String>,
200        ctx: &SpanContext,
201    ) -> Self {
202        Self::new(
203            ParseErrorKind::PestParse,
204            ctx.line,
205            ctx,
206            None,
207            expected,
208            hint,
209            message,
210        )
211    }
212
213    /// Known command or keyword with malformed arguments.
214    pub fn invalid_syntax(
215        command: &str,
216        message: String,
217        found: Option<String>,
218        expected: Vec<String>,
219        hint: Option<String>,
220        ctx: &SpanContext,
221    ) -> Self {
222        Self::new(
223            ParseErrorKind::InvalidSyntax {
224                command: command.to_string(),
225            },
226            ctx.line,
227            ctx,
228            found,
229            expected,
230            hint,
231            message,
232        )
233    }
234
235    /// Truly unknown command name. Callers must ensure `classify`
236    /// sent keyword led lines to `invalid_syntax` instead.
237    pub fn unknown_command(
238        name: &str,
239        message: String,
240        hint: Option<String>,
241        ctx: &SpanContext,
242    ) -> Self {
243        Self::new(
244            ParseErrorKind::UnknownCommand {
245                name: name.to_string(),
246            },
247            ctx.line,
248            ctx,
249            Some(name.to_string()),
250            Vec::new(),
251            hint,
252            message,
253        )
254    }
255
256    /// Structural failure (guards, blocks, `WITH_IO`, `LET`, `FOR`, `IF`).
257    pub fn structural(rule: &str, message: String, ctx: &SpanContext) -> Self {
258        Self::new(
259            ParseErrorKind::Structural {
260                rule: rule.to_string(),
261            },
262            ctx.line,
263            ctx,
264            None,
265            Vec::new(),
266            None,
267            message,
268        )
269    }
270
271    /// Arity, flag, or static type failure for a known command.
272    pub fn validation(command: &str, message: String, ctx: &SpanContext) -> Self {
273        Self::new(
274            ParseErrorKind::Validation {
275                command: command.to_string(),
276            },
277            ctx.line,
278            ctx,
279            None,
280            Vec::new(),
281            None,
282            message,
283        )
284    }
285
286    /// Enrich an error produced without span context (e.g. from a
287    /// direct `lower_command` call) with the caller's location.
288    /// Existing span fields are only overwritten when the current
289    /// value is `None`, so pest spans are never clobbered.
290    pub fn with_span(mut self, ctx: &SpanContext) -> Self {
291        if self.line == 0 {
292            self.line = ctx.line;
293        }
294        let detail = &mut self.detail;
295        if detail.col_start.is_none() {
296            detail.col_start = ctx.col_start;
297        }
298        if detail.col_end.is_none() {
299            detail.col_end = ctx.col_end;
300        }
301        if detail.source_line.is_none() {
302            detail.source_line = ctx.source_line.clone();
303        }
304        if detail.step_index.is_none() {
305            detail.step_index = ctx.step_index;
306        }
307        self
308    }
309
310    /// Attach a step index (builder style).
311    pub fn with_step(mut self, step_index: usize) -> Self {
312        self.detail.step_index = Some(step_index);
313        self
314    }
315
316    /// Machine readable kind.
317    pub fn kind(&self) -> &ParseErrorKind {
318        &self.kind
319    }
320
321    /// 1-based line number (`0` means unknown, direct calls only).
322    pub fn line(&self) -> usize {
323        self.line
324    }
325
326    /// 1-based start column, when known.
327    pub fn col_start(&self) -> Option<usize> {
328        self.detail.col_start
329    }
330
331    /// 1-based end column (inclusive), when known.
332    pub fn col_end(&self) -> Option<usize> {
333        self.detail.col_end
334    }
335
336    /// Raw source line text, when available.
337    pub fn source_line(&self) -> Option<&str> {
338        self.detail.source_line.as_deref()
339    }
340
341    /// Zero-based step index, when known.
342    pub fn step_index(&self) -> Option<usize> {
343        self.detail.step_index
344    }
345
346    /// What was found (command name or rendered args), when known.
347    pub fn found(&self) -> Option<&str> {
348        self.detail.found.as_deref()
349    }
350
351    /// Expected syntax alternatives.
352    pub fn expected(&self) -> &[String] {
353        &self.detail.expected
354    }
355
356    /// Concrete hint with an example, when known.
357    pub fn hint(&self) -> Option<&str> {
358        self.detail.hint.as_deref()
359    }
360
361    /// Render the caret block for the stored span, if complete.
362    fn caret_block(&self) -> Option<String> {
363        let (start, end) = match (self.detail.col_start, self.detail.col_end) {
364            (Some(s), Some(e)) => (s, e),
365            _ => return None,
366        };
367        let line_text = self.detail.source_line.as_deref()?;
368        if self.line == 0 || start == 0 {
369            return None;
370        }
371        let width = end.saturating_sub(start).saturating_add(1).max(1);
372        let pad = " ".repeat(start.saturating_sub(1));
373        let carets = "^".repeat(width.min(80));
374        Some(format!(
375            "\n  --> line {line}, col {start}-{end}\n  {line} | {line_text}\n    | {pad}{carets}",
376            line = self.line,
377        ))
378    }
379}
380
381impl fmt::Display for ParseError {
382    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
383        write!(f, "{}", self.detail.message)?;
384        if let Some(block) = self.caret_block() {
385            // Pest bodies already end with the caret block; do not duplicate it.
386            if !self.detail.message.contains("--> line") {
387                write!(f, "{block}")?;
388            }
389        } else if self.line > 0 && !self.detail.message.contains(&format!("line {}", self.line)) {
390            write!(f, "\n  --> line {}", self.line)?;
391        }
392        Ok(())
393    }
394}
395
396impl std::error::Error for ParseError {}