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