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