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