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::ParseContext { diagnostic, .. }) => {
385            return Err(Error::ParseContext {
386                display_name: "<source>".to_owned(),
387                source_text: enabled_chunk.clone().into_boxed_str(),
388                start_line: chunk_start_line,
389                barrier_context,
390                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 {
412        path: PathBuf::from("<programmatic-config>"),
413        details: FileParseError {
414            format: "runtime",
415            message: message.clone().into_boxed_str(),
416            line: None,
417            column: None,
418        },
419        source_message: message.into_boxed_str(),
420    }
421}
422
423fn detect_barrier(line: &str) -> Option<BarrierEvent<'_>> {
424    let trimmed = line.trim_start();
425    if !trimmed.starts_with('#') {
426        return None;
427    }
428
429    let body = trimmed[1..].trim_start().trim_end();
430    if body.starts_with("~~~") {
431        return Some(BarrierEvent::Fence);
432    }
433
434    if body == "cmake-format: off" {
435        return Some(BarrierEvent::DisableByDirective("cmake-format"));
436    }
437    if body == "cmake-format: on" {
438        return Some(BarrierEvent::EnableByDirective("cmake-format"));
439    }
440    if body == "cmakefmt: off" {
441        return Some(BarrierEvent::DisableByDirective("cmakefmt"));
442    }
443    if body == "cmakefmt: on" {
444        return Some(BarrierEvent::EnableByDirective("cmakefmt"));
445    }
446    if body == "fmt: off" {
447        return Some(BarrierEvent::DisableByDirective("fmt"));
448    }
449    if body == "fmt: on" {
450        return Some(BarrierEvent::EnableByDirective("fmt"));
451    }
452
453    None
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457enum BarrierMode {
458    Enabled,
459    DisabledByDirective,
460    DisabledByFence,
461}
462
463impl BarrierMode {
464    fn as_str(self) -> &'static str {
465        match self {
466            BarrierMode::Enabled => "enabled",
467            BarrierMode::DisabledByDirective => "disabled-by-directive",
468            BarrierMode::DisabledByFence => "disabled-by-fence",
469        }
470    }
471}
472
473#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474enum BarrierEvent<'a> {
475    DisableByDirective(&'a str),
476    EnableByDirective(&'a str),
477    Fence,
478}
479
480pub(crate) struct DebugLog<'a> {
481    lines: Option<&'a mut Vec<String>>,
482}
483
484impl<'a> DebugLog<'a> {
485    fn disabled() -> Self {
486        Self { lines: None }
487    }
488
489    fn enabled(lines: &'a mut Vec<String>) -> Self {
490        Self { lines: Some(lines) }
491    }
492
493    fn log(&mut self, message: impl Into<String>) {
494        if let Some(lines) = self.lines.as_deref_mut() {
495            lines.push(message.into());
496        }
497    }
498}
499
500fn block_dedent_before(command_name: &str) -> usize {
501    usize::from(matches_ascii_insensitive(
502        command_name,
503        &[
504            "elseif",
505            "else",
506            "endif",
507            "endforeach",
508            "endwhile",
509            "endfunction",
510            "endmacro",
511            "endblock",
512        ],
513    ))
514}
515
516fn block_indent_after(command_name: &str) -> usize {
517    usize::from(matches_ascii_insensitive(
518        command_name,
519        &[
520            "if", "foreach", "while", "function", "macro", "block", "elseif", "else",
521        ],
522    ))
523}
524
525fn matches_ascii_insensitive(input: &str, candidates: &[&str]) -> bool {
526    candidates
527        .iter()
528        .any(|candidate| input.eq_ignore_ascii_case(candidate))
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn format_parsed_file_honors_disable() {
537        let source = "set(  X  1 )\n";
538        let file = parser::parse(source).unwrap();
539        let config = Config {
540            disable: true,
541            ..Config::default()
542        };
543
544        let formatted =
545            format_parsed_file(source, &file, &config, CommandRegistry::builtins()).unwrap();
546
547        assert_eq!(formatted, source);
548    }
549
550    #[test]
551    fn format_parsed_file_applies_line_endings_relative_to_source() {
552        let source = "set(  X  1 )\r\n";
553        let file = parser::parse(source).unwrap();
554        let config = Config {
555            line_ending: LineEnding::Auto,
556            ..Config::default()
557        };
558
559        let formatted =
560            format_parsed_file(source, &file, &config, CommandRegistry::builtins()).unwrap();
561
562        assert_eq!(formatted, "set(X 1)\r\n");
563    }
564
565    #[test]
566    fn format_source_rejects_invalid_programmatic_regex_config() {
567        let config = Config {
568            fence_pattern: "[".to_owned(),
569            ..Config::default()
570        };
571
572        let err = format_source("set(X 1)\n", &config).unwrap_err();
573        match err {
574            Error::Config {
575                path,
576                details,
577                source_message,
578            } => {
579                assert_eq!(path, PathBuf::from("<programmatic-config>"));
580                assert_eq!(details.format, "runtime");
581                assert!(source_message.contains("invalid regex"));
582            }
583            other => panic!("expected config error, got {other:?}"),
584        }
585    }
586}