Skip to main content

cmakefmt/config/
mod.rs

1// SPDX-FileCopyrightText: Copyright 2026 Puneet Matharu
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Runtime formatter configuration.
6//!
7//! [`Config`] is the fully resolved in-memory configuration used by the
8//! formatter. It is built from defaults, user config files
9//! (`.cmakefmt.yaml`, `.cmakefmt.yml`, or `.cmakefmt.toml`), and CLI
10//! overrides.
11
12pub mod file;
13#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
14mod legacy;
15/// Render a commented starter config template.
16pub use file::default_config_template;
17#[cfg(feature = "cli")]
18pub use file::{
19    default_config_template_for, generate_json_schema, render_effective_config, DumpConfigFormat,
20};
21#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
22pub use legacy::convert_legacy_config_files;
23
24use std::collections::HashMap;
25
26use regex::Regex;
27use serde::{Deserialize, Serialize};
28
29/// How to normalise command/keyword casing.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
32#[serde(rename_all = "lowercase")]
33pub enum CaseStyle {
34    /// Force lowercase output.
35    Lower,
36    /// Force uppercase output.
37    #[default]
38    Upper,
39    /// Preserve the original source casing.
40    Unchanged,
41}
42
43/// Output line-ending style.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
45#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
46#[serde(rename_all = "lowercase")]
47pub enum LineEnding {
48    /// Unix-style LF (`\n`). The default.
49    #[default]
50    Unix,
51    /// Windows-style CRLF (`\r\n`).
52    Windows,
53    /// Auto-detect the line ending from the input source.
54    Auto,
55}
56
57/// How to handle fractional tab indentation when [`Config::use_tabchars`] is
58/// `true`.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
60#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
61#[serde(rename_all = "kebab-case")]
62pub enum FractionalTabPolicy {
63    /// Leave fractional spaces as-is (utf-8 0x20). The default.
64    #[default]
65    UseSpace,
66    /// Round fractional indentation up to the next full tab stop (utf-8 0x09).
67    RoundUp,
68}
69
70/// How to align the dangling closing paren.
71///
72/// Only takes effect when [`Config::dangle_parens`] is `true`.
73/// Controls where `)` is placed when a call wraps onto multiple lines:
74///
75/// ```cmake
76/// # Prefix / Close — `)` at the command-name column (tracks block depth):
77/// target_link_libraries(
78///   mylib PUBLIC dep1
79/// )
80///
81/// # Open — `)` at the opening-paren column:
82/// target_link_libraries(
83///   mylib PUBLIC dep1
84///                      )
85/// ```
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
87#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
88#[serde(rename_all = "lowercase")]
89pub enum DangleAlign {
90    /// Align with the start of the command name.
91    #[default]
92    Prefix,
93    /// Align with the opening paren column.
94    Open,
95    /// No extra indent (flush with current indent level).
96    Close,
97}
98
99/// Full formatter configuration.
100///
101/// Construct [`Config::default`] and set fields as needed before passing it to
102/// [`format_source`](crate::format_source) or related functions.
103///
104/// ```
105/// use cmakefmt::{Config, CaseStyle, DangleAlign};
106///
107/// let config = Config {
108///     line_width: 100,
109///     command_case: CaseStyle::Lower,
110///     dangle_parens: true,
111///     dangle_align: DangleAlign::Open,
112///     ..Config::default()
113/// };
114/// ```
115///
116/// # Defaults
117///
118/// | Field | Default |
119/// |-------|---------|
120/// | `line_width` | `80` |
121/// | `tab_size` | `2` |
122/// | `use_tabchars` | `false` |
123/// | `max_empty_lines` | `1` |
124/// | `command_case` | [`CaseStyle::Lower`] |
125/// | `keyword_case` | [`CaseStyle::Upper`] |
126/// | `dangle_parens` | `false` |
127/// | `dangle_align` | [`DangleAlign::Prefix`] |
128/// | `enable_markup` | `true` |
129/// | `reflow_comments` | `false` |
130/// | `first_comment_is_literal` | `true` |
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(default)]
133pub struct Config {
134    // ── Kill-switch ─────────────────────────────────────────────────────
135    /// When `true`, skip all formatting and return the source unchanged.
136    pub disable: bool,
137
138    // ── Line endings ─────────────────────────────────────────────────────
139    /// Output line-ending style.
140    pub line_ending: LineEnding,
141
142    // ── Layout ──────────────────────────────────────────────────────────
143    /// Maximum rendered line width before wrapping is attempted.
144    pub line_width: usize,
145    /// Number of spaces that make up one indentation level when
146    /// [`Self::use_tabchars`] is `false`.
147    pub tab_size: usize,
148    /// Emit tab characters for indentation instead of spaces.
149    pub use_tabchars: bool,
150    /// How to handle fractional indentation when [`Self::use_tabchars`] is
151    /// `true`.
152    pub fractional_tab_policy: FractionalTabPolicy,
153    /// Maximum number of consecutive empty lines to preserve.
154    pub max_empty_lines: usize,
155    /// Maximum number of wrapped lines tolerated before switching to a more
156    /// vertical layout.
157    pub max_lines_hwrap: usize,
158    /// Maximum number of positional arguments to keep in a hanging-wrap layout
159    /// before going vertical.
160    pub max_pargs_hwrap: usize,
161    /// Maximum number of keyword/flag subgroups to keep in a horizontal wrap.
162    pub max_subgroups_hwrap: usize,
163    /// Maximum rows a hanging-wrap positional group may consume before the
164    /// layout is rejected and nesting is forced.
165    pub max_rows_cmdline: usize,
166    /// Command names (lowercase) that must always use vertical layout,
167    /// regardless of line width.
168    pub always_wrap: Vec<String>,
169    /// Return an error when any formatted output line exceeds
170    /// [`Self::line_width`].
171    pub require_valid_layout: bool,
172
173    // ── Parenthesis style ───────────────────────────────────────────────
174    /// Place the closing `)` on its own line when a call wraps.
175    pub dangle_parens: bool,
176    /// Alignment strategy for a dangling closing `)`.
177    pub dangle_align: DangleAlign,
178    /// Lower bound used by layout heuristics when deciding whether a command
179    /// name is short enough to prefer one style over another.
180    pub min_prefix_chars: usize,
181    /// Upper bound used by layout heuristics when deciding whether a command
182    /// name is long enough to prefer one style over another.
183    pub max_prefix_chars: usize,
184    /// Insert a space before `(` for control-flow commands such as `if`.
185    pub separate_ctrl_name_with_space: bool,
186    /// Insert a space before `(` for `function`/`macro` definitions.
187    pub separate_fn_name_with_space: bool,
188
189    // ── Casing ──────────────────────────────────────────────────────────
190    /// Output casing policy for command names.
191    pub command_case: CaseStyle,
192    /// Output casing policy for recognized keywords and flags.
193    pub keyword_case: CaseStyle,
194
195    // ── Comment markup ──────────────────────────────────────────────────
196    /// Enable markup-aware comment handling.
197    pub enable_markup: bool,
198    /// Reflow plain line comments to fit within the configured width.
199    pub reflow_comments: bool,
200    /// Preserve the first comment block in a file literally.
201    pub first_comment_is_literal: bool,
202    /// Regex for comments that should never be reflowed.
203    pub literal_comment_pattern: String,
204    /// Preferred bullet character when normalizing list markup.
205    pub bullet_char: String,
206    /// Preferred enumeration punctuation when normalizing numbered list markup.
207    pub enum_char: String,
208    /// Regex describing fenced literal comment blocks.
209    pub fence_pattern: String,
210    /// Regex describing ruler-style comments.
211    pub ruler_pattern: String,
212    /// Minimum ruler length before a `#-----` style line is treated as a ruler.
213    pub hashruler_min_length: usize,
214    /// Normalize ruler comments when markup handling is enabled.
215    pub canonicalize_hashrulers: bool,
216    /// Regex pattern that marks an inline comment as explicitly trailing its
217    /// preceding argument. Matching comments are rendered on the same line as
218    /// the preceding token rather than on their own line.
219    pub explicit_trailing_pattern: String,
220
221    // ── Per-command overrides ────────────────────────────────────────────
222    /// Per-command configuration overrides keyed by lowercase command name.
223    pub per_command_overrides: HashMap<String, PerCommandConfig>,
224}
225
226/// Per-command overrides. All fields are optional — only specified fields
227/// override the global config for that command.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
229#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
230#[serde(deny_unknown_fields)]
231pub struct PerCommandConfig {
232    /// Override the command casing rule for this command only.
233    pub command_case: Option<CaseStyle>,
234    /// Override the keyword casing rule for this command only.
235    pub keyword_case: Option<CaseStyle>,
236    /// Override the line width for this command only.
237    pub line_width: Option<usize>,
238    /// Override the indentation width for this command only.
239    pub tab_size: Option<usize>,
240    /// Override dangling paren placement for this command only.
241    pub dangle_parens: Option<bool>,
242    /// Override dangling paren alignment for this command only.
243    pub dangle_align: Option<DangleAlign>,
244    /// Override the hanging-wrap positional argument threshold for this
245    /// command only.
246    #[serde(rename = "max_hanging_wrap_positional_args")]
247    pub max_pargs_hwrap: Option<usize>,
248    /// Override the hanging-wrap subgroup threshold for this command only.
249    #[serde(rename = "max_hanging_wrap_groups")]
250    pub max_subgroups_hwrap: Option<usize>,
251}
252
253impl Default for Config {
254    fn default() -> Self {
255        Self {
256            disable: false,
257            line_ending: LineEnding::Unix,
258            line_width: 80,
259            tab_size: 2,
260            use_tabchars: false,
261            fractional_tab_policy: FractionalTabPolicy::UseSpace,
262            max_empty_lines: 1,
263            max_lines_hwrap: 2,
264            max_pargs_hwrap: 6,
265            max_subgroups_hwrap: 2,
266            max_rows_cmdline: 2,
267            always_wrap: Vec::new(),
268            require_valid_layout: false,
269            dangle_parens: false,
270            dangle_align: DangleAlign::Prefix,
271            min_prefix_chars: 4,
272            max_prefix_chars: 10,
273            separate_ctrl_name_with_space: false,
274            separate_fn_name_with_space: false,
275            command_case: CaseStyle::Lower,
276            keyword_case: CaseStyle::Upper,
277            enable_markup: true,
278            reflow_comments: false,
279            first_comment_is_literal: true,
280            literal_comment_pattern: String::new(),
281            bullet_char: "*".to_string(),
282            enum_char: ".".to_string(),
283            fence_pattern: r"^\s*[`~]{3}[^`\n]*$".to_string(),
284            ruler_pattern: r"^[^\w\s]{3}.*[^\w\s]{3}$".to_string(),
285            hashruler_min_length: 10,
286            canonicalize_hashrulers: true,
287            explicit_trailing_pattern: "#<".to_string(),
288            per_command_overrides: HashMap::new(),
289        }
290    }
291}
292
293/// CMake control-flow commands that get `separate_ctrl_name_with_space`.
294const CONTROL_FLOW_COMMANDS: &[&str] = &[
295    "if",
296    "elseif",
297    "else",
298    "endif",
299    "foreach",
300    "endforeach",
301    "while",
302    "endwhile",
303    "break",
304    "continue",
305    "return",
306    "block",
307    "endblock",
308];
309
310/// CMake function/macro definition commands that get
311/// `separate_fn_name_with_space`.
312const FN_DEFINITION_COMMANDS: &[&str] = &["function", "endfunction", "macro", "endmacro"];
313
314impl Config {
315    /// Returns a `Config` with any per-command overrides applied for the
316    /// given command name, plus the appropriate space-before-paren setting.
317    pub fn for_command(&self, command_name: &str) -> CommandConfig<'_> {
318        let lower = command_name.to_ascii_lowercase();
319        let per_cmd = self.per_command_overrides.get(&lower);
320
321        let space_before_paren = if CONTROL_FLOW_COMMANDS.contains(&lower.as_str()) {
322            self.separate_ctrl_name_with_space
323        } else if FN_DEFINITION_COMMANDS.contains(&lower.as_str()) {
324            self.separate_fn_name_with_space
325        } else {
326            false
327        };
328
329        CommandConfig {
330            global: self,
331            per_cmd,
332            space_before_paren,
333        }
334    }
335
336    /// Apply the command_case rule to a command name.
337    pub fn apply_command_case(&self, name: &str) -> String {
338        apply_case(self.command_case, name)
339    }
340
341    /// Apply the keyword_case rule to a keyword token.
342    pub fn apply_keyword_case(&self, keyword: &str) -> String {
343        apply_case(self.keyword_case, keyword)
344    }
345
346    /// The indentation string (spaces or tab).
347    pub fn indent_str(&self) -> String {
348        if self.use_tabchars {
349            "\t".to_string()
350        } else {
351            " ".repeat(self.tab_size)
352        }
353    }
354
355    /// Validate that all regex patterns in the config are valid.
356    ///
357    /// Returns `Ok(())` if all patterns compile, or an error message
358    /// identifying the first invalid pattern.
359    pub fn validate_patterns(&self) -> Result<(), String> {
360        let patterns = [
361            ("literal_comment_pattern", &self.literal_comment_pattern),
362            ("explicit_trailing_pattern", &self.explicit_trailing_pattern),
363            ("fence_pattern", &self.fence_pattern),
364            ("ruler_pattern", &self.ruler_pattern),
365        ];
366        for (name, pattern) in &patterns {
367            if !pattern.is_empty() {
368                if let Err(err) = Regex::new(pattern) {
369                    return Err(format!("invalid regex in {name}: {err}"));
370                }
371            }
372        }
373        Ok(())
374    }
375
376    /// Compile all regex patterns into a cache for internal formatting use.
377    ///
378    /// Callers that build [`Config`] programmatically should use
379    /// [`Config::validate_patterns`] to validate regexes up front.
380    pub(crate) fn compiled_patterns(&self) -> Result<CompiledPatterns, String> {
381        Ok(CompiledPatterns {
382            literal_comment: compile_optional(
383                "literal_comment_pattern",
384                &self.literal_comment_pattern,
385            )?,
386            explicit_trailing: compile_optional(
387                "explicit_trailing_pattern",
388                &self.explicit_trailing_pattern,
389            )?,
390        })
391    }
392}
393
394fn compile_optional(name: &str, pattern: &str) -> Result<Option<Regex>, String> {
395    if pattern.is_empty() {
396        Ok(None)
397    } else {
398        Regex::new(pattern)
399            .map(Some)
400            .map_err(|err| format!("invalid regex in {name}: {err}"))
401    }
402}
403
404/// Pre-compiled regex patterns from [`Config`] used internally while formatting.
405pub(crate) struct CompiledPatterns {
406    /// Compiled `literal_comment_pattern`.
407    pub(crate) literal_comment: Option<Regex>,
408    /// Compiled `explicit_trailing_pattern`.
409    pub(crate) explicit_trailing: Option<Regex>,
410}
411
412/// A resolved config for formatting a specific command, with per-command
413/// overrides already applied.
414#[derive(Debug)]
415pub struct CommandConfig<'a> {
416    /// The global configuration before per-command overrides are applied.
417    pub global: &'a Config,
418    per_cmd: Option<&'a PerCommandConfig>,
419    /// Whether this command should render a space before `(`.
420    pub space_before_paren: bool,
421}
422
423impl CommandConfig<'_> {
424    /// Effective line width for the current command.
425    pub fn line_width(&self) -> usize {
426        self.per_cmd
427            .and_then(|p| p.line_width)
428            .unwrap_or(self.global.line_width)
429    }
430
431    /// Effective indentation width for the current command.
432    pub fn tab_size(&self) -> usize {
433        self.per_cmd
434            .and_then(|p| p.tab_size)
435            .unwrap_or(self.global.tab_size)
436    }
437
438    /// Effective dangling-paren setting for the current command.
439    pub fn dangle_parens(&self) -> bool {
440        self.per_cmd
441            .and_then(|p| p.dangle_parens)
442            .unwrap_or(self.global.dangle_parens)
443    }
444
445    /// Effective dangling-paren alignment for the current command.
446    pub fn dangle_align(&self) -> DangleAlign {
447        self.per_cmd
448            .and_then(|p| p.dangle_align)
449            .unwrap_or(self.global.dangle_align)
450    }
451
452    /// Effective command casing rule for the current command.
453    pub fn command_case(&self) -> CaseStyle {
454        self.per_cmd
455            .and_then(|p| p.command_case)
456            .unwrap_or(self.global.command_case)
457    }
458
459    /// Effective keyword casing rule for the current command.
460    pub fn keyword_case(&self) -> CaseStyle {
461        self.per_cmd
462            .and_then(|p| p.keyword_case)
463            .unwrap_or(self.global.keyword_case)
464    }
465
466    /// Effective hanging-wrap positional argument threshold for the current
467    /// command.
468    pub fn max_pargs_hwrap(&self) -> usize {
469        self.per_cmd
470            .and_then(|p| p.max_pargs_hwrap)
471            .unwrap_or(self.global.max_pargs_hwrap)
472    }
473
474    /// Effective hanging-wrap subgroup threshold for the current command.
475    pub fn max_subgroups_hwrap(&self) -> usize {
476        self.per_cmd
477            .and_then(|p| p.max_subgroups_hwrap)
478            .unwrap_or(self.global.max_subgroups_hwrap)
479    }
480
481    /// Effective indentation unit for the current command.
482    pub fn indent_str(&self) -> String {
483        if self.global.use_tabchars {
484            "\t".to_string()
485        } else {
486            " ".repeat(self.tab_size())
487        }
488    }
489}
490
491fn apply_case(style: CaseStyle, s: &str) -> String {
492    match style {
493        CaseStyle::Lower => s.to_ascii_lowercase(),
494        CaseStyle::Upper => s.to_ascii_uppercase(),
495        CaseStyle::Unchanged => s.to_string(),
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    // ── Config::for_command ───────────────────────────────────────────────
504
505    #[test]
506    fn for_command_control_flow_sets_space_before_paren() {
507        let config = Config {
508            separate_ctrl_name_with_space: true,
509            ..Config::default()
510        };
511        for cmd in ["if", "elseif", "foreach", "while", "return"] {
512            let cc = config.for_command(cmd);
513            assert!(
514                cc.space_before_paren,
515                "{cmd} should have space_before_paren=true"
516            );
517        }
518    }
519
520    #[test]
521    fn for_command_fn_definition_sets_space_before_paren() {
522        let config = Config {
523            separate_fn_name_with_space: true,
524            ..Config::default()
525        };
526        for cmd in ["function", "endfunction", "macro", "endmacro"] {
527            let cc = config.for_command(cmd);
528            assert!(
529                cc.space_before_paren,
530                "{cmd} should have space_before_paren=true"
531            );
532        }
533    }
534
535    #[test]
536    fn for_command_regular_command_no_space_before_paren() {
537        let config = Config {
538            separate_ctrl_name_with_space: true,
539            separate_fn_name_with_space: true,
540            ..Config::default()
541        };
542        let cc = config.for_command("message");
543        assert!(
544            !cc.space_before_paren,
545            "message should not have space_before_paren"
546        );
547    }
548
549    #[test]
550    fn for_command_lookup_is_case_insensitive() {
551        let mut overrides = HashMap::new();
552        overrides.insert(
553            "message".to_string(),
554            PerCommandConfig {
555                line_width: Some(120),
556                ..Default::default()
557            },
558        );
559        let config = Config {
560            per_command_overrides: overrides,
561            ..Config::default()
562        };
563        // uppercase lookup should still find the "message" override
564        assert_eq!(config.for_command("MESSAGE").line_width(), 120);
565    }
566
567    // ── CommandConfig accessors ───────────────────────────────────────────
568
569    #[test]
570    fn command_config_returns_global_defaults_when_no_override() {
571        let config = Config::default();
572        let cc = config.for_command("set");
573        assert_eq!(cc.line_width(), config.line_width);
574        assert_eq!(cc.tab_size(), config.tab_size);
575        assert_eq!(cc.dangle_parens(), config.dangle_parens);
576        assert_eq!(cc.command_case(), config.command_case);
577        assert_eq!(cc.keyword_case(), config.keyword_case);
578        assert_eq!(cc.max_pargs_hwrap(), config.max_pargs_hwrap);
579        assert_eq!(cc.max_subgroups_hwrap(), config.max_subgroups_hwrap);
580    }
581
582    #[test]
583    fn command_config_per_command_overrides_take_effect() {
584        let mut overrides = HashMap::new();
585        overrides.insert(
586            "set".to_string(),
587            PerCommandConfig {
588                line_width: Some(120),
589                tab_size: Some(4),
590                dangle_parens: Some(true),
591                dangle_align: Some(DangleAlign::Open),
592                command_case: Some(CaseStyle::Upper),
593                keyword_case: Some(CaseStyle::Lower),
594                max_pargs_hwrap: Some(10),
595                max_subgroups_hwrap: Some(5),
596            },
597        );
598        let config = Config {
599            per_command_overrides: overrides,
600            ..Config::default()
601        };
602        let cc = config.for_command("set");
603        assert_eq!(cc.line_width(), 120);
604        assert_eq!(cc.tab_size(), 4);
605        assert!(cc.dangle_parens());
606        assert_eq!(cc.dangle_align(), DangleAlign::Open);
607        assert_eq!(cc.command_case(), CaseStyle::Upper);
608        assert_eq!(cc.keyword_case(), CaseStyle::Lower);
609        assert_eq!(cc.max_pargs_hwrap(), 10);
610        assert_eq!(cc.max_subgroups_hwrap(), 5);
611    }
612
613    #[test]
614    fn indent_str_spaces() {
615        let config = Config {
616            tab_size: 4,
617            use_tabchars: false,
618            ..Config::default()
619        };
620        assert_eq!(config.indent_str(), "    ");
621        assert_eq!(config.for_command("set").indent_str(), "    ");
622    }
623
624    #[test]
625    fn indent_str_tab() {
626        let config = Config {
627            use_tabchars: true,
628            ..Config::default()
629        };
630        assert_eq!(config.indent_str(), "\t");
631        assert_eq!(config.for_command("set").indent_str(), "\t");
632    }
633
634    // ── Case helpers ─────────────────────────────────────────────────────
635
636    #[test]
637    fn apply_command_case_lower() {
638        let config = Config {
639            command_case: CaseStyle::Lower,
640            ..Config::default()
641        };
642        assert_eq!(
643            config.apply_command_case("TARGET_LINK_LIBRARIES"),
644            "target_link_libraries"
645        );
646    }
647
648    #[test]
649    fn apply_command_case_upper() {
650        let config = Config {
651            command_case: CaseStyle::Upper,
652            ..Config::default()
653        };
654        assert_eq!(
655            config.apply_command_case("target_link_libraries"),
656            "TARGET_LINK_LIBRARIES"
657        );
658    }
659
660    #[test]
661    fn apply_command_case_unchanged() {
662        let config = Config {
663            command_case: CaseStyle::Unchanged,
664            ..Config::default()
665        };
666        assert_eq!(
667            config.apply_command_case("Target_Link_Libraries"),
668            "Target_Link_Libraries"
669        );
670    }
671
672    #[test]
673    fn apply_keyword_case_variants() {
674        let config_upper = Config {
675            keyword_case: CaseStyle::Upper,
676            ..Config::default()
677        };
678        assert_eq!(config_upper.apply_keyword_case("public"), "PUBLIC");
679
680        let config_lower = Config {
681            keyword_case: CaseStyle::Lower,
682            ..Config::default()
683        };
684        assert_eq!(config_lower.apply_keyword_case("PUBLIC"), "public");
685    }
686
687    // ── Error Display ─────────────────────────────────────────────────────
688
689    #[test]
690    fn error_layout_too_wide_display() {
691        use crate::error::Error;
692        let err = Error::LayoutTooWide {
693            line_no: 5,
694            width: 95,
695            limit: 80,
696        };
697        let msg = err.to_string();
698        assert!(msg.contains("5"), "should mention line number");
699        assert!(msg.contains("95"), "should mention actual width");
700        assert!(msg.contains("80"), "should mention limit");
701    }
702
703    #[test]
704    fn error_formatter_display() {
705        use crate::error::Error;
706        let err = Error::Formatter("something went wrong".to_string());
707        assert!(err.to_string().contains("something went wrong"));
708    }
709}