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/// | `first_comment_is_literal` | `true` |
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(default)]
135pub struct Config {
136    // ── Kill-switch ─────────────────────────────────────────────────────
137    /// When `true`, skip all formatting and return the source unchanged.
138    pub disable: bool,
139
140    // ── Line endings ─────────────────────────────────────────────────────
141    /// Output line-ending style.
142    pub line_ending: LineEnding,
143
144    // ── Layout ──────────────────────────────────────────────────────────
145    /// Maximum rendered line width before wrapping is attempted.
146    pub line_width: usize,
147    /// Number of spaces that make up one indentation level when
148    /// [`Self::use_tabchars`] is `false`.
149    pub tab_size: usize,
150    /// Emit tab characters for indentation instead of spaces.
151    pub use_tabchars: bool,
152    /// How to handle fractional indentation when [`Self::use_tabchars`] is
153    /// `true`.
154    pub fractional_tab_policy: FractionalTabPolicy,
155    /// Maximum number of consecutive empty lines to preserve.
156    pub max_empty_lines: usize,
157    /// Maximum number of wrapped lines tolerated before switching to a more
158    /// vertical layout.
159    pub max_lines_hwrap: usize,
160    /// Maximum number of positional arguments to keep in a hanging-wrap layout
161    /// before going vertical.
162    pub max_pargs_hwrap: usize,
163    /// Maximum number of keyword/flag subgroups to keep in a horizontal wrap.
164    pub max_subgroups_hwrap: usize,
165    /// Maximum rows a hanging-wrap positional group may consume before the
166    /// layout is rejected and nesting is forced.
167    pub max_rows_cmdline: usize,
168    /// Command names (lowercase) that must always use vertical layout,
169    /// regardless of line width.
170    pub always_wrap: Vec<String>,
171    /// Return an error when any formatted output line exceeds
172    /// [`Self::line_width`].
173    pub require_valid_layout: bool,
174    /// When wrapping, keep the first positional argument on the command
175    /// line and align continuation to the open parenthesis. Can be
176    /// overridden per-command via `per_command_overrides` or the spec's
177    /// `layout.wrap_after_first_arg`.
178    pub wrap_after_first_arg: bool,
179    /// Sort arguments in keyword sections marked `sortable` in the
180    /// command spec. Sorting is lexicographic and case-insensitive.
181    pub enable_sort: bool,
182    /// Heuristically infer sortability for keyword sections without
183    /// an explicit `sortable` annotation. When enabled, a section is
184    /// considered sortable if all its arguments are simple unquoted
185    /// tokens (no variables, generator expressions, or quoted strings).
186    pub autosort: bool,
187
188    // ── Parenthesis style ───────────────────────────────────────────────
189    /// Place the closing `)` on its own line when a call wraps.
190    pub dangle_parens: bool,
191    /// Alignment strategy for a dangling closing `)`.
192    pub dangle_align: DangleAlign,
193    /// Lower bound used by layout heuristics when deciding whether a command
194    /// name is short enough to prefer one style over another.
195    pub min_prefix_chars: usize,
196    /// Upper bound used by layout heuristics when deciding whether a command
197    /// name is long enough to prefer one style over another.
198    pub max_prefix_chars: usize,
199    /// Insert a space before `(` for control-flow commands such as `if`.
200    pub separate_ctrl_name_with_space: bool,
201    /// Insert a space before `(` for `function`/`macro` definitions.
202    pub separate_fn_name_with_space: bool,
203
204    // ── Casing ──────────────────────────────────────────────────────────
205    /// Output casing policy for command names.
206    pub command_case: CaseStyle,
207    /// Output casing policy for recognized keywords and flags.
208    pub keyword_case: CaseStyle,
209
210    // ── Comment markup ──────────────────────────────────────────────────
211    /// Enable markup-aware comment handling and reflow plain line comments
212    /// to fit within the configured line width.
213    pub enable_markup: bool,
214    /// Preserve the first comment block in a file literally.
215    pub first_comment_is_literal: bool,
216    /// Regex for comments that should never be reflowed.
217    pub literal_comment_pattern: String,
218    /// Preferred bullet character when normalizing list markup.
219    pub bullet_char: String,
220    /// Preferred enumeration punctuation when normalizing numbered list markup.
221    pub enum_char: String,
222    /// Regex describing fenced literal comment blocks.
223    pub fence_pattern: String,
224    /// Regex describing ruler-style comments.
225    pub ruler_pattern: String,
226    /// Minimum ruler length before a `#-----` style line is treated as a ruler.
227    pub hashruler_min_length: usize,
228    /// Normalize ruler comments when markup handling is enabled.
229    pub canonicalize_hashrulers: bool,
230    /// Regex pattern that marks an inline comment as explicitly trailing its
231    /// preceding argument. Matching comments are rendered on the same line as
232    /// the preceding token rather than on their own line.
233    pub explicit_trailing_pattern: String,
234
235    // ── Per-command overrides ────────────────────────────────────────────
236    /// Per-command configuration overrides keyed by lowercase command name.
237    pub per_command_overrides: HashMap<String, PerCommandConfig>,
238
239    // ── Experimental ──────────────────────────────────────────────────
240    /// Opt-in experimental formatting options. These are unstable and may
241    /// change or be removed between releases. Enable all at once with
242    /// `--preview` on the CLI.
243    #[serde(default)]
244    pub experimental: Experimental,
245}
246
247/// Experimental formatting options gated behind `--preview` or the
248/// `[experimental]` config section. All options default to `false`.
249///
250/// These may be promoted to stable defaults in a future release, changed
251/// incompatibly, or removed entirely.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
253#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
254#[serde(default)]
255#[non_exhaustive]
256pub struct Experimental {
257    // No experimental options yet. Add new options here as they are
258    // developed. Each option should default to false.
259}
260
261/// Per-command overrides. All fields are optional — only specified fields
262/// override the global config for that command.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
264#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
265#[serde(deny_unknown_fields)]
266pub struct PerCommandConfig {
267    /// Override the command casing rule for this command only.
268    pub command_case: Option<CaseStyle>,
269    /// Override the keyword casing rule for this command only.
270    pub keyword_case: Option<CaseStyle>,
271    /// Override the line width for this command only.
272    pub line_width: Option<usize>,
273    /// Override the indentation width for this command only.
274    pub tab_size: Option<usize>,
275    /// Override dangling paren placement for this command only.
276    pub dangle_parens: Option<bool>,
277    /// Override dangling paren alignment for this command only.
278    pub dangle_align: Option<DangleAlign>,
279    /// Override the hanging-wrap positional argument threshold for this
280    /// command only.
281    #[serde(rename = "max_hanging_wrap_positional_args")]
282    pub max_pargs_hwrap: Option<usize>,
283    /// Override the hanging-wrap subgroup threshold for this command only.
284    #[serde(rename = "max_hanging_wrap_groups")]
285    pub max_subgroups_hwrap: Option<usize>,
286    /// Keep the first positional argument on the command line when wrapping.
287    pub wrap_after_first_arg: Option<bool>,
288}
289
290impl Default for Config {
291    fn default() -> Self {
292        Self {
293            disable: false,
294            line_ending: LineEnding::Unix,
295            line_width: 80,
296            tab_size: 2,
297            use_tabchars: false,
298            fractional_tab_policy: FractionalTabPolicy::UseSpace,
299            max_empty_lines: 1,
300            max_lines_hwrap: 2,
301            max_pargs_hwrap: 6,
302            max_subgroups_hwrap: 2,
303            max_rows_cmdline: 2,
304            always_wrap: Vec::new(),
305            require_valid_layout: false,
306            wrap_after_first_arg: false,
307            enable_sort: false,
308            autosort: false,
309            dangle_parens: false,
310            dangle_align: DangleAlign::Prefix,
311            min_prefix_chars: 4,
312            max_prefix_chars: 10,
313            separate_ctrl_name_with_space: false,
314            separate_fn_name_with_space: false,
315            command_case: CaseStyle::Lower,
316            keyword_case: CaseStyle::Upper,
317            enable_markup: true,
318            first_comment_is_literal: true,
319            literal_comment_pattern: String::new(),
320            bullet_char: "*".to_string(),
321            enum_char: ".".to_string(),
322            fence_pattern: DEFAULT_FENCE_PATTERN.to_string(),
323            ruler_pattern: DEFAULT_RULER_PATTERN.to_string(),
324            hashruler_min_length: 10,
325            canonicalize_hashrulers: true,
326            explicit_trailing_pattern: DEFAULT_EXPLICIT_TRAILING_PATTERN.to_string(),
327            per_command_overrides: HashMap::new(),
328            experimental: Experimental::default(),
329        }
330    }
331}
332
333/// CMake control-flow commands that get `separate_ctrl_name_with_space`.
334const CONTROL_FLOW_COMMANDS: &[&str] = &[
335    "if",
336    "elseif",
337    "else",
338    "endif",
339    "foreach",
340    "endforeach",
341    "while",
342    "endwhile",
343    "break",
344    "continue",
345    "return",
346    "block",
347    "endblock",
348];
349
350/// CMake function/macro definition commands that get
351/// `separate_fn_name_with_space`.
352const FN_DEFINITION_COMMANDS: &[&str] = &["function", "endfunction", "macro", "endmacro"];
353
354impl Config {
355    /// Returns a `Config` with any per-command overrides applied for the
356    /// given command name, plus the appropriate space-before-paren setting.
357    pub fn for_command(&self, command_name: &str) -> CommandConfig<'_> {
358        let lower = command_name.to_ascii_lowercase();
359        let per_cmd = self.per_command_overrides.get(&lower);
360
361        let space_before_paren = if CONTROL_FLOW_COMMANDS.contains(&lower.as_str()) {
362            self.separate_ctrl_name_with_space
363        } else if FN_DEFINITION_COMMANDS.contains(&lower.as_str()) {
364            self.separate_fn_name_with_space
365        } else {
366            false
367        };
368
369        CommandConfig {
370            global: self,
371            per_cmd,
372            space_before_paren,
373        }
374    }
375
376    /// Apply the command_case rule to a command name.
377    pub fn apply_command_case(&self, name: &str) -> String {
378        apply_case(self.command_case, name)
379    }
380
381    /// Apply the keyword_case rule to a keyword token.
382    pub fn apply_keyword_case(&self, keyword: &str) -> String {
383        apply_case(self.keyword_case, keyword)
384    }
385
386    /// The indentation string (spaces or tab).
387    pub fn indent_str(&self) -> String {
388        if self.use_tabchars {
389            "\t".to_string()
390        } else {
391            " ".repeat(self.tab_size)
392        }
393    }
394
395    /// Validate that all regex patterns in the config are valid.
396    ///
397    /// Returns `Ok(())` if all patterns compile, or an error message
398    /// identifying the first invalid pattern.
399    pub fn validate_patterns(&self) -> Result<(), String> {
400        // Fast path for defaults — the built-in pattern strings are known
401        // to be valid. Avoids compiling three regexes on every
402        // format_source() call, which dominates per-file overhead on
403        // whole-tree runs over many small files.
404        if self.has_default_regex_patterns() {
405            return Ok(());
406        }
407        let patterns = [
408            ("literal_comment_pattern", &self.literal_comment_pattern),
409            ("explicit_trailing_pattern", &self.explicit_trailing_pattern),
410            ("fence_pattern", &self.fence_pattern),
411            ("ruler_pattern", &self.ruler_pattern),
412        ];
413        for (name, pattern) in &patterns {
414            if !pattern.is_empty() {
415                if let Err(err) = Regex::new(pattern) {
416                    return Err(format!("invalid regex in {name}: {err}"));
417                }
418            }
419        }
420        Ok(())
421    }
422
423    fn has_default_regex_patterns(&self) -> bool {
424        self.literal_comment_pattern.is_empty()
425            && self.explicit_trailing_pattern == DEFAULT_EXPLICIT_TRAILING_PATTERN
426            && self.fence_pattern == DEFAULT_FENCE_PATTERN
427            && self.ruler_pattern == DEFAULT_RULER_PATTERN
428    }
429
430    /// Compile all regex patterns into a cache for internal formatting use.
431    ///
432    /// Callers that build [`Config`] programmatically should use
433    /// [`Config::validate_patterns`] to validate regexes up front.
434    pub(crate) fn compiled_patterns(&self) -> Result<CompiledPatterns, String> {
435        // Fast path for the common default configuration. Compiling the
436        // default regex repeatedly is a measurable cost on whole-tree runs
437        // that process many small files.
438        if self.literal_comment_pattern.is_empty()
439            && self.explicit_trailing_pattern == DEFAULT_EXPLICIT_TRAILING_PATTERN
440        {
441            return Ok(CompiledPatterns {
442                literal_comment: None,
443                explicit_trailing: Some(default_explicit_trailing_regex().clone()),
444            });
445        }
446        Ok(CompiledPatterns {
447            literal_comment: compile_optional(
448                "literal_comment_pattern",
449                &self.literal_comment_pattern,
450            )?,
451            explicit_trailing: compile_optional(
452                "explicit_trailing_pattern",
453                &self.explicit_trailing_pattern,
454            )?,
455        })
456    }
457}
458
459const DEFAULT_EXPLICIT_TRAILING_PATTERN: &str = "#<";
460const DEFAULT_FENCE_PATTERN: &str = r"^\s*[`~]{3}[^`\n]*$";
461const DEFAULT_RULER_PATTERN: &str = r"^[^\w\s]{3}.*[^\w\s]{3}$";
462
463fn default_explicit_trailing_regex() -> &'static Regex {
464    static CACHE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
465    CACHE.get_or_init(|| Regex::new(DEFAULT_EXPLICIT_TRAILING_PATTERN).expect("default regex"))
466}
467
468fn compile_optional(name: &str, pattern: &str) -> Result<Option<Regex>, String> {
469    if pattern.is_empty() {
470        Ok(None)
471    } else {
472        Regex::new(pattern)
473            .map(Some)
474            .map_err(|err| format!("invalid regex in {name}: {err}"))
475    }
476}
477
478/// Pre-compiled regex patterns from [`Config`] used internally while formatting.
479pub(crate) struct CompiledPatterns {
480    /// Compiled `literal_comment_pattern`.
481    pub(crate) literal_comment: Option<Regex>,
482    /// Compiled `explicit_trailing_pattern`. Currently unused by the
483    /// formatter (trailing comments are kept inline by width), but
484    /// retained for future use and config compatibility.
485    #[allow(dead_code)]
486    pub(crate) explicit_trailing: Option<Regex>,
487}
488
489/// A resolved config for formatting a specific command, with per-command
490/// overrides already applied.
491#[derive(Debug)]
492pub struct CommandConfig<'a> {
493    /// The global configuration before per-command overrides are applied.
494    global: &'a Config,
495    per_cmd: Option<&'a PerCommandConfig>,
496    /// Whether this command should render a space before `(`.
497    space_before_paren: bool,
498}
499
500impl CommandConfig<'_> {
501    /// Whether this command should render a space before `(`.
502    pub fn space_before_paren(&self) -> bool {
503        self.space_before_paren
504    }
505
506    pub(crate) fn global(&self) -> &Config {
507        self.global
508    }
509
510    /// Effective line width for the current command.
511    pub fn line_width(&self) -> usize {
512        self.per_cmd
513            .and_then(|p| p.line_width)
514            .unwrap_or(self.global.line_width)
515    }
516
517    /// Effective indentation width for the current command.
518    pub fn tab_size(&self) -> usize {
519        self.per_cmd
520            .and_then(|p| p.tab_size)
521            .unwrap_or(self.global.tab_size)
522    }
523
524    /// Effective dangling-paren setting for the current command.
525    pub fn dangle_parens(&self) -> bool {
526        self.per_cmd
527            .and_then(|p| p.dangle_parens)
528            .unwrap_or(self.global.dangle_parens)
529    }
530
531    /// Effective dangling-paren alignment for the current command.
532    pub fn dangle_align(&self) -> DangleAlign {
533        self.per_cmd
534            .and_then(|p| p.dangle_align)
535            .unwrap_or(self.global.dangle_align)
536    }
537
538    /// Effective command casing rule for the current command.
539    pub fn command_case(&self) -> CaseStyle {
540        self.per_cmd
541            .and_then(|p| p.command_case)
542            .unwrap_or(self.global.command_case)
543    }
544
545    /// Effective keyword casing rule for the current command.
546    pub fn keyword_case(&self) -> CaseStyle {
547        self.per_cmd
548            .and_then(|p| p.keyword_case)
549            .unwrap_or(self.global.keyword_case)
550    }
551
552    /// Effective hanging-wrap positional argument threshold for the current
553    /// command.
554    pub fn max_pargs_hwrap(&self) -> usize {
555        self.per_cmd
556            .and_then(|p| p.max_pargs_hwrap)
557            .unwrap_or(self.global.max_pargs_hwrap)
558    }
559
560    /// Effective hanging-wrap subgroup threshold for the current command.
561    pub fn max_subgroups_hwrap(&self) -> usize {
562        self.per_cmd
563            .and_then(|p| p.max_subgroups_hwrap)
564            .unwrap_or(self.global.max_subgroups_hwrap)
565    }
566
567    /// Effective `wrap_after_first_arg` for the current command.
568    ///
569    /// Resolution order: per-command user override > `spec_value` (from
570    /// the command spec's layout overrides) > global config default.
571    pub fn wrap_after_first_arg(&self, spec_value: Option<bool>) -> bool {
572        self.per_cmd
573            .and_then(|p| p.wrap_after_first_arg)
574            .or(spec_value)
575            .unwrap_or(self.global.wrap_after_first_arg)
576    }
577
578    /// Effective indentation unit for the current command.
579    pub fn indent_str(&self) -> String {
580        if self.global.use_tabchars {
581            "\t".to_string()
582        } else {
583            " ".repeat(self.tab_size())
584        }
585    }
586}
587
588fn apply_case(style: CaseStyle, s: &str) -> String {
589    match style {
590        CaseStyle::Lower => s.to_ascii_lowercase(),
591        CaseStyle::Upper => s.to_ascii_uppercase(),
592        CaseStyle::Unchanged => s.to_string(),
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    // ── Config::for_command ───────────────────────────────────────────────
601
602    #[test]
603    fn for_command_control_flow_sets_space_before_paren() {
604        let config = Config {
605            separate_ctrl_name_with_space: true,
606            ..Config::default()
607        };
608        for cmd in ["if", "elseif", "foreach", "while", "return"] {
609            let cc = config.for_command(cmd);
610            assert!(
611                cc.space_before_paren(),
612                "{cmd} should have space_before_paren=true"
613            );
614        }
615    }
616
617    #[test]
618    fn for_command_fn_definition_sets_space_before_paren() {
619        let config = Config {
620            separate_fn_name_with_space: true,
621            ..Config::default()
622        };
623        for cmd in ["function", "endfunction", "macro", "endmacro"] {
624            let cc = config.for_command(cmd);
625            assert!(
626                cc.space_before_paren(),
627                "{cmd} should have space_before_paren=true"
628            );
629        }
630    }
631
632    #[test]
633    fn for_command_regular_command_no_space_before_paren() {
634        let config = Config {
635            separate_ctrl_name_with_space: true,
636            separate_fn_name_with_space: true,
637            ..Config::default()
638        };
639        let cc = config.for_command("message");
640        assert!(
641            !cc.space_before_paren(),
642            "message should not have space_before_paren"
643        );
644    }
645
646    #[test]
647    fn for_command_lookup_is_case_insensitive() {
648        let mut overrides = HashMap::new();
649        overrides.insert(
650            "message".to_string(),
651            PerCommandConfig {
652                line_width: Some(120),
653                ..Default::default()
654            },
655        );
656        let config = Config {
657            per_command_overrides: overrides,
658            ..Config::default()
659        };
660        // uppercase lookup should still find the "message" override
661        assert_eq!(config.for_command("MESSAGE").line_width(), 120);
662    }
663
664    // ── CommandConfig accessors ───────────────────────────────────────────
665
666    #[test]
667    fn command_config_returns_global_defaults_when_no_override() {
668        let config = Config::default();
669        let cc = config.for_command("set");
670        assert_eq!(cc.line_width(), config.line_width);
671        assert_eq!(cc.tab_size(), config.tab_size);
672        assert_eq!(cc.dangle_parens(), config.dangle_parens);
673        assert_eq!(cc.command_case(), config.command_case);
674        assert_eq!(cc.keyword_case(), config.keyword_case);
675        assert_eq!(cc.max_pargs_hwrap(), config.max_pargs_hwrap);
676        assert_eq!(cc.max_subgroups_hwrap(), config.max_subgroups_hwrap);
677    }
678
679    #[test]
680    fn command_config_per_command_overrides_take_effect() {
681        let mut overrides = HashMap::new();
682        overrides.insert(
683            "set".to_string(),
684            PerCommandConfig {
685                line_width: Some(120),
686                tab_size: Some(4),
687                dangle_parens: Some(true),
688                dangle_align: Some(DangleAlign::Open),
689                command_case: Some(CaseStyle::Upper),
690                keyword_case: Some(CaseStyle::Lower),
691                max_pargs_hwrap: Some(10),
692                max_subgroups_hwrap: Some(5),
693                wrap_after_first_arg: None,
694            },
695        );
696        let config = Config {
697            per_command_overrides: overrides,
698            ..Config::default()
699        };
700        let cc = config.for_command("set");
701        assert_eq!(cc.line_width(), 120);
702        assert_eq!(cc.tab_size(), 4);
703        assert!(cc.dangle_parens());
704        assert_eq!(cc.dangle_align(), DangleAlign::Open);
705        assert_eq!(cc.command_case(), CaseStyle::Upper);
706        assert_eq!(cc.keyword_case(), CaseStyle::Lower);
707        assert_eq!(cc.max_pargs_hwrap(), 10);
708        assert_eq!(cc.max_subgroups_hwrap(), 5);
709    }
710
711    #[test]
712    fn indent_str_spaces() {
713        let config = Config {
714            tab_size: 4,
715            use_tabchars: false,
716            ..Config::default()
717        };
718        assert_eq!(config.indent_str(), "    ");
719        assert_eq!(config.for_command("set").indent_str(), "    ");
720    }
721
722    #[test]
723    fn indent_str_tab() {
724        let config = Config {
725            use_tabchars: true,
726            ..Config::default()
727        };
728        assert_eq!(config.indent_str(), "\t");
729        assert_eq!(config.for_command("set").indent_str(), "\t");
730    }
731
732    // ── Case helpers ─────────────────────────────────────────────────────
733
734    #[test]
735    fn apply_command_case_lower() {
736        let config = Config {
737            command_case: CaseStyle::Lower,
738            ..Config::default()
739        };
740        assert_eq!(
741            config.apply_command_case("TARGET_LINK_LIBRARIES"),
742            "target_link_libraries"
743        );
744    }
745
746    #[test]
747    fn apply_command_case_upper() {
748        let config = Config {
749            command_case: CaseStyle::Upper,
750            ..Config::default()
751        };
752        assert_eq!(
753            config.apply_command_case("target_link_libraries"),
754            "TARGET_LINK_LIBRARIES"
755        );
756    }
757
758    #[test]
759    fn apply_command_case_unchanged() {
760        let config = Config {
761            command_case: CaseStyle::Unchanged,
762            ..Config::default()
763        };
764        assert_eq!(
765            config.apply_command_case("Target_Link_Libraries"),
766            "Target_Link_Libraries"
767        );
768    }
769
770    #[test]
771    fn apply_keyword_case_variants() {
772        let config_upper = Config {
773            keyword_case: CaseStyle::Upper,
774            ..Config::default()
775        };
776        assert_eq!(config_upper.apply_keyword_case("public"), "PUBLIC");
777
778        let config_lower = Config {
779            keyword_case: CaseStyle::Lower,
780            ..Config::default()
781        };
782        assert_eq!(config_lower.apply_keyword_case("PUBLIC"), "public");
783    }
784
785    // ── Error Display ─────────────────────────────────────────────────────
786
787    #[test]
788    fn error_layout_too_wide_display() {
789        use crate::error::Error;
790        let err = Error::LayoutTooWide {
791            line_no: 5,
792            width: 95,
793            limit: 80,
794        };
795        let msg = err.to_string();
796        assert!(msg.contains("5"), "should mention line number");
797        assert!(msg.contains("95"), "should mention actual width");
798        assert!(msg.contains("80"), "should mention limit");
799    }
800
801    #[test]
802    fn error_formatter_display() {
803        use crate::error::Error;
804        let err = Error::Formatter("something went wrong".to_string());
805        assert!(err.to_string().contains("something went wrong"));
806    }
807
808    // ── Regex fast paths ──────────────────────────────────────────────────
809
810    #[test]
811    fn from_files_empty_path_returns_defaults() {
812        let config = Config::from_files(&[]).expect("default config should load");
813        let defaults = Config::default();
814        assert_eq!(
815            config.literal_comment_pattern,
816            defaults.literal_comment_pattern
817        );
818        assert_eq!(
819            config.explicit_trailing_pattern,
820            defaults.explicit_trailing_pattern
821        );
822        assert_eq!(config.fence_pattern, defaults.fence_pattern);
823        assert_eq!(config.ruler_pattern, defaults.ruler_pattern);
824        assert_eq!(config.line_width, defaults.line_width);
825    }
826
827    #[test]
828    fn validate_patterns_accepts_defaults() {
829        let config = Config::default();
830        assert!(
831            config.validate_patterns().is_ok(),
832            "default patterns must pass validation"
833        );
834    }
835
836    #[test]
837    fn validate_patterns_rejects_invalid_custom_pattern() {
838        let config = Config {
839            fence_pattern: "(".to_string(),
840            ..Config::default()
841        };
842        let err = config
843            .validate_patterns()
844            .expect_err("invalid fence_pattern must be rejected");
845        assert!(
846            err.contains("fence_pattern"),
847            "error should identify fence_pattern, got: {err}"
848        );
849    }
850
851    #[test]
852    fn validate_patterns_accepts_valid_custom_pattern() {
853        let config = Config {
854            fence_pattern: r"^\s*[#]{3,}$".to_string(),
855            ..Config::default()
856        };
857        assert!(config.validate_patterns().is_ok());
858    }
859
860    #[test]
861    fn compiled_patterns_uses_cached_default_regex() {
862        let config = Config::default();
863        let compiled = config.compiled_patterns().expect("defaults must compile");
864        assert!(
865            compiled.literal_comment.is_none(),
866            "empty literal_comment_pattern should produce None"
867        );
868        let explicit = compiled
869            .explicit_trailing
870            .expect("default explicit_trailing_pattern should compile to Some");
871        assert!(
872            explicit.is_match("#<"),
873            "default explicit_trailing regex should match the default marker"
874        );
875    }
876
877    #[test]
878    fn compiled_patterns_compiles_custom_literal_comment() {
879        let config = Config {
880            literal_comment_pattern: r"^\s*TODO:".to_string(),
881            ..Config::default()
882        };
883        let compiled = config
884            .compiled_patterns()
885            .expect("custom literal_comment_pattern must compile");
886        let literal = compiled
887            .literal_comment
888            .expect("custom literal_comment_pattern should compile to Some");
889        assert!(literal.is_match("  TODO: fix me"));
890        assert!(!literal.is_match("# regular comment"));
891    }
892
893    #[test]
894    fn compiled_patterns_compiles_custom_explicit_trailing() {
895        let config = Config {
896            explicit_trailing_pattern: r"^#>".to_string(),
897            ..Config::default()
898        };
899        let compiled = config
900            .compiled_patterns()
901            .expect("custom explicit_trailing_pattern must compile");
902        let explicit = compiled
903            .explicit_trailing
904            .expect("custom explicit_trailing_pattern should compile to Some");
905        assert!(explicit.is_match("#>"));
906        assert!(
907            !explicit.is_match("x#<"),
908            "custom pattern must not fall back to the cached default"
909        );
910    }
911
912    #[test]
913    fn compiled_patterns_errors_on_invalid_custom() {
914        let config = Config {
915            literal_comment_pattern: "(".to_string(),
916            ..Config::default()
917        };
918        match config.compiled_patterns() {
919            Ok(_) => panic!("invalid custom pattern must error"),
920            Err(err) => assert!(
921                err.contains("literal_comment_pattern"),
922                "error should identify literal_comment_pattern, got: {err}"
923            ),
924        }
925    }
926}