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