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}