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