1pub(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
22pub fn format_source(source: &str, config: &Config) -> Result<String> {
34 format_source_with_registry(source, config, CommandRegistry::builtins())
35}
36
37pub 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
43pub 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
75pub 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
94pub 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 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
261fn 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 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}