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
7use std::fmt;
8use std::path::PathBuf;
9
10use pest::error::{ErrorVariant, LineColLocation};
11use thiserror::Error;
12
13/// Structured config/spec deserialization failure metadata used for
14/// user-facing diagnostics.
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub struct FileParseError {
18    /// Parser format name, such as `TOML` or `YAML`.
19    pub format: &'static str,
20    /// Human-readable parser message.
21    pub message: Box<str>,
22    /// Optional 1-based line number.
23    pub line: Option<usize>,
24    /// Optional 1-based column number.
25    pub column: Option<usize>,
26}
27
28impl fmt::Display for FileParseError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        f.write_str(&self.message)
31    }
32}
33
34/// Crate-owned parser diagnostics used by [`enum@Error`] without exposing `pest`
35/// internals in the public API.
36#[derive(Debug, Clone, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct ParseDiagnostic {
39    /// Human-readable parser detail.
40    pub message: Box<str>,
41    /// 1-based source line number.
42    pub line: usize,
43    /// 1-based source column number.
44    pub column: usize,
45}
46
47impl ParseDiagnostic {
48    pub(crate) fn from_pest<R: pest::RuleType>(error: &pest::error::Error<R>) -> Self {
49        let (line, column) = match error.line_col {
50            LineColLocation::Pos((line, column)) => (line, column),
51            LineColLocation::Span((line, column), _) => (line, column),
52        };
53        let message = match &error.variant {
54            ErrorVariant::ParsingError { positives, .. } if !positives.is_empty() => format!(
55                "expected {}",
56                positives
57                    .iter()
58                    .map(|rule| format!("{rule:?}").replace('_', " "))
59                    .collect::<Vec<_>>()
60                    .join(", ")
61            ),
62            ErrorVariant::CustomError { message } => message.clone(),
63            _ => error.to_string(),
64        };
65        Self {
66            message: message.into_boxed_str(),
67            line,
68            column,
69        }
70    }
71}
72
73impl fmt::Display for ParseDiagnostic {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.write_str(&self.message)
76    }
77}
78
79/// Stable parse error returned by the public library API.
80#[derive(Debug, Clone, PartialEq, Eq, Error)]
81#[error("parse error in {display_name}: {diagnostic}")]
82#[non_exhaustive]
83pub struct ParseError {
84    /// Human-facing source name, for example a path or `<stdin>`.
85    pub display_name: String,
86    /// The source text that failed to parse.
87    pub source_text: Box<str>,
88    /// The 1-based source line number where this parser chunk started.
89    pub start_line: usize,
90    /// Structured parser diagnostic.
91    pub diagnostic: ParseDiagnostic,
92}
93
94impl ParseError {
95    fn with_display_name(mut self, display_name: impl Into<String>) -> Self {
96        self.display_name = display_name.into();
97        self
98    }
99}
100
101/// Stable config-file parse error returned by the public library API.
102#[derive(Debug, Clone, PartialEq, Eq, Error)]
103#[error("config error in {path}: {details}")]
104#[non_exhaustive]
105pub struct ConfigError {
106    /// The config file that failed to deserialize.
107    pub path: PathBuf,
108    /// Structured parser details for the failure.
109    pub details: FileParseError,
110}
111
112/// Stable command-spec parse error returned by the public library API.
113#[derive(Debug, Clone, PartialEq, Eq, Error)]
114#[error("spec error in {path}: {details}")]
115#[non_exhaustive]
116pub struct SpecError {
117    /// The spec file that failed to deserialize.
118    pub path: PathBuf,
119    /// Structured parser details for the failure.
120    pub details: FileParseError,
121}
122
123/// Errors that can be returned by parsing, config loading, spec loading, or
124/// formatting operations.
125#[derive(Debug, Error)]
126#[non_exhaustive]
127pub enum Error {
128    /// A parser error annotated with source text and line-offset context.
129    #[error("{0}")]
130    Parse(#[from] ParseError),
131
132    /// A user config parse error.
133    #[error("{0}")]
134    Config(#[from] ConfigError),
135
136    /// A built-in or user override spec parse error.
137    #[error("{0}")]
138    Spec(#[from] SpecError),
139
140    /// A filesystem or stream I/O failure.
141    #[error("I/O error: {0}")]
142    Io(#[from] std::io::Error),
143
144    /// A higher-level formatter or CLI error that does not fit another
145    /// structured variant.
146    #[error("formatter error: {0}")]
147    Formatter(String),
148
149    /// A formatted line exceeded the configured line width and
150    /// `require_valid_layout` is enabled.
151    #[error(
152        "line {line_no} is {width} characters wide, exceeding the configured limit of {limit}"
153    )]
154    LayoutTooWide {
155        /// 1-based line number in the formatted output.
156        line_no: usize,
157        /// Actual character width of the offending line.
158        width: usize,
159        /// Configured [`crate::Config::line_width`] limit.
160        limit: usize,
161    },
162}
163
164/// Convenience alias for crate-level results.
165pub type Result<T> = std::result::Result<T, Error>;
166
167impl Error {
168    /// Attach a human-facing source name to a contextual parser error.
169    pub fn with_display_name(self, display_name: impl Into<String>) -> Self {
170        match self {
171            Self::Parse(parse) => Self::Parse(parse.with_display_name(display_name)),
172            other => other,
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn parse_diagnostic_display_shows_message() {
183        let diag = ParseDiagnostic {
184            message: "expected argument part".into(),
185            line: 5,
186            column: 10,
187        };
188        assert_eq!(diag.to_string(), "expected argument part");
189    }
190
191    #[test]
192    fn parse_diagnostic_from_pest_parsing_error() {
193        let source = "if(\n";
194        let err = crate::parser::parse(source).unwrap_err();
195        if let Error::Parse(ParseError { diagnostic, .. }) = err {
196            assert!(diagnostic.line >= 1);
197            assert!(diagnostic.column >= 1);
198            assert!(!diagnostic.message.is_empty());
199        } else {
200            panic!("expected Parse, got {err:?}");
201        }
202    }
203
204    #[test]
205    fn error_parse_display() {
206        let err = Error::Parse(ParseError {
207            display_name: "test.cmake".to_owned(),
208            source_text: "if(\n".into(),
209            start_line: 1,
210            diagnostic: ParseDiagnostic {
211                message: "expected argument part".into(),
212                line: 1,
213                column: 4,
214            },
215        });
216        let msg = err.to_string();
217        assert!(msg.contains("test.cmake"));
218        assert!(msg.contains("expected argument part"));
219    }
220
221    #[test]
222    fn error_config_display() {
223        let err = Error::Config(ConfigError {
224            path: std::path::PathBuf::from("bad.yaml"),
225            details: FileParseError {
226                format: "YAML",
227                message: "unexpected key".into(),
228                line: Some(3),
229                column: Some(1),
230            },
231        });
232        let msg = err.to_string();
233        assert!(msg.contains("bad.yaml"));
234        assert!(msg.contains("unexpected key"));
235    }
236
237    #[test]
238    fn error_spec_display() {
239        let err = Error::Spec(SpecError {
240            path: std::path::PathBuf::from("commands.yaml"),
241            details: FileParseError {
242                format: "YAML",
243                message: "invalid nargs".into(),
244                line: None,
245                column: None,
246            },
247        });
248        let msg = err.to_string();
249        assert!(msg.contains("commands.yaml"));
250        assert!(msg.contains("invalid nargs"));
251    }
252
253    #[test]
254    fn error_io_display() {
255        let err = Error::Io(std::io::Error::new(
256            std::io::ErrorKind::NotFound,
257            "file not found",
258        ));
259        assert!(err.to_string().contains("file not found"));
260    }
261
262    #[test]
263    fn error_formatter_display() {
264        let err = Error::Formatter("something went wrong".to_owned());
265        assert!(err.to_string().contains("something went wrong"));
266    }
267
268    #[test]
269    fn error_layout_too_wide_display() {
270        let err = Error::LayoutTooWide {
271            line_no: 42,
272            width: 120,
273            limit: 80,
274        };
275        let msg = err.to_string();
276        assert!(msg.contains("42"));
277        assert!(msg.contains("120"));
278        assert!(msg.contains("80"));
279    }
280
281    #[test]
282    fn with_display_name_updates_parse() {
283        let err = Error::Parse(ParseError {
284            display_name: "original".to_owned(),
285            source_text: "set(\n".into(),
286            start_line: 1,
287            diagnostic: ParseDiagnostic {
288                message: "test".into(),
289                line: 1,
290                column: 5,
291            },
292        });
293        let renamed = err.with_display_name("renamed.cmake");
294        match renamed {
295            Error::Parse(ParseError { display_name, .. }) => {
296                assert_eq!(display_name, "renamed.cmake");
297            }
298            _ => panic!("expected Parse"),
299        }
300    }
301
302    #[test]
303    fn with_display_name_passes_through_non_parse_errors() {
304        let err = Error::Formatter("test".to_owned());
305        let result = err.with_display_name("ignored");
306        match result {
307            Error::Formatter(msg) => assert_eq!(msg, "test"),
308            _ => panic!("expected Formatter to pass through"),
309        }
310    }
311
312    #[test]
313    fn io_error_converts_from_std() {
314        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
315        let err: Error = io_err.into();
316        match err {
317            Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
318            _ => panic!("expected Io variant"),
319        }
320    }
321}