Skip to main content

cmakefmt/config/
mod.rs

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