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