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