Skip to main content

cmakefmt/config/
file.rs

1// SPDX-FileCopyrightText: Copyright 2026 Puneet Matharu
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Config-file loading and starter template generation.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::config::{
13    CaseStyle, Config, DangleAlign, FractionalTabPolicy, LineEnding, PerCommandConfig,
14};
15use crate::error::{Error, FileParseError, Result};
16
17/// The user-config file structure for `.cmakefmt.yaml`, `.cmakefmt.yml`, and
18/// `.cmakefmt.toml`.
19///
20/// All fields are optional — only specified values override the defaults.
21#[derive(Debug, Clone, Deserialize, Default, schemars::JsonSchema)]
22#[schemars(title = "cmakefmt configuration")]
23#[serde(default, deny_unknown_fields)]
24struct FileConfig {
25    /// JSON Schema reference (ignored at runtime, used by editors for
26    /// autocomplete and validation).
27    #[serde(rename = "$schema")]
28    #[schemars(skip)]
29    _schema: Option<String>,
30    /// Command spec overrides (parsed separately by the spec registry).
31    #[schemars(skip)]
32    commands: Option<serde_yaml::Value>,
33    /// Formatting options controlling line width, indentation, and layout.
34    format: FormatSection,
35    /// Style options controlling command and keyword casing.
36    style: StyleSection,
37    /// Comment markup processing options.
38    markup: MarkupSection,
39    /// Per-command configuration overrides keyed by lowercase command name.
40    #[serde(rename = "per_command_overrides")]
41    per_command_overrides: HashMap<String, PerCommandConfig>,
42    #[serde(rename = "per_command")]
43    #[schemars(skip)]
44    legacy_per_command: HashMap<String, PerCommandConfig>,
45}
46
47#[derive(Debug, Clone, Deserialize, Default, schemars::JsonSchema)]
48#[serde(default)]
49#[serde(deny_unknown_fields)]
50struct FormatSection {
51    /// Disable formatting entirely and return the source unchanged.
52    disable: Option<bool>,
53    /// Output line-ending style: `unix` (LF), `windows` (CRLF), or `auto` (detect from input).
54    line_ending: Option<LineEnding>,
55    /// Maximum rendered line width before cmakefmt wraps a call. Default: `80`.
56    line_width: Option<usize>,
57    /// Number of spaces per indentation level when `use_tabs` is `false`. Default: `2`.
58    tab_size: Option<usize>,
59    /// Indent with tab characters instead of spaces.
60    use_tabs: Option<bool>,
61    /// How to handle fractional indentation when `use_tabs` is `true`: `use-space` or `round-up`.
62    fractional_tab_policy: Option<FractionalTabPolicy>,
63    /// Maximum number of consecutive blank lines to preserve. Default: `1`.
64    max_empty_lines: Option<usize>,
65    /// Maximum wrapped lines to tolerate before switching to a more vertical layout. Default: `2`.
66    max_hanging_wrap_lines: Option<usize>,
67    /// Maximum positional arguments to keep in a hanging-wrap layout. Default: `6`.
68    max_hanging_wrap_positional_args: Option<usize>,
69    /// Maximum keyword/flag subgroups to keep in a hanging-wrap layout. Default: `2`.
70    max_hanging_wrap_groups: Option<usize>,
71    /// Maximum rows a hanging-wrap positional group may consume before nesting is forced. Default: `2`.
72    max_rows_cmdline: Option<usize>,
73    /// Command names (lowercase) that must always use vertical layout regardless of line width.
74    always_wrap: Option<Vec<String>>,
75    /// Return an error if any formatted output line exceeds `line_width`.
76    require_valid_layout: Option<bool>,
77    /// Place the closing `)` on its own line when a call wraps.
78    dangle_parens: Option<bool>,
79    /// Alignment strategy for a dangling `)`: `prefix`, `open`, or `close`.
80    dangle_align: Option<DangleAlign>,
81    /// Lower heuristic bound used when deciding between compact and wrapped layouts. Default: `4`.
82    min_prefix_length: Option<usize>,
83    /// Upper heuristic bound used when deciding between compact and wrapped layouts. Default: `10`.
84    max_prefix_length: Option<usize>,
85    /// Insert a space before `(` for control-flow commands such as `if`, `foreach`, `while`.
86    space_before_control_paren: Option<bool>,
87    /// Insert a space before `(` for `function()` and `macro()` definitions.
88    space_before_definition_paren: Option<bool>,
89}
90
91#[derive(Debug, Clone, Deserialize, Default, schemars::JsonSchema)]
92#[serde(default)]
93#[serde(deny_unknown_fields)]
94struct StyleSection {
95    /// Output casing for command names: `lower`, `upper`, or `unchanged`.
96    command_case: Option<CaseStyle>,
97    /// Output casing for recognized keywords and flags: `lower`, `upper`, or `unchanged`.
98    keyword_case: Option<CaseStyle>,
99}
100
101#[derive(Debug, Clone, Deserialize, Default, schemars::JsonSchema)]
102#[serde(default)]
103#[serde(deny_unknown_fields)]
104struct MarkupSection {
105    /// Enable markup-aware comment handling.
106    enable_markup: Option<bool>,
107    /// Reflow plain line comments to fit within the configured line width.
108    reflow_comments: Option<bool>,
109    /// Preserve the first comment block in a file literally.
110    first_comment_is_literal: Option<bool>,
111    /// Regex for comments that should never be reflowed.
112    literal_comment_pattern: Option<String>,
113    /// Preferred bullet character when normalizing markup lists. Default: `*`.
114    bullet_char: Option<String>,
115    /// Preferred punctuation for numbered lists when normalizing markup. Default: `.`.
116    enum_char: Option<String>,
117    /// Regex describing fenced literal comment blocks.
118    fence_pattern: Option<String>,
119    /// Regex describing ruler-style comments that should be treated specially.
120    ruler_pattern: Option<String>,
121    /// Minimum ruler length before a hash-only line is treated as a ruler. Default: `10`.
122    hashruler_min_length: Option<usize>,
123    /// Normalize ruler comments when markup handling is enabled.
124    canonicalize_hashrulers: Option<bool>,
125    /// Regex pattern for inline comments explicitly trailing their preceding argument. Default: `#<`.
126    explicit_trailing_pattern: Option<String>,
127}
128
129const CONFIG_FILE_NAME_TOML: &str = ".cmakefmt.toml";
130const CONFIG_FILE_NAME_YAML: &str = ".cmakefmt.yaml";
131const CONFIG_FILE_NAME_YML: &str = ".cmakefmt.yml";
132const CONFIG_FILE_NAMES: &[&str] = &[
133    CONFIG_FILE_NAME_YAML,
134    CONFIG_FILE_NAME_YML,
135    CONFIG_FILE_NAME_TOML,
136];
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub(crate) enum ConfigFileFormat {
140    Toml,
141    Yaml,
142}
143
144impl ConfigFileFormat {
145    pub(crate) fn as_str(self) -> &'static str {
146        match self {
147            Self::Toml => "TOML",
148            Self::Yaml => "YAML",
149        }
150    }
151}
152
153/// Supported `--dump-config` output formats.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
155pub enum DumpConfigFormat {
156    /// Emit YAML.
157    Yaml,
158    /// Emit TOML.
159    Toml,
160}
161
162/// Render a commented starter config in the requested format.
163///
164/// The template is intentionally verbose: every option is introduced by a
165/// short explanatory comment so users can understand the surface without
166/// needing to cross-reference the docs immediately.
167pub fn default_config_template_for(format: DumpConfigFormat) -> String {
168    match format {
169        DumpConfigFormat::Yaml => default_config_template_yaml(),
170        DumpConfigFormat::Toml => default_config_template_toml(),
171    }
172}
173
174/// Render a resolved runtime config back into the user-facing config schema.
175///
176/// This is primarily used by CLI introspection commands such as
177/// `cmakefmt --show-config`.
178pub fn render_effective_config(config: &Config, format: DumpConfigFormat) -> Result<String> {
179    let view = EffectiveConfigFile::from(config);
180    match format {
181        DumpConfigFormat::Yaml => serde_yaml::to_string(&view).map_err(|err| {
182            Error::Formatter(format!("failed to render effective config as YAML: {err}"))
183        }),
184        DumpConfigFormat::Toml => toml::to_string_pretty(&view).map_err(|err| {
185            Error::Formatter(format!("failed to render effective config as TOML: {err}"))
186        }),
187    }
188}
189
190/// Render a commented starter config using the default user-facing dump format.
191pub fn default_config_template() -> String {
192    default_config_template_for(DumpConfigFormat::Yaml)
193}
194
195/// Generate a JSON Schema for the cmakefmt config file format.
196///
197/// The output is a pretty-printed JSON string suitable for publishing to
198/// `cmakefmt.dev/schemas/v{version}/schema.json`.
199pub fn generate_json_schema() -> String {
200    let schema = schemars::schema_for!(FileConfig);
201    serde_json::to_string_pretty(&schema).expect("JSON schema serialization failed")
202}
203
204fn default_config_template_toml() -> String {
205    format!(
206        concat!(
207            "# Default cmakefmt configuration.\n",
208            "# Copy this to .cmakefmt.toml and uncomment the optional settings\n",
209            "# you want to customize.\n\n",
210            "[format]\n",
211            "# Disable formatting entirely (return source unchanged).\n",
212            "# disable = true\n\n",
213            "# Output line-ending style: unix (LF), windows (CRLF), or auto (detect from input).\n",
214            "# line_ending = \"windows\"\n\n",
215            "# Maximum rendered line width before cmakefmt wraps a call.\n",
216            "line_width = {line_width}\n\n",
217            "# Number of spaces per indentation level when use_tabs is false.\n",
218            "tab_size = {tab_size}\n\n",
219            "# Indent with tab characters instead of spaces.\n",
220            "# use_tabs = true\n\n",
221            "# How to handle fractional indentation when use_tabs is true: use-space or round-up.\n",
222            "# fractional_tab_policy = \"round-up\"\n\n",
223            "# Maximum number of consecutive blank lines to preserve.\n",
224            "max_empty_lines = {max_empty_lines}\n\n",
225            "# Maximum wrapped lines to tolerate before switching to a more vertical layout.\n",
226            "max_hanging_wrap_lines = {max_lines_hwrap}\n\n",
227            "# Maximum positional arguments to keep in a hanging-wrap layout.\n",
228            "max_hanging_wrap_positional_args = {max_pargs_hwrap}\n\n",
229            "# Maximum keyword/flag subgroups to keep in a hanging-wrap layout.\n",
230            "max_hanging_wrap_groups = {max_subgroups_hwrap}\n\n",
231            "# Maximum rows a hanging-wrap positional group may consume before nesting is forced.\n",
232            "max_rows_cmdline = {max_rows_cmdline}\n\n",
233            "# Commands that must always use vertical (wrapped) layout.\n",
234            "# always_wrap = [\"target_link_libraries\"]\n\n",
235            "# Return an error if any formatted line exceeds line_width.\n",
236            "# require_valid_layout = true\n\n",
237            "# Put the closing ')' on its own line when a call wraps.\n",
238            "dangle_parens = {dangle_parens}\n\n",
239            "# Alignment strategy for a dangling ')': prefix, open, or close.\n",
240            "dangle_align = \"{dangle_align}\"\n\n",
241            "# Lower heuristic bound used when deciding between compact and wrapped layouts.\n",
242            "min_prefix_length = {min_prefix_chars}\n\n",
243            "# Upper heuristic bound used when deciding between compact and wrapped layouts.\n",
244            "max_prefix_length = {max_prefix_chars}\n\n",
245            "# Insert a space before '(' for control-flow commands like if/foreach.\n",
246            "# space_before_control_paren = true\n\n",
247            "# Insert a space before '(' for function() and macro() definitions.\n",
248            "# space_before_definition_paren = true\n\n",
249            "[style]\n",
250            "# Output casing for command names: lower, upper, or unchanged.\n",
251            "command_case = \"{command_case}\"\n\n",
252            "# Output casing for recognized keywords and flags: lower, upper, or unchanged.\n",
253            "keyword_case = \"{keyword_case}\"\n\n",
254            "[markup]\n",
255            "# Enable markup-aware comment handling.\n",
256            "enable_markup = {enable_markup}\n\n",
257            "# Reflow plain line comments to fit within the configured line width.\n",
258            "# reflow_comments = true\n\n",
259            "# Preserve the first comment block in a file literally.\n",
260            "first_comment_is_literal = {first_comment_is_literal}\n\n",
261            "# Preserve comments matching a custom regex literally.\n",
262            "# literal_comment_pattern = \"^\\\\s*NOTE:\"\n\n",
263            "# Preferred bullet character when normalizing markup lists.\n",
264            "bullet_char = \"{bullet_char}\"\n\n",
265            "# Preferred punctuation for numbered lists when normalizing markup.\n",
266            "enum_char = \"{enum_char}\"\n\n",
267            "# Regex describing fenced literal comment blocks.\n",
268            "fence_pattern = '{fence_pattern}'\n\n",
269            "# Regex describing ruler-style comments that should be treated specially.\n",
270            "ruler_pattern = '{ruler_pattern}'\n\n",
271            "# Minimum ruler length before a hash-only line is treated as a ruler.\n",
272            "hashruler_min_length = {hashruler_min_length}\n\n",
273            "# Normalize ruler comments when markup handling is enabled.\n",
274            "canonicalize_hashrulers = {canonicalize_hashrulers}\n\n",
275            "# Regex pattern for inline comments that are explicitly trailing their preceding\n",
276            "# argument (rendered on the same line). Default: '#<'.\n",
277            "# explicit_trailing_pattern = \"#<\"\n\n",
278            "# Uncomment and edit a block like this to override formatting knobs\n",
279            "# for a specific command. This changes layout behavior for that\n",
280            "# command name only; it does not define new command syntax.\n",
281            "#\n",
282            "# [per_command_overrides.my_custom_command]\n",
283            "# Override the line width just for this command.\n",
284            "# line_width = 120\n\n",
285            "# Override command casing just for this command.\n",
286            "# command_case = \"unchanged\"\n\n",
287            "# Override keyword casing just for this command.\n",
288            "# keyword_case = \"upper\"\n\n",
289            "# Override indentation width just for this command.\n",
290            "# tab_size = 4\n\n",
291            "# Override dangling-paren placement just for this command.\n",
292            "# dangle_parens = false\n\n",
293            "# Override dangling-paren alignment just for this command.\n",
294            "# dangle_align = \"prefix\"\n\n",
295            "# Override the positional-argument hanging-wrap threshold just for this command.\n",
296            "# max_hanging_wrap_positional_args = 8\n\n",
297            "# Override the subgroup hanging-wrap threshold just for this command.\n",
298            "# max_hanging_wrap_groups = 3\n\n",
299            "# TOML custom-command specs live under [commands.<name>]. For\n",
300            "# user config, prefer YAML once these specs grow beyond a couple\n",
301            "# of simple flags/kwargs.\n",
302            "# Command specs tell the formatter which tokens are positional\n",
303            "# arguments, standalone flags, and keyword sections.\n",
304            "#\n",
305            "# Example: one positional argument, one flag, and two keyword\n",
306            "# sections that each take one or more values.\n",
307            "#\n",
308            "# [commands.my_custom_command]\n",
309            "# pargs = 1\n",
310            "# flags = [\"QUIET\"]\n",
311            "# kwargs = {{ SOURCES = {{ nargs = \"+\" }}, LIBRARIES = {{ nargs = \"+\" }} }}\n",
312        ),
313        line_width = Config::default().line_width,
314        tab_size = Config::default().tab_size,
315        max_empty_lines = Config::default().max_empty_lines,
316        max_lines_hwrap = Config::default().max_lines_hwrap,
317        max_pargs_hwrap = Config::default().max_pargs_hwrap,
318        max_subgroups_hwrap = Config::default().max_subgroups_hwrap,
319        max_rows_cmdline = Config::default().max_rows_cmdline,
320        dangle_parens = Config::default().dangle_parens,
321        dangle_align = "prefix",
322        min_prefix_chars = Config::default().min_prefix_chars,
323        max_prefix_chars = Config::default().max_prefix_chars,
324        command_case = "lower",
325        keyword_case = "upper",
326        enable_markup = Config::default().enable_markup,
327        first_comment_is_literal = Config::default().first_comment_is_literal,
328        bullet_char = Config::default().bullet_char,
329        enum_char = Config::default().enum_char,
330        fence_pattern = Config::default().fence_pattern,
331        ruler_pattern = Config::default().ruler_pattern,
332        hashruler_min_length = Config::default().hashruler_min_length,
333        canonicalize_hashrulers = Config::default().canonicalize_hashrulers,
334    )
335}
336
337#[derive(Debug, Clone, Serialize)]
338struct EffectiveConfigFile {
339    format: EffectiveFormatSection,
340    style: EffectiveStyleSection,
341    markup: EffectiveMarkupSection,
342    per_command_overrides: HashMap<String, PerCommandConfig>,
343}
344
345#[derive(Debug, Clone, Serialize)]
346struct EffectiveFormatSection {
347    #[serde(skip_serializing_if = "std::ops::Not::not")]
348    disable: bool,
349    line_ending: LineEnding,
350    line_width: usize,
351    tab_size: usize,
352    use_tabs: bool,
353    fractional_tab_policy: FractionalTabPolicy,
354    max_empty_lines: usize,
355    max_hanging_wrap_lines: usize,
356    max_hanging_wrap_positional_args: usize,
357    max_hanging_wrap_groups: usize,
358    max_rows_cmdline: usize,
359    #[serde(skip_serializing_if = "Vec::is_empty")]
360    always_wrap: Vec<String>,
361    #[serde(skip_serializing_if = "std::ops::Not::not")]
362    require_valid_layout: bool,
363    dangle_parens: bool,
364    dangle_align: DangleAlign,
365    min_prefix_length: usize,
366    max_prefix_length: usize,
367    space_before_control_paren: bool,
368    space_before_definition_paren: bool,
369}
370
371#[derive(Debug, Clone, Serialize)]
372struct EffectiveStyleSection {
373    command_case: CaseStyle,
374    keyword_case: CaseStyle,
375}
376
377#[derive(Debug, Clone, Serialize)]
378struct EffectiveMarkupSection {
379    enable_markup: bool,
380    reflow_comments: bool,
381    first_comment_is_literal: bool,
382    literal_comment_pattern: String,
383    bullet_char: String,
384    enum_char: String,
385    fence_pattern: String,
386    ruler_pattern: String,
387    hashruler_min_length: usize,
388    canonicalize_hashrulers: bool,
389    explicit_trailing_pattern: String,
390}
391
392impl From<&Config> for EffectiveConfigFile {
393    fn from(config: &Config) -> Self {
394        Self {
395            format: EffectiveFormatSection {
396                disable: config.disable,
397                line_ending: config.line_ending,
398                line_width: config.line_width,
399                tab_size: config.tab_size,
400                use_tabs: config.use_tabchars,
401                fractional_tab_policy: config.fractional_tab_policy,
402                max_empty_lines: config.max_empty_lines,
403                max_hanging_wrap_lines: config.max_lines_hwrap,
404                max_hanging_wrap_positional_args: config.max_pargs_hwrap,
405                max_hanging_wrap_groups: config.max_subgroups_hwrap,
406                max_rows_cmdline: config.max_rows_cmdline,
407                always_wrap: config.always_wrap.clone(),
408                require_valid_layout: config.require_valid_layout,
409                dangle_parens: config.dangle_parens,
410                dangle_align: config.dangle_align,
411                min_prefix_length: config.min_prefix_chars,
412                max_prefix_length: config.max_prefix_chars,
413                space_before_control_paren: config.separate_ctrl_name_with_space,
414                space_before_definition_paren: config.separate_fn_name_with_space,
415            },
416            style: EffectiveStyleSection {
417                command_case: config.command_case,
418                keyword_case: config.keyword_case,
419            },
420            markup: EffectiveMarkupSection {
421                enable_markup: config.enable_markup,
422                reflow_comments: config.reflow_comments,
423                first_comment_is_literal: config.first_comment_is_literal,
424                literal_comment_pattern: config.literal_comment_pattern.clone(),
425                bullet_char: config.bullet_char.clone(),
426                enum_char: config.enum_char.clone(),
427                fence_pattern: config.fence_pattern.clone(),
428                ruler_pattern: config.ruler_pattern.clone(),
429                hashruler_min_length: config.hashruler_min_length,
430                canonicalize_hashrulers: config.canonicalize_hashrulers,
431                explicit_trailing_pattern: config.explicit_trailing_pattern.clone(),
432            },
433            per_command_overrides: config.per_command_overrides.clone(),
434        }
435    }
436}
437
438fn default_config_template_yaml() -> String {
439    format!(
440        concat!(
441            "# yaml-language-server: $schema=https://cmakefmt.dev/schemas/latest/schema.json\n",
442            "# Default cmakefmt configuration.\n",
443            "# Copy this to .cmakefmt.yaml and uncomment the optional settings\n",
444            "# you want to customize.\n\n",
445            "format:\n",
446            "  # Disable formatting entirely (return source unchanged).\n",
447            "  # disable: true\n\n",
448            "  # Output line-ending style: unix (LF), windows (CRLF), or auto (detect from input).\n",
449            "  # line_ending: windows\n\n",
450            "  # Maximum rendered line width before cmakefmt wraps a call.\n",
451            "  line_width: {line_width}\n\n",
452            "  # Number of spaces per indentation level when use_tabs is false.\n",
453            "  tab_size: {tab_size}\n\n",
454            "  # Indent with tab characters instead of spaces.\n",
455            "  # use_tabs: true\n\n",
456            "  # How to handle fractional indentation when use_tabs is true: use-space or round-up.\n",
457            "  # fractional_tab_policy: round-up\n\n",
458            "  # Maximum number of consecutive blank lines to preserve.\n",
459            "  max_empty_lines: {max_empty_lines}\n\n",
460            "  # Maximum wrapped lines to tolerate before switching to a more vertical layout.\n",
461            "  max_hanging_wrap_lines: {max_lines_hwrap}\n\n",
462            "  # Maximum positional arguments to keep in a hanging-wrap layout.\n",
463            "  max_hanging_wrap_positional_args: {max_pargs_hwrap}\n\n",
464            "  # Maximum keyword/flag subgroups to keep in a hanging-wrap layout.\n",
465            "  max_hanging_wrap_groups: {max_subgroups_hwrap}\n\n",
466            "  # Maximum rows a hanging-wrap positional group may consume before nesting is forced.\n",
467            "  max_rows_cmdline: {max_rows_cmdline}\n\n",
468            "  # Commands that must always use vertical (wrapped) layout.\n",
469            "  # always_wrap:\n",
470            "  #   - target_link_libraries\n\n",
471            "  # Return an error if any formatted line exceeds line_width.\n",
472            "  # require_valid_layout: true\n\n",
473            "  # Put the closing ')' on its own line when a call wraps.\n",
474            "  dangle_parens: {dangle_parens}\n\n",
475            "  # Alignment strategy for a dangling ')': prefix, open, or close.\n",
476            "  dangle_align: {dangle_align}\n\n",
477            "  # Lower heuristic bound used when deciding between compact and wrapped layouts.\n",
478            "  min_prefix_length: {min_prefix_chars}\n\n",
479            "  # Upper heuristic bound used when deciding between compact and wrapped layouts.\n",
480            "  max_prefix_length: {max_prefix_chars}\n\n",
481            "  # Insert a space before '(' for control-flow commands like if/foreach.\n",
482            "  # space_before_control_paren: true\n\n",
483            "  # Insert a space before '(' for function() and macro() definitions.\n",
484            "  # space_before_definition_paren: true\n\n",
485            "style:\n",
486            "  # Output casing for command names: lower, upper, or unchanged.\n",
487            "  command_case: {command_case}\n\n",
488            "  # Output casing for recognized keywords and flags: lower, upper, or unchanged.\n",
489            "  keyword_case: {keyword_case}\n\n",
490            "markup:\n",
491            "  # Enable markup-aware comment handling.\n",
492            "  enable_markup: {enable_markup}\n\n",
493            "  # Reflow plain line comments to fit within the configured line width.\n",
494            "  # reflow_comments: true\n\n",
495            "  # Preserve the first comment block in a file literally.\n",
496            "  first_comment_is_literal: {first_comment_is_literal}\n\n",
497            "  # Preserve comments matching a custom regex literally.\n",
498            "  # literal_comment_pattern: '^\\s*NOTE:'\n\n",
499            "  # Preferred bullet character when normalizing markup lists.\n",
500            "  bullet_char: '{bullet_char}'\n\n",
501            "  # Preferred punctuation for numbered lists when normalizing markup.\n",
502            "  enum_char: '{enum_char}'\n\n",
503            "  # Regex describing fenced literal comment blocks.\n",
504            "  fence_pattern: '{fence_pattern}'\n\n",
505            "  # Regex describing ruler-style comments that should be treated specially.\n",
506            "  ruler_pattern: '{ruler_pattern}'\n\n",
507            "  # Minimum ruler length before a hash-only line is treated as a ruler.\n",
508            "  hashruler_min_length: {hashruler_min_length}\n\n",
509            "  # Normalize ruler comments when markup handling is enabled.\n",
510            "  canonicalize_hashrulers: {canonicalize_hashrulers}\n\n",
511            "  # Regex pattern for inline comments that are explicitly trailing their preceding\n",
512            "  # argument (rendered on the same line). Default: '#<'.\n",
513            "  # explicit_trailing_pattern: '#<'\n\n",
514            "# Uncomment and edit a block like this to override formatting knobs\n",
515            "# for a specific command. This changes layout behavior for that\n",
516            "# command name only; it does not define new command syntax.\n",
517            "#\n",
518            "# per_command_overrides:\n",
519            "#   my_custom_command:\n",
520            "#     # Override the line width just for this command.\n",
521            "#     line_width: 120\n",
522            "#\n",
523            "#     # Override command casing just for this command.\n",
524            "#     command_case: unchanged\n",
525            "#\n",
526            "#     # Override keyword casing just for this command.\n",
527            "#     keyword_case: upper\n",
528            "#\n",
529            "#     # Override indentation width just for this command.\n",
530            "#     tab_size: 4\n",
531            "#\n",
532            "#     # Override dangling-paren placement just for this command.\n",
533            "#     dangle_parens: false\n",
534            "#\n",
535            "#     # Override dangling-paren alignment just for this command.\n",
536            "#     dangle_align: prefix\n",
537            "#\n",
538            "#     # Override the positional-argument hanging-wrap threshold just for this command.\n",
539            "#     max_hanging_wrap_positional_args: 8\n",
540            "#\n",
541            "#     # Override the subgroup hanging-wrap threshold just for this command.\n",
542            "#     max_hanging_wrap_groups: 3\n\n",
543            "# YAML custom-command specs live under commands:<name>. Command\n",
544            "# specs tell the formatter which tokens are positional arguments,\n",
545            "# standalone flags, and keyword sections.\n",
546            "#\n",
547            "# Example: one positional argument, one flag, and two keyword\n",
548            "# sections that each take one or more values.\n",
549            "#\n",
550            "# commands:\n",
551            "#   my_custom_command:\n",
552            "#     pargs: 1\n",
553            "#     flags:\n",
554            "#       - QUIET\n",
555            "#     kwargs:\n",
556            "#       SOURCES:\n",
557            "#         nargs: \"+\"\n",
558            "#       LIBRARIES:\n",
559            "#         nargs: \"+\"\n",
560        ),
561        line_width = Config::default().line_width,
562        tab_size = Config::default().tab_size,
563        max_empty_lines = Config::default().max_empty_lines,
564        max_lines_hwrap = Config::default().max_lines_hwrap,
565        max_pargs_hwrap = Config::default().max_pargs_hwrap,
566        max_subgroups_hwrap = Config::default().max_subgroups_hwrap,
567        max_rows_cmdline = Config::default().max_rows_cmdline,
568        dangle_parens = Config::default().dangle_parens,
569        dangle_align = "prefix",
570        min_prefix_chars = Config::default().min_prefix_chars,
571        max_prefix_chars = Config::default().max_prefix_chars,
572        command_case = "lower",
573        keyword_case = "upper",
574        enable_markup = Config::default().enable_markup,
575        first_comment_is_literal = Config::default().first_comment_is_literal,
576        bullet_char = Config::default().bullet_char,
577        enum_char = Config::default().enum_char,
578        fence_pattern = Config::default().fence_pattern,
579        ruler_pattern = Config::default().ruler_pattern,
580        hashruler_min_length = Config::default().hashruler_min_length,
581        canonicalize_hashrulers = Config::default().canonicalize_hashrulers,
582    )
583}
584
585impl Config {
586    /// Load configuration for a file at the given path.
587    ///
588    /// Searches for the nearest supported user config (`.cmakefmt.yaml`,
589    /// `.cmakefmt.yml`, then `.cmakefmt.toml`) starting from the file's
590    /// directory and walking up to the repository/filesystem root. If none is
591    /// found, falls back to the same filenames in the home directory.
592    pub fn for_file(file_path: &Path) -> Result<Self> {
593        let config_paths = find_config_files(file_path);
594        Self::from_files(&config_paths)
595    }
596
597    /// Load configuration from a specific supported config file.
598    pub fn from_file(path: &Path) -> Result<Self> {
599        let paths = [path.to_path_buf()];
600        Self::from_files(&paths)
601    }
602
603    /// Load configuration by merging several supported config files in order.
604    ///
605    /// Later files override earlier files.
606    pub fn from_files(paths: &[PathBuf]) -> Result<Self> {
607        let mut config = Config::default();
608        for path in paths {
609            let file_config = load_config_file(path)?;
610            config.apply(file_config);
611        }
612        config.validate_patterns().map_err(Error::Formatter)?;
613        Ok(config)
614    }
615
616    /// Return the config files that would be applied for the given file.
617    ///
618    /// When config discovery is used, this is either the nearest
619    /// supported config file found by walking upward from the file, or a home
620    /// directory config if no nearer config exists.
621    pub fn config_sources_for(file_path: &Path) -> Vec<PathBuf> {
622        find_config_files(file_path)
623    }
624
625    fn apply(&mut self, fc: FileConfig) {
626        // Format section
627        if let Some(v) = fc.format.disable {
628            self.disable = v;
629        }
630        if let Some(v) = fc.format.line_ending {
631            self.line_ending = v;
632        }
633        if let Some(v) = fc.format.line_width {
634            self.line_width = v;
635        }
636        if let Some(v) = fc.format.tab_size {
637            self.tab_size = v;
638        }
639        if let Some(v) = fc.format.use_tabs {
640            self.use_tabchars = v;
641        }
642        if let Some(v) = fc.format.fractional_tab_policy {
643            self.fractional_tab_policy = v;
644        }
645        if let Some(v) = fc.format.max_empty_lines {
646            self.max_empty_lines = v;
647        }
648        if let Some(v) = fc.format.max_hanging_wrap_lines {
649            self.max_lines_hwrap = v;
650        }
651        if let Some(v) = fc.format.max_hanging_wrap_positional_args {
652            self.max_pargs_hwrap = v;
653        }
654        if let Some(v) = fc.format.max_hanging_wrap_groups {
655            self.max_subgroups_hwrap = v;
656        }
657        if let Some(v) = fc.format.max_rows_cmdline {
658            self.max_rows_cmdline = v;
659        }
660        if let Some(v) = fc.format.always_wrap {
661            self.always_wrap = v.into_iter().map(|s| s.to_ascii_lowercase()).collect();
662        }
663        if let Some(v) = fc.format.require_valid_layout {
664            self.require_valid_layout = v;
665        }
666        if let Some(v) = fc.format.dangle_parens {
667            self.dangle_parens = v;
668        }
669        if let Some(v) = fc.format.dangle_align {
670            self.dangle_align = v;
671        }
672        if let Some(v) = fc.format.min_prefix_length {
673            self.min_prefix_chars = v;
674        }
675        if let Some(v) = fc.format.max_prefix_length {
676            self.max_prefix_chars = v;
677        }
678        if let Some(v) = fc.format.space_before_control_paren {
679            self.separate_ctrl_name_with_space = v;
680        }
681        if let Some(v) = fc.format.space_before_definition_paren {
682            self.separate_fn_name_with_space = v;
683        }
684
685        // Style section
686        if let Some(v) = fc.style.command_case {
687            self.command_case = v;
688        }
689        if let Some(v) = fc.style.keyword_case {
690            self.keyword_case = v;
691        }
692
693        // Markup section
694        if let Some(v) = fc.markup.enable_markup {
695            self.enable_markup = v;
696        }
697        if let Some(v) = fc.markup.reflow_comments {
698            self.reflow_comments = v;
699        }
700        if let Some(v) = fc.markup.first_comment_is_literal {
701            self.first_comment_is_literal = v;
702        }
703        if let Some(v) = fc.markup.literal_comment_pattern {
704            self.literal_comment_pattern = v;
705        }
706        if let Some(v) = fc.markup.bullet_char {
707            self.bullet_char = v;
708        }
709        if let Some(v) = fc.markup.enum_char {
710            self.enum_char = v;
711        }
712        if let Some(v) = fc.markup.fence_pattern {
713            self.fence_pattern = v;
714        }
715        if let Some(v) = fc.markup.ruler_pattern {
716            self.ruler_pattern = v;
717        }
718        if let Some(v) = fc.markup.hashruler_min_length {
719            self.hashruler_min_length = v;
720        }
721        if let Some(v) = fc.markup.canonicalize_hashrulers {
722            self.canonicalize_hashrulers = v;
723        }
724        if let Some(v) = fc.markup.explicit_trailing_pattern {
725            self.explicit_trailing_pattern = v;
726        }
727
728        // Per-command overrides (merge, don't replace)
729        for (name, overrides) in fc.per_command_overrides {
730            self.per_command_overrides.insert(name, overrides);
731        }
732    }
733}
734
735fn load_config_file(path: &Path) -> Result<FileConfig> {
736    let contents = std::fs::read_to_string(path).map_err(Error::Io)?;
737    let config: FileConfig = match detect_config_format(path)? {
738        ConfigFileFormat::Toml => toml::from_str(&contents).map_err(|source| {
739            let (line, column) = toml_line_col(&contents, source.span().map(|span| span.start));
740            Error::Config {
741                path: path.to_path_buf(),
742                details: FileParseError {
743                    format: ConfigFileFormat::Toml.as_str(),
744                    message: source.to_string().into_boxed_str(),
745                    line,
746                    column,
747                },
748                source_message: source.to_string().into_boxed_str(),
749            }
750        }),
751        ConfigFileFormat::Yaml => serde_yaml::from_str(&contents).map_err(|source| {
752            let location = source.location();
753            let line = location.as_ref().map(|loc| loc.line());
754            let column = location.as_ref().map(|loc| loc.column());
755            Error::Config {
756                path: path.to_path_buf(),
757                details: FileParseError {
758                    format: ConfigFileFormat::Yaml.as_str(),
759                    message: source.to_string().into_boxed_str(),
760                    line,
761                    column,
762                },
763                source_message: source.to_string().into_boxed_str(),
764            }
765        }),
766    }?;
767
768    if !config.legacy_per_command.is_empty() {
769        return Err(Error::Formatter(format!(
770            "{}: `per_command` has been renamed to `per_command_overrides`",
771            path.display()
772        )));
773    }
774
775    Ok(config)
776}
777
778/// Find the config files that apply to `file_path`.
779///
780/// The nearest supported config discovered while walking upward wins. If
781/// multiple supported config filenames exist in the same directory, YAML is
782/// preferred over TOML. If no project-local config is found, the user home
783/// config is returned when present.
784fn find_config_files(file_path: &Path) -> Vec<PathBuf> {
785    let start_dir = if file_path.is_dir() {
786        file_path.to_path_buf()
787    } else {
788        file_path
789            .parent()
790            .map(Path::to_path_buf)
791            .unwrap_or_else(|| PathBuf::from("."))
792    };
793
794    let mut dir = Some(start_dir.as_path());
795    while let Some(d) = dir {
796        if let Some(candidate) = preferred_config_in_dir(d) {
797            return vec![candidate];
798        }
799
800        if d.join(".git").exists() {
801            break;
802        }
803
804        dir = d.parent();
805    }
806
807    if let Some(home) = home_dir() {
808        if let Some(home_config) = preferred_config_in_dir(&home) {
809            return vec![home_config];
810        }
811    }
812
813    Vec::new()
814}
815
816pub(crate) fn detect_config_format(path: &Path) -> Result<ConfigFileFormat> {
817    let file_name = path
818        .file_name()
819        .and_then(|name| name.to_str())
820        .unwrap_or_default();
821    if file_name == CONFIG_FILE_NAME_TOML
822        || path.extension().and_then(|ext| ext.to_str()) == Some("toml")
823    {
824        return Ok(ConfigFileFormat::Toml);
825    }
826    if matches!(file_name, CONFIG_FILE_NAME_YAML | CONFIG_FILE_NAME_YML)
827        || matches!(
828            path.extension().and_then(|ext| ext.to_str()),
829            Some("yaml" | "yml")
830        )
831    {
832        return Ok(ConfigFileFormat::Yaml);
833    }
834
835    Err(Error::Formatter(format!(
836        "{}: unsupported config format; use .cmakefmt.yaml, .cmakefmt.yml, or .cmakefmt.toml",
837        path.display()
838    )))
839}
840
841fn preferred_config_in_dir(dir: &Path) -> Option<PathBuf> {
842    CONFIG_FILE_NAMES
843        .iter()
844        .map(|name| dir.join(name))
845        .find(|candidate| candidate.is_file())
846}
847
848pub(crate) fn toml_line_col(
849    contents: &str,
850    offset: Option<usize>,
851) -> (Option<usize>, Option<usize>) {
852    let Some(offset) = offset else {
853        return (None, None);
854    };
855    let mut line = 1usize;
856    let mut column = 1usize;
857    for (index, ch) in contents.char_indices() {
858        if index >= offset {
859            break;
860        }
861        if ch == '\n' {
862            line += 1;
863            column = 1;
864        } else {
865            column += 1;
866        }
867    }
868    (Some(line), Some(column))
869}
870
871fn home_dir() -> Option<PathBuf> {
872    std::env::var_os("HOME")
873        .or_else(|| std::env::var_os("USERPROFILE"))
874        .map(PathBuf::from)
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880    use std::fs;
881
882    #[test]
883    fn parse_empty_config() {
884        let config: FileConfig = toml::from_str("").unwrap();
885        assert!(config.format.line_width.is_none());
886    }
887
888    #[test]
889    fn parse_full_config() {
890        let toml_str = r#"
891[format]
892line_width = 120
893tab_size = 4
894use_tabs = true
895max_empty_lines = 2
896dangle_parens = true
897dangle_align = "open"
898space_before_control_paren = true
899space_before_definition_paren = true
900max_hanging_wrap_positional_args = 3
901max_hanging_wrap_groups = 1
902
903[style]
904command_case = "upper"
905keyword_case = "lower"
906
907[markup]
908enable_markup = false
909hashruler_min_length = 20
910
911[per_command_overrides.message]
912dangle_parens = true
913line_width = 100
914"#;
915        let config: FileConfig = toml::from_str(toml_str).unwrap();
916        assert_eq!(config.format.line_width, Some(120));
917        assert_eq!(config.format.tab_size, Some(4));
918        assert_eq!(config.format.use_tabs, Some(true));
919        assert_eq!(config.format.dangle_parens, Some(true));
920        assert_eq!(config.format.dangle_align, Some(DangleAlign::Open));
921        assert_eq!(config.style.command_case, Some(CaseStyle::Upper));
922        assert_eq!(config.style.keyword_case, Some(CaseStyle::Lower));
923        assert_eq!(config.markup.enable_markup, Some(false));
924
925        let msg = config.per_command_overrides.get("message").unwrap();
926        assert_eq!(msg.dangle_parens, Some(true));
927        assert_eq!(msg.line_width, Some(100));
928    }
929
930    #[test]
931    fn old_format_key_aliases_are_rejected() {
932        let toml_str = r#"
933[format]
934use_tabchars = true
935max_lines_hwrap = 4
936max_pargs_hwrap = 3
937max_subgroups_hwrap = 2
938min_prefix_chars = 5
939max_prefix_chars = 11
940separate_ctrl_name_with_space = true
941separate_fn_name_with_space = true
942"#;
943        let err = toml::from_str::<FileConfig>(toml_str)
944            .unwrap_err()
945            .to_string();
946        assert!(err.contains("unknown field"));
947    }
948
949    #[test]
950    fn config_from_file_applies_overrides() {
951        let dir = tempfile::tempdir().unwrap();
952        let config_path = dir.path().join(CONFIG_FILE_NAME_TOML);
953        fs::write(
954            &config_path,
955            r#"
956[format]
957line_width = 100
958tab_size = 4
959
960[style]
961command_case = "upper"
962"#,
963        )
964        .unwrap();
965
966        let config = Config::from_file(&config_path).unwrap();
967        assert_eq!(config.line_width, 100);
968        assert_eq!(config.tab_size, 4);
969        assert_eq!(config.command_case, CaseStyle::Upper);
970        // Unspecified values keep defaults
971        assert!(!config.use_tabchars);
972        assert_eq!(config.max_empty_lines, 1);
973    }
974
975    #[test]
976    fn default_yaml_config_template_parses() {
977        let template = default_config_template();
978        let parsed: FileConfig = serde_yaml::from_str(&template).unwrap();
979        assert_eq!(parsed.format.line_width, Some(Config::default().line_width));
980        assert_eq!(
981            parsed.style.command_case,
982            Some(Config::default().command_case)
983        );
984        assert_eq!(
985            parsed.markup.enable_markup,
986            Some(Config::default().enable_markup)
987        );
988    }
989
990    #[test]
991    fn toml_config_template_parses() {
992        let template = default_config_template_for(DumpConfigFormat::Toml);
993        let parsed: FileConfig = toml::from_str(&template).unwrap();
994        assert_eq!(parsed.format.line_width, Some(Config::default().line_width));
995        assert_eq!(
996            parsed.style.command_case,
997            Some(Config::default().command_case)
998        );
999        assert_eq!(
1000            parsed.markup.enable_markup,
1001            Some(Config::default().enable_markup)
1002        );
1003    }
1004
1005    #[test]
1006    fn missing_config_file_uses_defaults() {
1007        let dir = tempfile::tempdir().unwrap();
1008        let fake_file = dir.path().join("CMakeLists.txt");
1009        fs::write(&fake_file, "").unwrap();
1010
1011        let config = Config::for_file(&fake_file).unwrap();
1012        assert_eq!(config, Config::default());
1013    }
1014
1015    #[test]
1016    fn config_file_in_parent_is_found() {
1017        let dir = tempfile::tempdir().unwrap();
1018        // Create a .git dir to act as root
1019        fs::create_dir(dir.path().join(".git")).unwrap();
1020        fs::write(
1021            dir.path().join(CONFIG_FILE_NAME_TOML),
1022            "[format]\nline_width = 120\n",
1023        )
1024        .unwrap();
1025
1026        let subdir = dir.path().join("src");
1027        fs::create_dir(&subdir).unwrap();
1028        let file = subdir.join("CMakeLists.txt");
1029        fs::write(&file, "").unwrap();
1030
1031        let config = Config::for_file(&file).unwrap();
1032        assert_eq!(config.line_width, 120);
1033    }
1034
1035    #[test]
1036    fn closer_config_wins() {
1037        let dir = tempfile::tempdir().unwrap();
1038        fs::create_dir(dir.path().join(".git")).unwrap();
1039        fs::write(
1040            dir.path().join(CONFIG_FILE_NAME_TOML),
1041            "[format]\nline_width = 120\ntab_size = 4\n",
1042        )
1043        .unwrap();
1044
1045        let subdir = dir.path().join("src");
1046        fs::create_dir(&subdir).unwrap();
1047        fs::write(
1048            subdir.join(CONFIG_FILE_NAME_TOML),
1049            "[format]\nline_width = 100\n",
1050        )
1051        .unwrap();
1052
1053        let file = subdir.join("CMakeLists.txt");
1054        fs::write(&file, "").unwrap();
1055
1056        let config = Config::for_file(&file).unwrap();
1057        // Only the nearest config is used automatically.
1058        assert_eq!(config.line_width, 100);
1059        assert_eq!(config.tab_size, Config::default().tab_size);
1060    }
1061
1062    #[test]
1063    fn from_files_merges_in_order() {
1064        let dir = tempfile::tempdir().unwrap();
1065        let first = dir.path().join("first.toml");
1066        let second = dir.path().join("second.toml");
1067        fs::write(&first, "[format]\nline_width = 120\ntab_size = 4\n").unwrap();
1068        fs::write(&second, "[format]\nline_width = 100\n").unwrap();
1069
1070        let config = Config::from_files(&[first, second]).unwrap();
1071        assert_eq!(config.line_width, 100);
1072        assert_eq!(config.tab_size, 4);
1073    }
1074
1075    #[test]
1076    fn yaml_config_from_file_applies_overrides() {
1077        let dir = tempfile::tempdir().unwrap();
1078        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1079        fs::write(
1080            &config_path,
1081            "format:\n  line_width: 100\n  tab_size: 4\nstyle:\n  command_case: upper\n",
1082        )
1083        .unwrap();
1084
1085        let config = Config::from_file(&config_path).unwrap();
1086        assert_eq!(config.line_width, 100);
1087        assert_eq!(config.tab_size, 4);
1088        assert_eq!(config.command_case, CaseStyle::Upper);
1089    }
1090
1091    #[test]
1092    fn yml_config_from_file_applies_overrides() {
1093        let dir = tempfile::tempdir().unwrap();
1094        let config_path = dir.path().join(CONFIG_FILE_NAME_YML);
1095        fs::write(
1096            &config_path,
1097            "style:\n  keyword_case: lower\nformat:\n  line_width: 90\n",
1098        )
1099        .unwrap();
1100
1101        let config = Config::from_file(&config_path).unwrap();
1102        assert_eq!(config.line_width, 90);
1103        assert_eq!(config.keyword_case, CaseStyle::Lower);
1104    }
1105
1106    #[test]
1107    fn yaml_is_preferred_over_toml_during_discovery() {
1108        let dir = tempfile::tempdir().unwrap();
1109        fs::create_dir(dir.path().join(".git")).unwrap();
1110        fs::write(
1111            dir.path().join(CONFIG_FILE_NAME_TOML),
1112            "[format]\nline_width = 120\n",
1113        )
1114        .unwrap();
1115        fs::write(
1116            dir.path().join(CONFIG_FILE_NAME_YAML),
1117            "format:\n  line_width: 90\n",
1118        )
1119        .unwrap();
1120
1121        let file = dir.path().join("CMakeLists.txt");
1122        fs::write(&file, "").unwrap();
1123
1124        let config = Config::for_file(&file).unwrap();
1125        assert_eq!(config.line_width, 90);
1126        assert_eq!(
1127            Config::config_sources_for(&file),
1128            vec![dir.path().join(CONFIG_FILE_NAME_YAML)]
1129        );
1130    }
1131
1132    #[test]
1133    fn invalid_config_returns_error() {
1134        let dir = tempfile::tempdir().unwrap();
1135        let path = dir.path().join(CONFIG_FILE_NAME_TOML);
1136        fs::write(&path, "this is not valid toml {{{").unwrap();
1137
1138        let result = Config::from_file(&path);
1139        assert!(result.is_err());
1140        let err = result.unwrap_err();
1141        assert!(err.to_string().contains("config error"));
1142    }
1143
1144    #[test]
1145    fn config_from_yaml_file_applies_all_sections_and_overrides() {
1146        let dir = tempfile::tempdir().unwrap();
1147        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1148        fs::write(
1149            &config_path,
1150            r#"
1151format:
1152  line_width: 96
1153  tab_size: 3
1154  use_tabs: true
1155  max_empty_lines: 2
1156  max_hanging_wrap_lines: 4
1157  max_hanging_wrap_positional_args: 7
1158  max_hanging_wrap_groups: 5
1159  dangle_parens: true
1160  dangle_align: open
1161  min_prefix_length: 2
1162  max_prefix_length: 12
1163  space_before_control_paren: true
1164  space_before_definition_paren: true
1165style:
1166  command_case: unchanged
1167  keyword_case: lower
1168markup:
1169  enable_markup: false
1170  reflow_comments: true
1171  first_comment_is_literal: false
1172  literal_comment_pattern: '^\\s*KEEP'
1173  bullet_char: '-'
1174  enum_char: ')'
1175  fence_pattern: '^\\s*(```+).*'
1176  ruler_pattern: '^\\s*={5,}\\s*$'
1177  hashruler_min_length: 42
1178  canonicalize_hashrulers: false
1179per_command_overrides:
1180  my_custom_command:
1181    line_width: 101
1182    tab_size: 5
1183    dangle_parens: false
1184    dangle_align: prefix
1185    max_hanging_wrap_positional_args: 8
1186    max_hanging_wrap_groups: 9
1187"#,
1188        )
1189        .unwrap();
1190
1191        let config = Config::from_file(&config_path).unwrap();
1192        assert_eq!(config.line_width, 96);
1193        assert_eq!(config.tab_size, 3);
1194        assert!(config.use_tabchars);
1195        assert_eq!(config.max_empty_lines, 2);
1196        assert_eq!(config.max_lines_hwrap, 4);
1197        assert_eq!(config.max_pargs_hwrap, 7);
1198        assert_eq!(config.max_subgroups_hwrap, 5);
1199        assert!(config.dangle_parens);
1200        assert_eq!(config.dangle_align, DangleAlign::Open);
1201        assert_eq!(config.min_prefix_chars, 2);
1202        assert_eq!(config.max_prefix_chars, 12);
1203        assert!(config.separate_ctrl_name_with_space);
1204        assert!(config.separate_fn_name_with_space);
1205        assert_eq!(config.command_case, CaseStyle::Unchanged);
1206        assert_eq!(config.keyword_case, CaseStyle::Lower);
1207        assert!(!config.enable_markup);
1208        assert!(config.reflow_comments);
1209        assert!(!config.first_comment_is_literal);
1210        assert_eq!(config.literal_comment_pattern, "^\\\\s*KEEP");
1211        assert_eq!(config.bullet_char, "-");
1212        assert_eq!(config.enum_char, ")");
1213        assert_eq!(config.fence_pattern, "^\\\\s*(```+).*");
1214        assert_eq!(config.ruler_pattern, "^\\\\s*={5,}\\\\s*$");
1215        assert_eq!(config.hashruler_min_length, 42);
1216        assert!(!config.canonicalize_hashrulers);
1217        let per_command = config
1218            .per_command_overrides
1219            .get("my_custom_command")
1220            .unwrap();
1221        assert_eq!(per_command.line_width, Some(101));
1222        assert_eq!(per_command.tab_size, Some(5));
1223        assert_eq!(per_command.dangle_parens, Some(false));
1224        assert_eq!(per_command.dangle_align, Some(DangleAlign::Prefix));
1225        assert_eq!(per_command.max_pargs_hwrap, Some(8));
1226        assert_eq!(per_command.max_subgroups_hwrap, Some(9));
1227    }
1228
1229    #[test]
1230    fn detect_config_format_supports_yaml_and_rejects_unknown() {
1231        assert!(matches!(
1232            detect_config_format(Path::new(".cmakefmt.yml")).unwrap(),
1233            ConfigFileFormat::Yaml
1234        ));
1235        assert!(matches!(
1236            detect_config_format(Path::new("tooling/settings.yaml")).unwrap(),
1237            ConfigFileFormat::Yaml
1238        ));
1239        assert!(matches!(
1240            detect_config_format(Path::new("project.toml")).unwrap(),
1241            ConfigFileFormat::Toml
1242        ));
1243        let err = detect_config_format(Path::new("config.json")).unwrap_err();
1244        assert!(err.to_string().contains("unsupported config format"));
1245    }
1246
1247    #[test]
1248    fn yaml_config_with_legacy_per_command_key_is_rejected() {
1249        let dir = tempfile::tempdir().unwrap();
1250        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1251        fs::write(
1252            &config_path,
1253            "per_command:\n  message:\n    line_width: 120\n",
1254        )
1255        .unwrap();
1256        let err = Config::from_file(&config_path).unwrap_err();
1257        assert!(err
1258            .to_string()
1259            .contains("`per_command` has been renamed to `per_command_overrides`"));
1260    }
1261
1262    #[test]
1263    fn invalid_yaml_reports_line_and_column() {
1264        let dir = tempfile::tempdir().unwrap();
1265        let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1266        fs::write(&config_path, "format:\n  line_width: [\n").unwrap();
1267
1268        let err = Config::from_file(&config_path).unwrap_err();
1269        match err {
1270            Error::Config { details, .. } => {
1271                assert_eq!(details.format, "YAML");
1272                assert!(details.line.is_some());
1273                assert!(details.column.is_some());
1274            }
1275            other => panic!("expected config parse error, got {other:?}"),
1276        }
1277    }
1278
1279    #[test]
1280    fn toml_line_col_returns_none_when_offset_is_missing() {
1281        assert_eq!(toml_line_col("line = true\n", None), (None, None));
1282    }
1283}