Skip to main content

cmakefmt/formatter/
mod.rs

1// SPDX-FileCopyrightText: Copyright 2026 Puneet Matharu
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Top-level formatter entry points.
6//!
7//! These functions parse input, apply barrier handling, and render a formatted
8//! output string using the command registry and runtime configuration.
9//!
10//! # Format-barrier directives
11//!
12//! The source-string entry points ([`format_source`],
13//! [`format_source_with_registry`], [`format_source_with_debug`],
14//! [`format_source_with_registry_debug`]) scan each input line for
15//! *barrier directives* that toggle formatting on and off:
16//!
17//! | Directive | Effect |
18//! |-----------|--------|
19//! | `# cmake-format: off` / `# cmake-format: on` | Skip / resume formatting |
20//! | `# cmakefmt: off` / `# cmakefmt: on` | Same, cmakefmt-branded |
21//! | `# fmt: off` / `# fmt: on` | Generic alias |
22//! | `# ~~~` (matched pair) | Fence region — content between fences is emitted verbatim |
23//!
24//! Leading whitespace before the `#` is allowed. Lines inside a
25//! disabled region are passed through unchanged.
26//!
27//! Note: [`format_parsed_file`] does **not** honour these directives
28//! — barrier detection happens pre-parse, so if you have the AST
29//! already you've bypassed that step. Use a source-string entry
30//! point if you need barriers.
31
32pub(crate) mod comment;
33pub(crate) mod node;
34
35pub(crate) use node::{split_sections, HeaderKind};
36
37use std::path::PathBuf;
38
39use crate::config::{Config, LineEnding};
40use crate::error::{Error, FileParseError, Result};
41use crate::parser::{self, ast::File, ast::Statement};
42use crate::spec::registry::CommandRegistry;
43
44/// Format raw CMake source using the built-in command registry.
45///
46/// The output always ends with a newline. When
47/// [`Config::line_ending`] is [`LineEnding::Auto`], the output line
48/// ending is detected from the input (CRLF if the source contains
49/// any `\r\n`, otherwise LF).
50///
51/// # Examples
52///
53/// ```
54/// use cmakefmt::{format_source, Config};
55///
56/// let cmake = "CMAKE_MINIMUM_REQUIRED(VERSION 3.20)\n";
57/// let formatted = format_source(cmake, &Config::default()).unwrap();
58/// assert_eq!(formatted, "cmake_minimum_required(VERSION 3.20)\n");
59/// ```
60pub fn format_source(source: &str, config: &Config) -> Result<String> {
61    format_source_with_registry(source, config, CommandRegistry::builtins())
62}
63
64/// Format raw CMake source using the built-in registry and also return debug
65/// lines describing the formatter's decisions.
66///
67/// The returned `Vec<String>` contains one human-readable log line
68/// per formatting decision (layout choice, section split, fallback
69/// paths, barrier events). The exact wording is **unstable across
70/// releases** and intended for interactive debugging and bug
71/// reports, not programmatic consumption.
72pub fn format_source_with_debug(source: &str, config: &Config) -> Result<(String, Vec<String>)> {
73    format_source_with_registry_debug(source, config, CommandRegistry::builtins())
74}
75
76/// Format raw CMake source using an explicit command registry.
77///
78/// Use this when you need a registry that merges the built-ins with a user
79/// override file.
80///
81/// # Examples
82///
83/// ```
84/// use cmakefmt::{format_source_with_registry, Config, CommandRegistry};
85///
86/// let registry = CommandRegistry::from_builtins_and_overrides(
87///     None::<&std::path::Path>,
88/// ).unwrap();
89/// let cmake = "TARGET_LINK_LIBRARIES(mylib PUBLIC dep1)\n";
90/// let formatted = format_source_with_registry(
91///     cmake, &Config::default(), &registry,
92/// ).unwrap();
93/// assert_eq!(formatted, "target_link_libraries(mylib PUBLIC dep1)\n");
94/// ```
95pub fn format_source_with_registry(
96    source: &str,
97    config: &Config,
98    registry: &CommandRegistry,
99) -> Result<String> {
100    if config.disable {
101        return Ok(source.to_owned());
102    }
103    validate_runtime_config(config)?;
104    let formatted = format_source_impl(source, config, registry, &mut DebugLog::disabled())?.0;
105    Ok(apply_line_ending(source, &formatted, config.line_ending))
106}
107
108/// Format raw CMake source using an explicit registry and return debug output.
109pub fn format_source_with_registry_debug(
110    source: &str,
111    config: &Config,
112    registry: &CommandRegistry,
113) -> Result<(String, Vec<String>)> {
114    if config.disable {
115        return Ok((source.to_owned(), Vec::new()));
116    }
117    validate_runtime_config(config)?;
118    let mut lines = Vec::new();
119    let mut debug = DebugLog::enabled(&mut lines);
120    let (formatted, _) = format_source_impl(source, config, registry, &mut debug)?;
121    Ok((
122        apply_line_ending(source, &formatted, config.line_ending),
123        lines,
124    ))
125}
126
127/// Format an already parsed AST file using the original source text.
128///
129/// This entry point preserves the same high-level config semantics as
130/// [`format_source_with_registry`]: `disable` returns the original `source`
131/// unchanged and `line_ending` is applied relative to the original source.
132///
133/// Useful when you want to parse once and format the same AST repeatedly with
134/// different [`Config`] or registry settings, avoiding re-parsing overhead.
135///
136/// # Caveat: no barrier handling
137///
138/// Unlike [`format_source`] and its siblings, this function does
139/// **not** honour `# cmake-format: off/on`, `# cmakefmt: off/on`,
140/// `# fmt: off/on`, or `# ~~~` fence regions. Barrier detection
141/// happens pre-parse in the source-string pipeline, so by the time
142/// you hand in a parsed AST the opportunity has passed. Use one of
143/// the source-string entry points if your input contains barrier
144/// directives.
145///
146/// # Examples
147///
148/// ```
149/// use cmakefmt::{format_parsed_file, Config, CommandRegistry};
150///
151/// let cmake = "PROJECT(MyProject)\n";
152/// let file = cmakefmt::parser::parse(cmake).unwrap();
153/// let formatted = format_parsed_file(
154///     cmake,
155///     &file,
156///     &Config::default(),
157///     CommandRegistry::builtins(),
158/// ).unwrap();
159/// assert_eq!(formatted, "project(MyProject)\n");
160/// ```
161pub fn format_parsed_file(
162    source: &str,
163    file: &File,
164    config: &Config,
165    registry: &CommandRegistry,
166) -> Result<String> {
167    if config.disable {
168        return Ok(source.to_owned());
169    }
170    validate_runtime_config(config)?;
171    let formatted =
172        format_parsed_file_with_debug(file, config, registry, &mut DebugLog::disabled())?;
173    Ok(apply_line_ending(source, &formatted, config.line_ending))
174}
175
176fn format_parsed_file_with_debug(
177    file: &File,
178    config: &Config,
179    registry: &CommandRegistry,
180    debug: &mut DebugLog<'_>,
181) -> Result<String> {
182    let patterns = config.compiled_patterns().map_err(runtime_config_error)?;
183    let mut output = String::new();
184    let mut previous_was_content = false;
185    let mut block_depth = 0usize;
186
187    for statement in &file.statements {
188        match statement {
189            Statement::Command(command) => {
190                block_depth = block_depth.saturating_sub(block_dedent_before(&command.name));
191
192                if previous_was_content {
193                    output.push('\n');
194                }
195
196                output.push_str(&node::format_command(
197                    command,
198                    config,
199                    &patterns,
200                    registry,
201                    block_depth,
202                    debug,
203                )?);
204
205                if let Some(trailing) = &command.trailing_comment {
206                    let comment_indent_width = output
207                        .rsplit('\n')
208                        .next()
209                        .unwrap_or_default()
210                        .chars()
211                        .count()
212                        + 1;
213                    let comment_lines = comment::format_comment_lines(
214                        trailing,
215                        config,
216                        &patterns,
217                        comment_indent_width,
218                        config.line_width,
219                    );
220                    if let Some((first, rest)) = comment_lines.split_first() {
221                        output.push(' ');
222                        output.push_str(first);
223                        let continuation_indent = " ".repeat(comment_indent_width);
224                        for line in rest {
225                            output.push('\n');
226                            output.push_str(&continuation_indent);
227                            output.push_str(line);
228                        }
229                    }
230                }
231
232                previous_was_content = true;
233                block_depth += block_indent_after(&command.name);
234            }
235            Statement::TemplatePlaceholder(placeholder) => {
236                if previous_was_content {
237                    output.push('\n');
238                }
239
240                output.push_str(placeholder);
241                previous_was_content = true;
242            }
243            Statement::BlankLines(count) => {
244                let newline_count = if previous_was_content {
245                    count + 1
246                } else {
247                    *count
248                };
249                let newline_count = newline_count.min(config.max_empty_lines + 1);
250                for _ in 0..newline_count {
251                    output.push('\n');
252                }
253                previous_was_content = false;
254            }
255            Statement::Comment(c) => {
256                if previous_was_content {
257                    output.push('\n');
258                }
259
260                let indent = config.indent_str().repeat(block_depth);
261                let comment_lines = comment::format_comment_lines(
262                    c,
263                    config,
264                    &patterns,
265                    indent.chars().count(),
266                    config.line_width,
267                );
268                for (index, line) in comment_lines.iter().enumerate() {
269                    if index > 0 {
270                        output.push('\n');
271                    }
272                    output.push_str(&indent);
273                    output.push_str(line);
274                }
275                previous_was_content = true;
276            }
277        }
278    }
279
280    if !output.ends_with('\n') {
281        output.push('\n');
282    }
283
284    if config.require_valid_layout {
285        for (i, line) in output.split('\n').enumerate() {
286            // Skip the final empty string produced by the trailing newline.
287            if line.is_empty() {
288                continue;
289            }
290            let width = line.chars().count();
291            if width > config.line_width {
292                return Err(Error::LayoutTooWide {
293                    line_no: i + 1,
294                    width,
295                    limit: config.line_width,
296                });
297            }
298        }
299    }
300
301    Ok(output)
302}
303
304/// Apply the configured line-ending style to `formatted` output.
305///
306/// The formatter always emits LF internally. `source` is consulted when
307/// `line_ending` is [`LineEnding::Auto`] to detect the predominant style.
308fn apply_line_ending(source: &str, formatted: &str, line_ending: LineEnding) -> String {
309    let use_crlf = match line_ending {
310        LineEnding::Unix => false,
311        LineEnding::Windows => true,
312        LineEnding::Auto => {
313            // Detect from input: if any \r\n is present, assume CRLF.
314            source.contains("\r\n")
315        }
316    };
317    if use_crlf {
318        formatted.replace('\n', "\r\n")
319    } else {
320        formatted.to_owned()
321    }
322}
323
324fn format_source_impl(
325    source: &str,
326    config: &Config,
327    registry: &CommandRegistry,
328    debug: &mut DebugLog<'_>,
329) -> Result<(String, usize)> {
330    let mut output = String::new();
331    let mut enabled_chunk = String::new();
332    let mut total_statements = 0usize;
333    let mut mode = BarrierMode::Enabled;
334    let mut enabled_chunk_start_line = 1usize;
335    let mut saw_barrier = false;
336
337    for (line_index, line) in source.split_inclusive('\n').enumerate() {
338        let line_no = line_index + 1;
339        match detect_barrier(line) {
340            Some(BarrierEvent::DisableByDirective(kind)) => {
341                let statements = flush_enabled_chunk(
342                    &mut output,
343                    &mut enabled_chunk,
344                    config,
345                    registry,
346                    debug,
347                    enabled_chunk_start_line,
348                    saw_barrier,
349                )?;
350                total_statements += statements;
351                debug.log(format!(
352                    "formatter: disabled formatting at line {line_no} via {kind}: off"
353                ));
354                output.push_str(line);
355                mode = BarrierMode::DisabledByDirective;
356                saw_barrier = true;
357            }
358            Some(BarrierEvent::EnableByDirective(kind)) => {
359                let statements = flush_enabled_chunk(
360                    &mut output,
361                    &mut enabled_chunk,
362                    config,
363                    registry,
364                    debug,
365                    enabled_chunk_start_line,
366                    saw_barrier,
367                )?;
368                total_statements += statements;
369                debug.log(format!(
370                    "formatter: enabled formatting at line {line_no} via {kind}: on"
371                ));
372                output.push_str(line);
373                if matches!(mode, BarrierMode::DisabledByDirective) {
374                    mode = BarrierMode::Enabled;
375                }
376                saw_barrier = true;
377            }
378            Some(BarrierEvent::Fence) => {
379                let statements = flush_enabled_chunk(
380                    &mut output,
381                    &mut enabled_chunk,
382                    config,
383                    registry,
384                    debug,
385                    enabled_chunk_start_line,
386                    saw_barrier,
387                )?;
388                total_statements += statements;
389                let next_mode = if matches!(mode, BarrierMode::DisabledByFence) {
390                    BarrierMode::Enabled
391                } else {
392                    BarrierMode::DisabledByFence
393                };
394                debug.log(format!(
395                    "formatter: toggled fence region at line {line_no} -> {}",
396                    next_mode.as_str()
397                ));
398                output.push_str(line);
399                mode = next_mode;
400                saw_barrier = true;
401            }
402            None => {
403                if matches!(mode, BarrierMode::Enabled) {
404                    if enabled_chunk.is_empty() {
405                        enabled_chunk_start_line = line_no;
406                    }
407                    enabled_chunk.push_str(line);
408                } else {
409                    output.push_str(line);
410                }
411            }
412        }
413    }
414
415    total_statements += flush_enabled_chunk(
416        &mut output,
417        &mut enabled_chunk,
418        config,
419        registry,
420        debug,
421        enabled_chunk_start_line,
422        saw_barrier,
423    )?;
424    Ok((output, total_statements))
425}
426
427fn flush_enabled_chunk(
428    output: &mut String,
429    enabled_chunk: &mut String,
430    config: &Config,
431    registry: &CommandRegistry,
432    debug: &mut DebugLog<'_>,
433    chunk_start_line: usize,
434    barrier_context: bool,
435) -> Result<usize> {
436    if enabled_chunk.is_empty() {
437        return Ok(0);
438    }
439
440    let file = match parser::parse(enabled_chunk) {
441        Ok(file) => file,
442        Err(Error::Parse(parse_error)) => {
443            let _ = barrier_context;
444            return Err(Error::Parse(crate::error::ParseError {
445                display_name: "<source>".to_owned(),
446                source_text: enabled_chunk.clone().into_boxed_str(),
447                start_line: chunk_start_line,
448                diagnostic: parse_error.diagnostic,
449            }));
450        }
451        Err(err) => return Err(err),
452    };
453    let statement_count = file.statements.len();
454    debug.log(format!(
455        "formatter: formatting enabled chunk with {statement_count} statement(s) starting at source line {chunk_start_line}"
456    ));
457    let formatted = format_parsed_file_with_debug(&file, config, registry, debug)?;
458    output.push_str(&formatted);
459    enabled_chunk.clear();
460    Ok(statement_count)
461}
462
463fn validate_runtime_config(config: &Config) -> Result<()> {
464    config.validate_patterns().map_err(runtime_config_error)?;
465    Ok(())
466}
467
468fn runtime_config_error(message: String) -> Error {
469    Error::Config(crate::error::ConfigError {
470        path: PathBuf::from("<programmatic-config>"),
471        details: FileParseError {
472            format: "runtime",
473            message: message.into_boxed_str(),
474            line: None,
475            column: None,
476        },
477    })
478}
479
480fn detect_barrier(line: &str) -> Option<BarrierEvent<'_>> {
481    let trimmed = line.trim_start();
482    if !trimmed.starts_with('#') {
483        return None;
484    }
485
486    let body = trimmed[1..].trim_start().trim_end();
487    if body.starts_with("~~~") {
488        return Some(BarrierEvent::Fence);
489    }
490
491    if body == "cmake-format: off" {
492        return Some(BarrierEvent::DisableByDirective("cmake-format"));
493    }
494    if body == "cmake-format: on" {
495        return Some(BarrierEvent::EnableByDirective("cmake-format"));
496    }
497    if body == "cmakefmt: off" {
498        return Some(BarrierEvent::DisableByDirective("cmakefmt"));
499    }
500    if body == "cmakefmt: on" {
501        return Some(BarrierEvent::EnableByDirective("cmakefmt"));
502    }
503    if body == "fmt: off" {
504        return Some(BarrierEvent::DisableByDirective("fmt"));
505    }
506    if body == "fmt: on" {
507        return Some(BarrierEvent::EnableByDirective("fmt"));
508    }
509
510    None
511}
512
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514enum BarrierMode {
515    Enabled,
516    DisabledByDirective,
517    DisabledByFence,
518}
519
520impl BarrierMode {
521    fn as_str(self) -> &'static str {
522        match self {
523            BarrierMode::Enabled => "enabled",
524            BarrierMode::DisabledByDirective => "disabled-by-directive",
525            BarrierMode::DisabledByFence => "disabled-by-fence",
526        }
527    }
528}
529
530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531enum BarrierEvent<'a> {
532    DisableByDirective(&'a str),
533    EnableByDirective(&'a str),
534    Fence,
535}
536
537pub(crate) struct DebugLog<'a> {
538    lines: Option<&'a mut Vec<String>>,
539}
540
541impl<'a> DebugLog<'a> {
542    fn disabled() -> Self {
543        Self { lines: None }
544    }
545
546    fn enabled(lines: &'a mut Vec<String>) -> Self {
547        Self { lines: Some(lines) }
548    }
549
550    fn log(&mut self, message: impl Into<String>) {
551        if let Some(lines) = self.lines.as_deref_mut() {
552            lines.push(message.into());
553        }
554    }
555}
556
557fn block_dedent_before(command_name: &str) -> usize {
558    usize::from(matches_ascii_insensitive(
559        command_name,
560        &[
561            "elseif",
562            "else",
563            "endif",
564            "endforeach",
565            "endwhile",
566            "endfunction",
567            "endmacro",
568            "endblock",
569        ],
570    ))
571}
572
573fn block_indent_after(command_name: &str) -> usize {
574    usize::from(matches_ascii_insensitive(
575        command_name,
576        &[
577            "if", "foreach", "while", "function", "macro", "block", "elseif", "else",
578        ],
579    ))
580}
581
582fn matches_ascii_insensitive(input: &str, candidates: &[&str]) -> bool {
583    candidates
584        .iter()
585        .any(|candidate| input.eq_ignore_ascii_case(candidate))
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    #[test]
593    fn format_parsed_file_honors_disable() {
594        let source = "set(  X  1 )\n";
595        let file = parser::parse(source).unwrap();
596        let config = Config {
597            disable: true,
598            ..Config::default()
599        };
600
601        let formatted =
602            format_parsed_file(source, &file, &config, CommandRegistry::builtins()).unwrap();
603
604        assert_eq!(formatted, source);
605    }
606
607    #[test]
608    fn format_parsed_file_applies_line_endings_relative_to_source() {
609        let source = "set(  X  1 )\r\n";
610        let file = parser::parse(source).unwrap();
611        let config = Config {
612            line_ending: LineEnding::Auto,
613            ..Config::default()
614        };
615
616        let formatted =
617            format_parsed_file(source, &file, &config, CommandRegistry::builtins()).unwrap();
618
619        assert_eq!(formatted, "set(X 1)\r\n");
620    }
621
622    #[test]
623    fn format_source_rejects_invalid_programmatic_regex_config() {
624        let config = Config {
625            fence_pattern: "[".to_owned(),
626            ..Config::default()
627        };
628
629        let err = format_source("set(X 1)\n", &config).unwrap_err();
630        match err {
631            Error::Config(config_err) => {
632                assert_eq!(config_err.path, PathBuf::from("<programmatic-config>"));
633                assert_eq!(config_err.details.format, "runtime");
634                assert!(config_err.details.message.contains("invalid regex"));
635            }
636            other => panic!("expected config error, got {other:?}"),
637        }
638    }
639}