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