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, LineEnding};
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    if config.disable {
64        return Ok(source.to_owned());
65    }
66    let formatted = format_source_impl(source, config, registry, &mut DebugLog::disabled())?.0;
67    Ok(apply_line_ending(source, &formatted, config.line_ending))
68}
69
70/// Format raw CMake source using an explicit registry and return debug output.
71pub fn format_source_with_registry_debug(
72    source: &str,
73    config: &Config,
74    registry: &CommandRegistry,
75) -> Result<(String, Vec<String>)> {
76    if config.disable {
77        return Ok((source.to_owned(), Vec::new()));
78    }
79    let mut lines = Vec::new();
80    let mut debug = DebugLog::enabled(&mut lines);
81    let (formatted, _) = format_source_impl(source, config, registry, &mut debug)?;
82    Ok((
83        apply_line_ending(source, &formatted, config.line_ending),
84        lines,
85    ))
86}
87
88/// Format an already parsed AST file.
89///
90/// Useful when you want to parse once and format the same AST repeatedly with
91/// different [`Config`] or registry settings, avoiding re-parsing overhead.
92///
93/// # Examples
94///
95/// ```
96/// use cmakefmt::{format_file, Config, CommandRegistry};
97///
98/// let cmake = "PROJECT(MyProject)\n";
99/// let file = cmakefmt::parser::parse(cmake).unwrap();
100/// let formatted = format_file(
101///     &file, &Config::default(), CommandRegistry::builtins(),
102/// ).unwrap();
103/// assert_eq!(formatted, "project(MyProject)\n");
104/// ```
105pub fn format_file(file: &File, config: &Config, registry: &CommandRegistry) -> Result<String> {
106    format_file_with_debug(file, config, registry, &mut DebugLog::disabled())
107}
108
109fn format_file_with_debug(
110    file: &File,
111    config: &Config,
112    registry: &CommandRegistry,
113    debug: &mut DebugLog<'_>,
114) -> Result<String> {
115    let mut output = String::new();
116    let mut previous_was_content = false;
117    let mut block_depth = 0usize;
118
119    for statement in &file.statements {
120        match statement {
121            Statement::Command(command) => {
122                block_depth = block_depth.saturating_sub(block_dedent_before(&command.name));
123
124                if previous_was_content {
125                    output.push('\n');
126                }
127
128                output.push_str(&node::format_command(
129                    command,
130                    config,
131                    registry,
132                    block_depth,
133                    debug,
134                )?);
135
136                if let Some(trailing) = &command.trailing_comment {
137                    let comment_lines =
138                        comment::format_comment_lines(trailing, config, 0, config.line_width);
139                    if let Some(first) = comment_lines.first() {
140                        output.push(' ');
141                        output.push_str(first);
142                    }
143                }
144
145                previous_was_content = true;
146                block_depth += block_indent_after(&command.name);
147            }
148            Statement::TemplatePlaceholder(placeholder) => {
149                if previous_was_content {
150                    output.push('\n');
151                }
152
153                output.push_str(placeholder);
154                previous_was_content = true;
155            }
156            Statement::BlankLines(count) => {
157                let newline_count = if previous_was_content {
158                    count + 1
159                } else {
160                    *count
161                };
162                let newline_count = newline_count.min(config.max_empty_lines + 1);
163                for _ in 0..newline_count {
164                    output.push('\n');
165                }
166                previous_was_content = false;
167            }
168            Statement::Comment(c) => {
169                if previous_was_content {
170                    output.push('\n');
171                }
172
173                let indent = config.indent_str().repeat(block_depth);
174                let comment_lines = comment::format_comment_lines(
175                    c,
176                    config,
177                    indent.chars().count(),
178                    config.line_width,
179                );
180                for (index, line) in comment_lines.iter().enumerate() {
181                    if index > 0 {
182                        output.push('\n');
183                    }
184                    output.push_str(&indent);
185                    output.push_str(line);
186                }
187                previous_was_content = true;
188            }
189        }
190    }
191
192    if !output.ends_with('\n') {
193        output.push('\n');
194    }
195
196    if config.require_valid_layout {
197        for (i, line) in output.split('\n').enumerate() {
198            // Skip the final empty string produced by the trailing newline.
199            if line.is_empty() {
200                continue;
201            }
202            let width = line.chars().count();
203            if width > config.line_width {
204                return Err(Error::LayoutTooWide {
205                    line_no: i + 1,
206                    width,
207                    limit: config.line_width,
208                });
209            }
210        }
211    }
212
213    Ok(output)
214}
215
216/// Apply the configured line-ending style to `formatted` output.
217///
218/// The formatter always emits LF internally. `source` is consulted when
219/// `line_ending` is [`LineEnding::Auto`] to detect the predominant style.
220fn apply_line_ending(source: &str, formatted: &str, line_ending: LineEnding) -> String {
221    let use_crlf = match line_ending {
222        LineEnding::Unix => false,
223        LineEnding::Windows => true,
224        LineEnding::Auto => {
225            // Detect from input: if any \r\n is present, assume CRLF.
226            source.contains("\r\n")
227        }
228    };
229    if use_crlf {
230        formatted.replace('\n', "\r\n")
231    } else {
232        formatted.to_owned()
233    }
234}
235
236fn format_source_impl(
237    source: &str,
238    config: &Config,
239    registry: &CommandRegistry,
240    debug: &mut DebugLog<'_>,
241) -> Result<(String, usize)> {
242    let mut output = String::new();
243    let mut enabled_chunk = String::new();
244    let mut total_statements = 0usize;
245    let mut mode = BarrierMode::Enabled;
246    let mut enabled_chunk_start_line = 1usize;
247    let mut saw_barrier = false;
248
249    for (line_index, line) in source.split_inclusive('\n').enumerate() {
250        let line_no = line_index + 1;
251        match detect_barrier(line) {
252            Some(BarrierEvent::DisableByDirective(kind)) => {
253                let statements = flush_enabled_chunk(
254                    &mut output,
255                    &mut enabled_chunk,
256                    config,
257                    registry,
258                    debug,
259                    enabled_chunk_start_line,
260                    saw_barrier,
261                )?;
262                total_statements += statements;
263                debug.log(format!(
264                    "formatter: disabled formatting at line {line_no} via {kind}: off"
265                ));
266                output.push_str(line);
267                mode = BarrierMode::DisabledByDirective;
268                saw_barrier = true;
269            }
270            Some(BarrierEvent::EnableByDirective(kind)) => {
271                let statements = flush_enabled_chunk(
272                    &mut output,
273                    &mut enabled_chunk,
274                    config,
275                    registry,
276                    debug,
277                    enabled_chunk_start_line,
278                    saw_barrier,
279                )?;
280                total_statements += statements;
281                debug.log(format!(
282                    "formatter: enabled formatting at line {line_no} via {kind}: on"
283                ));
284                output.push_str(line);
285                if matches!(mode, BarrierMode::DisabledByDirective) {
286                    mode = BarrierMode::Enabled;
287                }
288                saw_barrier = true;
289            }
290            Some(BarrierEvent::Fence) => {
291                let statements = flush_enabled_chunk(
292                    &mut output,
293                    &mut enabled_chunk,
294                    config,
295                    registry,
296                    debug,
297                    enabled_chunk_start_line,
298                    saw_barrier,
299                )?;
300                total_statements += statements;
301                let next_mode = if matches!(mode, BarrierMode::DisabledByFence) {
302                    BarrierMode::Enabled
303                } else {
304                    BarrierMode::DisabledByFence
305                };
306                debug.log(format!(
307                    "formatter: toggled fence region at line {line_no} -> {}",
308                    next_mode.as_str()
309                ));
310                output.push_str(line);
311                mode = next_mode;
312                saw_barrier = true;
313            }
314            None => {
315                if matches!(mode, BarrierMode::Enabled) {
316                    if enabled_chunk.is_empty() {
317                        enabled_chunk_start_line = line_no;
318                    }
319                    enabled_chunk.push_str(line);
320                } else {
321                    output.push_str(line);
322                }
323            }
324        }
325    }
326
327    total_statements += flush_enabled_chunk(
328        &mut output,
329        &mut enabled_chunk,
330        config,
331        registry,
332        debug,
333        enabled_chunk_start_line,
334        saw_barrier,
335    )?;
336    Ok((output, total_statements))
337}
338
339fn flush_enabled_chunk(
340    output: &mut String,
341    enabled_chunk: &mut String,
342    config: &Config,
343    registry: &CommandRegistry,
344    debug: &mut DebugLog<'_>,
345    chunk_start_line: usize,
346    barrier_context: bool,
347) -> Result<usize> {
348    if enabled_chunk.is_empty() {
349        return Ok(0);
350    }
351
352    let file = match parser::parse(enabled_chunk) {
353        Ok(file) => file,
354        Err(Error::Parse(source)) => {
355            return Err(Error::ParseContext {
356                display_name: "<source>".to_owned(),
357                source_text: enabled_chunk.clone().into_boxed_str(),
358                start_line: chunk_start_line,
359                barrier_context,
360                source,
361            });
362        }
363        Err(err) => return Err(err),
364    };
365    let statement_count = file.statements.len();
366    debug.log(format!(
367        "formatter: formatting enabled chunk with {statement_count} statement(s) starting at source line {chunk_start_line}"
368    ));
369    let formatted = format_file_with_debug(&file, config, registry, debug)?;
370    output.push_str(&formatted);
371    enabled_chunk.clear();
372    Ok(statement_count)
373}
374
375fn detect_barrier(line: &str) -> Option<BarrierEvent<'_>> {
376    let trimmed = line.trim_start();
377    if !trimmed.starts_with('#') {
378        return None;
379    }
380
381    let body = trimmed[1..].trim_start().trim_end();
382    if body.starts_with("~~~") {
383        return Some(BarrierEvent::Fence);
384    }
385
386    if body == "cmake-format: off" {
387        return Some(BarrierEvent::DisableByDirective("cmake-format"));
388    }
389    if body == "cmake-format: on" {
390        return Some(BarrierEvent::EnableByDirective("cmake-format"));
391    }
392    if body == "cmakefmt: off" {
393        return Some(BarrierEvent::DisableByDirective("cmakefmt"));
394    }
395    if body == "cmakefmt: on" {
396        return Some(BarrierEvent::EnableByDirective("cmakefmt"));
397    }
398    if body == "fmt: off" {
399        return Some(BarrierEvent::DisableByDirective("fmt"));
400    }
401    if body == "fmt: on" {
402        return Some(BarrierEvent::EnableByDirective("fmt"));
403    }
404
405    None
406}
407
408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
409enum BarrierMode {
410    Enabled,
411    DisabledByDirective,
412    DisabledByFence,
413}
414
415impl BarrierMode {
416    fn as_str(self) -> &'static str {
417        match self {
418            BarrierMode::Enabled => "enabled",
419            BarrierMode::DisabledByDirective => "disabled-by-directive",
420            BarrierMode::DisabledByFence => "disabled-by-fence",
421        }
422    }
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426enum BarrierEvent<'a> {
427    DisableByDirective(&'a str),
428    EnableByDirective(&'a str),
429    Fence,
430}
431
432pub(crate) struct DebugLog<'a> {
433    lines: Option<&'a mut Vec<String>>,
434}
435
436impl<'a> DebugLog<'a> {
437    fn disabled() -> Self {
438        Self { lines: None }
439    }
440
441    fn enabled(lines: &'a mut Vec<String>) -> Self {
442        Self { lines: Some(lines) }
443    }
444
445    fn log(&mut self, message: impl Into<String>) {
446        if let Some(lines) = self.lines.as_deref_mut() {
447            lines.push(message.into());
448        }
449    }
450}
451
452fn block_dedent_before(command_name: &str) -> usize {
453    usize::from(matches_ascii_insensitive(
454        command_name,
455        &[
456            "elseif",
457            "else",
458            "endif",
459            "endforeach",
460            "endwhile",
461            "endfunction",
462            "endmacro",
463            "endblock",
464        ],
465    ))
466}
467
468fn block_indent_after(command_name: &str) -> usize {
469    usize::from(matches_ascii_insensitive(
470        command_name,
471        &[
472            "if", "foreach", "while", "function", "macro", "block", "elseif", "else",
473        ],
474    ))
475}
476
477fn matches_ascii_insensitive(input: &str, candidates: &[&str]) -> bool {
478    candidates
479        .iter()
480        .any(|candidate| input.eq_ignore_ascii_case(candidate))
481}