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::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
117/// Stable command-spec parse error returned by the public library API.
118#[derive(Debug, Clone, PartialEq, Eq, Error)]
119#[error("spec error in {path}: {details}")]
120#[non_exhaustive]
121pub struct SpecError {
122    /// The spec file that failed to deserialize.
123    pub path: PathBuf,
124    /// Structured parser details for the failure.
125    pub details: FileParseError,
126}
127
128/// Errors that can be returned by parsing, config loading, spec loading, or
129/// formatting operations.
130#[derive(Debug, Error)]
131#[non_exhaustive]
132pub enum Error {
133    /// A parser error annotated with source text and line-offset context.
134    #[error("{0}")]
135    Parse(#[from] ParseError),
136
137    /// A user config parse error.
138    #[error("{0}")]
139    Config(#[from] ConfigError),
140
141    /// A built-in or user override spec parse error.
142    #[error("{0}")]
143    Spec(#[from] SpecError),
144
145    /// A filesystem or stream I/O failure.
146    #[error("I/O error: {0}")]
147    Io(#[from] std::io::Error),
148
149    /// A higher-level formatter or CLI error that does not fit another
150    /// structured variant. In practice this covers: runtime regex
151    /// validation failures on a programmatically-built [`crate::Config`]
152    /// (before the config is saved to disk), CLI argument validation
153    /// failures, and rendering failures in the config/spec pretty-printers.
154    #[error("formatter error: {0}")]
155    Formatter(String),
156
157    /// A formatted line exceeded the configured line width and
158    /// `require_valid_layout` is enabled.
159    #[error(
160        "line {line_no} is {width} characters wide, exceeding the configured limit of {limit}"
161    )]
162    LayoutTooWide {
163        /// 1-based line number in the formatted output.
164        line_no: usize,
165        /// Actual character width of the offending line.
166        width: usize,
167        /// Configured [`crate::Config::line_width`] limit.
168        limit: usize,
169    },
170}
171
172/// Convenience alias for crate-level results.
173pub type Result<T> = std::result::Result<T, Error>;
174
175impl Error {
176    /// Attach a human-facing source name (e.g. a file path) to a
177    /// contextual [`ParseError`]. No-op for any other variant —
178    /// `Config`, `Spec`, `Io`, `Formatter`, and `LayoutTooWide`
179    /// already carry the context they need and are returned
180    /// unchanged.
181    pub fn with_display_name(self, display_name: impl Into<String>) -> Self {
182        match self {
183            Self::Parse(parse) => Self::Parse(parse.with_display_name(display_name)),
184            other => other,
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn parse_diagnostic_display_shows_message() {
195        let diag = ParseDiagnostic {
196            message: "expected argument part".into(),
197            line: 5,
198            column: 10,
199        };
200        assert_eq!(diag.to_string(), "expected argument part");
201    }
202
203    #[test]
204    fn parse_diagnostic_from_parse_error() {
205        let source = "if(\n";
206        let err = crate::parser::parse(source).unwrap_err();
207        if let Error::Parse(ParseError { diagnostic, .. }) = err {
208            assert!(diagnostic.line >= 1);
209            assert!(diagnostic.column >= 1);
210            assert!(!diagnostic.message.is_empty());
211        } else {
212            panic!("expected Parse, got {err:?}");
213        }
214    }
215
216    #[test]
217    fn error_parse_display() {
218        let err = Error::Parse(ParseError {
219            display_name: "test.cmake".to_owned(),
220            source_text: "if(\n".into(),
221            start_line: 1,
222            diagnostic: ParseDiagnostic {
223                message: "expected argument part".into(),
224                line: 1,
225                column: 4,
226            },
227        });
228        let msg = err.to_string();
229        assert!(msg.contains("test.cmake"));
230        assert!(msg.contains("expected argument part"));
231    }
232
233    #[test]
234    fn error_config_display() {
235        let err = Error::Config(ConfigError {
236            path: std::path::PathBuf::from("bad.yaml"),
237            details: FileParseError {
238                format: "YAML",
239                message: "unexpected key".into(),
240                line: Some(3),
241                column: Some(1),
242            },
243        });
244        let msg = err.to_string();
245        assert!(msg.contains("bad.yaml"));
246        assert!(msg.contains("unexpected key"));
247    }
248
249    #[test]
250    fn error_spec_display() {
251        let err = Error::Spec(SpecError {
252            path: std::path::PathBuf::from("commands.yaml"),
253            details: FileParseError {
254                format: "YAML",
255                message: "invalid nargs".into(),
256                line: None,
257                column: None,
258            },
259        });
260        let msg = err.to_string();
261        assert!(msg.contains("commands.yaml"));
262        assert!(msg.contains("invalid nargs"));
263    }
264
265    #[test]
266    fn error_io_display() {
267        let err = Error::Io(std::io::Error::new(
268            std::io::ErrorKind::NotFound,
269            "file not found",
270        ));
271        assert!(err.to_string().contains("file not found"));
272    }
273
274    #[test]
275    fn error_formatter_display() {
276        let err = Error::Formatter("something went wrong".to_owned());
277        assert!(err.to_string().contains("something went wrong"));
278    }
279
280    #[test]
281    fn error_layout_too_wide_display() {
282        let err = Error::LayoutTooWide {
283            line_no: 42,
284            width: 120,
285            limit: 80,
286        };
287        let msg = err.to_string();
288        assert!(msg.contains("42"));
289        assert!(msg.contains("120"));
290        assert!(msg.contains("80"));
291    }
292
293    #[test]
294    fn with_display_name_updates_parse() {
295        let err = Error::Parse(ParseError {
296            display_name: "original".to_owned(),
297            source_text: "set(\n".into(),
298            start_line: 1,
299            diagnostic: ParseDiagnostic {
300                message: "test".into(),
301                line: 1,
302                column: 5,
303            },
304        });
305        let renamed = err.with_display_name("renamed.cmake");
306        match renamed {
307            Error::Parse(ParseError { display_name, .. }) => {
308                assert_eq!(display_name, "renamed.cmake");
309            }
310            _ => panic!("expected Parse"),
311        }
312    }
313
314    #[test]
315    fn with_display_name_passes_through_non_parse_errors() {
316        let err = Error::Formatter("test".to_owned());
317        let result = err.with_display_name("ignored");
318        match result {
319            Error::Formatter(msg) => assert_eq!(msg, "test"),
320            _ => panic!("expected Formatter to pass through"),
321        }
322    }
323
324    #[test]
325    fn io_error_converts_from_std() {
326        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
327        let err: Error = io_err.into();
328        match err {
329            Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
330            _ => panic!("expected Io variant"),
331        }
332    }
333}