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//! # User config file schema
13//!
14//! User-facing config files are parsed under a separate schema
15//! (internal to this module) that groups options into named sections:
16//!
17//! | Section | Purpose |
18//! |---------|---------|
19//! | `[format]` | Line width, indentation, casing, dangle-paren policy, wrapping heuristics |
20//! | `[markup]` | Comment reflow knobs, markup detection patterns, ruler canonicalization |
21//! | `[per_command_overrides]` | Per-command layout overrides keyed by lowercase command name |
22//! | `[commands]` | Command-spec extensions (parsed by [`crate::spec::registry::CommandRegistry`]) |
23//!
24//! [`Config::from_file`], [`Config::from_yaml_str`], and
25//! [`Config::for_file`] load these files and return a resolved
26//! runtime [`Config`]. Unknown fields are rejected.
27
28#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
29#[doc(hidden)]
30pub mod editorconfig;
31pub mod file;
32#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
33mod legacy;
34/// Render a commented starter config template.
35pub use file::default_config_template;
36#[cfg(feature = "cli")]
37pub use file::{
38    default_config_template_for, generate_json_schema, render_effective_config, DumpConfigFormat,
39};
40#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
41pub use legacy::convert_legacy_config_files;
42
43use std::collections::HashMap;
44
45use regex::Regex;
46use serde::{Deserialize, Serialize};
47
48/// How to normalise command/keyword casing.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
51#[serde(rename_all = "lowercase")]
52#[non_exhaustive]
53pub enum CaseStyle {
54    /// Force lowercase output.
55    Lower,
56    /// Force uppercase output.
57    #[default]
58    Upper,
59    /// Preserve the original source casing.
60    Unchanged,
61}
62
63/// Output line-ending style.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
65#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
66#[serde(rename_all = "lowercase")]
67#[non_exhaustive]
68pub enum LineEnding {
69    /// Unix-style LF (`\n`). The default.
70    #[default]
71    Unix,
72    /// Windows-style CRLF (`\r\n`).
73    Windows,
74    /// Auto-detect the line ending from the input source.
75    Auto,
76}
77
78/// How to handle fractional tab indentation when [`Config::use_tabchars`] is
79/// `true`.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
81#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
82#[serde(rename_all = "kebab-case")]
83#[non_exhaustive]
84pub enum FractionalTabPolicy {
85    /// Leave fractional spaces as-is (utf-8 0x20). The default.
86    #[default]
87    UseSpace,
88    /// Round fractional indentation up to the next full tab stop (utf-8 0x09).
89    RoundUp,
90}
91
92/// How to indent continuation lines when a wrapped keyword section
93/// overflows [`Config::line_width`].
94///
95/// Suppose `PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ
96/// GROUP_EXECUTE GROUP_READ` exceeds the line budget under a
97/// `PATTERN *.h` subgroup:
98///
99/// ```cmake
100/// # SameIndent — continuation wraps at the subkwarg indent:
101/// PATTERN *.h
102///   PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ
103///   GROUP_EXECUTE GROUP_READ
104///
105/// # UnderFirstValue — continuation aligns under the first value
106/// # after the keyword:
107/// PATTERN *.h
108///   PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ
109///               GROUP_EXECUTE GROUP_READ
110/// ```
111///
112/// cmakefmt defaults to [`ContinuationAlign::UnderFirstValue`]: when
113/// a subkwarg group overflows, continuation lands under the first
114/// value column so the eye can tell continuation values apart from
115/// sibling subkwargs. This also matches cmake-format's hanging-indent
116/// style, easing migration. [`ContinuationAlign::SameIndent`] is
117/// available for consumers who prefer continuation at the subkwarg's
118/// own column — consistent with how flat keyword sections
119/// (`PUBLIC`/`PRIVATE`/…) and positional lists wrap elsewhere in the
120/// formatter.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
122#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
123#[serde(rename_all = "kebab-case")]
124#[non_exhaustive]
125pub enum ContinuationAlign {
126    /// Continuation lines wrap at the same indent as the keyword
127    /// itself. Consistent with how the rest of the formatter wraps
128    /// flat-list sections and positional argument lists.
129    SameIndent,
130    /// Continuation lines align under the first value after the
131    /// keyword (cmake-format's hanging-indent style). The default.
132    #[default]
133    UnderFirstValue,
134}
135
136/// How to align the dangling closing paren.
137///
138/// Only takes effect when [`Config::dangle_parens`] is `true`.
139/// Controls where `)` is placed when a call wraps onto multiple lines.
140///
141/// At the top level (block depth = 0) `Prefix` and `Close` both place
142/// the `)` at column 0 because the command sits there — the two
143/// variants are visually identical in this case:
144///
145/// ```cmake
146/// # Prefix / Close at top level — `)` at column 0:
147/// target_link_libraries(
148///   mylib PUBLIC dep1
149/// )
150///
151/// # Open — `)` at the opening-paren column:
152/// target_link_libraries(
153///   mylib PUBLIC dep1
154///                      )
155/// ```
156///
157/// Inside a nested block (`if/foreach/while/function/...`) the
158/// variants diverge: `Prefix` tracks the command-name indent (one
159/// tab stop per nesting level), while `Close` places the `)` at the
160/// current indent level — one tab stop shallower than the command
161/// name, i.e. flush with the enclosing block.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
163#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
164#[serde(rename_all = "lowercase")]
165#[non_exhaustive]
166pub enum DangleAlign {
167    /// Align with the start of the command name.
168    #[default]
169    Prefix,
170    /// Align with the opening paren column.
171    Open,
172    /// No extra indent (flush with current indent level).
173    Close,
174}
175
176/// Full formatter configuration.
177///
178/// Construct [`Config::default`] and set fields as needed before passing it to
179/// [`format_source`](crate::format_source) or related functions.
180///
181/// ```
182/// use cmakefmt::{Config, CaseStyle, DangleAlign};
183///
184/// let config = Config {
185///     line_width: 100,
186///     command_case: CaseStyle::Lower,
187///     dangle_parens: true,
188///     dangle_align: DangleAlign::Open,
189///     ..Config::default()
190/// };
191/// ```
192///
193/// # Loading from disk
194///
195/// Programmatic callers typically don't build a [`Config`] from
196/// scratch — they load a user config file:
197///
198/// - [`Config::for_file`] — auto-discover the nearest
199///   `.cmakefmt.yaml|yml|toml` starting from a source file's parent
200///   directory, walking up to the repository root and then the
201///   user's home directory.
202/// - [`Config::from_file`] — load a specific config file.
203/// - [`Config::from_files`] — load and merge several in order (later
204///   files override earlier ones).
205/// - [`Config::from_yaml_str`] — deserialise from an in-memory YAML
206///   string (used by the WASM playground and tests).
207///
208/// # Defaults
209///
210/// Headline defaults for the most commonly-adjusted knobs:
211///
212/// | Field | Default |
213/// |-------|---------|
214/// | `line_width` | `80` |
215/// | `tab_size` | `2` |
216/// | `use_tabchars` | `false` |
217/// | `line_ending` | [`LineEnding::Unix`] |
218/// | `max_empty_lines` | `1` |
219/// | `max_lines_hwrap` | `2` |
220/// | `max_pargs_hwrap` | `6` |
221/// | `max_subgroups_hwrap` | `2` |
222/// | `max_rows_cmdline` | `2` |
223/// | `command_case` | [`CaseStyle::Lower`] |
224/// | `keyword_case` | [`CaseStyle::Upper`] |
225/// | `dangle_parens` | `false` |
226/// | `dangle_align` | [`DangleAlign::Prefix`] |
227/// | `enable_markup` | `true` |
228/// | `first_comment_is_literal` | `true` |
229/// | `canonicalize_hashrulers` | `true` |
230/// | `hashruler_min_length` | `10` |
231///
232/// Fields not listed here default to `false`, empty, or their
233/// variant-level defaults — see the per-field documentation below.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(default)]
236pub struct Config {
237    // ── Kill-switch ─────────────────────────────────────────────────────
238    /// When `true`, skip all formatting and return the source unchanged.
239    pub disable: bool,
240
241    // ── Line endings ─────────────────────────────────────────────────────
242    /// Output line-ending style.
243    pub line_ending: LineEnding,
244
245    // ── Layout ──────────────────────────────────────────────────────────
246    /// Maximum rendered line width before wrapping is attempted.
247    pub line_width: usize,
248    /// Number of spaces that make up one indentation level when
249    /// [`Self::use_tabchars`] is `false`.
250    pub tab_size: usize,
251    /// Emit tab characters for indentation instead of spaces.
252    pub use_tabchars: bool,
253    /// How to handle fractional indentation when [`Self::use_tabchars`] is
254    /// `true`.
255    pub fractional_tab_policy: FractionalTabPolicy,
256    /// Maximum number of consecutive empty lines to preserve.
257    pub max_empty_lines: usize,
258    /// Maximum number of wrapped lines tolerated before switching to a more
259    /// vertical layout.
260    pub max_lines_hwrap: usize,
261    /// Maximum number of positional arguments to keep in a hanging-wrap layout
262    /// before going vertical.
263    pub max_pargs_hwrap: usize,
264    /// Maximum number of keyword/flag subgroups to keep in a horizontal wrap.
265    pub max_subgroups_hwrap: usize,
266    /// Maximum rows a hanging-wrap positional group may consume before the
267    /// layout is rejected and nesting is forced.
268    pub max_rows_cmdline: usize,
269    /// Command names (lowercase) that must always use vertical layout,
270    /// regardless of line width.
271    pub always_wrap: Vec<String>,
272    /// Return an error when any formatted output line exceeds
273    /// [`Self::line_width`].
274    pub require_valid_layout: bool,
275    /// When wrapping, keep the first positional argument on the command
276    /// line and align continuation to the open parenthesis. Can be
277    /// overridden per-command via `per_command_overrides` or the spec's
278    /// `layout.wrap_after_first_arg`.
279    pub wrap_after_first_arg: bool,
280    /// How to indent continuation lines when a wrapped keyword
281    /// section overflows [`Self::line_width`]. Can be overridden
282    /// per-command via `per_command_overrides` or the spec's
283    /// `layout.continuation_align`.
284    pub continuation_align: ContinuationAlign,
285    /// Sort arguments in keyword sections marked `sortable` in the
286    /// command spec. Sorting is lexicographic and case-insensitive.
287    pub enable_sort: bool,
288    /// Heuristically infer sortability for keyword sections without
289    /// an explicit `sortable` annotation. When enabled, a section is
290    /// considered sortable if all its arguments are simple unquoted
291    /// tokens (no variables, generator expressions, or quoted strings).
292    pub autosort: bool,
293
294    // ── Parenthesis style ───────────────────────────────────────────────
295    /// Place the closing `)` on its own line when a call wraps.
296    pub dangle_parens: bool,
297    /// Alignment strategy for a dangling closing `)`.
298    pub dangle_align: DangleAlign,
299    /// Lower bound used by layout heuristics when deciding whether a command
300    /// name is short enough to prefer one style over another.
301    pub min_prefix_chars: usize,
302    /// Upper bound used by layout heuristics when deciding whether a command
303    /// name is long enough to prefer one style over another.
304    pub max_prefix_chars: usize,
305    /// Insert a space before `(` for control-flow commands such as `if`.
306    pub separate_ctrl_name_with_space: bool,
307    /// Insert a space before `(` for `function`/`macro` definitions.
308    pub separate_fn_name_with_space: bool,
309
310    // ── Casing ──────────────────────────────────────────────────────────
311    /// Output casing policy for command names.
312    pub command_case: CaseStyle,
313    /// Output casing policy for recognized keywords and flags.
314    pub keyword_case: CaseStyle,
315
316    // ── Comment markup ──────────────────────────────────────────────────
317    /// Enable markup-aware comment handling and reflow plain line comments
318    /// to fit within the configured line width.
319    pub enable_markup: bool,
320    /// Preserve the first comment block in a file literally.
321    pub first_comment_is_literal: bool,
322    /// Regex for comments that should never be reflowed.
323    pub literal_comment_pattern: String,
324    /// Preferred bullet character when normalizing list markup.
325    pub bullet_char: String,
326    /// Preferred enumeration punctuation when normalizing numbered list markup.
327    pub enum_char: String,
328    /// Regex describing fenced literal comment blocks.
329    pub fence_pattern: String,
330    /// Regex describing ruler-style comments.
331    pub ruler_pattern: String,
332    /// Minimum ruler length before a `#-----` style line is treated as a ruler.
333    pub hashruler_min_length: usize,
334    /// Normalize ruler comments when markup handling is enabled.
335    pub canonicalize_hashrulers: bool,
336
337    // ── Per-command overrides ────────────────────────────────────────────
338    /// Per-command configuration overrides keyed by lowercase command name.
339    pub per_command_overrides: HashMap<String, PerCommandConfig>,
340}
341
342/// Per-command overrides. All fields are optional — only specified fields
343/// override the global config for that command.
344///
345/// # YAML/TOML key names
346///
347/// Two fields use different names in config files than in this Rust
348/// struct (for historical reasons):
349///
350/// | Rust field | YAML/TOML key |
351/// |------------|---------------|
352/// | `max_pargs_hwrap` | `max_hanging_wrap_positional_args` |
353/// | `max_subgroups_hwrap` | `max_hanging_wrap_groups` |
354///
355/// All other fields use the same name in both.
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
357#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
358#[serde(deny_unknown_fields)]
359pub struct PerCommandConfig {
360    /// Override the command casing rule for this command only.
361    pub command_case: Option<CaseStyle>,
362    /// Override the keyword casing rule for this command only.
363    pub keyword_case: Option<CaseStyle>,
364    /// Override the line width for this command only.
365    pub line_width: Option<usize>,
366    /// Override the indentation width for this command only.
367    pub tab_size: Option<usize>,
368    /// Override dangling paren placement for this command only.
369    pub dangle_parens: Option<bool>,
370    /// Override dangling paren alignment for this command only.
371    pub dangle_align: Option<DangleAlign>,
372    /// Override the hanging-wrap positional argument threshold for this
373    /// command only.
374    #[serde(rename = "max_hanging_wrap_positional_args")]
375    pub max_pargs_hwrap: Option<usize>,
376    /// Override the hanging-wrap subgroup threshold for this command only.
377    #[serde(rename = "max_hanging_wrap_groups")]
378    pub max_subgroups_hwrap: Option<usize>,
379    /// Keep the first positional argument on the command line when wrapping.
380    pub wrap_after_first_arg: Option<bool>,
381    /// Override the continuation-alignment rule for this command.
382    pub continuation_align: Option<ContinuationAlign>,
383}
384
385impl Default for Config {
386    fn default() -> Self {
387        Self {
388            disable: false,
389            line_ending: LineEnding::Unix,
390            line_width: 80,
391            tab_size: 2,
392            use_tabchars: false,
393            fractional_tab_policy: FractionalTabPolicy::UseSpace,
394            max_empty_lines: 1,
395            max_lines_hwrap: 2,
396            max_pargs_hwrap: 6,
397            max_subgroups_hwrap: 2,
398            max_rows_cmdline: 2,
399            always_wrap: Vec::new(),
400            require_valid_layout: false,
401            wrap_after_first_arg: false,
402            continuation_align: ContinuationAlign::UnderFirstValue,
403            enable_sort: false,
404            autosort: false,
405            dangle_parens: false,
406            dangle_align: DangleAlign::Prefix,
407            min_prefix_chars: 4,
408            max_prefix_chars: 10,
409            separate_ctrl_name_with_space: false,
410            separate_fn_name_with_space: false,
411            command_case: CaseStyle::Lower,
412            keyword_case: CaseStyle::Upper,
413            enable_markup: true,
414            first_comment_is_literal: true,
415            literal_comment_pattern: String::new(),
416            bullet_char: "*".to_string(),
417            enum_char: ".".to_string(),
418            fence_pattern: DEFAULT_FENCE_PATTERN.to_string(),
419            ruler_pattern: DEFAULT_RULER_PATTERN.to_string(),
420            hashruler_min_length: 10,
421            canonicalize_hashrulers: true,
422            per_command_overrides: HashMap::new(),
423        }
424    }
425}
426
427/// CMake control-flow commands that get `separate_ctrl_name_with_space`.
428const CONTROL_FLOW_COMMANDS: &[&str] = &[
429    "if",
430    "elseif",
431    "else",
432    "endif",
433    "foreach",
434    "endforeach",
435    "while",
436    "endwhile",
437    "break",
438    "continue",
439    "return",
440    "block",
441    "endblock",
442];
443
444/// CMake function/macro definition commands that get
445/// `separate_fn_name_with_space`.
446const FN_DEFINITION_COMMANDS: &[&str] = &["function", "endfunction", "macro", "endmacro"];
447
448impl Config {
449    /// Returns a `Config` with any per-command overrides applied for the
450    /// given command name, plus the appropriate space-before-paren setting.
451    pub fn for_command(&self, command_name: &str) -> CommandConfig<'_> {
452        let lower = command_name.to_ascii_lowercase();
453        let per_cmd = self.per_command_overrides.get(&lower);
454
455        let space_before_paren = if CONTROL_FLOW_COMMANDS.contains(&lower.as_str()) {
456            self.separate_ctrl_name_with_space
457        } else if FN_DEFINITION_COMMANDS.contains(&lower.as_str()) {
458            self.separate_fn_name_with_space
459        } else {
460            false
461        };
462
463        CommandConfig {
464            global: self,
465            per_cmd,
466            space_before_paren,
467        }
468    }
469
470    /// Apply the command_case rule to a command name.
471    pub fn apply_command_case(&self, name: &str) -> String {
472        apply_case(self.command_case, name)
473    }
474
475    /// Apply the keyword_case rule to a keyword token.
476    pub fn apply_keyword_case(&self, keyword: &str) -> String {
477        apply_case(self.keyword_case, keyword)
478    }
479
480    /// The indentation string (spaces or tab).
481    pub fn indent_str(&self) -> String {
482        if self.use_tabchars {
483            "\t".to_string()
484        } else {
485            " ".repeat(self.tab_size)
486        }
487    }
488
489    /// Validate that all regex patterns in the config are valid.
490    ///
491    /// Returns `Ok(())` if all patterns compile, or an error message
492    /// identifying the first invalid pattern. Internal callers that
493    /// want a structured error chain should use
494    /// [`Config::validate_patterns_structured`] instead.
495    pub fn validate_patterns(&self) -> Result<(), String> {
496        self.validate_patterns_structured()
497            .map_err(|err| err.to_string())
498    }
499
500    /// Validate that all regex patterns in the config are valid,
501    /// returning a structured [`enum@crate::Error`] on failure so
502    /// callers can surface the underlying [`regex::Error`] source
503    /// chain.
504    pub(crate) fn validate_patterns_structured(&self) -> crate::error::Result<()> {
505        // Fast path for defaults — the built-in pattern strings are known
506        // to be valid. Avoids compiling three regexes on every
507        // format_source() call, which dominates per-file overhead on
508        // whole-tree runs over many small files.
509        if self.has_default_regex_patterns() {
510            return Ok(());
511        }
512        let patterns = [
513            ("literal_comment_pattern", &self.literal_comment_pattern),
514            ("fence_pattern", &self.fence_pattern),
515            ("ruler_pattern", &self.ruler_pattern),
516        ];
517        for (name, pattern) in &patterns {
518            if !pattern.is_empty() {
519                if let Err(source) = Regex::new(pattern) {
520                    return Err(crate::error::Error::InvalidRegex {
521                        pattern: format!("{name} = {pattern:?}"),
522                        source,
523                    });
524                }
525            }
526        }
527        Ok(())
528    }
529
530    fn has_default_regex_patterns(&self) -> bool {
531        self.literal_comment_pattern.is_empty()
532            && self.fence_pattern == DEFAULT_FENCE_PATTERN
533            && self.ruler_pattern == DEFAULT_RULER_PATTERN
534    }
535
536    /// Compile all regex patterns into a cache for internal formatting use.
537    ///
538    /// Callers that build [`Config`] programmatically should use
539    /// [`Config::validate_patterns`] to validate regexes up front.
540    pub(crate) fn compiled_patterns(&self) -> Result<CompiledPatterns, String> {
541        // Fast path for the common default configuration. Compiling the
542        // default regex repeatedly is a measurable cost on whole-tree runs
543        // that process many small files.
544        if self.literal_comment_pattern.is_empty() {
545            return Ok(CompiledPatterns {
546                literal_comment: None,
547            });
548        }
549        Ok(CompiledPatterns {
550            literal_comment: compile_optional(
551                "literal_comment_pattern",
552                &self.literal_comment_pattern,
553            )?,
554        })
555    }
556}
557
558const DEFAULT_FENCE_PATTERN: &str = r"^\s*[`~]{3}[^`\n]*$";
559const DEFAULT_RULER_PATTERN: &str = r"^[^\w\s]{3}.*[^\w\s]{3}$";
560
561fn compile_optional(name: &str, pattern: &str) -> Result<Option<Regex>, String> {
562    if pattern.is_empty() {
563        Ok(None)
564    } else {
565        Regex::new(pattern)
566            .map(Some)
567            .map_err(|err| format!("invalid regex in {name}: {err}"))
568    }
569}
570
571/// Pre-compiled regex patterns from [`Config`] used internally while formatting.
572pub(crate) struct CompiledPatterns {
573    /// Compiled `literal_comment_pattern`.
574    pub(crate) literal_comment: Option<Regex>,
575}
576
577/// A resolved config for formatting a specific command, with per-command
578/// overrides already applied.
579///
580/// Each accessor resolves values in this priority order:
581///
582/// 1. Per-command user override from
583///    [`Config::per_command_overrides`] (if set for this command).
584/// 2. Command-spec `layout` overrides for the selected form (passed
585///    in where applicable, e.g. [`CommandConfig::wrap_after_first_arg`]).
586/// 3. Global [`Config`] default.
587///
588/// Construct via [`Config::for_command`].
589#[derive(Debug)]
590pub struct CommandConfig<'a> {
591    /// The global configuration before per-command overrides are applied.
592    global: &'a Config,
593    per_cmd: Option<&'a PerCommandConfig>,
594    /// Whether this command should render a space before `(`.
595    space_before_paren: bool,
596}
597
598impl CommandConfig<'_> {
599    /// Whether this command should render a space before `(`.
600    pub fn space_before_paren(&self) -> bool {
601        self.space_before_paren
602    }
603
604    pub(crate) fn global(&self) -> &Config {
605        self.global
606    }
607
608    /// Effective line width for the current command.
609    pub fn line_width(&self) -> usize {
610        self.per_cmd
611            .and_then(|p| p.line_width)
612            .unwrap_or(self.global.line_width)
613    }
614
615    /// Effective indentation width for the current command.
616    pub fn tab_size(&self) -> usize {
617        self.per_cmd
618            .and_then(|p| p.tab_size)
619            .unwrap_or(self.global.tab_size)
620    }
621
622    /// Effective dangling-paren setting for the current command.
623    pub fn dangle_parens(&self) -> bool {
624        self.per_cmd
625            .and_then(|p| p.dangle_parens)
626            .unwrap_or(self.global.dangle_parens)
627    }
628
629    /// Effective dangling-paren alignment for the current command.
630    pub fn dangle_align(&self) -> DangleAlign {
631        self.per_cmd
632            .and_then(|p| p.dangle_align)
633            .unwrap_or(self.global.dangle_align)
634    }
635
636    /// Effective command casing rule for the current command.
637    pub fn command_case(&self) -> CaseStyle {
638        self.per_cmd
639            .and_then(|p| p.command_case)
640            .unwrap_or(self.global.command_case)
641    }
642
643    /// Effective keyword casing rule for the current command.
644    pub fn keyword_case(&self) -> CaseStyle {
645        self.per_cmd
646            .and_then(|p| p.keyword_case)
647            .unwrap_or(self.global.keyword_case)
648    }
649
650    /// Effective hanging-wrap positional argument threshold for the current
651    /// command.
652    pub fn max_pargs_hwrap(&self) -> usize {
653        self.per_cmd
654            .and_then(|p| p.max_pargs_hwrap)
655            .unwrap_or(self.global.max_pargs_hwrap)
656    }
657
658    /// Effective hanging-wrap subgroup threshold for the current command.
659    pub fn max_subgroups_hwrap(&self) -> usize {
660        self.per_cmd
661            .and_then(|p| p.max_subgroups_hwrap)
662            .unwrap_or(self.global.max_subgroups_hwrap)
663    }
664
665    /// Effective `wrap_after_first_arg` for the current command.
666    ///
667    /// Resolution order: per-command user override > `spec_value` (from
668    /// the command spec's layout overrides) > global config default.
669    pub fn wrap_after_first_arg(&self, spec_value: Option<bool>) -> bool {
670        self.per_cmd
671            .and_then(|p| p.wrap_after_first_arg)
672            .or(spec_value)
673            .unwrap_or(self.global.wrap_after_first_arg)
674    }
675
676    /// Effective continuation-alignment rule for the current command.
677    ///
678    /// Resolution order: per-command user override > `spec_value`
679    /// (from the command spec's layout overrides) > global config
680    /// default.
681    pub fn continuation_align(&self, spec_value: Option<ContinuationAlign>) -> ContinuationAlign {
682        self.per_cmd
683            .and_then(|p| p.continuation_align)
684            .or(spec_value)
685            .unwrap_or(self.global.continuation_align)
686    }
687
688    /// Effective indentation unit for the current command.
689    pub fn indent_str(&self) -> String {
690        if self.global.use_tabchars {
691            "\t".to_string()
692        } else {
693            " ".repeat(self.tab_size())
694        }
695    }
696}
697
698pub(crate) fn apply_case(style: CaseStyle, s: &str) -> String {
699    match style {
700        CaseStyle::Lower => s.to_ascii_lowercase(),
701        CaseStyle::Upper => s.to_ascii_uppercase(),
702        CaseStyle::Unchanged => s.to_string(),
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    // ── Config::for_command ───────────────────────────────────────────────
711
712    #[test]
713    fn for_command_control_flow_sets_space_before_paren() {
714        let config = Config {
715            separate_ctrl_name_with_space: true,
716            ..Config::default()
717        };
718        for cmd in ["if", "elseif", "foreach", "while", "return"] {
719            let cc = config.for_command(cmd);
720            assert!(
721                cc.space_before_paren(),
722                "{cmd} should have space_before_paren=true"
723            );
724        }
725    }
726
727    #[test]
728    fn for_command_fn_definition_sets_space_before_paren() {
729        let config = Config {
730            separate_fn_name_with_space: true,
731            ..Config::default()
732        };
733        for cmd in ["function", "endfunction", "macro", "endmacro"] {
734            let cc = config.for_command(cmd);
735            assert!(
736                cc.space_before_paren(),
737                "{cmd} should have space_before_paren=true"
738            );
739        }
740    }
741
742    #[test]
743    fn for_command_regular_command_no_space_before_paren() {
744        let config = Config {
745            separate_ctrl_name_with_space: true,
746            separate_fn_name_with_space: true,
747            ..Config::default()
748        };
749        let cc = config.for_command("message");
750        assert!(
751            !cc.space_before_paren(),
752            "message should not have space_before_paren"
753        );
754    }
755
756    #[test]
757    fn for_command_lookup_is_case_insensitive() {
758        let mut overrides = HashMap::new();
759        overrides.insert(
760            "message".to_string(),
761            PerCommandConfig {
762                line_width: Some(120),
763                ..Default::default()
764            },
765        );
766        let config = Config {
767            per_command_overrides: overrides,
768            ..Config::default()
769        };
770        // uppercase lookup should still find the "message" override
771        assert_eq!(config.for_command("MESSAGE").line_width(), 120);
772    }
773
774    // ── CommandConfig accessors ───────────────────────────────────────────
775
776    #[test]
777    fn command_config_returns_global_defaults_when_no_override() {
778        let config = Config::default();
779        let cc = config.for_command("set");
780        assert_eq!(cc.line_width(), config.line_width);
781        assert_eq!(cc.tab_size(), config.tab_size);
782        assert_eq!(cc.dangle_parens(), config.dangle_parens);
783        assert_eq!(cc.command_case(), config.command_case);
784        assert_eq!(cc.keyword_case(), config.keyword_case);
785        assert_eq!(cc.max_pargs_hwrap(), config.max_pargs_hwrap);
786        assert_eq!(cc.max_subgroups_hwrap(), config.max_subgroups_hwrap);
787    }
788
789    #[test]
790    fn command_config_per_command_overrides_take_effect() {
791        let mut overrides = HashMap::new();
792        overrides.insert(
793            "set".to_string(),
794            PerCommandConfig {
795                line_width: Some(120),
796                tab_size: Some(4),
797                dangle_parens: Some(true),
798                dangle_align: Some(DangleAlign::Open),
799                command_case: Some(CaseStyle::Upper),
800                keyword_case: Some(CaseStyle::Lower),
801                max_pargs_hwrap: Some(10),
802                max_subgroups_hwrap: Some(5),
803                wrap_after_first_arg: None,
804                continuation_align: None,
805            },
806        );
807        let config = Config {
808            per_command_overrides: overrides,
809            ..Config::default()
810        };
811        let cc = config.for_command("set");
812        assert_eq!(cc.line_width(), 120);
813        assert_eq!(cc.tab_size(), 4);
814        assert!(cc.dangle_parens());
815        assert_eq!(cc.dangle_align(), DangleAlign::Open);
816        assert_eq!(cc.command_case(), CaseStyle::Upper);
817        assert_eq!(cc.keyword_case(), CaseStyle::Lower);
818        assert_eq!(cc.max_pargs_hwrap(), 10);
819        assert_eq!(cc.max_subgroups_hwrap(), 5);
820    }
821
822    #[test]
823    fn indent_str_spaces() {
824        let config = Config {
825            tab_size: 4,
826            use_tabchars: false,
827            ..Config::default()
828        };
829        assert_eq!(config.indent_str(), "    ");
830        assert_eq!(config.for_command("set").indent_str(), "    ");
831    }
832
833    #[test]
834    fn indent_str_tab() {
835        let config = Config {
836            use_tabchars: true,
837            ..Config::default()
838        };
839        assert_eq!(config.indent_str(), "\t");
840        assert_eq!(config.for_command("set").indent_str(), "\t");
841    }
842
843    // ── Case helpers ─────────────────────────────────────────────────────
844
845    #[test]
846    fn apply_command_case_lower() {
847        let config = Config {
848            command_case: CaseStyle::Lower,
849            ..Config::default()
850        };
851        assert_eq!(
852            config.apply_command_case("TARGET_LINK_LIBRARIES"),
853            "target_link_libraries"
854        );
855    }
856
857    #[test]
858    fn apply_command_case_upper() {
859        let config = Config {
860            command_case: CaseStyle::Upper,
861            ..Config::default()
862        };
863        assert_eq!(
864            config.apply_command_case("target_link_libraries"),
865            "TARGET_LINK_LIBRARIES"
866        );
867    }
868
869    #[test]
870    fn apply_command_case_unchanged() {
871        let config = Config {
872            command_case: CaseStyle::Unchanged,
873            ..Config::default()
874        };
875        assert_eq!(
876            config.apply_command_case("Target_Link_Libraries"),
877            "Target_Link_Libraries"
878        );
879    }
880
881    #[test]
882    fn apply_keyword_case_variants() {
883        let config_upper = Config {
884            keyword_case: CaseStyle::Upper,
885            ..Config::default()
886        };
887        assert_eq!(config_upper.apply_keyword_case("public"), "PUBLIC");
888
889        let config_lower = Config {
890            keyword_case: CaseStyle::Lower,
891            ..Config::default()
892        };
893        assert_eq!(config_lower.apply_keyword_case("PUBLIC"), "public");
894    }
895
896    // ── Error Display ─────────────────────────────────────────────────────
897
898    #[test]
899    fn error_layout_too_wide_display() {
900        use crate::error::Error;
901        let err = Error::LayoutTooWide {
902            line_no: 5,
903            width: 95,
904            limit: 80,
905        };
906        let msg = err.to_string();
907        assert!(msg.contains("5"), "should mention line number");
908        assert!(msg.contains("95"), "should mention actual width");
909        assert!(msg.contains("80"), "should mention limit");
910    }
911
912    #[test]
913    fn error_formatter_display() {
914        use crate::error::Error;
915        let err = Error::Formatter("something went wrong".to_string());
916        assert!(err.to_string().contains("something went wrong"));
917    }
918
919    // ── Regex fast paths ──────────────────────────────────────────────────
920
921    #[test]
922    fn from_files_empty_path_returns_defaults() {
923        let config = Config::from_files(&[]).expect("default config should load");
924        let defaults = Config::default();
925        assert_eq!(
926            config.literal_comment_pattern,
927            defaults.literal_comment_pattern
928        );
929        assert_eq!(config.fence_pattern, defaults.fence_pattern);
930        assert_eq!(config.ruler_pattern, defaults.ruler_pattern);
931        assert_eq!(config.line_width, defaults.line_width);
932    }
933
934    #[test]
935    fn validate_patterns_accepts_defaults() {
936        let config = Config::default();
937        assert!(
938            config.validate_patterns().is_ok(),
939            "default patterns must pass validation"
940        );
941    }
942
943    #[test]
944    fn validate_patterns_rejects_invalid_custom_pattern() {
945        let config = Config {
946            fence_pattern: "(".to_string(),
947            ..Config::default()
948        };
949        let err = config
950            .validate_patterns()
951            .expect_err("invalid fence_pattern must be rejected");
952        assert!(
953            err.contains("fence_pattern"),
954            "error should identify fence_pattern, got: {err}"
955        );
956    }
957
958    #[test]
959    fn validate_patterns_accepts_valid_custom_pattern() {
960        let config = Config {
961            fence_pattern: r"^\s*[#]{3,}$".to_string(),
962            ..Config::default()
963        };
964        assert!(config.validate_patterns().is_ok());
965    }
966
967    #[test]
968    fn compiled_patterns_uses_cached_default_regex() {
969        let config = Config::default();
970        let compiled = config.compiled_patterns().expect("defaults must compile");
971        assert!(
972            compiled.literal_comment.is_none(),
973            "empty literal_comment_pattern should produce None"
974        );
975    }
976
977    #[test]
978    fn compiled_patterns_compiles_custom_literal_comment() {
979        let config = Config {
980            literal_comment_pattern: r"^\s*TODO:".to_string(),
981            ..Config::default()
982        };
983        let compiled = config
984            .compiled_patterns()
985            .expect("custom literal_comment_pattern must compile");
986        let literal = compiled
987            .literal_comment
988            .expect("custom literal_comment_pattern should compile to Some");
989        assert!(literal.is_match("  TODO: fix me"));
990        assert!(!literal.is_match("# regular comment"));
991    }
992
993    #[test]
994    fn compiled_patterns_errors_on_invalid_custom() {
995        let config = Config {
996            literal_comment_pattern: "(".to_string(),
997            ..Config::default()
998        };
999        match config.compiled_patterns() {
1000            Ok(_) => panic!("invalid custom pattern must error"),
1001            Err(err) => assert!(
1002                err.contains("literal_comment_pattern"),
1003                "error should identify literal_comment_pattern, got: {err}"
1004            ),
1005        }
1006    }
1007}