1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::Deserialize;
11#[cfg(feature = "cli")]
12use serde::Serialize;
13
14use crate::config::{
15 CaseStyle, Config, DangleAlign, Experimental, FractionalTabPolicy, LineEnding, PerCommandConfig,
16};
17use crate::error::{Error, FileParseError, Result};
18
19#[derive(Debug, Clone, Deserialize, Default)]
24#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
25#[cfg_attr(feature = "cli", schemars(title = "cmakefmt configuration"))]
26#[serde(default, deny_unknown_fields)]
27struct FileConfig {
28 #[serde(rename = "$schema")]
31 #[cfg_attr(feature = "cli", schemars(skip))]
32 _schema: Option<String>,
33 #[cfg_attr(feature = "cli", schemars(skip))]
35 commands: Option<serde_yaml::Value>,
36 format: FormatSection,
38 markup: MarkupSection,
40 #[serde(rename = "per_command_overrides")]
42 per_command_overrides: HashMap<String, PerCommandConfig>,
43 #[serde(rename = "per_command")]
44 #[cfg_attr(feature = "cli", schemars(skip))]
45 legacy_per_command: HashMap<String, PerCommandConfig>,
46 #[serde(default)]
48 experimental: Experimental,
49}
50
51#[derive(Debug, Clone, Deserialize, Default)]
52#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
53#[serde(default)]
54#[serde(deny_unknown_fields)]
55struct FormatSection {
56 disable: Option<bool>,
58 line_ending: Option<LineEnding>,
60 line_width: Option<usize>,
62 tab_size: Option<usize>,
64 use_tabs: Option<bool>,
66 fractional_tab_policy: Option<FractionalTabPolicy>,
68 max_empty_lines: Option<usize>,
70 max_hanging_wrap_lines: Option<usize>,
72 max_hanging_wrap_positional_args: Option<usize>,
74 max_hanging_wrap_groups: Option<usize>,
76 max_rows_cmdline: Option<usize>,
78 always_wrap: Option<Vec<String>>,
80 require_valid_layout: Option<bool>,
82 wrap_after_first_arg: Option<bool>,
84 enable_sort: Option<bool>,
86 autosort: Option<bool>,
88 dangle_parens: Option<bool>,
90 dangle_align: Option<DangleAlign>,
92 min_prefix_length: Option<usize>,
94 max_prefix_length: Option<usize>,
96 space_before_control_paren: Option<bool>,
98 space_before_definition_paren: Option<bool>,
100 command_case: Option<CaseStyle>,
102 keyword_case: Option<CaseStyle>,
104}
105
106#[derive(Debug, Clone, Deserialize, Default)]
107#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
108#[serde(default)]
109#[serde(deny_unknown_fields)]
110struct MarkupSection {
111 enable_markup: Option<bool>,
113 first_comment_is_literal: Option<bool>,
115 literal_comment_pattern: Option<String>,
117 bullet_char: Option<String>,
119 enum_char: Option<String>,
121 fence_pattern: Option<String>,
123 ruler_pattern: Option<String>,
125 hashruler_min_length: Option<usize>,
127 canonicalize_hashrulers: Option<bool>,
129 explicit_trailing_pattern: Option<String>,
131}
132
133const CONFIG_FILE_NAME_TOML: &str = ".cmakefmt.toml";
134const CONFIG_FILE_NAME_YAML: &str = ".cmakefmt.yaml";
135const CONFIG_FILE_NAME_YML: &str = ".cmakefmt.yml";
136const CONFIG_FILE_NAMES: &[&str] = &[
137 CONFIG_FILE_NAME_YAML,
138 CONFIG_FILE_NAME_YML,
139 CONFIG_FILE_NAME_TOML,
140];
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub(crate) enum ConfigFileFormat {
144 Toml,
145 Yaml,
146}
147
148impl ConfigFileFormat {
149 pub(crate) fn as_str(self) -> &'static str {
150 match self {
151 Self::Toml => "TOML",
152 Self::Yaml => "YAML",
153 }
154 }
155}
156
157#[cfg(feature = "cli")]
159#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
160pub enum DumpConfigFormat {
161 Yaml,
163 Toml,
165}
166
167#[cfg(feature = "cli")]
168pub fn default_config_template_for(format: DumpConfigFormat) -> String {
174 match format {
175 DumpConfigFormat::Yaml => default_config_template_yaml(),
176 DumpConfigFormat::Toml => default_config_template_toml(),
177 }
178}
179
180#[cfg(feature = "cli")]
181pub fn render_effective_config(config: &Config, format: DumpConfigFormat) -> Result<String> {
186 let view = EffectiveConfigFile::from(config);
187 match format {
188 DumpConfigFormat::Yaml => serde_yaml::to_string(&view).map_err(|err| {
189 Error::Formatter(format!("failed to render effective config as YAML: {err}"))
190 }),
191 DumpConfigFormat::Toml => toml::to_string_pretty(&view).map_err(|err| {
192 Error::Formatter(format!("failed to render effective config as TOML: {err}"))
193 }),
194 }
195}
196
197pub fn default_config_template() -> String {
199 default_config_template_yaml()
200}
201
202#[cfg(feature = "cli")]
203pub fn generate_json_schema() -> String {
208 let schema = schemars::schema_for!(FileConfig);
209 serde_json::to_string_pretty(&schema).expect("JSON schema serialization failed")
210}
211
212#[cfg(feature = "cli")]
213fn default_config_template_toml() -> String {
214 format!(
215 concat!(
216 "# Default cmakefmt configuration.\n",
217 "# Copy this to .cmakefmt.toml and uncomment the optional settings\n",
218 "# you want to customize.\n\n",
219 "[format]\n",
220 "# Disable formatting entirely (return source unchanged).\n",
221 "# disable = true\n\n",
222 "# Output line-ending style: unix (LF), windows (CRLF), or auto (detect from input).\n",
223 "# line_ending = \"windows\"\n\n",
224 "# Maximum rendered line width before cmakefmt wraps a call.\n",
225 "line_width = {line_width}\n\n",
226 "# Number of spaces per indentation level when use_tabs is false.\n",
227 "tab_size = {tab_size}\n\n",
228 "# Indent with tab characters instead of spaces.\n",
229 "# use_tabs = true\n\n",
230 "# How to handle fractional indentation when use_tabs is true: use-space or round-up.\n",
231 "# fractional_tab_policy = \"round-up\"\n\n",
232 "# Maximum number of consecutive blank lines to preserve.\n",
233 "max_empty_lines = {max_empty_lines}\n\n",
234 "# Maximum wrapped lines to tolerate before switching to a more vertical layout.\n",
235 "max_hanging_wrap_lines = {max_lines_hwrap}\n\n",
236 "# Maximum positional arguments to keep in a hanging-wrap layout.\n",
237 "max_hanging_wrap_positional_args = {max_pargs_hwrap}\n\n",
238 "# Maximum keyword/flag subgroups to keep in a hanging-wrap layout.\n",
239 "max_hanging_wrap_groups = {max_subgroups_hwrap}\n\n",
240 "# Maximum rows a hanging-wrap positional group may consume before nesting is forced.\n",
241 "max_rows_cmdline = {max_rows_cmdline}\n\n",
242 "# Commands that must always use vertical (wrapped) layout.\n",
243 "# always_wrap = [\"target_link_libraries\"]\n\n",
244 "# Return an error if any formatted line exceeds line_width.\n",
245 "# require_valid_layout = true\n\n",
246 "# Keep the first positional argument on the command line when wrapping.\n",
247 "# wrap_after_first_arg = true\n\n",
248 "# Sort arguments in keyword sections marked sortable in the command spec.\n",
249 "# enable_sort = true\n\n",
250 "# Heuristically sort keyword sections where all arguments are simple unquoted tokens.\n",
251 "# autosort = true\n\n",
252 "# Put the closing ')' on its own line when a call wraps.\n",
253 "dangle_parens = {dangle_parens}\n\n",
254 "# Alignment strategy for a dangling ')': prefix, open, or close.\n",
255 "dangle_align = \"{dangle_align}\"\n\n",
256 "# Lower heuristic bound used when deciding between compact and wrapped layouts.\n",
257 "min_prefix_length = {min_prefix_chars}\n\n",
258 "# Upper heuristic bound used when deciding between compact and wrapped layouts.\n",
259 "max_prefix_length = {max_prefix_chars}\n\n",
260 "# Insert a space before '(' for control-flow commands like if/foreach.\n",
261 "# space_before_control_paren = true\n\n",
262 "# Insert a space before '(' for function() and macro() definitions.\n",
263 "# space_before_definition_paren = true\n\n",
264 "# Output casing for command names: lower, upper, or unchanged.\n",
265 "command_case = \"{command_case}\"\n\n",
266 "# Output casing for recognized keywords and flags: lower, upper, or unchanged.\n",
267 "keyword_case = \"{keyword_case}\"\n\n",
268 "[markup]\n",
269 "# Enable markup-aware comment handling.\n",
270 "enable_markup = {enable_markup}\n\n",
271 "# Preserve the first comment block in a file literally.\n",
272 "first_comment_is_literal = {first_comment_is_literal}\n\n",
273 "# Preserve comments matching a custom regex literally.\n",
274 "# literal_comment_pattern = \"^\\\\s*NOTE:\"\n\n",
275 "# Preferred bullet character when normalizing markup lists.\n",
276 "bullet_char = \"{bullet_char}\"\n\n",
277 "# Preferred punctuation for numbered lists when normalizing markup.\n",
278 "enum_char = \"{enum_char}\"\n\n",
279 "# Regex describing fenced literal comment blocks.\n",
280 "fence_pattern = '{fence_pattern}'\n\n",
281 "# Regex describing ruler-style comments that should be treated specially.\n",
282 "ruler_pattern = '{ruler_pattern}'\n\n",
283 "# Minimum ruler length before a hash-only line is treated as a ruler.\n",
284 "hashruler_min_length = {hashruler_min_length}\n\n",
285 "# Normalize ruler comments when markup handling is enabled.\n",
286 "canonicalize_hashrulers = {canonicalize_hashrulers}\n\n",
287 "# Regex pattern for inline comments that are explicitly trailing their preceding\n",
288 "# argument (rendered on the same line). Default: '#<'.\n",
289 "# explicit_trailing_pattern = \"#<\"\n\n",
290 "# Uncomment and edit a block like this to override formatting knobs\n",
291 "# for a specific command. This changes layout behavior for that\n",
292 "# command name only; it does not define new command syntax.\n",
293 "#\n",
294 "# [per_command_overrides.my_add_test]\n",
295 "# Override the line width just for this command.\n",
296 "# line_width = 120\n\n",
297 "# Override command casing just for this command.\n",
298 "# command_case = \"unchanged\"\n\n",
299 "# Override keyword casing just for this command.\n",
300 "# keyword_case = \"upper\"\n\n",
301 "# Override indentation width just for this command.\n",
302 "# tab_size = 4\n\n",
303 "# Override dangling-paren placement just for this command.\n",
304 "# dangle_parens = false\n\n",
305 "# Override dangling-paren alignment just for this command.\n",
306 "# dangle_align = \"prefix\"\n\n",
307 "# Override the positional-argument hanging-wrap threshold just for this command.\n",
308 "# max_hanging_wrap_positional_args = 8\n\n",
309 "# Override the subgroup hanging-wrap threshold just for this command.\n",
310 "# max_hanging_wrap_groups = 3\n\n",
311 "# TOML custom-command specs live under [commands.<name>]. For\n",
312 "# user config, prefer YAML once these specs grow beyond a couple\n",
313 "# of simple kwargs.\n",
314 "# Command specs tell the formatter which tokens are positional\n",
315 "# arguments, standalone flags, and keyword sections.\n",
316 "#\n",
317 "# Example: a custom test command with a flag and four keyword sections.\n",
318 "# Uncomment this block to teach cmakefmt the argument structure.\n",
319 "#\n",
320 "# [commands.my_add_test]\n",
321 "# pargs = 0\n",
322 "# flags = [\"VERBOSE\"]\n",
323 "# kwargs = {{ NAME = {{ nargs = 1 }}, SOURCES = {{ nargs = \"+\" }}, LIBRARIES = {{ nargs = \"+\" }}, TIMEOUT = {{ nargs = 1 }} }}\n",
324 ),
325 line_width = Config::default().line_width,
326 tab_size = Config::default().tab_size,
327 max_empty_lines = Config::default().max_empty_lines,
328 max_lines_hwrap = Config::default().max_lines_hwrap,
329 max_pargs_hwrap = Config::default().max_pargs_hwrap,
330 max_subgroups_hwrap = Config::default().max_subgroups_hwrap,
331 max_rows_cmdline = Config::default().max_rows_cmdline,
332 dangle_parens = Config::default().dangle_parens,
333 dangle_align = "prefix",
334 min_prefix_chars = Config::default().min_prefix_chars,
335 max_prefix_chars = Config::default().max_prefix_chars,
336 command_case = "lower",
337 keyword_case = "upper",
338 enable_markup = Config::default().enable_markup,
339 first_comment_is_literal = Config::default().first_comment_is_literal,
340 bullet_char = Config::default().bullet_char,
341 enum_char = Config::default().enum_char,
342 fence_pattern = Config::default().fence_pattern,
343 ruler_pattern = Config::default().ruler_pattern,
344 hashruler_min_length = Config::default().hashruler_min_length,
345 canonicalize_hashrulers = Config::default().canonicalize_hashrulers,
346 )
347}
348
349#[cfg(feature = "cli")]
350#[derive(Debug, Clone, Serialize)]
351struct EffectiveConfigFile {
352 format: EffectiveFormatSection,
353 markup: EffectiveMarkupSection,
354 per_command_overrides: HashMap<String, PerCommandConfig>,
355 experimental: Experimental,
356}
357
358#[derive(Debug, Clone, Serialize)]
359#[cfg(feature = "cli")]
360struct EffectiveFormatSection {
361 #[serde(skip_serializing_if = "std::ops::Not::not")]
362 disable: bool,
363 line_ending: LineEnding,
364 line_width: usize,
365 tab_size: usize,
366 use_tabs: bool,
367 fractional_tab_policy: FractionalTabPolicy,
368 max_empty_lines: usize,
369 max_hanging_wrap_lines: usize,
370 max_hanging_wrap_positional_args: usize,
371 max_hanging_wrap_groups: usize,
372 max_rows_cmdline: usize,
373 #[serde(skip_serializing_if = "Vec::is_empty")]
374 always_wrap: Vec<String>,
375 #[serde(skip_serializing_if = "std::ops::Not::not")]
376 require_valid_layout: bool,
377 #[serde(skip_serializing_if = "std::ops::Not::not")]
378 wrap_after_first_arg: bool,
379 #[serde(skip_serializing_if = "std::ops::Not::not")]
380 enable_sort: bool,
381 #[serde(skip_serializing_if = "std::ops::Not::not")]
382 autosort: bool,
383 dangle_parens: bool,
384 dangle_align: DangleAlign,
385 min_prefix_length: usize,
386 max_prefix_length: usize,
387 space_before_control_paren: bool,
388 space_before_definition_paren: bool,
389 command_case: CaseStyle,
390 keyword_case: CaseStyle,
391}
392
393#[derive(Debug, Clone, Serialize)]
394#[cfg(feature = "cli")]
395struct EffectiveMarkupSection {
396 enable_markup: bool,
397 first_comment_is_literal: bool,
398 literal_comment_pattern: String,
399 bullet_char: String,
400 enum_char: String,
401 fence_pattern: String,
402 ruler_pattern: String,
403 hashruler_min_length: usize,
404 canonicalize_hashrulers: bool,
405 explicit_trailing_pattern: String,
406}
407
408#[cfg(feature = "cli")]
409impl From<&Config> for EffectiveConfigFile {
410 fn from(config: &Config) -> Self {
411 Self {
412 format: EffectiveFormatSection {
413 disable: config.disable,
414 line_ending: config.line_ending,
415 line_width: config.line_width,
416 tab_size: config.tab_size,
417 use_tabs: config.use_tabchars,
418 fractional_tab_policy: config.fractional_tab_policy,
419 max_empty_lines: config.max_empty_lines,
420 max_hanging_wrap_lines: config.max_lines_hwrap,
421 max_hanging_wrap_positional_args: config.max_pargs_hwrap,
422 max_hanging_wrap_groups: config.max_subgroups_hwrap,
423 max_rows_cmdline: config.max_rows_cmdline,
424 always_wrap: config.always_wrap.clone(),
425 require_valid_layout: config.require_valid_layout,
426 wrap_after_first_arg: config.wrap_after_first_arg,
427 enable_sort: config.enable_sort,
428 autosort: config.autosort,
429 dangle_parens: config.dangle_parens,
430 dangle_align: config.dangle_align,
431 min_prefix_length: config.min_prefix_chars,
432 max_prefix_length: config.max_prefix_chars,
433 space_before_control_paren: config.separate_ctrl_name_with_space,
434 space_before_definition_paren: config.separate_fn_name_with_space,
435 command_case: config.command_case,
436 keyword_case: config.keyword_case,
437 },
438 markup: EffectiveMarkupSection {
439 enable_markup: config.enable_markup,
440 first_comment_is_literal: config.first_comment_is_literal,
441 literal_comment_pattern: config.literal_comment_pattern.clone(),
442 bullet_char: config.bullet_char.clone(),
443 enum_char: config.enum_char.clone(),
444 fence_pattern: config.fence_pattern.clone(),
445 ruler_pattern: config.ruler_pattern.clone(),
446 hashruler_min_length: config.hashruler_min_length,
447 canonicalize_hashrulers: config.canonicalize_hashrulers,
448 explicit_trailing_pattern: config.explicit_trailing_pattern.clone(),
449 },
450 per_command_overrides: config.per_command_overrides.clone(),
451 experimental: config.experimental.clone(),
452 }
453 }
454}
455
456fn default_config_template_yaml() -> String {
457 format!(
458 concat!(
459 "# yaml-language-server: $schema=https://cmakefmt.dev/schemas/latest/schema.json\n",
460 "# Default cmakefmt configuration.\n",
461 "# Copy this to .cmakefmt.yaml and uncomment the optional settings\n",
462 "# you want to customize.\n\n",
463 "format:\n",
464 " # Disable formatting entirely (return source unchanged).\n",
465 " # disable: true\n\n",
466 " # Output line-ending style: unix (LF), windows (CRLF), or auto (detect from input).\n",
467 " # line_ending: windows\n\n",
468 " # Maximum rendered line width before cmakefmt wraps a call.\n",
469 " line_width: {line_width}\n\n",
470 " # Number of spaces per indentation level when use_tabs is false.\n",
471 " tab_size: {tab_size}\n\n",
472 " # Indent with tab characters instead of spaces.\n",
473 " # use_tabs: true\n\n",
474 " # How to handle fractional indentation when use_tabs is true: use-space or round-up.\n",
475 " # fractional_tab_policy: round-up\n\n",
476 " # Maximum number of consecutive blank lines to preserve.\n",
477 " max_empty_lines: {max_empty_lines}\n\n",
478 " # Maximum wrapped lines to tolerate before switching to a more vertical layout.\n",
479 " max_hanging_wrap_lines: {max_lines_hwrap}\n\n",
480 " # Maximum positional arguments to keep in a hanging-wrap layout.\n",
481 " max_hanging_wrap_positional_args: {max_pargs_hwrap}\n\n",
482 " # Maximum keyword/flag subgroups to keep in a hanging-wrap layout.\n",
483 " max_hanging_wrap_groups: {max_subgroups_hwrap}\n\n",
484 " # Maximum rows a hanging-wrap positional group may consume before nesting is forced.\n",
485 " max_rows_cmdline: {max_rows_cmdline}\n\n",
486 " # Commands that must always use vertical (wrapped) layout.\n",
487 " # always_wrap:\n",
488 " # - target_link_libraries\n\n",
489 " # Return an error if any formatted line exceeds line_width.\n",
490 " # require_valid_layout: true\n\n",
491 " # Keep the first positional argument on the command line when wrapping.\n",
492 " # wrap_after_first_arg: true\n\n",
493 " # Sort arguments in keyword sections marked sortable in the command spec.\n",
494 " # enable_sort: true\n\n",
495 " # Heuristically sort keyword sections where all arguments are simple unquoted tokens.\n",
496 " # autosort: true\n\n",
497 " # Put the closing ')' on its own line when a call wraps.\n",
498 " dangle_parens: {dangle_parens}\n\n",
499 " # Alignment strategy for a dangling ')': prefix, open, or close.\n",
500 " dangle_align: {dangle_align}\n\n",
501 " # Lower heuristic bound used when deciding between compact and wrapped layouts.\n",
502 " min_prefix_length: {min_prefix_chars}\n\n",
503 " # Upper heuristic bound used when deciding between compact and wrapped layouts.\n",
504 " max_prefix_length: {max_prefix_chars}\n\n",
505 " # Insert a space before '(' for control-flow commands like if/foreach.\n",
506 " # space_before_control_paren: true\n\n",
507 " # Insert a space before '(' for function() and macro() definitions.\n",
508 " # space_before_definition_paren: true\n\n",
509 " # Output casing for command names: lower, upper, or unchanged.\n",
510 " command_case: {command_case}\n\n",
511 " # Output casing for recognized keywords and flags: lower, upper, or unchanged.\n",
512 " keyword_case: {keyword_case}\n\n",
513 "markup:\n",
514 " # Enable markup-aware comment handling.\n",
515 " enable_markup: {enable_markup}\n\n",
516 " # Preserve the first comment block in a file literally.\n",
517 " first_comment_is_literal: {first_comment_is_literal}\n\n",
518 " # Preserve comments matching a custom regex literally.\n",
519 " # literal_comment_pattern: '^\\s*NOTE:'\n\n",
520 " # Preferred bullet character when normalizing markup lists.\n",
521 " bullet_char: '{bullet_char}'\n\n",
522 " # Preferred punctuation for numbered lists when normalizing markup.\n",
523 " enum_char: '{enum_char}'\n\n",
524 " # Regex describing fenced literal comment blocks.\n",
525 " fence_pattern: '{fence_pattern}'\n\n",
526 " # Regex describing ruler-style comments that should be treated specially.\n",
527 " ruler_pattern: '{ruler_pattern}'\n\n",
528 " # Minimum ruler length before a hash-only line is treated as a ruler.\n",
529 " hashruler_min_length: {hashruler_min_length}\n\n",
530 " # Normalize ruler comments when markup handling is enabled.\n",
531 " canonicalize_hashrulers: {canonicalize_hashrulers}\n\n",
532 " # Regex pattern for inline comments that are explicitly trailing their preceding\n",
533 " # argument (rendered on the same line). Default: '#<'.\n",
534 " # explicit_trailing_pattern: '#<'\n\n",
535 "# Uncomment and edit a block like this to override formatting knobs\n",
536 "# for a specific command. This changes layout behavior for that\n",
537 "# command name only; it does not define new command syntax.\n",
538 "#\n",
539 "# per_command_overrides:\n",
540 "# my_add_test:\n",
541 "# # Override the line width just for this command.\n",
542 "# line_width: 120\n",
543 "#\n",
544 "# # Override command casing just for this command.\n",
545 "# command_case: unchanged\n",
546 "#\n",
547 "# # Override keyword casing just for this command.\n",
548 "# keyword_case: upper\n",
549 "#\n",
550 "# # Override indentation width just for this command.\n",
551 "# tab_size: 4\n",
552 "#\n",
553 "# # Override dangling-paren placement just for this command.\n",
554 "# dangle_parens: false\n",
555 "#\n",
556 "# # Override dangling-paren alignment just for this command.\n",
557 "# dangle_align: prefix\n",
558 "#\n",
559 "# # Override the positional-argument hanging-wrap threshold just for this command.\n",
560 "# max_hanging_wrap_positional_args: 8\n",
561 "#\n",
562 "# # Override the subgroup hanging-wrap threshold just for this command.\n",
563 "# max_hanging_wrap_groups: 3\n\n",
564 "# YAML custom-command specs live under commands:<name>. Command\n",
565 "# specs tell the formatter which tokens are positional arguments,\n",
566 "# standalone flags, and keyword sections.\n",
567 "#\n",
568 "# Example: a custom test command with a flag and four keyword sections.\n",
569 "# Uncomment this block to teach cmakefmt the argument structure.\n",
570 "#\n",
571 "# commands:\n",
572 "# my_add_test:\n",
573 "# pargs: 0\n",
574 "# flags:\n",
575 "# - VERBOSE\n",
576 "# kwargs:\n",
577 "# NAME:\n",
578 "# nargs: 1\n",
579 "# SOURCES:\n",
580 "# nargs: \"+\"\n",
581 "# LIBRARIES:\n",
582 "# nargs: \"+\"\n",
583 "# TIMEOUT:\n",
584 "# nargs: 1\n",
585 ),
586 line_width = Config::default().line_width,
587 tab_size = Config::default().tab_size,
588 max_empty_lines = Config::default().max_empty_lines,
589 max_lines_hwrap = Config::default().max_lines_hwrap,
590 max_pargs_hwrap = Config::default().max_pargs_hwrap,
591 max_subgroups_hwrap = Config::default().max_subgroups_hwrap,
592 max_rows_cmdline = Config::default().max_rows_cmdline,
593 dangle_parens = Config::default().dangle_parens,
594 dangle_align = "prefix",
595 min_prefix_chars = Config::default().min_prefix_chars,
596 max_prefix_chars = Config::default().max_prefix_chars,
597 command_case = "lower",
598 keyword_case = "upper",
599 enable_markup = Config::default().enable_markup,
600 first_comment_is_literal = Config::default().first_comment_is_literal,
601 bullet_char = Config::default().bullet_char,
602 enum_char = Config::default().enum_char,
603 fence_pattern = Config::default().fence_pattern,
604 ruler_pattern = Config::default().ruler_pattern,
605 hashruler_min_length = Config::default().hashruler_min_length,
606 canonicalize_hashrulers = Config::default().canonicalize_hashrulers,
607 )
608}
609
610impl Config {
611 pub fn for_file(file_path: &Path) -> Result<Self> {
618 let config_paths = find_config_files(file_path);
619 Self::from_files(&config_paths)
620 }
621
622 pub fn from_file(path: &Path) -> Result<Self> {
624 let paths = [path.to_path_buf()];
625 Self::from_files(&paths)
626 }
627
628 pub fn from_files(paths: &[PathBuf]) -> Result<Self> {
632 let mut config = Config::default();
633 if paths.is_empty() {
637 return Ok(config);
638 }
639 for path in paths {
640 let file_config = load_config_file(path)?;
641 config.apply(file_config);
642 }
643 config.validate_patterns().map_err(Error::Formatter)?;
644 Ok(config)
645 }
646
647 pub fn from_yaml_str(yaml: &str) -> Result<Self> {
653 Ok(parse_yaml_config(yaml)?.config)
654 }
655
656 #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
659 pub(crate) fn from_yaml_str_with_commands(yaml: &str) -> Result<(Self, Option<Box<str>>)> {
660 let parsed = parse_yaml_config(yaml)?;
661 Ok((parsed.config, parsed.commands_yaml))
662 }
663
664 pub fn config_sources_for(file_path: &Path) -> Vec<PathBuf> {
670 find_config_files(file_path)
671 }
672
673 fn apply(&mut self, fc: FileConfig) {
674 if let Some(v) = fc.format.disable {
676 self.disable = v;
677 }
678 if let Some(v) = fc.format.line_ending {
679 self.line_ending = v;
680 }
681 if let Some(v) = fc.format.line_width {
682 self.line_width = v;
683 }
684 if let Some(v) = fc.format.tab_size {
685 self.tab_size = v;
686 }
687 if let Some(v) = fc.format.use_tabs {
688 self.use_tabchars = v;
689 }
690 if let Some(v) = fc.format.fractional_tab_policy {
691 self.fractional_tab_policy = v;
692 }
693 if let Some(v) = fc.format.max_empty_lines {
694 self.max_empty_lines = v;
695 }
696 if let Some(v) = fc.format.max_hanging_wrap_lines {
697 self.max_lines_hwrap = v;
698 }
699 if let Some(v) = fc.format.max_hanging_wrap_positional_args {
700 self.max_pargs_hwrap = v;
701 }
702 if let Some(v) = fc.format.max_hanging_wrap_groups {
703 self.max_subgroups_hwrap = v;
704 }
705 if let Some(v) = fc.format.max_rows_cmdline {
706 self.max_rows_cmdline = v;
707 }
708 if let Some(v) = fc.format.always_wrap {
709 self.always_wrap = v.into_iter().map(|s| s.to_ascii_lowercase()).collect();
710 }
711 if let Some(v) = fc.format.require_valid_layout {
712 self.require_valid_layout = v;
713 }
714 if let Some(v) = fc.format.wrap_after_first_arg {
715 self.wrap_after_first_arg = v;
716 }
717 if let Some(v) = fc.format.enable_sort {
718 self.enable_sort = v;
719 }
720 if let Some(v) = fc.format.autosort {
721 self.autosort = v;
722 }
723 if let Some(v) = fc.format.dangle_parens {
724 self.dangle_parens = v;
725 }
726 if let Some(v) = fc.format.dangle_align {
727 self.dangle_align = v;
728 }
729 if let Some(v) = fc.format.min_prefix_length {
730 self.min_prefix_chars = v;
731 }
732 if let Some(v) = fc.format.max_prefix_length {
733 self.max_prefix_chars = v;
734 }
735 if let Some(v) = fc.format.space_before_control_paren {
736 self.separate_ctrl_name_with_space = v;
737 }
738 if let Some(v) = fc.format.space_before_definition_paren {
739 self.separate_fn_name_with_space = v;
740 }
741 if let Some(v) = fc.format.command_case {
742 self.command_case = v;
743 }
744 if let Some(v) = fc.format.keyword_case {
745 self.keyword_case = v;
746 }
747
748 if let Some(v) = fc.markup.enable_markup {
750 self.enable_markup = v;
751 }
752 if let Some(v) = fc.markup.first_comment_is_literal {
753 self.first_comment_is_literal = v;
754 }
755 if let Some(v) = fc.markup.literal_comment_pattern {
756 self.literal_comment_pattern = v;
757 }
758 if let Some(v) = fc.markup.bullet_char {
759 self.bullet_char = v;
760 }
761 if let Some(v) = fc.markup.enum_char {
762 self.enum_char = v;
763 }
764 if let Some(v) = fc.markup.fence_pattern {
765 self.fence_pattern = v;
766 }
767 if let Some(v) = fc.markup.ruler_pattern {
768 self.ruler_pattern = v;
769 }
770 if let Some(v) = fc.markup.hashruler_min_length {
771 self.hashruler_min_length = v;
772 }
773 if let Some(v) = fc.markup.canonicalize_hashrulers {
774 self.canonicalize_hashrulers = v;
775 }
776 if let Some(v) = fc.markup.explicit_trailing_pattern {
777 self.explicit_trailing_pattern = v;
778 }
779
780 for (name, overrides) in fc.per_command_overrides {
782 self.per_command_overrides.insert(name, overrides);
783 }
784
785 self.experimental = fc.experimental;
786 }
787}
788
789#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
790#[derive(Debug, Clone)]
791pub(crate) struct ParsedYamlConfig {
792 pub(crate) config: Config,
793 pub(crate) commands_yaml: Option<Box<str>>,
794}
795
796fn parse_yaml_config(yaml: &str) -> Result<ParsedYamlConfig> {
797 let file_config: FileConfig = serde_yaml::from_str(yaml).map_err(|source| {
798 Error::Config(crate::error::ConfigError {
799 path: std::path::PathBuf::from("<yaml-string>"),
800 details: FileParseError {
801 format: "yaml",
802 message: source.to_string().into_boxed_str(),
803 line: source.location().map(|loc| loc.line()),
804 column: source.location().map(|loc| loc.column()),
805 },
806 })
807 })?;
808 if !file_config.legacy_per_command.is_empty() {
809 return Err(Error::Config(crate::error::ConfigError {
810 path: std::path::PathBuf::from("<yaml-string>"),
811 details: FileParseError {
812 format: "yaml",
813 message: "`per_command` has been renamed to `per_command_overrides`"
814 .to_owned()
815 .into_boxed_str(),
816 line: None,
817 column: None,
818 },
819 }));
820 }
821 let commands_yaml = file_config
822 .commands
823 .as_ref()
824 .filter(|commands| !commands.is_null())
825 .map(serialize_commands_yaml)
826 .transpose()?;
827 let mut config = Config::default();
828 config.apply(file_config);
829 config.validate_patterns().map_err(|msg| {
830 Error::Config(crate::error::ConfigError {
831 path: std::path::PathBuf::from("<yaml-string>"),
832 details: FileParseError {
833 format: "yaml",
834 message: msg.into_boxed_str(),
835 line: None,
836 column: None,
837 },
838 })
839 })?;
840 Ok(ParsedYamlConfig {
841 config,
842 commands_yaml,
843 })
844}
845
846fn serialize_commands_yaml(commands: &serde_yaml::Value) -> Result<Box<str>> {
847 let key = serde_yaml::Value::String("commands".into());
848 let mut wrapper = serde_yaml::Mapping::new();
849 wrapper.insert(key, commands.clone());
850 serde_yaml::to_string(&wrapper)
851 .map(|yaml| yaml.into_boxed_str())
852 .map_err(|source| {
853 Error::Config(crate::error::ConfigError {
854 path: std::path::PathBuf::from("<yaml-string>"),
855 details: FileParseError {
856 format: "yaml",
857 message: format!("failed to serialize commands overrides: {source}")
858 .into_boxed_str(),
859 line: None,
860 column: None,
861 },
862 })
863 })
864}
865
866fn load_config_file(path: &Path) -> Result<FileConfig> {
867 let contents = std::fs::read_to_string(path).map_err(Error::Io)?;
868 let config: FileConfig = match detect_config_format(path)? {
869 ConfigFileFormat::Toml => toml::from_str(&contents).map_err(|source| {
870 let (line, column) = toml_line_col(&contents, source.span().map(|span| span.start));
871 Error::Config(crate::error::ConfigError {
872 path: path.to_path_buf(),
873 details: FileParseError {
874 format: ConfigFileFormat::Toml.as_str(),
875 message: source.to_string().into_boxed_str(),
876 line,
877 column,
878 },
879 })
880 }),
881 ConfigFileFormat::Yaml => serde_yaml::from_str(&contents).map_err(|source| {
882 let location = source.location();
883 let line = location.as_ref().map(|loc| loc.line());
884 let column = location.as_ref().map(|loc| loc.column());
885 Error::Config(crate::error::ConfigError {
886 path: path.to_path_buf(),
887 details: FileParseError {
888 format: ConfigFileFormat::Yaml.as_str(),
889 message: source.to_string().into_boxed_str(),
890 line,
891 column,
892 },
893 })
894 }),
895 }?;
896
897 if !config.legacy_per_command.is_empty() {
898 return Err(Error::Formatter(format!(
899 "{}: `per_command` has been renamed to `per_command_overrides`",
900 path.display()
901 )));
902 }
903
904 Ok(config)
905}
906
907fn find_config_files(file_path: &Path) -> Vec<PathBuf> {
914 let start_dir = if file_path.is_dir() {
915 file_path.to_path_buf()
916 } else {
917 file_path
918 .parent()
919 .map(Path::to_path_buf)
920 .unwrap_or_else(|| PathBuf::from("."))
921 };
922
923 let mut dir = Some(start_dir.as_path());
924 while let Some(d) = dir {
925 if let Some(candidate) = preferred_config_in_dir(d) {
926 return vec![candidate];
927 }
928
929 if d.join(".git").exists() {
930 break;
931 }
932
933 dir = d.parent();
934 }
935
936 if let Some(home) = home_dir() {
937 if let Some(home_config) = preferred_config_in_dir(&home) {
938 return vec![home_config];
939 }
940 }
941
942 Vec::new()
943}
944
945pub(crate) fn detect_config_format(path: &Path) -> Result<ConfigFileFormat> {
946 let file_name = path
947 .file_name()
948 .and_then(|name| name.to_str())
949 .unwrap_or_default();
950 if file_name == CONFIG_FILE_NAME_TOML
951 || path.extension().and_then(|ext| ext.to_str()) == Some("toml")
952 {
953 return Ok(ConfigFileFormat::Toml);
954 }
955 if matches!(file_name, CONFIG_FILE_NAME_YAML | CONFIG_FILE_NAME_YML)
956 || matches!(
957 path.extension().and_then(|ext| ext.to_str()),
958 Some("yaml" | "yml")
959 )
960 {
961 return Ok(ConfigFileFormat::Yaml);
962 }
963
964 Err(Error::Formatter(format!(
965 "{}: unsupported config format; use .cmakefmt.yaml, .cmakefmt.yml, or .cmakefmt.toml",
966 path.display()
967 )))
968}
969
970fn preferred_config_in_dir(dir: &Path) -> Option<PathBuf> {
971 CONFIG_FILE_NAMES
972 .iter()
973 .map(|name| dir.join(name))
974 .find(|candidate| candidate.is_file())
975}
976
977pub(crate) fn toml_line_col(
978 contents: &str,
979 offset: Option<usize>,
980) -> (Option<usize>, Option<usize>) {
981 let Some(offset) = offset else {
982 return (None, None);
983 };
984 let mut line = 1usize;
985 let mut column = 1usize;
986 for (index, ch) in contents.char_indices() {
987 if index >= offset {
988 break;
989 }
990 if ch == '\n' {
991 line += 1;
992 column = 1;
993 } else {
994 column += 1;
995 }
996 }
997 (Some(line), Some(column))
998}
999
1000fn home_dir() -> Option<PathBuf> {
1001 std::env::var_os("HOME")
1002 .or_else(|| std::env::var_os("USERPROFILE"))
1003 .map(PathBuf::from)
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009 use std::fs;
1010
1011 #[test]
1012 fn parse_empty_config() {
1013 let config: FileConfig = toml::from_str("").unwrap();
1014 assert!(config.format.line_width.is_none());
1015 }
1016
1017 #[test]
1018 fn parse_full_config() {
1019 let toml_str = r#"
1020[format]
1021line_width = 120
1022tab_size = 4
1023use_tabs = true
1024max_empty_lines = 2
1025dangle_parens = true
1026dangle_align = "open"
1027space_before_control_paren = true
1028space_before_definition_paren = true
1029max_hanging_wrap_positional_args = 3
1030max_hanging_wrap_groups = 1
1031command_case = "upper"
1032keyword_case = "lower"
1033
1034[markup]
1035enable_markup = false
1036hashruler_min_length = 20
1037
1038[per_command_overrides.message]
1039dangle_parens = true
1040line_width = 100
1041"#;
1042 let config: FileConfig = toml::from_str(toml_str).unwrap();
1043 assert_eq!(config.format.line_width, Some(120));
1044 assert_eq!(config.format.tab_size, Some(4));
1045 assert_eq!(config.format.use_tabs, Some(true));
1046 assert_eq!(config.format.dangle_parens, Some(true));
1047 assert_eq!(config.format.dangle_align, Some(DangleAlign::Open));
1048 assert_eq!(config.format.command_case, Some(CaseStyle::Upper));
1049 assert_eq!(config.format.keyword_case, Some(CaseStyle::Lower));
1050 assert_eq!(config.markup.enable_markup, Some(false));
1051
1052 let msg = config.per_command_overrides.get("message").unwrap();
1053 assert_eq!(msg.dangle_parens, Some(true));
1054 assert_eq!(msg.line_width, Some(100));
1055 }
1056
1057 #[test]
1058 fn old_format_key_aliases_are_rejected() {
1059 let toml_str = r#"
1060[format]
1061use_tabchars = true
1062max_lines_hwrap = 4
1063max_pargs_hwrap = 3
1064max_subgroups_hwrap = 2
1065min_prefix_chars = 5
1066max_prefix_chars = 11
1067separate_ctrl_name_with_space = true
1068separate_fn_name_with_space = true
1069"#;
1070 let err = toml::from_str::<FileConfig>(toml_str)
1071 .unwrap_err()
1072 .to_string();
1073 assert!(err.contains("unknown field"));
1074 }
1075
1076 #[test]
1077 fn config_from_file_applies_overrides() {
1078 let dir = tempfile::tempdir().unwrap();
1079 let config_path = dir.path().join(CONFIG_FILE_NAME_TOML);
1080 fs::write(
1081 &config_path,
1082 r#"
1083[format]
1084line_width = 100
1085tab_size = 4
1086command_case = "upper"
1087"#,
1088 )
1089 .unwrap();
1090
1091 let config = Config::from_file(&config_path).unwrap();
1092 assert_eq!(config.line_width, 100);
1093 assert_eq!(config.tab_size, 4);
1094 assert_eq!(config.command_case, CaseStyle::Upper);
1095 assert!(!config.use_tabchars);
1097 assert_eq!(config.max_empty_lines, 1);
1098 }
1099
1100 #[test]
1101 fn default_yaml_config_template_parses() {
1102 let template = default_config_template();
1103 let parsed: FileConfig = serde_yaml::from_str(&template).unwrap();
1104 assert_eq!(parsed.format.line_width, Some(Config::default().line_width));
1105 assert_eq!(
1106 parsed.format.command_case,
1107 Some(Config::default().command_case)
1108 );
1109 assert_eq!(
1110 parsed.markup.enable_markup,
1111 Some(Config::default().enable_markup)
1112 );
1113 }
1114
1115 #[test]
1116 fn toml_config_template_parses() {
1117 let template = default_config_template_for(DumpConfigFormat::Toml);
1118 let parsed: FileConfig = toml::from_str(&template).unwrap();
1119 assert_eq!(parsed.format.line_width, Some(Config::default().line_width));
1120 assert_eq!(
1121 parsed.format.command_case,
1122 Some(Config::default().command_case)
1123 );
1124 assert_eq!(
1125 parsed.markup.enable_markup,
1126 Some(Config::default().enable_markup)
1127 );
1128 }
1129
1130 #[test]
1131 fn missing_config_file_uses_defaults() {
1132 let dir = tempfile::tempdir().unwrap();
1133 let fake_file = dir.path().join("CMakeLists.txt");
1134 fs::write(&fake_file, "").unwrap();
1135
1136 let config = Config::for_file(&fake_file).unwrap();
1137 assert_eq!(config, Config::default());
1138 }
1139
1140 #[test]
1141 fn config_file_in_parent_is_found() {
1142 let dir = tempfile::tempdir().unwrap();
1143 fs::create_dir(dir.path().join(".git")).unwrap();
1145 fs::write(
1146 dir.path().join(CONFIG_FILE_NAME_TOML),
1147 "[format]\nline_width = 120\n",
1148 )
1149 .unwrap();
1150
1151 let subdir = dir.path().join("src");
1152 fs::create_dir(&subdir).unwrap();
1153 let file = subdir.join("CMakeLists.txt");
1154 fs::write(&file, "").unwrap();
1155
1156 let config = Config::for_file(&file).unwrap();
1157 assert_eq!(config.line_width, 120);
1158 }
1159
1160 #[test]
1161 fn closer_config_wins() {
1162 let dir = tempfile::tempdir().unwrap();
1163 fs::create_dir(dir.path().join(".git")).unwrap();
1164 fs::write(
1165 dir.path().join(CONFIG_FILE_NAME_TOML),
1166 "[format]\nline_width = 120\ntab_size = 4\n",
1167 )
1168 .unwrap();
1169
1170 let subdir = dir.path().join("src");
1171 fs::create_dir(&subdir).unwrap();
1172 fs::write(
1173 subdir.join(CONFIG_FILE_NAME_TOML),
1174 "[format]\nline_width = 100\n",
1175 )
1176 .unwrap();
1177
1178 let file = subdir.join("CMakeLists.txt");
1179 fs::write(&file, "").unwrap();
1180
1181 let config = Config::for_file(&file).unwrap();
1182 assert_eq!(config.line_width, 100);
1184 assert_eq!(config.tab_size, Config::default().tab_size);
1185 }
1186
1187 #[test]
1188 fn from_files_merges_in_order() {
1189 let dir = tempfile::tempdir().unwrap();
1190 let first = dir.path().join("first.toml");
1191 let second = dir.path().join("second.toml");
1192 fs::write(&first, "[format]\nline_width = 120\ntab_size = 4\n").unwrap();
1193 fs::write(&second, "[format]\nline_width = 100\n").unwrap();
1194
1195 let config = Config::from_files(&[first, second]).unwrap();
1196 assert_eq!(config.line_width, 100);
1197 assert_eq!(config.tab_size, 4);
1198 }
1199
1200 #[test]
1201 fn yaml_config_from_file_applies_overrides() {
1202 let dir = tempfile::tempdir().unwrap();
1203 let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1204 fs::write(
1205 &config_path,
1206 "format:\n line_width: 100\n tab_size: 4\n command_case: upper\n",
1207 )
1208 .unwrap();
1209
1210 let config = Config::from_file(&config_path).unwrap();
1211 assert_eq!(config.line_width, 100);
1212 assert_eq!(config.tab_size, 4);
1213 assert_eq!(config.command_case, CaseStyle::Upper);
1214 }
1215
1216 #[test]
1217 fn yml_config_from_file_applies_overrides() {
1218 let dir = tempfile::tempdir().unwrap();
1219 let config_path = dir.path().join(CONFIG_FILE_NAME_YML);
1220 fs::write(
1221 &config_path,
1222 "format:\n keyword_case: lower\n line_width: 90\n",
1223 )
1224 .unwrap();
1225
1226 let config = Config::from_file(&config_path).unwrap();
1227 assert_eq!(config.line_width, 90);
1228 assert_eq!(config.keyword_case, CaseStyle::Lower);
1229 }
1230
1231 #[test]
1232 fn yaml_is_preferred_over_toml_during_discovery() {
1233 let dir = tempfile::tempdir().unwrap();
1234 fs::create_dir(dir.path().join(".git")).unwrap();
1235 fs::write(
1236 dir.path().join(CONFIG_FILE_NAME_TOML),
1237 "[format]\nline_width = 120\n",
1238 )
1239 .unwrap();
1240 fs::write(
1241 dir.path().join(CONFIG_FILE_NAME_YAML),
1242 "format:\n line_width: 90\n",
1243 )
1244 .unwrap();
1245
1246 let file = dir.path().join("CMakeLists.txt");
1247 fs::write(&file, "").unwrap();
1248
1249 let config = Config::for_file(&file).unwrap();
1250 assert_eq!(config.line_width, 90);
1251 assert_eq!(
1252 Config::config_sources_for(&file),
1253 vec![dir.path().join(CONFIG_FILE_NAME_YAML)]
1254 );
1255 }
1256
1257 #[test]
1258 fn invalid_config_returns_error() {
1259 let dir = tempfile::tempdir().unwrap();
1260 let path = dir.path().join(CONFIG_FILE_NAME_TOML);
1261 fs::write(&path, "this is not valid toml {{{").unwrap();
1262
1263 let result = Config::from_file(&path);
1264 assert!(result.is_err());
1265 let err = result.unwrap_err();
1266 assert!(err.to_string().contains("config error"));
1267 }
1268
1269 #[test]
1270 fn config_from_yaml_file_applies_all_sections_and_overrides() {
1271 let dir = tempfile::tempdir().unwrap();
1272 let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1273 fs::write(
1274 &config_path,
1275 r#"
1276format:
1277 line_width: 96
1278 tab_size: 3
1279 use_tabs: true
1280 max_empty_lines: 2
1281 max_hanging_wrap_lines: 4
1282 max_hanging_wrap_positional_args: 7
1283 max_hanging_wrap_groups: 5
1284 dangle_parens: true
1285 dangle_align: open
1286 min_prefix_length: 2
1287 max_prefix_length: 12
1288 space_before_control_paren: true
1289 space_before_definition_paren: true
1290 command_case: unchanged
1291 keyword_case: lower
1292markup:
1293 enable_markup: false
1294 first_comment_is_literal: false
1295 literal_comment_pattern: '^\\s*KEEP'
1296 bullet_char: '-'
1297 enum_char: ')'
1298 fence_pattern: '^\\s*(```+).*'
1299 ruler_pattern: '^\\s*={5,}\\s*$'
1300 hashruler_min_length: 42
1301 canonicalize_hashrulers: false
1302per_command_overrides:
1303 my_custom_command:
1304 line_width: 101
1305 tab_size: 5
1306 dangle_parens: false
1307 dangle_align: prefix
1308 max_hanging_wrap_positional_args: 8
1309 max_hanging_wrap_groups: 9
1310"#,
1311 )
1312 .unwrap();
1313
1314 let config = Config::from_file(&config_path).unwrap();
1315 assert_eq!(config.line_width, 96);
1316 assert_eq!(config.tab_size, 3);
1317 assert!(config.use_tabchars);
1318 assert_eq!(config.max_empty_lines, 2);
1319 assert_eq!(config.max_lines_hwrap, 4);
1320 assert_eq!(config.max_pargs_hwrap, 7);
1321 assert_eq!(config.max_subgroups_hwrap, 5);
1322 assert!(config.dangle_parens);
1323 assert_eq!(config.dangle_align, DangleAlign::Open);
1324 assert_eq!(config.min_prefix_chars, 2);
1325 assert_eq!(config.max_prefix_chars, 12);
1326 assert!(config.separate_ctrl_name_with_space);
1327 assert!(config.separate_fn_name_with_space);
1328 assert_eq!(config.command_case, CaseStyle::Unchanged);
1329 assert_eq!(config.keyword_case, CaseStyle::Lower);
1330 assert!(!config.enable_markup);
1331 assert!(!config.first_comment_is_literal);
1332 assert_eq!(config.literal_comment_pattern, "^\\\\s*KEEP");
1333 assert_eq!(config.bullet_char, "-");
1334 assert_eq!(config.enum_char, ")");
1335 assert_eq!(config.fence_pattern, "^\\\\s*(```+).*");
1336 assert_eq!(config.ruler_pattern, "^\\\\s*={5,}\\\\s*$");
1337 assert_eq!(config.hashruler_min_length, 42);
1338 assert!(!config.canonicalize_hashrulers);
1339 let per_command = config
1340 .per_command_overrides
1341 .get("my_custom_command")
1342 .unwrap();
1343 assert_eq!(per_command.line_width, Some(101));
1344 assert_eq!(per_command.tab_size, Some(5));
1345 assert_eq!(per_command.dangle_parens, Some(false));
1346 assert_eq!(per_command.dangle_align, Some(DangleAlign::Prefix));
1347 assert_eq!(per_command.max_pargs_hwrap, Some(8));
1348 assert_eq!(per_command.max_subgroups_hwrap, Some(9));
1349 }
1350
1351 #[test]
1352 fn detect_config_format_supports_yaml_and_rejects_unknown() {
1353 assert!(matches!(
1354 detect_config_format(Path::new(".cmakefmt.yml")).unwrap(),
1355 ConfigFileFormat::Yaml
1356 ));
1357 assert!(matches!(
1358 detect_config_format(Path::new("tooling/settings.yaml")).unwrap(),
1359 ConfigFileFormat::Yaml
1360 ));
1361 assert!(matches!(
1362 detect_config_format(Path::new("project.toml")).unwrap(),
1363 ConfigFileFormat::Toml
1364 ));
1365 let err = detect_config_format(Path::new("config.json")).unwrap_err();
1366 assert!(err.to_string().contains("unsupported config format"));
1367 }
1368
1369 #[test]
1370 fn yaml_config_with_legacy_per_command_key_is_rejected() {
1371 let dir = tempfile::tempdir().unwrap();
1372 let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1373 fs::write(
1374 &config_path,
1375 "per_command:\n message:\n line_width: 120\n",
1376 )
1377 .unwrap();
1378 let err = Config::from_file(&config_path).unwrap_err();
1379 assert!(err
1380 .to_string()
1381 .contains("`per_command` has been renamed to `per_command_overrides`"));
1382 }
1383
1384 #[test]
1385 fn invalid_yaml_reports_line_and_column() {
1386 let dir = tempfile::tempdir().unwrap();
1387 let config_path = dir.path().join(CONFIG_FILE_NAME_YAML);
1388 fs::write(&config_path, "format:\n line_width: [\n").unwrap();
1389
1390 let err = Config::from_file(&config_path).unwrap_err();
1391 match err {
1392 Error::Config(config_err) => {
1393 let details = &config_err.details;
1394 assert_eq!(details.format, "YAML");
1395 assert!(details.line.is_some());
1396 assert!(details.column.is_some());
1397 }
1398 other => panic!("expected config parse error, got {other:?}"),
1399 }
1400 }
1401
1402 #[test]
1403 fn toml_line_col_returns_none_when_offset_is_missing() {
1404 assert_eq!(toml_line_col("line = true\n", None), (None, None));
1405 }
1406
1407 #[test]
1410 fn from_yaml_str_parses_format_section() {
1411 let config = Config::from_yaml_str("format:\n line_width: 120\n tab_size: 4").unwrap();
1412 assert_eq!(config.line_width, 120);
1413 assert_eq!(config.tab_size, 4);
1414 }
1415
1416 #[test]
1417 fn from_yaml_str_parses_casing_in_format_section() {
1418 let config = Config::from_yaml_str("format:\n command_case: upper").unwrap();
1419 assert_eq!(config.command_case, CaseStyle::Upper);
1420 }
1421
1422 #[test]
1423 fn from_yaml_str_parses_markup_section() {
1424 let config = Config::from_yaml_str("markup:\n enable_markup: false").unwrap();
1425 assert!(!config.enable_markup);
1426 }
1427
1428 #[test]
1429 fn from_yaml_str_with_commands_extracts_serialized_commands_block() {
1430 let (_, commands_yaml) =
1431 Config::from_yaml_str_with_commands("commands:\n my_cmd:\n pargs: 1").unwrap();
1432 let commands_yaml = commands_yaml.expect("expected serialized commands YAML");
1433 assert!(commands_yaml.contains("commands:"));
1434 assert!(commands_yaml.contains("my_cmd:"));
1435 }
1436
1437 #[test]
1438 fn from_yaml_str_rejects_unknown_top_level_field() {
1439 let result = Config::from_yaml_str("bogus_section:\n foo: bar");
1440 assert!(result.is_err());
1441 }
1442
1443 #[test]
1444 fn from_yaml_str_rejects_unknown_format_field() {
1445 let result = Config::from_yaml_str("format:\n nonexistent: 42");
1446 assert!(result.is_err());
1447 }
1448
1449 #[test]
1450 fn from_yaml_str_rejects_invalid_yaml() {
1451 let result = Config::from_yaml_str("{{invalid");
1452 assert!(result.is_err());
1453 }
1454
1455 #[test]
1456 fn from_yaml_str_empty_string_returns_defaults() {
1457 let config = Config::from_yaml_str("").unwrap();
1458 assert_eq!(config.line_width, Config::default().line_width);
1459 }
1460
1461 #[test]
1462 fn from_yaml_str_multiple_sections() {
1463 let config =
1464 Config::from_yaml_str("format:\n line_width: 100\n command_case: upper").unwrap();
1465 assert_eq!(config.line_width, 100);
1466 assert_eq!(config.command_case, CaseStyle::Upper);
1467 }
1468
1469 #[test]
1470 fn from_yaml_str_rejects_legacy_per_command() {
1471 let result = Config::from_yaml_str("per_command:\n message:\n line_width: 120");
1472 assert!(result.is_err());
1473 let err = result.unwrap_err().to_string();
1474 assert!(
1475 err.contains("per_command_overrides"),
1476 "error should mention the new key name: {err}"
1477 );
1478 }
1479}