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
10pub mod comment;
11pub mod node;
12
13use crate::config::Config;
14use crate::error::{Error, Result};
15use crate::parser::{self, ast::File, ast::Statement};
16use crate::spec::registry::CommandRegistry;
17
18/// Format raw CMake source using the built-in command registry.
19pub fn format_source(source: &str, config: &Config) -> Result<String> {
20    format_source_with_registry(source, config, CommandRegistry::builtins())
21}
22
23/// Format raw CMake source using the built-in registry and also return debug
24/// lines describing the formatter's decisions.
25pub fn format_source_with_debug(source: &str, config: &Config) -> Result<(String, Vec<String>)> {
26    format_source_with_registry_debug(source, config, CommandRegistry::builtins())
27}
28
29/// Format raw CMake source using an explicit command registry.
30pub fn format_source_with_registry(
31    source: &str,
32    config: &Config,
33    registry: &CommandRegistry,
34) -> Result<String> {
35    Ok(format_source_impl(source, config, registry, &mut DebugLog::disabled())?.0)
36}
37
38/// Format raw CMake source using an explicit registry and return debug output.
39pub fn format_source_with_registry_debug(
40    source: &str,
41    config: &Config,
42    registry: &CommandRegistry,
43) -> Result<(String, Vec<String>)> {
44    let mut lines = Vec::new();
45    let mut debug = DebugLog::enabled(&mut lines);
46    let (formatted, _) = format_source_impl(source, config, registry, &mut debug)?;
47    Ok((formatted, lines))
48}
49
50/// Format an already parsed AST file.
51///
52/// This is useful when callers want to parse once and format repeatedly with
53/// different config or registry settings.
54pub fn format_file(file: &File, config: &Config, registry: &CommandRegistry) -> Result<String> {
55    format_file_with_debug(file, config, registry, &mut DebugLog::disabled())
56}
57
58fn format_file_with_debug(
59    file: &File,
60    config: &Config,
61    registry: &CommandRegistry,
62    debug: &mut DebugLog<'_>,
63) -> Result<String> {
64    let mut output = String::new();
65    let mut previous_was_content = false;
66    let mut block_depth = 0usize;
67
68    for statement in &file.statements {
69        match statement {
70            Statement::Command(command) => {
71                block_depth = block_depth.saturating_sub(block_dedent_before(&command.name));
72
73                if previous_was_content {
74                    output.push('\n');
75                }
76
77                output.push_str(&node::format_command(
78                    command,
79                    config,
80                    registry,
81                    block_depth,
82                    debug,
83                )?);
84
85                if let Some(trailing) = &command.trailing_comment {
86                    let comment_lines =
87                        comment::format_comment_lines(trailing, config, 0, config.line_width);
88                    if let Some(first) = comment_lines.first() {
89                        output.push(' ');
90                        output.push_str(first);
91                    }
92                }
93
94                previous_was_content = true;
95                block_depth += block_indent_after(&command.name);
96            }
97            Statement::TemplatePlaceholder(placeholder) => {
98                if previous_was_content {
99                    output.push('\n');
100                }
101
102                output.push_str(placeholder);
103                previous_was_content = true;
104            }
105            Statement::BlankLines(count) => {
106                let newline_count = if previous_was_content {
107                    count + 1
108                } else {
109                    *count
110                };
111                let newline_count = newline_count.min(config.max_empty_lines + 1);
112                for _ in 0..newline_count {
113                    output.push('\n');
114                }
115                previous_was_content = false;
116            }
117            Statement::Comment(c) => {
118                if previous_was_content {
119                    output.push('\n');
120                }
121
122                let indent = config.indent_str().repeat(block_depth);
123                let comment_lines = comment::format_comment_lines(
124                    c,
125                    config,
126                    indent.chars().count(),
127                    config.line_width,
128                );
129                for (index, line) in comment_lines.iter().enumerate() {
130                    if index > 0 {
131                        output.push('\n');
132                    }
133                    output.push_str(&indent);
134                    output.push_str(line);
135                }
136                previous_was_content = true;
137            }
138        }
139    }
140
141    if !output.ends_with('\n') {
142        output.push('\n');
143    }
144
145    Ok(output)
146}
147
148fn format_source_impl(
149    source: &str,
150    config: &Config,
151    registry: &CommandRegistry,
152    debug: &mut DebugLog<'_>,
153) -> Result<(String, usize)> {
154    let mut output = String::new();
155    let mut enabled_chunk = String::new();
156    let mut total_statements = 0usize;
157    let mut mode = BarrierMode::Enabled;
158    let mut enabled_chunk_start_line = 1usize;
159    let mut saw_barrier = false;
160
161    for (line_index, line) in source.split_inclusive('\n').enumerate() {
162        let line_no = line_index + 1;
163        match detect_barrier(line) {
164            Some(BarrierEvent::DisableByDirective(kind)) => {
165                let statements = flush_enabled_chunk(
166                    &mut output,
167                    &mut enabled_chunk,
168                    config,
169                    registry,
170                    debug,
171                    enabled_chunk_start_line,
172                    saw_barrier,
173                )?;
174                total_statements += statements;
175                debug.log(format!(
176                    "formatter: disabled formatting at line {line_no} via {kind}: off"
177                ));
178                output.push_str(line);
179                mode = BarrierMode::DisabledByDirective;
180                saw_barrier = true;
181            }
182            Some(BarrierEvent::EnableByDirective(kind)) => {
183                let statements = flush_enabled_chunk(
184                    &mut output,
185                    &mut enabled_chunk,
186                    config,
187                    registry,
188                    debug,
189                    enabled_chunk_start_line,
190                    saw_barrier,
191                )?;
192                total_statements += statements;
193                debug.log(format!(
194                    "formatter: enabled formatting at line {line_no} via {kind}: on"
195                ));
196                output.push_str(line);
197                if matches!(mode, BarrierMode::DisabledByDirective) {
198                    mode = BarrierMode::Enabled;
199                }
200                saw_barrier = true;
201            }
202            Some(BarrierEvent::Fence) => {
203                let statements = flush_enabled_chunk(
204                    &mut output,
205                    &mut enabled_chunk,
206                    config,
207                    registry,
208                    debug,
209                    enabled_chunk_start_line,
210                    saw_barrier,
211                )?;
212                total_statements += statements;
213                let next_mode = if matches!(mode, BarrierMode::DisabledByFence) {
214                    BarrierMode::Enabled
215                } else {
216                    BarrierMode::DisabledByFence
217                };
218                debug.log(format!(
219                    "formatter: toggled fence region at line {line_no} -> {}",
220                    next_mode.as_str()
221                ));
222                output.push_str(line);
223                mode = next_mode;
224                saw_barrier = true;
225            }
226            None => {
227                if matches!(mode, BarrierMode::Enabled) {
228                    if enabled_chunk.is_empty() {
229                        enabled_chunk_start_line = line_no;
230                    }
231                    enabled_chunk.push_str(line);
232                } else {
233                    output.push_str(line);
234                }
235            }
236        }
237    }
238
239    total_statements += flush_enabled_chunk(
240        &mut output,
241        &mut enabled_chunk,
242        config,
243        registry,
244        debug,
245        enabled_chunk_start_line,
246        saw_barrier,
247    )?;
248    Ok((output, total_statements))
249}
250
251fn flush_enabled_chunk(
252    output: &mut String,
253    enabled_chunk: &mut String,
254    config: &Config,
255    registry: &CommandRegistry,
256    debug: &mut DebugLog<'_>,
257    chunk_start_line: usize,
258    barrier_context: bool,
259) -> Result<usize> {
260    if enabled_chunk.is_empty() {
261        return Ok(0);
262    }
263
264    let file = match parser::parse(enabled_chunk) {
265        Ok(file) => file,
266        Err(Error::Parse(source)) => {
267            return Err(Error::ParseContext {
268                display_name: "<source>".to_owned(),
269                source_text: enabled_chunk.clone().into_boxed_str(),
270                start_line: chunk_start_line,
271                barrier_context,
272                source,
273            });
274        }
275        Err(err) => return Err(err),
276    };
277    let statement_count = file.statements.len();
278    debug.log(format!(
279        "formatter: formatting enabled chunk with {statement_count} statement(s) starting at source line {chunk_start_line}"
280    ));
281    let formatted = format_file_with_debug(&file, config, registry, debug)?;
282    output.push_str(&formatted);
283    enabled_chunk.clear();
284    Ok(statement_count)
285}
286
287fn detect_barrier(line: &str) -> Option<BarrierEvent<'_>> {
288    let trimmed = line.trim_start();
289    if !trimmed.starts_with('#') {
290        return None;
291    }
292
293    let body = trimmed[1..].trim_start().trim_end();
294    if body.starts_with("~~~") {
295        return Some(BarrierEvent::Fence);
296    }
297
298    if body == "cmake-format: off" {
299        return Some(BarrierEvent::DisableByDirective("cmake-format"));
300    }
301    if body == "cmake-format: on" {
302        return Some(BarrierEvent::EnableByDirective("cmake-format"));
303    }
304    if body == "cmakefmt: off" {
305        return Some(BarrierEvent::DisableByDirective("cmakefmt"));
306    }
307    if body == "cmakefmt: on" {
308        return Some(BarrierEvent::EnableByDirective("cmakefmt"));
309    }
310    if body == "fmt: off" {
311        return Some(BarrierEvent::DisableByDirective("fmt"));
312    }
313    if body == "fmt: on" {
314        return Some(BarrierEvent::EnableByDirective("fmt"));
315    }
316
317    None
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321enum BarrierMode {
322    Enabled,
323    DisabledByDirective,
324    DisabledByFence,
325}
326
327impl BarrierMode {
328    fn as_str(self) -> &'static str {
329        match self {
330            BarrierMode::Enabled => "enabled",
331            BarrierMode::DisabledByDirective => "disabled-by-directive",
332            BarrierMode::DisabledByFence => "disabled-by-fence",
333        }
334    }
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338enum BarrierEvent<'a> {
339    DisableByDirective(&'a str),
340    EnableByDirective(&'a str),
341    Fence,
342}
343
344pub(crate) struct DebugLog<'a> {
345    lines: Option<&'a mut Vec<String>>,
346}
347
348impl<'a> DebugLog<'a> {
349    fn disabled() -> Self {
350        Self { lines: None }
351    }
352
353    fn enabled(lines: &'a mut Vec<String>) -> Self {
354        Self { lines: Some(lines) }
355    }
356
357    fn log(&mut self, message: impl Into<String>) {
358        if let Some(lines) = self.lines.as_deref_mut() {
359            lines.push(message.into());
360        }
361    }
362}
363
364fn block_dedent_before(command_name: &str) -> usize {
365    usize::from(matches_ascii_insensitive(
366        command_name,
367        &[
368            "elseif",
369            "else",
370            "endif",
371            "endforeach",
372            "endwhile",
373            "endfunction",
374            "endmacro",
375            "endblock",
376        ],
377    ))
378}
379
380fn block_indent_after(command_name: &str) -> usize {
381    usize::from(matches_ascii_insensitive(
382        command_name,
383        &[
384            "if", "foreach", "while", "function", "macro", "block", "elseif", "else",
385        ],
386    ))
387}
388
389fn matches_ascii_insensitive(input: &str, candidates: &[&str]) -> bool {
390    candidates
391        .iter()
392        .any(|candidate| input.eq_ignore_ascii_case(candidate))
393}