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