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