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