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