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 {}