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