1pub(crate) mod comment;
33pub(crate) mod node;
34
35#[cfg(feature = "cli")]
39pub(crate) use node::{split_sections, HeaderKind};
40
41use std::path::PathBuf;
42
43use crate::config::{Config, LineEnding};
44use crate::error::{Error, FileParseError, Result};
45use crate::parser::{self, ast::File, ast::Statement};
46use crate::spec::registry::CommandRegistry;
47
48pub fn format_source(source: &str, config: &Config) -> Result<String> {
65 format_source_with_registry(source, config, CommandRegistry::builtins())
66}
67
68pub fn format_source_with_debug(source: &str, config: &Config) -> Result<(String, Vec<String>)> {
77 format_source_with_registry_debug(source, config, CommandRegistry::builtins())
78}
79
80pub fn format_source_with_registry(
100 source: &str,
101 config: &Config,
102 registry: &CommandRegistry,
103) -> Result<String> {
104 if config.disable {
105 return Ok(source.to_owned());
106 }
107 validate_runtime_config(config)?;
108 let formatted = format_source_impl(source, config, registry, &mut DebugLog::disabled())?.0;
109 Ok(apply_line_ending(source, &formatted, config.line_ending))
110}
111
112pub fn format_source_with_registry_debug(
114 source: &str,
115 config: &Config,
116 registry: &CommandRegistry,
117) -> Result<(String, Vec<String>)> {
118 if config.disable {
119 return Ok((source.to_owned(), Vec::new()));
120 }
121 validate_runtime_config(config)?;
122 let mut lines = Vec::new();
123 let mut debug = DebugLog::enabled(&mut lines);
124 let (formatted, _) = format_source_impl(source, config, registry, &mut debug)?;
125 Ok((
126 apply_line_ending(source, &formatted, config.line_ending),
127 lines,
128 ))
129}
130
131pub fn format_parsed_file(
166 source: &str,
167 file: &File,
168 config: &Config,
169 registry: &CommandRegistry,
170) -> Result<String> {
171 if config.disable {
172 return Ok(source.to_owned());
173 }
174 validate_runtime_config(config)?;
175 let formatted =
176 format_parsed_file_with_debug(file, config, registry, &mut DebugLog::disabled())?;
177 Ok(apply_line_ending(source, &formatted, config.line_ending))
178}
179
180fn format_parsed_file_with_debug(
181 file: &File,
182 config: &Config,
183 registry: &CommandRegistry,
184 debug: &mut DebugLog<'_>,
185) -> Result<String> {
186 let patterns = config.compiled_patterns().map_err(runtime_config_error)?;
187 let mut output = String::new();
188 let mut previous_was_content = false;
189 let mut block_depth = 0usize;
190
191 for statement in &file.statements {
192 match statement {
193 Statement::Command(command) => {
194 block_depth = block_depth.saturating_sub(block_dedent_before(&command.name));
195
196 if previous_was_content {
197 output.push('\n');
198 }
199
200 output.push_str(&node::format_command(
201 command,
202 config,
203 &patterns,
204 registry,
205 block_depth,
206 debug,
207 )?);
208
209 if let Some(trailing) = &command.trailing_comment {
210 let comment_indent_width = output
211 .rsplit('\n')
212 .next()
213 .unwrap_or_default()
214 .chars()
215 .count()
216 + 1;
217 let comment_lines = comment::format_comment_lines(
218 trailing,
219 config,
220 &patterns,
221 comment_indent_width,
222 config.line_width,
223 );
224 if let Some((first, rest)) = comment_lines.split_first() {
225 output.push(' ');
226 output.push_str(first);
227 let continuation_indent = " ".repeat(comment_indent_width);
228 for line in rest {
229 output.push('\n');
230 output.push_str(&continuation_indent);
231 output.push_str(line);
232 }
233 }
234 }
235
236 previous_was_content = true;
237 block_depth += block_indent_after(&command.name);
238 }
239 Statement::TemplatePlaceholder(placeholder) => {
240 if previous_was_content {
241 output.push('\n');
242 }
243
244 output.push_str(placeholder);
245 previous_was_content = true;
246 }
247 Statement::BlankLines(count) => {
248 let newline_count = if previous_was_content {
249 count + 1
250 } else {
251 *count
252 };
253 let newline_count = newline_count.min(config.max_empty_lines + 1);
254 for _ in 0..newline_count {
255 output.push('\n');
256 }
257 previous_was_content = false;
258 }
259 Statement::Comment(c) => {
260 if previous_was_content {
261 output.push('\n');
262 }
263
264 let indent = config.indent_str().repeat(block_depth);
265 let comment_lines = comment::format_comment_lines(
266 c,
267 config,
268 &patterns,
269 indent.chars().count(),
270 config.line_width,
271 );
272 for (index, line) in comment_lines.iter().enumerate() {
273 if index > 0 {
274 output.push('\n');
275 }
276 output.push_str(&indent);
277 output.push_str(line);
278 }
279 previous_was_content = true;
280 }
281 }
282 }
283
284 if !output.ends_with('\n') {
285 output.push('\n');
286 }
287
288 if config.require_valid_layout {
289 for (i, line) in output.split('\n').enumerate() {
290 if line.is_empty() {
292 continue;
293 }
294 let width = line.chars().count();
295 if width > config.line_width {
296 return Err(Error::LayoutTooWide {
297 line_no: i + 1,
298 width,
299 limit: config.line_width,
300 });
301 }
302 }
303 }
304
305 Ok(output)
306}
307
308fn apply_line_ending(source: &str, formatted: &str, line_ending: LineEnding) -> String {
313 let use_crlf = match line_ending {
314 LineEnding::Unix => false,
315 LineEnding::Windows => true,
316 LineEnding::Auto => {
317 source.contains("\r\n")
319 }
320 };
321 if use_crlf {
322 formatted.replace('\n', "\r\n")
323 } else {
324 formatted.to_owned()
325 }
326}
327
328fn format_source_impl(
329 source: &str,
330 config: &Config,
331 registry: &CommandRegistry,
332 debug: &mut DebugLog<'_>,
333) -> Result<(String, usize)> {
334 let mut output = String::new();
335 let mut enabled_chunk = String::new();
336 let mut total_statements = 0usize;
337 let mut mode = BarrierMode::Enabled;
338 let mut enabled_chunk_start_line = 1usize;
339 let mut saw_barrier = false;
340
341 for (line_index, line) in source.split_inclusive('\n').enumerate() {
342 let line_no = line_index + 1;
343 match detect_barrier(line) {
344 Some(BarrierEvent::DisableByDirective(kind)) => {
345 let statements = flush_enabled_chunk(
346 &mut output,
347 &mut enabled_chunk,
348 config,
349 registry,
350 debug,
351 enabled_chunk_start_line,
352 saw_barrier,
353 )?;
354 total_statements += statements;
355 debug.log(format!(
356 "formatter: disabled formatting at line {line_no} via {kind}: off"
357 ));
358 output.push_str(line);
359 mode = BarrierMode::DisabledByDirective;
360 saw_barrier = true;
361 }
362 Some(BarrierEvent::EnableByDirective(kind)) => {
363 let statements = flush_enabled_chunk(
364 &mut output,
365 &mut enabled_chunk,
366 config,
367 registry,
368 debug,
369 enabled_chunk_start_line,
370 saw_barrier,
371 )?;
372 total_statements += statements;
373 debug.log(format!(
374 "formatter: enabled formatting at line {line_no} via {kind}: on"
375 ));
376 output.push_str(line);
377 if matches!(mode, BarrierMode::DisabledByDirective) {
378 mode = BarrierMode::Enabled;
379 }
380 saw_barrier = true;
381 }
382 Some(BarrierEvent::Fence) => {
383 let statements = flush_enabled_chunk(
384 &mut output,
385 &mut enabled_chunk,
386 config,
387 registry,
388 debug,
389 enabled_chunk_start_line,
390 saw_barrier,
391 )?;
392 total_statements += statements;
393 let next_mode = if matches!(mode, BarrierMode::DisabledByFence) {
394 BarrierMode::Enabled
395 } else {
396 BarrierMode::DisabledByFence
397 };
398 debug.log(format!(
399 "formatter: toggled fence region at line {line_no} -> {}",
400 next_mode.as_str()
401 ));
402 output.push_str(line);
403 mode = next_mode;
404 saw_barrier = true;
405 }
406 None => {
407 if matches!(mode, BarrierMode::Enabled) {
408 if enabled_chunk.is_empty() {
409 enabled_chunk_start_line = line_no;
410 }
411 enabled_chunk.push_str(line);
412 } else {
413 output.push_str(line);
414 }
415 }
416 }
417 }
418
419 total_statements += flush_enabled_chunk(
420 &mut output,
421 &mut enabled_chunk,
422 config,
423 registry,
424 debug,
425 enabled_chunk_start_line,
426 saw_barrier,
427 )?;
428 Ok((output, total_statements))
429}
430
431fn flush_enabled_chunk(
432 output: &mut String,
433 enabled_chunk: &mut String,
434 config: &Config,
435 registry: &CommandRegistry,
436 debug: &mut DebugLog<'_>,
437 chunk_start_line: usize,
438 barrier_context: bool,
439) -> Result<usize> {
440 if enabled_chunk.is_empty() {
441 return Ok(0);
442 }
443
444 let file = match parser::parse(enabled_chunk) {
445 Ok(file) => file,
446 Err(Error::Parse(parse_error)) => {
447 let _ = barrier_context;
448 return Err(Error::Parse(crate::error::ParseError {
449 display_name: "<source>".to_owned(),
450 source_text: enabled_chunk.clone().into_boxed_str(),
451 start_line: chunk_start_line,
452 diagnostic: parse_error.diagnostic,
453 }));
454 }
455 Err(err) => return Err(err),
456 };
457 let statement_count = file.statements.len();
458 debug.log(format!(
459 "formatter: formatting enabled chunk with {statement_count} statement(s) starting at source line {chunk_start_line}"
460 ));
461 let formatted = format_parsed_file_with_debug(&file, config, registry, debug)?;
462 output.push_str(&formatted);
463 enabled_chunk.clear();
464 Ok(statement_count)
465}
466
467fn validate_runtime_config(config: &Config) -> Result<()> {
468 config.validate_patterns().map_err(runtime_config_error)?;
469 Ok(())
470}
471
472fn runtime_config_error(message: String) -> Error {
473 Error::Config(crate::error::ConfigError {
474 path: PathBuf::from("<programmatic-config>"),
475 details: FileParseError {
476 format: "runtime",
477 message: message.into_boxed_str(),
478 line: None,
479 column: None,
480 },
481 })
482}
483
484fn detect_barrier(line: &str) -> Option<BarrierEvent<'_>> {
485 let trimmed = line.trim_start();
486 if !trimmed.starts_with('#') {
487 return None;
488 }
489
490 let body = trimmed[1..].trim_start().trim_end();
491 if body.starts_with("~~~") {
492 return Some(BarrierEvent::Fence);
493 }
494
495 if body == "cmake-format: off" {
496 return Some(BarrierEvent::DisableByDirective("cmake-format"));
497 }
498 if body == "cmake-format: on" {
499 return Some(BarrierEvent::EnableByDirective("cmake-format"));
500 }
501 if body == "cmakefmt: off" {
502 return Some(BarrierEvent::DisableByDirective("cmakefmt"));
503 }
504 if body == "cmakefmt: on" {
505 return Some(BarrierEvent::EnableByDirective("cmakefmt"));
506 }
507 if body == "fmt: off" {
508 return Some(BarrierEvent::DisableByDirective("fmt"));
509 }
510 if body == "fmt: on" {
511 return Some(BarrierEvent::EnableByDirective("fmt"));
512 }
513
514 None
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
518enum BarrierMode {
519 Enabled,
520 DisabledByDirective,
521 DisabledByFence,
522}
523
524impl BarrierMode {
525 fn as_str(self) -> &'static str {
526 match self {
527 BarrierMode::Enabled => "enabled",
528 BarrierMode::DisabledByDirective => "disabled-by-directive",
529 BarrierMode::DisabledByFence => "disabled-by-fence",
530 }
531 }
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535enum BarrierEvent<'a> {
536 DisableByDirective(&'a str),
537 EnableByDirective(&'a str),
538 Fence,
539}
540
541pub(crate) struct DebugLog<'a> {
542 lines: Option<&'a mut Vec<String>>,
543}
544
545impl<'a> DebugLog<'a> {
546 fn disabled() -> Self {
547 Self { lines: None }
548 }
549
550 fn enabled(lines: &'a mut Vec<String>) -> Self {
551 Self { lines: Some(lines) }
552 }
553
554 fn log(&mut self, message: impl Into<String>) {
555 if let Some(lines) = self.lines.as_deref_mut() {
556 lines.push(message.into());
557 }
558 }
559}
560
561fn block_dedent_before(command_name: &str) -> usize {
562 usize::from(matches_ascii_insensitive(
563 command_name,
564 &[
565 "elseif",
566 "else",
567 "endif",
568 "endforeach",
569 "endwhile",
570 "endfunction",
571 "endmacro",
572 "endblock",
573 ],
574 ))
575}
576
577fn block_indent_after(command_name: &str) -> usize {
578 usize::from(matches_ascii_insensitive(
579 command_name,
580 &[
581 "if", "foreach", "while", "function", "macro", "block", "elseif", "else",
582 ],
583 ))
584}
585
586fn matches_ascii_insensitive(input: &str, candidates: &[&str]) -> bool {
587 candidates
588 .iter()
589 .any(|candidate| input.eq_ignore_ascii_case(candidate))
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 #[test]
597 fn format_parsed_file_honors_disable() {
598 let source = "set( X 1 )\n";
599 let file = parser::parse(source).unwrap();
600 let config = Config {
601 disable: true,
602 ..Config::default()
603 };
604
605 let formatted =
606 format_parsed_file(source, &file, &config, CommandRegistry::builtins()).unwrap();
607
608 assert_eq!(formatted, source);
609 }
610
611 #[test]
612 fn format_parsed_file_applies_line_endings_relative_to_source() {
613 let source = "set( X 1 )\r\n";
614 let file = parser::parse(source).unwrap();
615 let config = Config {
616 line_ending: LineEnding::Auto,
617 ..Config::default()
618 };
619
620 let formatted =
621 format_parsed_file(source, &file, &config, CommandRegistry::builtins()).unwrap();
622
623 assert_eq!(formatted, "set(X 1)\r\n");
624 }
625
626 #[test]
627 fn format_source_rejects_invalid_programmatic_regex_config() {
628 let config = Config {
629 fence_pattern: "[".to_owned(),
630 ..Config::default()
631 };
632
633 let err = format_source("set(X 1)\n", &config).unwrap_err();
634 match err {
635 Error::Config(config_err) => {
636 assert_eq!(config_err.path, PathBuf::from("<programmatic-config>"));
637 assert_eq!(config_err.details.format, "runtime");
638 assert!(config_err.details.message.contains("invalid regex"));
639 }
640 other => panic!("expected config error, got {other:?}"),
641 }
642 }
643}