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