Skip to main content

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::CliArg`] — a CLI argument validation failure
20//!   (incompatible flag combinations, missing required arguments,
21//!   conflicting overrides).
22//! - [`Error::InvalidRegex`] — a regex pattern from the user (CLI
23//!   flag, config file, or spec override) failed to compile.
24//! - [`Error::Render`] — a failure rendering a Config or Spec to
25//!   text (TOML / YAML / JSON), or building a machine-format
26//!   report (SARIF / Checkstyle / JUnit / JSON edit).
27//! - [`Error::LegacyMigration`] — a failure during legacy
28//!   `cmake-format` config migration.
29//! - [`Error::Formatter`] — miscellaneous higher-level formatter
30//!   or CLI failure that does not fit any of the structured
31//!   sub-variants above. Prefer [`Error::CliArg`],
32//!   [`Error::InvalidRegex`], [`Error::Render`], or
33//!   [`Error::LegacyMigration`] when applicable.
34//! - [`Error::LayoutTooWide`] — *only* produced when
35//!   [`crate::Config::require_valid_layout`] is enabled and a
36//!   formatted line exceeded the configured width. Not a bug in the
37//!   formatter — a signal to the caller.
38//!
39//! [`crate::error::FileParseError`] and
40//! [`crate::error::ParseDiagnostic`] carry structured line/column
41//! metadata (1-based, both) so editor integrations can point at the
42//! offending source without re-parsing the error string.
43
44use std::fmt;
45use std::path::PathBuf;
46
47use thiserror::Error;
48
49/// Structured config/spec deserialization failure metadata used for
50/// user-facing diagnostics.
51///
52/// When present, `line` and `column` are **1-based** (not 0-based),
53/// matching the convention used by editors and the `ParseDiagnostic`
54/// counterpart for CMake source errors.
55#[derive(Debug, Clone, PartialEq, Eq)]
56#[non_exhaustive]
57pub struct FileParseError {
58    /// Parser format name, such as `TOML` or `YAML`.
59    pub format: &'static str,
60    /// Human-readable parser message.
61    pub message: Box<str>,
62    /// Optional 1-based line number.
63    pub line: Option<usize>,
64    /// Optional 1-based column number.
65    pub column: Option<usize>,
66}
67
68impl fmt::Display for FileParseError {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(&self.message)
71    }
72}
73
74/// Crate-owned parser diagnostics used by [`enum@Error`] without exposing `pest`
75/// internals in the public API.
76///
77/// `line` and `column` are **1-based** and count columns by characters
78/// (not bytes), so multi-byte UTF-8 characters occupy a single
79/// column.
80#[derive(Debug, Clone, PartialEq, Eq)]
81#[non_exhaustive]
82pub struct ParseDiagnostic {
83    /// Human-readable parser detail.
84    pub message: Box<str>,
85    /// 1-based source line number.
86    pub line: usize,
87    /// 1-based source column number.
88    pub column: usize,
89}
90
91impl fmt::Display for ParseDiagnostic {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str(&self.message)
94    }
95}
96
97/// Stable parse error returned by the public library API.
98#[derive(Debug, Clone, PartialEq, Eq, Error)]
99#[error("parse error in {display_name}: {diagnostic}")]
100#[non_exhaustive]
101pub struct ParseError {
102    /// Human-facing source name, for example a path or `<stdin>`.
103    pub display_name: String,
104    /// The source text that failed to parse.
105    pub source_text: Box<str>,
106    /// The 1-based source line number where this parser chunk started.
107    pub start_line: usize,
108    /// Structured parser diagnostic.
109    pub diagnostic: ParseDiagnostic,
110}
111
112impl ParseError {
113    fn with_display_name(mut self, display_name: impl Into<String>) -> Self {
114        self.display_name = display_name.into();
115        self
116    }
117}
118
119/// Stable config-file parse error returned by the public library API.
120#[derive(Debug, Clone, PartialEq, Eq, Error)]
121#[error("config error in {path}: {details}")]
122#[non_exhaustive]
123pub struct ConfigError {
124    /// The config file that failed to deserialize.
125    pub path: PathBuf,
126    /// Structured parser details for the failure.
127    pub details: FileParseError,
128}
129
130impl ConfigError {
131    /// Build a `ConfigError` from its component parts. Used at the
132    /// many call sites that wrap `serde` parser errors into the
133    /// crate's structured error type.
134    pub(crate) fn new(
135        path: PathBuf,
136        format: &'static str,
137        message: impl Into<Box<str>>,
138        line: Option<usize>,
139        column: Option<usize>,
140    ) -> Self {
141        Self {
142            path,
143            details: FileParseError {
144                format,
145                message: message.into(),
146                line,
147                column,
148            },
149        }
150    }
151}
152
153/// Stable command-spec parse error returned by the public library API.
154#[derive(Debug, Clone, PartialEq, Eq, Error)]
155#[error("spec error in {path}: {details}")]
156#[non_exhaustive]
157pub struct SpecError {
158    /// The spec file that failed to deserialize.
159    pub path: PathBuf,
160    /// Structured parser details for the failure.
161    pub details: FileParseError,
162}
163
164impl SpecError {
165    /// Build a `SpecError` from its component parts. Mirror of
166    /// [`ConfigError::new`] for spec-file parse failures.
167    pub(crate) fn new(
168        path: PathBuf,
169        format: &'static str,
170        message: impl Into<Box<str>>,
171        line: Option<usize>,
172        column: Option<usize>,
173    ) -> Self {
174        Self {
175            path,
176            details: FileParseError {
177                format,
178                message: message.into(),
179                line,
180                column,
181            },
182        }
183    }
184}
185
186/// Errors that can be returned by parsing, config loading, spec loading, or
187/// formatting operations.
188#[derive(Debug, Error)]
189#[non_exhaustive]
190pub enum Error {
191    /// A parser error annotated with source text and line-offset context.
192    #[error("{0}")]
193    Parse(#[from] ParseError),
194
195    /// A user config parse error.
196    #[error("{0}")]
197    Config(#[from] ConfigError),
198
199    /// A built-in or user override spec parse error.
200    #[error("{0}")]
201    Spec(#[from] SpecError),
202
203    /// A filesystem or stream I/O failure where no path is attached.
204    /// Use [`Error::io_at`] (or the [`IoResultExt::with_path`] adapter)
205    /// when reporting an error against a specific file — the
206    /// path-bearing variant is far more useful in user-facing
207    /// diagnostics.
208    #[error("I/O error: {0}")]
209    Io(#[from] std::io::Error),
210
211    /// A filesystem I/O failure annotated with the path that caused
212    /// it. Used at every site where we have a path in scope; far more
213    /// actionable than a bare `permission denied` from [`Error::Io`].
214    #[error("I/O error reading {path}: {source}")]
215    #[non_exhaustive]
216    IoAt {
217        /// The file or directory that failed.
218        path: PathBuf,
219        /// The underlying I/O error.
220        #[source]
221        source: std::io::Error,
222    },
223
224    /// A higher-level formatter or CLI error that does not fit another
225    /// structured variant. Prefer [`Error::CliArg`], [`Error::InvalidRegex`],
226    /// [`Error::Render`], [`Error::LegacyMigration`], or [`Error::IoAt`]
227    /// when the failure mode is one of those — `Error::Formatter` is the
228    /// catch-all for the small set of cases that legitimately don't fit
229    /// any of those categories (e.g. LSP runtime failures, semantic
230    /// verification failures, watch-loop infrastructure, git subprocess
231    /// failures, spec parsing from in-memory strings without a path).
232    #[error("formatter error: {0}")]
233    Formatter(String),
234
235    /// A CLI argument validation failure — incompatible flag combinations,
236    /// missing required arguments, conflicting overrides.
237    #[error("{message}")]
238    #[non_exhaustive]
239    CliArg {
240        /// Human-readable description of what argument combination is
241        /// invalid.
242        message: String,
243    },
244
245    /// A regex pattern from the user (CLI flag, config file, or spec
246    /// override) failed to compile or apply.
247    #[error("invalid regex {pattern:?}: {source}")]
248    #[non_exhaustive]
249    InvalidRegex {
250        /// The pattern (or named config slot) that failed to compile.
251        pattern: String,
252        /// The underlying `regex` crate error.
253        #[source]
254        source: regex::Error,
255    },
256
257    /// A failure rendering a [`crate::Config`] or spec to text (TOML /
258    /// YAML / JSON), or building a machine-format report (SARIF /
259    /// Checkstyle / JUnit / JSON edit). The `format` field names the
260    /// target format.
261    #[error("failed to render {format}: {message}")]
262    #[non_exhaustive]
263    Render {
264        /// Name of the target format (`"YAML"`, `"TOML"`, `"JSON"`,
265        /// `"SARIF"`, etc.).
266        format: String,
267        /// Human-readable detail of what went wrong.
268        message: String,
269    },
270
271    /// A failure during legacy `cmake-format` config migration —
272    /// parsing the old format, converting it, or writing the
273    /// modernised file. The `path` field carries the legacy file the
274    /// user was trying to migrate.
275    #[error("legacy migration failed for {}: {message}", path.display())]
276    #[non_exhaustive]
277    LegacyMigration {
278        /// The legacy config file being migrated.
279        path: PathBuf,
280        /// Human-readable description of the migration failure.
281        message: String,
282    },
283
284    /// A formatted line exceeded the configured line width and
285    /// `require_valid_layout` is enabled.
286    #[error(
287        "line {line_no} is {width} characters wide, exceeding the configured limit of {limit}"
288    )]
289    #[non_exhaustive]
290    LayoutTooWide {
291        /// 1-based line number in the formatted output.
292        line_no: usize,
293        /// Actual character width of the offending line.
294        width: usize,
295        /// Configured [`crate::Config::line_width`] limit.
296        limit: usize,
297    },
298}
299
300/// Convenience alias for crate-level results.
301pub type Result<T> = std::result::Result<T, Error>;
302
303impl Error {
304    /// Attach a human-facing source name (e.g. a file path) to a
305    /// contextual [`ParseError`]. No-op for any other variant —
306    /// `Config`, `Spec`, `Io`, `IoAt`, `Formatter`, `CliArg`,
307    /// `InvalidRegex`, `Render`, `LegacyMigration`, and
308    /// `LayoutTooWide` already carry the context they need and are
309    /// returned unchanged.
310    pub fn with_display_name(self, display_name: impl Into<String>) -> Self {
311        match self {
312            Self::Parse(parse) => Self::Parse(parse.with_display_name(display_name)),
313            other => other,
314        }
315    }
316
317    /// Build an [`Error::IoAt`] variant from a path and an underlying
318    /// `io::Error`. Use this at every I/O call site where you have a
319    /// path in scope — [`Error::Io`] is reserved for streams (stdout,
320    /// stdin) and similar path-less I/O.
321    pub fn io_at(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
322        Self::IoAt {
323            path: path.into(),
324            source,
325        }
326    }
327
328    /// Build an [`Error::CliArg`] variant from a human-readable
329    /// description of the invalid argument combination.
330    pub fn cli_arg(message: impl Into<String>) -> Self {
331        Self::CliArg {
332            message: message.into(),
333        }
334    }
335
336    /// Build an [`Error::InvalidRegex`] variant from the pattern that
337    /// failed to compile and the underlying [`regex::Error`].
338    pub fn invalid_regex(pattern: impl Into<String>, source: regex::Error) -> Self {
339        Self::InvalidRegex {
340            pattern: pattern.into(),
341            source,
342        }
343    }
344
345    /// Build an [`Error::Render`] variant from the target format name
346    /// and a human-readable failure message.
347    pub fn render(format: impl Into<String>, message: impl Into<String>) -> Self {
348        Self::Render {
349            format: format.into(),
350            message: message.into(),
351        }
352    }
353
354    /// Build an [`Error::LegacyMigration`] variant from the legacy
355    /// config path and a human-readable failure message.
356    pub fn legacy_migration(path: impl Into<PathBuf>, message: impl Into<String>) -> Self {
357        Self::LegacyMigration {
358            path: path.into(),
359            message: message.into(),
360        }
361    }
362}
363
364/// Extension trait for ergonomic conversion of `io::Result<T>` into
365/// the crate's path-bearing `Result<T>`. Reads at call sites as
366/// `std::fs::read_to_string(&path).with_path(&path)?` — one extra
367/// token compared to `.map_err(Error::Io)?`, with much better
368/// diagnostics on failure.
369pub trait IoResultExt<T> {
370    /// Wrap an `io::Error` with the path that produced it, returning
371    /// an [`Error::IoAt`] on failure.
372    fn with_path(self, path: impl Into<PathBuf>) -> Result<T>;
373}
374
375impl<T> IoResultExt<T> for std::io::Result<T> {
376    fn with_path(self, path: impl Into<PathBuf>) -> Result<T> {
377        self.map_err(|source| Error::io_at(path, source))
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn parse_diagnostic_display_shows_message() {
387        let diag = ParseDiagnostic {
388            message: "expected argument part".into(),
389            line: 5,
390            column: 10,
391        };
392        assert_eq!(diag.to_string(), "expected argument part");
393    }
394
395    #[test]
396    fn parse_diagnostic_from_parse_error() {
397        let source = "if(\n";
398        let err = crate::parser::parse(source).unwrap_err();
399        if let Error::Parse(ParseError { diagnostic, .. }) = err {
400            assert!(diagnostic.line >= 1);
401            assert!(diagnostic.column >= 1);
402            assert!(!diagnostic.message.is_empty());
403        } else {
404            panic!("expected Parse, got {err:?}");
405        }
406    }
407
408    #[test]
409    fn error_parse_display() {
410        let err = Error::Parse(ParseError {
411            display_name: "test.cmake".to_owned(),
412            source_text: "if(\n".into(),
413            start_line: 1,
414            diagnostic: ParseDiagnostic {
415                message: "expected argument part".into(),
416                line: 1,
417                column: 4,
418            },
419        });
420        let msg = err.to_string();
421        assert!(msg.contains("test.cmake"));
422        assert!(msg.contains("expected argument part"));
423    }
424
425    #[test]
426    fn error_config_display() {
427        let err = Error::Config(ConfigError {
428            path: std::path::PathBuf::from("bad.yaml"),
429            details: FileParseError {
430                format: "YAML",
431                message: "unexpected key".into(),
432                line: Some(3),
433                column: Some(1),
434            },
435        });
436        let msg = err.to_string();
437        assert!(msg.contains("bad.yaml"));
438        assert!(msg.contains("unexpected key"));
439    }
440
441    #[test]
442    fn error_spec_display() {
443        let err = Error::Spec(SpecError {
444            path: std::path::PathBuf::from("commands.yaml"),
445            details: FileParseError {
446                format: "YAML",
447                message: "invalid nargs".into(),
448                line: None,
449                column: None,
450            },
451        });
452        let msg = err.to_string();
453        assert!(msg.contains("commands.yaml"));
454        assert!(msg.contains("invalid nargs"));
455    }
456
457    #[test]
458    fn error_io_display() {
459        let err = Error::Io(std::io::Error::new(
460            std::io::ErrorKind::NotFound,
461            "file not found",
462        ));
463        assert!(err.to_string().contains("file not found"));
464    }
465
466    #[test]
467    fn error_formatter_display() {
468        let err = Error::Formatter("something went wrong".to_owned());
469        assert!(err.to_string().contains("something went wrong"));
470    }
471
472    #[test]
473    fn error_cli_arg_display() {
474        let err = Error::CliArg {
475            message: "--foo cannot be combined with --bar".to_owned(),
476        };
477        let msg = err.to_string();
478        assert!(msg.contains("--foo"));
479        assert!(msg.contains("--bar"));
480    }
481
482    #[test]
483    fn error_invalid_regex_display() {
484        // Build a bad regex pattern at runtime so clippy's
485        // `invalid_regex` lint doesn't trip on the literal.
486        let bad_pattern = ["[", "invalid", "("].concat();
487        let source = regex::Regex::new(&bad_pattern).unwrap_err();
488        let err = Error::InvalidRegex {
489            pattern: bad_pattern.clone(),
490            source,
491        };
492        let msg = err.to_string();
493        assert!(msg.contains(&bad_pattern));
494        assert!(msg.contains("invalid regex"));
495    }
496
497    #[test]
498    fn error_render_display() {
499        let err = Error::Render {
500            format: "YAML".to_owned(),
501            message: "unsupported type".to_owned(),
502        };
503        let msg = err.to_string();
504        assert!(msg.contains("YAML"));
505        assert!(msg.contains("unsupported type"));
506    }
507
508    #[test]
509    fn error_legacy_migration_display() {
510        let err = Error::LegacyMigration {
511            path: std::path::PathBuf::from("legacy.py"),
512            message: "section is not a table".to_owned(),
513        };
514        let msg = err.to_string();
515        assert!(msg.contains("legacy.py"));
516        assert!(msg.contains("section is not a table"));
517    }
518
519    #[test]
520    fn error_layout_too_wide_display() {
521        let err = Error::LayoutTooWide {
522            line_no: 42,
523            width: 120,
524            limit: 80,
525        };
526        let msg = err.to_string();
527        assert!(msg.contains("42"));
528        assert!(msg.contains("120"));
529        assert!(msg.contains("80"));
530    }
531
532    #[test]
533    fn with_display_name_updates_parse() {
534        let err = Error::Parse(ParseError {
535            display_name: "original".to_owned(),
536            source_text: "set(\n".into(),
537            start_line: 1,
538            diagnostic: ParseDiagnostic {
539                message: "test".into(),
540                line: 1,
541                column: 5,
542            },
543        });
544        let renamed = err.with_display_name("renamed.cmake");
545        match renamed {
546            Error::Parse(ParseError { display_name, .. }) => {
547                assert_eq!(display_name, "renamed.cmake");
548            }
549            _ => panic!("expected Parse"),
550        }
551    }
552
553    #[test]
554    fn with_display_name_passes_through_non_parse_errors() {
555        let err = Error::Formatter("test".to_owned());
556        let result = err.with_display_name("ignored");
557        match result {
558            Error::Formatter(msg) => assert_eq!(msg, "test"),
559            _ => panic!("expected Formatter to pass through"),
560        }
561    }
562
563    #[test]
564    fn io_error_converts_from_std() {
565        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
566        let err: Error = io_err.into();
567        match err {
568            Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
569            _ => panic!("expected Io variant"),
570        }
571    }
572}