cmakefmt/error.rs
1// SPDX-FileCopyrightText: Copyright 2026 Puneet Matharu
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Structured error types returned by parsing, config loading, and formatting.
6//!
7//! Every fallible crate API returns [`Result`], which is
8//! `std::result::Result<T, Error>`. The [`enum@Error`] enum
9//! distinguishes sources:
10//!
11//! - [`Error::Parse`] — CMake source failed to parse; line/column
12//! info is 1-based.
13//! - [`Error::Config`] — a `.cmakefmt.yaml|yml|toml` (or
14//! `from_yaml_str` input) failed to deserialise, or a programmatic
15//! [`crate::Config`] had an invalid regex pattern.
16//! - [`Error::Spec`] — a `commands:` override file (or string)
17//! failed to deserialise, or the built-in spec file itself did.
18//! - [`Error::Io`] — filesystem or stream I/O failure.
19//! - [`Error::Formatter`] — higher-level formatter or CLI failure
20//! that doesn't fit another variant.
21//! - [`Error::LayoutTooWide`] — *only* produced when
22//! [`crate::Config::require_valid_layout`] is enabled and a
23//! formatted line exceeded the configured width. Not a bug in the
24//! formatter — a signal to the caller.
25//!
26//! [`crate::error::FileParseError`] and
27//! [`crate::error::ParseDiagnostic`] carry structured line/column
28//! metadata (1-based, both) so editor integrations can point at the
29//! offending source without re-parsing the error string.
30
31use std::fmt;
32use std::path::PathBuf;
33
34use thiserror::Error;
35
36/// Structured config/spec deserialization failure metadata used for
37/// user-facing diagnostics.
38///
39/// When present, `line` and `column` are **1-based** (not 0-based),
40/// matching the convention used by editors and the `ParseDiagnostic`
41/// counterpart for CMake source errors.
42#[derive(Debug, Clone, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct FileParseError {
45 /// Parser format name, such as `TOML` or `YAML`.
46 pub format: &'static str,
47 /// Human-readable parser message.
48 pub message: Box<str>,
49 /// Optional 1-based line number.
50 pub line: Option<usize>,
51 /// Optional 1-based column number.
52 pub column: Option<usize>,
53}
54
55impl fmt::Display for FileParseError {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 f.write_str(&self.message)
58 }
59}
60
61/// Crate-owned parser diagnostics used by [`enum@Error`] without exposing `pest`
62/// internals in the public API.
63///
64/// `line` and `column` are **1-based** and count columns by characters
65/// (not bytes), so multi-byte UTF-8 characters occupy a single
66/// column.
67#[derive(Debug, Clone, PartialEq, Eq)]
68#[non_exhaustive]
69pub struct ParseDiagnostic {
70 /// Human-readable parser detail.
71 pub message: Box<str>,
72 /// 1-based source line number.
73 pub line: usize,
74 /// 1-based source column number.
75 pub column: usize,
76}
77
78impl fmt::Display for ParseDiagnostic {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 f.write_str(&self.message)
81 }
82}
83
84/// Stable parse error returned by the public library API.
85#[derive(Debug, Clone, PartialEq, Eq, Error)]
86#[error("parse error in {display_name}: {diagnostic}")]
87#[non_exhaustive]
88pub struct ParseError {
89 /// Human-facing source name, for example a path or `<stdin>`.
90 pub display_name: String,
91 /// The source text that failed to parse.
92 pub source_text: Box<str>,
93 /// The 1-based source line number where this parser chunk started.
94 pub start_line: usize,
95 /// Structured parser diagnostic.
96 pub diagnostic: ParseDiagnostic,
97}
98
99impl ParseError {
100 fn with_display_name(mut self, display_name: impl Into<String>) -> Self {
101 self.display_name = display_name.into();
102 self
103 }
104}
105
106/// Stable config-file parse error returned by the public library API.
107#[derive(Debug, Clone, PartialEq, Eq, Error)]
108#[error("config error in {path}: {details}")]
109#[non_exhaustive]
110pub struct ConfigError {
111 /// The config file that failed to deserialize.
112 pub path: PathBuf,
113 /// Structured parser details for the failure.
114 pub details: FileParseError,
115}
116
117impl ConfigError {
118 /// Build a `ConfigError` from its component parts. Used at the
119 /// many call sites that wrap `serde` parser errors into the
120 /// crate's structured error type.
121 pub(crate) fn new(
122 path: PathBuf,
123 format: &'static str,
124 message: impl Into<Box<str>>,
125 line: Option<usize>,
126 column: Option<usize>,
127 ) -> Self {
128 Self {
129 path,
130 details: FileParseError {
131 format,
132 message: message.into(),
133 line,
134 column,
135 },
136 }
137 }
138}
139
140/// Stable command-spec parse error returned by the public library API.
141#[derive(Debug, Clone, PartialEq, Eq, Error)]
142#[error("spec error in {path}: {details}")]
143#[non_exhaustive]
144pub struct SpecError {
145 /// The spec file that failed to deserialize.
146 pub path: PathBuf,
147 /// Structured parser details for the failure.
148 pub details: FileParseError,
149}
150
151impl SpecError {
152 /// Build a `SpecError` from its component parts. Mirror of
153 /// [`ConfigError::new`] for spec-file parse failures.
154 pub(crate) fn new(
155 path: PathBuf,
156 format: &'static str,
157 message: impl Into<Box<str>>,
158 line: Option<usize>,
159 column: Option<usize>,
160 ) -> Self {
161 Self {
162 path,
163 details: FileParseError {
164 format,
165 message: message.into(),
166 line,
167 column,
168 },
169 }
170 }
171}
172
173/// Errors that can be returned by parsing, config loading, spec loading, or
174/// formatting operations.
175#[derive(Debug, Error)]
176#[non_exhaustive]
177pub enum Error {
178 /// A parser error annotated with source text and line-offset context.
179 #[error("{0}")]
180 Parse(#[from] ParseError),
181
182 /// A user config parse error.
183 #[error("{0}")]
184 Config(#[from] ConfigError),
185
186 /// A built-in or user override spec parse error.
187 #[error("{0}")]
188 Spec(#[from] SpecError),
189
190 /// A filesystem or stream I/O failure where no path is attached.
191 /// Use [`Error::io_at`] (or the [`IoResultExt::with_path`] adapter)
192 /// when reporting an error against a specific file — the
193 /// path-bearing variant is far more useful in user-facing
194 /// diagnostics.
195 #[error("I/O error: {0}")]
196 Io(#[from] std::io::Error),
197
198 /// A filesystem I/O failure annotated with the path that caused
199 /// it. Used at every site where we have a path in scope; far more
200 /// actionable than a bare `permission denied` from [`Error::Io`].
201 #[error("I/O error reading {path}: {source}")]
202 #[non_exhaustive]
203 IoAt {
204 /// The file or directory that failed.
205 path: PathBuf,
206 /// The underlying I/O error.
207 #[source]
208 source: std::io::Error,
209 },
210
211 /// A higher-level formatter or CLI error that does not fit another
212 /// structured variant. In practice this covers: runtime regex
213 /// validation failures on a programmatically-built [`crate::Config`]
214 /// (before the config is saved to disk), CLI argument validation
215 /// failures, and rendering failures in the config/spec pretty-printers.
216 #[error("formatter error: {0}")]
217 Formatter(String),
218
219 /// A formatted line exceeded the configured line width and
220 /// `require_valid_layout` is enabled.
221 #[error(
222 "line {line_no} is {width} characters wide, exceeding the configured limit of {limit}"
223 )]
224 #[non_exhaustive]
225 LayoutTooWide {
226 /// 1-based line number in the formatted output.
227 line_no: usize,
228 /// Actual character width of the offending line.
229 width: usize,
230 /// Configured [`crate::Config::line_width`] limit.
231 limit: usize,
232 },
233}
234
235/// Convenience alias for crate-level results.
236pub type Result<T> = std::result::Result<T, Error>;
237
238impl Error {
239 /// Attach a human-facing source name (e.g. a file path) to a
240 /// contextual [`ParseError`]. No-op for any other variant —
241 /// `Config`, `Spec`, `Io`, `IoAt`, `Formatter`, and
242 /// `LayoutTooWide` already carry the context they need and are
243 /// returned unchanged.
244 pub fn with_display_name(self, display_name: impl Into<String>) -> Self {
245 match self {
246 Self::Parse(parse) => Self::Parse(parse.with_display_name(display_name)),
247 other => other,
248 }
249 }
250
251 /// Build an [`Error::IoAt`] variant from a path and an underlying
252 /// `io::Error`. Use this at every I/O call site where you have a
253 /// path in scope — [`Error::Io`] is reserved for streams (stdout,
254 /// stdin) and similar path-less I/O.
255 pub fn io_at(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
256 Self::IoAt {
257 path: path.into(),
258 source,
259 }
260 }
261}
262
263/// Extension trait for ergonomic conversion of `io::Result<T>` into
264/// the crate's path-bearing `Result<T>`. Reads at call sites as
265/// `std::fs::read_to_string(&path).with_path(&path)?` — one extra
266/// token compared to `.map_err(Error::Io)?`, with much better
267/// diagnostics on failure.
268pub trait IoResultExt<T> {
269 /// Wrap an `io::Error` with the path that produced it, returning
270 /// an [`Error::IoAt`] on failure.
271 fn with_path(self, path: impl Into<PathBuf>) -> Result<T>;
272}
273
274impl<T> IoResultExt<T> for std::io::Result<T> {
275 fn with_path(self, path: impl Into<PathBuf>) -> Result<T> {
276 self.map_err(|source| Error::io_at(path, source))
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn parse_diagnostic_display_shows_message() {
286 let diag = ParseDiagnostic {
287 message: "expected argument part".into(),
288 line: 5,
289 column: 10,
290 };
291 assert_eq!(diag.to_string(), "expected argument part");
292 }
293
294 #[test]
295 fn parse_diagnostic_from_parse_error() {
296 let source = "if(\n";
297 let err = crate::parser::parse(source).unwrap_err();
298 if let Error::Parse(ParseError { diagnostic, .. }) = err {
299 assert!(diagnostic.line >= 1);
300 assert!(diagnostic.column >= 1);
301 assert!(!diagnostic.message.is_empty());
302 } else {
303 panic!("expected Parse, got {err:?}");
304 }
305 }
306
307 #[test]
308 fn error_parse_display() {
309 let err = Error::Parse(ParseError {
310 display_name: "test.cmake".to_owned(),
311 source_text: "if(\n".into(),
312 start_line: 1,
313 diagnostic: ParseDiagnostic {
314 message: "expected argument part".into(),
315 line: 1,
316 column: 4,
317 },
318 });
319 let msg = err.to_string();
320 assert!(msg.contains("test.cmake"));
321 assert!(msg.contains("expected argument part"));
322 }
323
324 #[test]
325 fn error_config_display() {
326 let err = Error::Config(ConfigError {
327 path: std::path::PathBuf::from("bad.yaml"),
328 details: FileParseError {
329 format: "YAML",
330 message: "unexpected key".into(),
331 line: Some(3),
332 column: Some(1),
333 },
334 });
335 let msg = err.to_string();
336 assert!(msg.contains("bad.yaml"));
337 assert!(msg.contains("unexpected key"));
338 }
339
340 #[test]
341 fn error_spec_display() {
342 let err = Error::Spec(SpecError {
343 path: std::path::PathBuf::from("commands.yaml"),
344 details: FileParseError {
345 format: "YAML",
346 message: "invalid nargs".into(),
347 line: None,
348 column: None,
349 },
350 });
351 let msg = err.to_string();
352 assert!(msg.contains("commands.yaml"));
353 assert!(msg.contains("invalid nargs"));
354 }
355
356 #[test]
357 fn error_io_display() {
358 let err = Error::Io(std::io::Error::new(
359 std::io::ErrorKind::NotFound,
360 "file not found",
361 ));
362 assert!(err.to_string().contains("file not found"));
363 }
364
365 #[test]
366 fn error_formatter_display() {
367 let err = Error::Formatter("something went wrong".to_owned());
368 assert!(err.to_string().contains("something went wrong"));
369 }
370
371 #[test]
372 fn error_layout_too_wide_display() {
373 let err = Error::LayoutTooWide {
374 line_no: 42,
375 width: 120,
376 limit: 80,
377 };
378 let msg = err.to_string();
379 assert!(msg.contains("42"));
380 assert!(msg.contains("120"));
381 assert!(msg.contains("80"));
382 }
383
384 #[test]
385 fn with_display_name_updates_parse() {
386 let err = Error::Parse(ParseError {
387 display_name: "original".to_owned(),
388 source_text: "set(\n".into(),
389 start_line: 1,
390 diagnostic: ParseDiagnostic {
391 message: "test".into(),
392 line: 1,
393 column: 5,
394 },
395 });
396 let renamed = err.with_display_name("renamed.cmake");
397 match renamed {
398 Error::Parse(ParseError { display_name, .. }) => {
399 assert_eq!(display_name, "renamed.cmake");
400 }
401 _ => panic!("expected Parse"),
402 }
403 }
404
405 #[test]
406 fn with_display_name_passes_through_non_parse_errors() {
407 let err = Error::Formatter("test".to_owned());
408 let result = err.with_display_name("ignored");
409 match result {
410 Error::Formatter(msg) => assert_eq!(msg, "test"),
411 _ => panic!("expected Formatter to pass through"),
412 }
413 }
414
415 #[test]
416 fn io_error_converts_from_std() {
417 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
418 let err: Error = io_err.into();
419 match err {
420 Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
421 _ => panic!("expected Io variant"),
422 }
423 }
424}