cmakefmt/config/mod.rs
1// SPDX-FileCopyrightText: Copyright 2026 Puneet Matharu
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Runtime formatter configuration.
6//!
7//! [`Config`] is the fully resolved in-memory configuration used by the
8//! formatter. It is built from defaults, user config files
9//! (`.cmakefmt.yaml`, `.cmakefmt.yml`, or `.cmakefmt.toml`), and CLI
10//! overrides.
11//!
12//! # User config file schema
13//!
14//! User-facing config files are parsed under a separate schema
15//! (internal to this module) that groups options into named sections:
16//!
17//! | Section | Purpose |
18//! |---------|---------|
19//! | `[format]` | Line width, indentation, casing, dangle-paren policy, wrapping heuristics |
20//! | `[markup]` | Comment reflow knobs, markup detection patterns, ruler canonicalization |
21//! | `[per_command_overrides]` | Per-command layout overrides keyed by lowercase command name |
22//! | `[commands]` | Command-spec extensions (parsed by [`crate::spec::registry::CommandRegistry`]) |
23//!
24//! [`Config::from_file`], [`Config::from_yaml_str`], and
25//! [`Config::for_file`] load these files and return a resolved
26//! runtime [`Config`]. Unknown fields are rejected.
27
28#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
29#[doc(hidden)]
30pub mod editorconfig;
31pub mod file;
32#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
33mod legacy;
34/// Render a commented starter config template.
35pub use file::default_config_template;
36#[cfg(feature = "cli")]
37pub use file::{
38 default_config_template_for, generate_json_schema, render_effective_config, DumpConfigFormat,
39};
40#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
41pub use legacy::convert_legacy_config_files;
42
43use std::collections::HashMap;
44
45use regex::Regex;
46use serde::{Deserialize, Serialize};
47
48/// How to normalise command/keyword casing.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
51#[serde(rename_all = "lowercase")]
52#[non_exhaustive]
53pub enum CaseStyle {
54 /// Force lowercase output.
55 Lower,
56 /// Force uppercase output.
57 #[default]
58 Upper,
59 /// Preserve the original source casing.
60 Unchanged,
61}
62
63/// Output line-ending style.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
65#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
66#[serde(rename_all = "lowercase")]
67#[non_exhaustive]
68pub enum LineEnding {
69 /// Unix-style LF (`\n`). The default.
70 #[default]
71 Unix,
72 /// Windows-style CRLF (`\r\n`).
73 Windows,
74 /// Auto-detect the line ending from the input source.
75 Auto,
76}
77
78/// How to handle fractional tab indentation when [`Config::use_tabchars`] is
79/// `true`.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
81#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
82#[serde(rename_all = "kebab-case")]
83#[non_exhaustive]
84pub enum FractionalTabPolicy {
85 /// Leave fractional spaces as-is (utf-8 0x20). The default.
86 #[default]
87 UseSpace,
88 /// Round fractional indentation up to the next full tab stop (utf-8 0x09).
89 RoundUp,
90}
91
92/// How to indent continuation lines when a wrapped keyword section
93/// overflows [`Config::line_width`].
94///
95/// Suppose `PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ
96/// GROUP_EXECUTE GROUP_READ` exceeds the line budget under a
97/// `PATTERN *.h` subgroup:
98///
99/// ```cmake
100/// # SameIndent — continuation wraps at the subkwarg indent:
101/// PATTERN *.h
102/// PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ
103/// GROUP_EXECUTE GROUP_READ
104///
105/// # UnderFirstValue — continuation aligns under the first value
106/// # after the keyword:
107/// PATTERN *.h
108/// PERMISSIONS OWNER_EXECUTE OWNER_WRITE OWNER_READ
109/// GROUP_EXECUTE GROUP_READ
110/// ```
111///
112/// cmakefmt defaults to [`ContinuationAlign::UnderFirstValue`]: when
113/// a subkwarg group overflows, continuation lands under the first
114/// value column so the eye can tell continuation values apart from
115/// sibling subkwargs. This also matches cmake-format's hanging-indent
116/// style, easing migration. [`ContinuationAlign::SameIndent`] is
117/// available for consumers who prefer continuation at the subkwarg's
118/// own column — consistent with how flat keyword sections
119/// (`PUBLIC`/`PRIVATE`/…) and positional lists wrap elsewhere in the
120/// formatter.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
122#[cfg_attr(feature = "cli", derive(clap::ValueEnum, schemars::JsonSchema))]
123#[serde(rename_all = "kebab-case")]
124#[non_exhaustive]
125pub enum ContinuationAlign {
126 /// Continuation lines wrap at the same indent as the keyword
127 /// itself. Consistent with how the rest of the formatter wraps
128 /// flat-list sections and positional argument lists.
129 SameIndent,
130 /// Continuation lines align under the first value after the
131 /// keyword (cmake-format's hanging-indent style). The default.
132 #[default]
133 UnderFirstValue,
134}
135
136/// How to align the dangling closing paren.
137///
138/// Only takes effect when [`Config::dangle_parens`] is `true`.
139/// Controls where `)` is placed when a call wraps onto multiple lines.
140///
141/// At the top level (block depth = 0) `Prefix` and `Close` both place
142/// the `)` at column 0 because the command sits there — the two
143/// variants are visually identical in this case:
144///
145/// ```cmake
146/// # Prefix / Close at top level — `)` at column 0:
147/// target_link_libraries(
148/// mylib PUBLIC dep1
149/// )
150///
151/// # Open — `)` at the opening-paren column:
152/// target_link_libraries(
153/// mylib PUBLIC dep1
154/// )
155/// ```
156///
157/// Inside a nested block (`if/foreach/while/function/...`) the
158/// variants diverge: `Prefix` tracks the command-name indent (one
159/// tab stop per nesting level), while `Close` places the `)` at the
160/// current indent level — one tab stop shallower than the command
161/// name, i.e. flush with the enclosing block.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
163#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
164#[serde(rename_all = "lowercase")]
165#[non_exhaustive]
166pub enum DangleAlign {
167 /// Align with the start of the command name.
168 #[default]
169 Prefix,
170 /// Align with the opening paren column.
171 Open,
172 /// No extra indent (flush with current indent level).
173 Close,
174}
175
176/// Full formatter configuration.
177///
178/// Construct [`Config::default`] and set fields as needed before passing it to
179/// [`format_source`](crate::format_source) or related functions.
180///
181/// ```
182/// use cmakefmt::{Config, CaseStyle, DangleAlign};
183///
184/// let config = Config {
185/// line_width: 100,
186/// command_case: CaseStyle::Lower,
187/// dangle_parens: true,
188/// dangle_align: DangleAlign::Open,
189/// ..Config::default()
190/// };
191/// ```
192///
193/// # Loading from disk
194///
195/// Programmatic callers typically don't build a [`Config`] from
196/// scratch — they load a user config file:
197///
198/// - [`Config::for_file`] — auto-discover the nearest
199/// `.cmakefmt.yaml|yml|toml` starting from a source file's parent
200/// directory, walking up to the repository root and then the
201/// user's home directory.
202/// - [`Config::from_file`] — load a specific config file.
203/// - [`Config::from_files`] — load and merge several in order (later
204/// files override earlier ones).
205/// - [`Config::from_yaml_str`] — deserialise from an in-memory YAML
206/// string (used by the WASM playground and tests).
207///
208/// # Defaults
209///
210/// Headline defaults for the most commonly-adjusted knobs:
211///
212/// | Field | Default |
213/// |-------|---------|
214/// | `line_width` | `80` |
215/// | `tab_size` | `2` |
216/// | `use_tabchars` | `false` |
217/// | `line_ending` | [`LineEnding::Unix`] |
218/// | `max_empty_lines` | `1` |
219/// | `max_lines_hwrap` | `2` |
220/// | `max_pargs_hwrap` | `6` |
221/// | `max_subgroups_hwrap` | `2` |
222/// | `max_rows_cmdline` | `2` |
223/// | `command_case` | [`CaseStyle::Lower`] |
224/// | `keyword_case` | [`CaseStyle::Upper`] |
225/// | `dangle_parens` | `false` |
226/// | `dangle_align` | [`DangleAlign::Prefix`] |
227/// | `enable_markup` | `true` |
228/// | `first_comment_is_literal` | `true` |
229/// | `canonicalize_hashrulers` | `true` |
230/// | `hashruler_min_length` | `10` |
231///
232/// Fields not listed here default to `false`, empty, or their
233/// variant-level defaults — see the per-field documentation below.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(default)]
236pub struct Config {
237 // ── Kill-switch ─────────────────────────────────────────────────────
238 /// When `true`, skip all formatting and return the source unchanged.
239 pub disable: bool,
240
241 // ── Line endings ─────────────────────────────────────────────────────
242 /// Output line-ending style.
243 pub line_ending: LineEnding,
244
245 // ── Layout ──────────────────────────────────────────────────────────
246 /// Maximum rendered line width before wrapping is attempted.
247 pub line_width: usize,
248 /// Number of spaces that make up one indentation level when
249 /// [`Self::use_tabchars`] is `false`.
250 pub tab_size: usize,
251 /// Emit tab characters for indentation instead of spaces.
252 pub use_tabchars: bool,
253 /// How to handle fractional indentation when [`Self::use_tabchars`] is
254 /// `true`.
255 pub fractional_tab_policy: FractionalTabPolicy,
256 /// Maximum number of consecutive empty lines to preserve.
257 pub max_empty_lines: usize,
258 /// Maximum number of wrapped lines tolerated before switching to a more
259 /// vertical layout.
260 pub max_lines_hwrap: usize,
261 /// Maximum number of positional arguments to keep in a hanging-wrap layout
262 /// before going vertical.
263 pub max_pargs_hwrap: usize,
264 /// Maximum number of keyword/flag subgroups to keep in a horizontal wrap.
265 pub max_subgroups_hwrap: usize,
266 /// Maximum rows a hanging-wrap positional group may consume before the
267 /// layout is rejected and nesting is forced.
268 pub max_rows_cmdline: usize,
269 /// Command names (lowercase) that must always use vertical layout,
270 /// regardless of line width.
271 pub always_wrap: Vec<String>,
272 /// Return an error when any formatted output line exceeds
273 /// [`Self::line_width`].
274 pub require_valid_layout: bool,
275 /// When wrapping, keep the first positional argument on the command
276 /// line and align continuation to the open parenthesis. Can be
277 /// overridden per-command via `per_command_overrides` or the spec's
278 /// `layout.wrap_after_first_arg`.
279 pub wrap_after_first_arg: bool,
280 /// How to indent continuation lines when a wrapped keyword
281 /// section overflows [`Self::line_width`]. Can be overridden
282 /// per-command via `per_command_overrides` or the spec's
283 /// `layout.continuation_align`.
284 pub continuation_align: ContinuationAlign,
285 /// Sort arguments in keyword sections marked `sortable` in the
286 /// command spec. Sorting is lexicographic and case-insensitive.
287 pub enable_sort: bool,
288 /// Heuristically infer sortability for keyword sections without
289 /// an explicit `sortable` annotation. When enabled, a section is
290 /// considered sortable if all its arguments are simple unquoted
291 /// tokens (no variables, generator expressions, or quoted strings).
292 pub autosort: bool,
293
294 // ── Parenthesis style ───────────────────────────────────────────────
295 /// Place the closing `)` on its own line when a call wraps.
296 pub dangle_parens: bool,
297 /// Alignment strategy for a dangling closing `)`.
298 pub dangle_align: DangleAlign,
299 /// Lower bound used by layout heuristics when deciding whether a command
300 /// name is short enough to prefer one style over another.
301 pub min_prefix_chars: usize,
302 /// Upper bound used by layout heuristics when deciding whether a command
303 /// name is long enough to prefer one style over another.
304 pub max_prefix_chars: usize,
305 /// Insert a space before `(` for control-flow commands such as `if`.
306 pub separate_ctrl_name_with_space: bool,
307 /// Insert a space before `(` for `function`/`macro` definitions.
308 pub separate_fn_name_with_space: bool,
309
310 // ── Casing ──────────────────────────────────────────────────────────
311 /// Output casing policy for command names.
312 pub command_case: CaseStyle,
313 /// Output casing policy for recognized keywords and flags.
314 pub keyword_case: CaseStyle,
315
316 // ── Comment markup ──────────────────────────────────────────────────
317 /// Enable markup-aware comment handling and reflow plain line comments
318 /// to fit within the configured line width.
319 pub enable_markup: bool,
320 /// Preserve the first comment block in a file literally.
321 pub first_comment_is_literal: bool,
322 /// Regex for comments that should never be reflowed.
323 pub literal_comment_pattern: String,
324 /// Preferred bullet character when normalizing list markup.
325 pub bullet_char: String,
326 /// Preferred enumeration punctuation when normalizing numbered list markup.
327 pub enum_char: String,
328 /// Regex describing fenced literal comment blocks.
329 pub fence_pattern: String,
330 /// Regex describing ruler-style comments.
331 pub ruler_pattern: String,
332 /// Minimum ruler length before a `#-----` style line is treated as a ruler.
333 pub hashruler_min_length: usize,
334 /// Normalize ruler comments when markup handling is enabled.
335 pub canonicalize_hashrulers: bool,
336
337 // ── Per-command overrides ────────────────────────────────────────────
338 /// Per-command configuration overrides keyed by lowercase command name.
339 pub per_command_overrides: HashMap<String, PerCommandConfig>,
340}
341
342/// Per-command overrides. All fields are optional — only specified fields
343/// override the global config for that command.
344///
345/// # YAML/TOML key names
346///
347/// Two fields use different names in config files than in this Rust
348/// struct (for historical reasons):
349///
350/// | Rust field | YAML/TOML key |
351/// |------------|---------------|
352/// | `max_pargs_hwrap` | `max_hanging_wrap_positional_args` |
353/// | `max_subgroups_hwrap` | `max_hanging_wrap_groups` |
354///
355/// All other fields use the same name in both.
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
357#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
358#[serde(deny_unknown_fields)]
359pub struct PerCommandConfig {
360 /// Override the command casing rule for this command only.
361 pub command_case: Option<CaseStyle>,
362 /// Override the keyword casing rule for this command only.
363 pub keyword_case: Option<CaseStyle>,
364 /// Override the line width for this command only.
365 pub line_width: Option<usize>,
366 /// Override the indentation width for this command only.
367 pub tab_size: Option<usize>,
368 /// Override dangling paren placement for this command only.
369 pub dangle_parens: Option<bool>,
370 /// Override dangling paren alignment for this command only.
371 pub dangle_align: Option<DangleAlign>,
372 /// Override the hanging-wrap positional argument threshold for this
373 /// command only.
374 #[serde(rename = "max_hanging_wrap_positional_args")]
375 pub max_pargs_hwrap: Option<usize>,
376 /// Override the hanging-wrap subgroup threshold for this command only.
377 #[serde(rename = "max_hanging_wrap_groups")]
378 pub max_subgroups_hwrap: Option<usize>,
379 /// Keep the first positional argument on the command line when wrapping.
380 pub wrap_after_first_arg: Option<bool>,
381 /// Override the continuation-alignment rule for this command.
382 pub continuation_align: Option<ContinuationAlign>,
383}
384
385impl Default for Config {
386 fn default() -> Self {
387 Self {
388 disable: false,
389 line_ending: LineEnding::Unix,
390 line_width: 80,
391 tab_size: 2,
392 use_tabchars: false,
393 fractional_tab_policy: FractionalTabPolicy::UseSpace,
394 max_empty_lines: 1,
395 max_lines_hwrap: 2,
396 max_pargs_hwrap: 6,
397 max_subgroups_hwrap: 2,
398 max_rows_cmdline: 2,
399 always_wrap: Vec::new(),
400 require_valid_layout: false,
401 wrap_after_first_arg: false,
402 continuation_align: ContinuationAlign::UnderFirstValue,
403 enable_sort: false,
404 autosort: false,
405 dangle_parens: false,
406 dangle_align: DangleAlign::Prefix,
407 min_prefix_chars: 4,
408 max_prefix_chars: 10,
409 separate_ctrl_name_with_space: false,
410 separate_fn_name_with_space: false,
411 command_case: CaseStyle::Lower,
412 keyword_case: CaseStyle::Upper,
413 enable_markup: true,
414 first_comment_is_literal: true,
415 literal_comment_pattern: String::new(),
416 bullet_char: "*".to_string(),
417 enum_char: ".".to_string(),
418 fence_pattern: DEFAULT_FENCE_PATTERN.to_string(),
419 ruler_pattern: DEFAULT_RULER_PATTERN.to_string(),
420 hashruler_min_length: 10,
421 canonicalize_hashrulers: true,
422 per_command_overrides: HashMap::new(),
423 }
424 }
425}
426
427/// CMake control-flow commands that get `separate_ctrl_name_with_space`.
428const CONTROL_FLOW_COMMANDS: &[&str] = &[
429 "if",
430 "elseif",
431 "else",
432 "endif",
433 "foreach",
434 "endforeach",
435 "while",
436 "endwhile",
437 "break",
438 "continue",
439 "return",
440 "block",
441 "endblock",
442];
443
444/// CMake function/macro definition commands that get
445/// `separate_fn_name_with_space`.
446const FN_DEFINITION_COMMANDS: &[&str] = &["function", "endfunction", "macro", "endmacro"];
447
448impl Config {
449 /// Returns a `Config` with any per-command overrides applied for the
450 /// given command name, plus the appropriate space-before-paren setting.
451 pub fn for_command(&self, command_name: &str) -> CommandConfig<'_> {
452 let lower = command_name.to_ascii_lowercase();
453 let per_cmd = self.per_command_overrides.get(&lower);
454
455 let space_before_paren = if CONTROL_FLOW_COMMANDS.contains(&lower.as_str()) {
456 self.separate_ctrl_name_with_space
457 } else if FN_DEFINITION_COMMANDS.contains(&lower.as_str()) {
458 self.separate_fn_name_with_space
459 } else {
460 false
461 };
462
463 CommandConfig {
464 global: self,
465 per_cmd,
466 space_before_paren,
467 }
468 }
469
470 /// Apply the command_case rule to a command name.
471 pub fn apply_command_case(&self, name: &str) -> String {
472 apply_case(self.command_case, name)
473 }
474
475 /// Apply the keyword_case rule to a keyword token.
476 pub fn apply_keyword_case(&self, keyword: &str) -> String {
477 apply_case(self.keyword_case, keyword)
478 }
479
480 /// The indentation string (spaces or tab).
481 pub fn indent_str(&self) -> String {
482 if self.use_tabchars {
483 "\t".to_string()
484 } else {
485 " ".repeat(self.tab_size)
486 }
487 }
488
489 /// Validate that all regex patterns in the config are valid.
490 ///
491 /// Returns `Ok(())` if all patterns compile, or an error message
492 /// identifying the first invalid pattern.
493 pub fn validate_patterns(&self) -> Result<(), String> {
494 // Fast path for defaults — the built-in pattern strings are known
495 // to be valid. Avoids compiling three regexes on every
496 // format_source() call, which dominates per-file overhead on
497 // whole-tree runs over many small files.
498 if self.has_default_regex_patterns() {
499 return Ok(());
500 }
501 let patterns = [
502 ("literal_comment_pattern", &self.literal_comment_pattern),
503 ("fence_pattern", &self.fence_pattern),
504 ("ruler_pattern", &self.ruler_pattern),
505 ];
506 for (name, pattern) in &patterns {
507 if !pattern.is_empty() {
508 if let Err(err) = Regex::new(pattern) {
509 return Err(format!("invalid regex in {name}: {err}"));
510 }
511 }
512 }
513 Ok(())
514 }
515
516 fn has_default_regex_patterns(&self) -> bool {
517 self.literal_comment_pattern.is_empty()
518 && self.fence_pattern == DEFAULT_FENCE_PATTERN
519 && self.ruler_pattern == DEFAULT_RULER_PATTERN
520 }
521
522 /// Compile all regex patterns into a cache for internal formatting use.
523 ///
524 /// Callers that build [`Config`] programmatically should use
525 /// [`Config::validate_patterns`] to validate regexes up front.
526 pub(crate) fn compiled_patterns(&self) -> Result<CompiledPatterns, String> {
527 // Fast path for the common default configuration. Compiling the
528 // default regex repeatedly is a measurable cost on whole-tree runs
529 // that process many small files.
530 if self.literal_comment_pattern.is_empty() {
531 return Ok(CompiledPatterns {
532 literal_comment: None,
533 });
534 }
535 Ok(CompiledPatterns {
536 literal_comment: compile_optional(
537 "literal_comment_pattern",
538 &self.literal_comment_pattern,
539 )?,
540 })
541 }
542}
543
544const DEFAULT_FENCE_PATTERN: &str = r"^\s*[`~]{3}[^`\n]*$";
545const DEFAULT_RULER_PATTERN: &str = r"^[^\w\s]{3}.*[^\w\s]{3}$";
546
547fn compile_optional(name: &str, pattern: &str) -> Result<Option<Regex>, String> {
548 if pattern.is_empty() {
549 Ok(None)
550 } else {
551 Regex::new(pattern)
552 .map(Some)
553 .map_err(|err| format!("invalid regex in {name}: {err}"))
554 }
555}
556
557/// Pre-compiled regex patterns from [`Config`] used internally while formatting.
558pub(crate) struct CompiledPatterns {
559 /// Compiled `literal_comment_pattern`.
560 pub(crate) literal_comment: Option<Regex>,
561}
562
563/// A resolved config for formatting a specific command, with per-command
564/// overrides already applied.
565///
566/// Each accessor resolves values in this priority order:
567///
568/// 1. Per-command user override from
569/// [`Config::per_command_overrides`] (if set for this command).
570/// 2. Command-spec `layout` overrides for the selected form (passed
571/// in where applicable, e.g. [`CommandConfig::wrap_after_first_arg`]).
572/// 3. Global [`Config`] default.
573///
574/// Construct via [`Config::for_command`].
575#[derive(Debug)]
576pub struct CommandConfig<'a> {
577 /// The global configuration before per-command overrides are applied.
578 global: &'a Config,
579 per_cmd: Option<&'a PerCommandConfig>,
580 /// Whether this command should render a space before `(`.
581 space_before_paren: bool,
582}
583
584impl CommandConfig<'_> {
585 /// Whether this command should render a space before `(`.
586 pub fn space_before_paren(&self) -> bool {
587 self.space_before_paren
588 }
589
590 pub(crate) fn global(&self) -> &Config {
591 self.global
592 }
593
594 /// Effective line width for the current command.
595 pub fn line_width(&self) -> usize {
596 self.per_cmd
597 .and_then(|p| p.line_width)
598 .unwrap_or(self.global.line_width)
599 }
600
601 /// Effective indentation width for the current command.
602 pub fn tab_size(&self) -> usize {
603 self.per_cmd
604 .and_then(|p| p.tab_size)
605 .unwrap_or(self.global.tab_size)
606 }
607
608 /// Effective dangling-paren setting for the current command.
609 pub fn dangle_parens(&self) -> bool {
610 self.per_cmd
611 .and_then(|p| p.dangle_parens)
612 .unwrap_or(self.global.dangle_parens)
613 }
614
615 /// Effective dangling-paren alignment for the current command.
616 pub fn dangle_align(&self) -> DangleAlign {
617 self.per_cmd
618 .and_then(|p| p.dangle_align)
619 .unwrap_or(self.global.dangle_align)
620 }
621
622 /// Effective command casing rule for the current command.
623 pub fn command_case(&self) -> CaseStyle {
624 self.per_cmd
625 .and_then(|p| p.command_case)
626 .unwrap_or(self.global.command_case)
627 }
628
629 /// Effective keyword casing rule for the current command.
630 pub fn keyword_case(&self) -> CaseStyle {
631 self.per_cmd
632 .and_then(|p| p.keyword_case)
633 .unwrap_or(self.global.keyword_case)
634 }
635
636 /// Effective hanging-wrap positional argument threshold for the current
637 /// command.
638 pub fn max_pargs_hwrap(&self) -> usize {
639 self.per_cmd
640 .and_then(|p| p.max_pargs_hwrap)
641 .unwrap_or(self.global.max_pargs_hwrap)
642 }
643
644 /// Effective hanging-wrap subgroup threshold for the current command.
645 pub fn max_subgroups_hwrap(&self) -> usize {
646 self.per_cmd
647 .and_then(|p| p.max_subgroups_hwrap)
648 .unwrap_or(self.global.max_subgroups_hwrap)
649 }
650
651 /// Effective `wrap_after_first_arg` for the current command.
652 ///
653 /// Resolution order: per-command user override > `spec_value` (from
654 /// the command spec's layout overrides) > global config default.
655 pub fn wrap_after_first_arg(&self, spec_value: Option<bool>) -> bool {
656 self.per_cmd
657 .and_then(|p| p.wrap_after_first_arg)
658 .or(spec_value)
659 .unwrap_or(self.global.wrap_after_first_arg)
660 }
661
662 /// Effective continuation-alignment rule for the current command.
663 ///
664 /// Resolution order: per-command user override > `spec_value`
665 /// (from the command spec's layout overrides) > global config
666 /// default.
667 pub fn continuation_align(&self, spec_value: Option<ContinuationAlign>) -> ContinuationAlign {
668 self.per_cmd
669 .and_then(|p| p.continuation_align)
670 .or(spec_value)
671 .unwrap_or(self.global.continuation_align)
672 }
673
674 /// Effective indentation unit for the current command.
675 pub fn indent_str(&self) -> String {
676 if self.global.use_tabchars {
677 "\t".to_string()
678 } else {
679 " ".repeat(self.tab_size())
680 }
681 }
682}
683
684pub(crate) fn apply_case(style: CaseStyle, s: &str) -> String {
685 match style {
686 CaseStyle::Lower => s.to_ascii_lowercase(),
687 CaseStyle::Upper => s.to_ascii_uppercase(),
688 CaseStyle::Unchanged => s.to_string(),
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 // ── Config::for_command ───────────────────────────────────────────────
697
698 #[test]
699 fn for_command_control_flow_sets_space_before_paren() {
700 let config = Config {
701 separate_ctrl_name_with_space: true,
702 ..Config::default()
703 };
704 for cmd in ["if", "elseif", "foreach", "while", "return"] {
705 let cc = config.for_command(cmd);
706 assert!(
707 cc.space_before_paren(),
708 "{cmd} should have space_before_paren=true"
709 );
710 }
711 }
712
713 #[test]
714 fn for_command_fn_definition_sets_space_before_paren() {
715 let config = Config {
716 separate_fn_name_with_space: true,
717 ..Config::default()
718 };
719 for cmd in ["function", "endfunction", "macro", "endmacro"] {
720 let cc = config.for_command(cmd);
721 assert!(
722 cc.space_before_paren(),
723 "{cmd} should have space_before_paren=true"
724 );
725 }
726 }
727
728 #[test]
729 fn for_command_regular_command_no_space_before_paren() {
730 let config = Config {
731 separate_ctrl_name_with_space: true,
732 separate_fn_name_with_space: true,
733 ..Config::default()
734 };
735 let cc = config.for_command("message");
736 assert!(
737 !cc.space_before_paren(),
738 "message should not have space_before_paren"
739 );
740 }
741
742 #[test]
743 fn for_command_lookup_is_case_insensitive() {
744 let mut overrides = HashMap::new();
745 overrides.insert(
746 "message".to_string(),
747 PerCommandConfig {
748 line_width: Some(120),
749 ..Default::default()
750 },
751 );
752 let config = Config {
753 per_command_overrides: overrides,
754 ..Config::default()
755 };
756 // uppercase lookup should still find the "message" override
757 assert_eq!(config.for_command("MESSAGE").line_width(), 120);
758 }
759
760 // ── CommandConfig accessors ───────────────────────────────────────────
761
762 #[test]
763 fn command_config_returns_global_defaults_when_no_override() {
764 let config = Config::default();
765 let cc = config.for_command("set");
766 assert_eq!(cc.line_width(), config.line_width);
767 assert_eq!(cc.tab_size(), config.tab_size);
768 assert_eq!(cc.dangle_parens(), config.dangle_parens);
769 assert_eq!(cc.command_case(), config.command_case);
770 assert_eq!(cc.keyword_case(), config.keyword_case);
771 assert_eq!(cc.max_pargs_hwrap(), config.max_pargs_hwrap);
772 assert_eq!(cc.max_subgroups_hwrap(), config.max_subgroups_hwrap);
773 }
774
775 #[test]
776 fn command_config_per_command_overrides_take_effect() {
777 let mut overrides = HashMap::new();
778 overrides.insert(
779 "set".to_string(),
780 PerCommandConfig {
781 line_width: Some(120),
782 tab_size: Some(4),
783 dangle_parens: Some(true),
784 dangle_align: Some(DangleAlign::Open),
785 command_case: Some(CaseStyle::Upper),
786 keyword_case: Some(CaseStyle::Lower),
787 max_pargs_hwrap: Some(10),
788 max_subgroups_hwrap: Some(5),
789 wrap_after_first_arg: None,
790 continuation_align: None,
791 },
792 );
793 let config = Config {
794 per_command_overrides: overrides,
795 ..Config::default()
796 };
797 let cc = config.for_command("set");
798 assert_eq!(cc.line_width(), 120);
799 assert_eq!(cc.tab_size(), 4);
800 assert!(cc.dangle_parens());
801 assert_eq!(cc.dangle_align(), DangleAlign::Open);
802 assert_eq!(cc.command_case(), CaseStyle::Upper);
803 assert_eq!(cc.keyword_case(), CaseStyle::Lower);
804 assert_eq!(cc.max_pargs_hwrap(), 10);
805 assert_eq!(cc.max_subgroups_hwrap(), 5);
806 }
807
808 #[test]
809 fn indent_str_spaces() {
810 let config = Config {
811 tab_size: 4,
812 use_tabchars: false,
813 ..Config::default()
814 };
815 assert_eq!(config.indent_str(), " ");
816 assert_eq!(config.for_command("set").indent_str(), " ");
817 }
818
819 #[test]
820 fn indent_str_tab() {
821 let config = Config {
822 use_tabchars: true,
823 ..Config::default()
824 };
825 assert_eq!(config.indent_str(), "\t");
826 assert_eq!(config.for_command("set").indent_str(), "\t");
827 }
828
829 // ── Case helpers ─────────────────────────────────────────────────────
830
831 #[test]
832 fn apply_command_case_lower() {
833 let config = Config {
834 command_case: CaseStyle::Lower,
835 ..Config::default()
836 };
837 assert_eq!(
838 config.apply_command_case("TARGET_LINK_LIBRARIES"),
839 "target_link_libraries"
840 );
841 }
842
843 #[test]
844 fn apply_command_case_upper() {
845 let config = Config {
846 command_case: CaseStyle::Upper,
847 ..Config::default()
848 };
849 assert_eq!(
850 config.apply_command_case("target_link_libraries"),
851 "TARGET_LINK_LIBRARIES"
852 );
853 }
854
855 #[test]
856 fn apply_command_case_unchanged() {
857 let config = Config {
858 command_case: CaseStyle::Unchanged,
859 ..Config::default()
860 };
861 assert_eq!(
862 config.apply_command_case("Target_Link_Libraries"),
863 "Target_Link_Libraries"
864 );
865 }
866
867 #[test]
868 fn apply_keyword_case_variants() {
869 let config_upper = Config {
870 keyword_case: CaseStyle::Upper,
871 ..Config::default()
872 };
873 assert_eq!(config_upper.apply_keyword_case("public"), "PUBLIC");
874
875 let config_lower = Config {
876 keyword_case: CaseStyle::Lower,
877 ..Config::default()
878 };
879 assert_eq!(config_lower.apply_keyword_case("PUBLIC"), "public");
880 }
881
882 // ── Error Display ─────────────────────────────────────────────────────
883
884 #[test]
885 fn error_layout_too_wide_display() {
886 use crate::error::Error;
887 let err = Error::LayoutTooWide {
888 line_no: 5,
889 width: 95,
890 limit: 80,
891 };
892 let msg = err.to_string();
893 assert!(msg.contains("5"), "should mention line number");
894 assert!(msg.contains("95"), "should mention actual width");
895 assert!(msg.contains("80"), "should mention limit");
896 }
897
898 #[test]
899 fn error_formatter_display() {
900 use crate::error::Error;
901 let err = Error::Formatter("something went wrong".to_string());
902 assert!(err.to_string().contains("something went wrong"));
903 }
904
905 // ── Regex fast paths ──────────────────────────────────────────────────
906
907 #[test]
908 fn from_files_empty_path_returns_defaults() {
909 let config = Config::from_files(&[]).expect("default config should load");
910 let defaults = Config::default();
911 assert_eq!(
912 config.literal_comment_pattern,
913 defaults.literal_comment_pattern
914 );
915 assert_eq!(config.fence_pattern, defaults.fence_pattern);
916 assert_eq!(config.ruler_pattern, defaults.ruler_pattern);
917 assert_eq!(config.line_width, defaults.line_width);
918 }
919
920 #[test]
921 fn validate_patterns_accepts_defaults() {
922 let config = Config::default();
923 assert!(
924 config.validate_patterns().is_ok(),
925 "default patterns must pass validation"
926 );
927 }
928
929 #[test]
930 fn validate_patterns_rejects_invalid_custom_pattern() {
931 let config = Config {
932 fence_pattern: "(".to_string(),
933 ..Config::default()
934 };
935 let err = config
936 .validate_patterns()
937 .expect_err("invalid fence_pattern must be rejected");
938 assert!(
939 err.contains("fence_pattern"),
940 "error should identify fence_pattern, got: {err}"
941 );
942 }
943
944 #[test]
945 fn validate_patterns_accepts_valid_custom_pattern() {
946 let config = Config {
947 fence_pattern: r"^\s*[#]{3,}$".to_string(),
948 ..Config::default()
949 };
950 assert!(config.validate_patterns().is_ok());
951 }
952
953 #[test]
954 fn compiled_patterns_uses_cached_default_regex() {
955 let config = Config::default();
956 let compiled = config.compiled_patterns().expect("defaults must compile");
957 assert!(
958 compiled.literal_comment.is_none(),
959 "empty literal_comment_pattern should produce None"
960 );
961 }
962
963 #[test]
964 fn compiled_patterns_compiles_custom_literal_comment() {
965 let config = Config {
966 literal_comment_pattern: r"^\s*TODO:".to_string(),
967 ..Config::default()
968 };
969 let compiled = config
970 .compiled_patterns()
971 .expect("custom literal_comment_pattern must compile");
972 let literal = compiled
973 .literal_comment
974 .expect("custom literal_comment_pattern should compile to Some");
975 assert!(literal.is_match(" TODO: fix me"));
976 assert!(!literal.is_match("# regular comment"));
977 }
978
979 #[test]
980 fn compiled_patterns_errors_on_invalid_custom() {
981 let config = Config {
982 literal_comment_pattern: "(".to_string(),
983 ..Config::default()
984 };
985 match config.compiled_patterns() {
986 Ok(_) => panic!("invalid custom pattern must error"),
987 Err(err) => assert!(
988 err.contains("literal_comment_pattern"),
989 "error should identify literal_comment_pattern, got: {err}"
990 ),
991 }
992 }
993}