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