1pub(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
20pub fn format_source(source: &str, config: &Config) -> Result<String> {
32 format_source_with_registry(source, config, CommandRegistry::builtins())
33}
34
35pub 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
41pub 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
73pub 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
92pub 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 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
246fn 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 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}