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#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
13pub mod file;
14#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
15mod legacy;
16/// Render a commented starter config template.
17#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
18pub use file::{
19 default_config_template, default_config_template_for, render_effective_config, DumpConfigFormat,
20};
21#[cfg(all(not(target_arch = "wasm32"), feature = "cli"))]
22pub use legacy::convert_legacy_config_files;
23
24use std::collections::HashMap;
25
26use serde::{Deserialize, Serialize};
27
28/// How to normalise command/keyword casing.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
30#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
31#[serde(rename_all = "lowercase")]
32pub enum CaseStyle {
33 /// Force lowercase output.
34 Lower,
35 /// Force uppercase output.
36 #[default]
37 Upper,
38 /// Preserve the original source casing.
39 Unchanged,
40}
41
42/// Output line-ending style.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
44#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
45#[serde(rename_all = "lowercase")]
46pub enum LineEnding {
47 /// Unix-style LF (`\n`). The default.
48 #[default]
49 Unix,
50 /// Windows-style CRLF (`\r\n`).
51 Windows,
52 /// Auto-detect the line ending from the input source.
53 Auto,
54}
55
56/// How to handle fractional tab indentation when [`Config::use_tabchars`] is
57/// `true`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
59#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
60#[serde(rename_all = "kebab-case")]
61pub enum FractionalTabPolicy {
62 /// Leave fractional spaces as-is (utf-8 0x20). The default.
63 #[default]
64 UseSpace,
65 /// Round fractional indentation up to the next full tab stop (utf-8 0x09).
66 RoundUp,
67}
68
69/// How to align the dangling closing paren.
70///
71/// Only takes effect when [`Config::dangle_parens`] is `true`.
72/// Controls where `)` is placed when a call wraps onto multiple lines:
73///
74/// ```cmake
75/// # Prefix / Close — `)` at the command-name column (tracks block depth):
76/// target_link_libraries(
77/// mylib PUBLIC dep1
78/// )
79///
80/// # Open — `)` at the opening-paren column:
81/// target_link_libraries(
82/// mylib PUBLIC dep1
83/// )
84/// ```
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
86#[serde(rename_all = "lowercase")]
87pub enum DangleAlign {
88 /// Align with the start of the command name.
89 #[default]
90 Prefix,
91 /// Align with the opening paren column.
92 Open,
93 /// No extra indent (flush with current indent level).
94 Close,
95}
96
97/// Full formatter configuration.
98///
99/// Construct [`Config::default`] and set fields as needed before passing it to
100/// [`format_source`](crate::format_source) or related functions.
101///
102/// ```
103/// use cmakefmt::{Config, CaseStyle, DangleAlign};
104///
105/// let config = Config {
106/// line_width: 100,
107/// command_case: CaseStyle::Lower,
108/// dangle_parens: true,
109/// dangle_align: DangleAlign::Open,
110/// ..Config::default()
111/// };
112/// ```
113///
114/// # Defaults
115///
116/// | Field | Default |
117/// |-------|---------|
118/// | `line_width` | `80` |
119/// | `tab_size` | `2` |
120/// | `use_tabchars` | `false` |
121/// | `max_empty_lines` | `1` |
122/// | `command_case` | [`CaseStyle::Lower`] |
123/// | `keyword_case` | [`CaseStyle::Upper`] |
124/// | `dangle_parens` | `false` |
125/// | `dangle_align` | [`DangleAlign::Prefix`] |
126/// | `enable_markup` | `true` |
127/// | `reflow_comments` | `false` |
128/// | `first_comment_is_literal` | `true` |
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(default)]
131pub struct Config {
132 // ── Kill-switch ─────────────────────────────────────────────────────
133 /// When `true`, skip all formatting and return the source unchanged.
134 pub disable: bool,
135
136 // ── Line endings ─────────────────────────────────────────────────────
137 /// Output line-ending style.
138 pub line_ending: LineEnding,
139
140 // ── Layout ──────────────────────────────────────────────────────────
141 /// Maximum rendered line width before wrapping is attempted.
142 pub line_width: usize,
143 /// Number of spaces that make up one indentation level when
144 /// [`Self::use_tabchars`] is `false`.
145 pub tab_size: usize,
146 /// Emit tab characters for indentation instead of spaces.
147 pub use_tabchars: bool,
148 /// How to handle fractional indentation when [`Self::use_tabchars`] is
149 /// `true`.
150 pub fractional_tab_policy: FractionalTabPolicy,
151 /// Maximum number of consecutive empty lines to preserve.
152 pub max_empty_lines: usize,
153 /// Maximum number of wrapped lines tolerated before switching to a more
154 /// vertical layout.
155 pub max_lines_hwrap: usize,
156 /// Maximum number of positional arguments to keep in a hanging-wrap layout
157 /// before going vertical.
158 pub max_pargs_hwrap: usize,
159 /// Maximum number of keyword/flag subgroups to keep in a horizontal wrap.
160 pub max_subgroups_hwrap: usize,
161 /// Maximum rows a hanging-wrap positional group may consume before the
162 /// layout is rejected and nesting is forced.
163 pub max_rows_cmdline: usize,
164 /// Command names (lowercase) that must always use vertical layout,
165 /// regardless of line width.
166 pub always_wrap: Vec<String>,
167 /// Return an error when any formatted output line exceeds
168 /// [`Self::line_width`].
169 pub require_valid_layout: bool,
170
171 // ── Parenthesis style ───────────────────────────────────────────────
172 /// Place the closing `)` on its own line when a call wraps.
173 pub dangle_parens: bool,
174 /// Alignment strategy for a dangling closing `)`.
175 pub dangle_align: DangleAlign,
176 /// Lower bound used by layout heuristics when deciding whether a command
177 /// name is short enough to prefer one style over another.
178 pub min_prefix_chars: usize,
179 /// Upper bound used by layout heuristics when deciding whether a command
180 /// name is long enough to prefer one style over another.
181 pub max_prefix_chars: usize,
182 /// Insert a space before `(` for control-flow commands such as `if`.
183 pub separate_ctrl_name_with_space: bool,
184 /// Insert a space before `(` for `function`/`macro` definitions.
185 pub separate_fn_name_with_space: bool,
186
187 // ── Casing ──────────────────────────────────────────────────────────
188 /// Output casing policy for command names.
189 pub command_case: CaseStyle,
190 /// Output casing policy for recognized keywords and flags.
191 pub keyword_case: CaseStyle,
192
193 // ── Comment markup ──────────────────────────────────────────────────
194 /// Enable markup-aware comment handling.
195 pub enable_markup: bool,
196 /// Reflow plain line comments to fit within the configured width.
197 pub reflow_comments: bool,
198 /// Preserve the first comment block in a file literally.
199 pub first_comment_is_literal: bool,
200 /// Regex for comments that should never be reflowed.
201 pub literal_comment_pattern: String,
202 /// Preferred bullet character when normalizing list markup.
203 pub bullet_char: String,
204 /// Preferred enumeration punctuation when normalizing numbered list markup.
205 pub enum_char: String,
206 /// Regex describing fenced literal comment blocks.
207 pub fence_pattern: String,
208 /// Regex describing ruler-style comments.
209 pub ruler_pattern: String,
210 /// Minimum ruler length before a `#-----` style line is treated as a ruler.
211 pub hashruler_min_length: usize,
212 /// Normalize ruler comments when markup handling is enabled.
213 pub canonicalize_hashrulers: bool,
214 /// Regex pattern that marks an inline comment as explicitly trailing its
215 /// preceding argument. Matching comments are rendered on the same line as
216 /// the preceding token rather than on their own line.
217 pub explicit_trailing_pattern: String,
218
219 // ── Per-command overrides ────────────────────────────────────────────
220 /// Per-command configuration overrides keyed by lowercase command name.
221 pub per_command_overrides: HashMap<String, PerCommandConfig>,
222}
223
224/// Per-command overrides. All fields are optional — only specified fields
225/// override the global config for that command.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
227#[serde(deny_unknown_fields)]
228pub struct PerCommandConfig {
229 /// Override the command casing rule for this command only.
230 pub command_case: Option<CaseStyle>,
231 /// Override the keyword casing rule for this command only.
232 pub keyword_case: Option<CaseStyle>,
233 /// Override the line width for this command only.
234 pub line_width: Option<usize>,
235 /// Override the indentation width for this command only.
236 pub tab_size: Option<usize>,
237 /// Override dangling paren placement for this command only.
238 pub dangle_parens: Option<bool>,
239 /// Override dangling paren alignment for this command only.
240 pub dangle_align: Option<DangleAlign>,
241 /// Override the hanging-wrap positional argument threshold for this
242 /// command only.
243 #[serde(rename = "max_hanging_wrap_positional_args")]
244 pub max_pargs_hwrap: Option<usize>,
245 /// Override the hanging-wrap subgroup threshold for this command only.
246 #[serde(rename = "max_hanging_wrap_groups")]
247 pub max_subgroups_hwrap: Option<usize>,
248}
249
250impl Default for Config {
251 fn default() -> Self {
252 Self {
253 disable: false,
254 line_ending: LineEnding::Unix,
255 line_width: 80,
256 tab_size: 2,
257 use_tabchars: false,
258 fractional_tab_policy: FractionalTabPolicy::UseSpace,
259 max_empty_lines: 1,
260 max_lines_hwrap: 2,
261 max_pargs_hwrap: 6,
262 max_subgroups_hwrap: 2,
263 max_rows_cmdline: 2,
264 always_wrap: Vec::new(),
265 require_valid_layout: false,
266 dangle_parens: false,
267 dangle_align: DangleAlign::Prefix,
268 min_prefix_chars: 4,
269 max_prefix_chars: 10,
270 separate_ctrl_name_with_space: false,
271 separate_fn_name_with_space: false,
272 command_case: CaseStyle::Lower,
273 keyword_case: CaseStyle::Upper,
274 enable_markup: true,
275 reflow_comments: false,
276 first_comment_is_literal: true,
277 literal_comment_pattern: String::new(),
278 bullet_char: "*".to_string(),
279 enum_char: ".".to_string(),
280 fence_pattern: r"^\s*[`~]{3}[^`\n]*$".to_string(),
281 ruler_pattern: r"^[^\w\s]{3}.*[^\w\s]{3}$".to_string(),
282 hashruler_min_length: 10,
283 canonicalize_hashrulers: true,
284 explicit_trailing_pattern: "#<".to_string(),
285 per_command_overrides: HashMap::new(),
286 }
287 }
288}
289
290/// CMake control-flow commands that get `separate_ctrl_name_with_space`.
291const CONTROL_FLOW_COMMANDS: &[&str] = &[
292 "if",
293 "elseif",
294 "else",
295 "endif",
296 "foreach",
297 "endforeach",
298 "while",
299 "endwhile",
300 "break",
301 "continue",
302 "return",
303 "block",
304 "endblock",
305];
306
307/// CMake function/macro definition commands that get
308/// `separate_fn_name_with_space`.
309const FN_DEFINITION_COMMANDS: &[&str] = &["function", "endfunction", "macro", "endmacro"];
310
311impl Config {
312 /// Returns a `Config` with any per-command overrides applied for the
313 /// given command name, plus the appropriate space-before-paren setting.
314 pub fn for_command(&self, command_name: &str) -> CommandConfig<'_> {
315 let lower = command_name.to_ascii_lowercase();
316 let per_cmd = self.per_command_overrides.get(&lower);
317
318 let space_before_paren = if CONTROL_FLOW_COMMANDS.contains(&lower.as_str()) {
319 self.separate_ctrl_name_with_space
320 } else if FN_DEFINITION_COMMANDS.contains(&lower.as_str()) {
321 self.separate_fn_name_with_space
322 } else {
323 false
324 };
325
326 CommandConfig {
327 global: self,
328 per_cmd,
329 space_before_paren,
330 }
331 }
332
333 /// Apply the command_case rule to a command name.
334 pub fn apply_command_case(&self, name: &str) -> String {
335 apply_case(self.command_case, name)
336 }
337
338 /// Apply the keyword_case rule to a keyword token.
339 pub fn apply_keyword_case(&self, keyword: &str) -> String {
340 apply_case(self.keyword_case, keyword)
341 }
342
343 /// The indentation string (spaces or tab).
344 pub fn indent_str(&self) -> String {
345 if self.use_tabchars {
346 "\t".to_string()
347 } else {
348 " ".repeat(self.tab_size)
349 }
350 }
351}
352
353/// A resolved config for formatting a specific command, with per-command
354/// overrides already applied.
355#[derive(Debug)]
356pub struct CommandConfig<'a> {
357 /// The global configuration before per-command overrides are applied.
358 pub global: &'a Config,
359 per_cmd: Option<&'a PerCommandConfig>,
360 /// Whether this command should render a space before `(`.
361 pub space_before_paren: bool,
362}
363
364impl CommandConfig<'_> {
365 /// Effective line width for the current command.
366 pub fn line_width(&self) -> usize {
367 self.per_cmd
368 .and_then(|p| p.line_width)
369 .unwrap_or(self.global.line_width)
370 }
371
372 /// Effective indentation width for the current command.
373 pub fn tab_size(&self) -> usize {
374 self.per_cmd
375 .and_then(|p| p.tab_size)
376 .unwrap_or(self.global.tab_size)
377 }
378
379 /// Effective dangling-paren setting for the current command.
380 pub fn dangle_parens(&self) -> bool {
381 self.per_cmd
382 .and_then(|p| p.dangle_parens)
383 .unwrap_or(self.global.dangle_parens)
384 }
385
386 /// Effective dangling-paren alignment for the current command.
387 pub fn dangle_align(&self) -> DangleAlign {
388 self.per_cmd
389 .and_then(|p| p.dangle_align)
390 .unwrap_or(self.global.dangle_align)
391 }
392
393 /// Effective command casing rule for the current command.
394 pub fn command_case(&self) -> CaseStyle {
395 self.per_cmd
396 .and_then(|p| p.command_case)
397 .unwrap_or(self.global.command_case)
398 }
399
400 /// Effective keyword casing rule for the current command.
401 pub fn keyword_case(&self) -> CaseStyle {
402 self.per_cmd
403 .and_then(|p| p.keyword_case)
404 .unwrap_or(self.global.keyword_case)
405 }
406
407 /// Effective hanging-wrap positional argument threshold for the current
408 /// command.
409 pub fn max_pargs_hwrap(&self) -> usize {
410 self.per_cmd
411 .and_then(|p| p.max_pargs_hwrap)
412 .unwrap_or(self.global.max_pargs_hwrap)
413 }
414
415 /// Effective hanging-wrap subgroup threshold for the current command.
416 pub fn max_subgroups_hwrap(&self) -> usize {
417 self.per_cmd
418 .and_then(|p| p.max_subgroups_hwrap)
419 .unwrap_or(self.global.max_subgroups_hwrap)
420 }
421
422 /// Effective indentation unit for the current command.
423 pub fn indent_str(&self) -> String {
424 if self.global.use_tabchars {
425 "\t".to_string()
426 } else {
427 " ".repeat(self.tab_size())
428 }
429 }
430}
431
432fn apply_case(style: CaseStyle, s: &str) -> String {
433 match style {
434 CaseStyle::Lower => s.to_ascii_lowercase(),
435 CaseStyle::Upper => s.to_ascii_uppercase(),
436 CaseStyle::Unchanged => s.to_string(),
437 }
438}