Skip to main content

cmakefmt/config/
file.rs

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