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