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