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