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