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