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::Parse(parse_error)) => {
385 let _ = barrier_context;
386 return Err(Error::Parse(crate::error::ParseError {
387 display_name: "<source>".to_owned(),
388 source_text: enabled_chunk.clone().into_boxed_str(),
389 start_line: chunk_start_line,
390 diagnostic: parse_error.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(crate::error::ConfigError {
412 path: PathBuf::from("<programmatic-config>"),
413 details: FileParseError {
414 format: "runtime",
415 message: message.into_boxed_str(),
416 line: None,
417 column: None,
418 },
419 })
420}
421
422fn detect_barrier(line: &str) -> Option<BarrierEvent<'_>> {
423 let trimmed = line.trim_start();
424 if !trimmed.starts_with('#') {
425 return None;
426 }
427
428 let body = trimmed[1..].trim_start().trim_end();
429 if body.starts_with("~~~") {
430 return Some(BarrierEvent::Fence);
431 }
432
433 if body == "cmake-format: off" {
434 return Some(BarrierEvent::DisableByDirective("cmake-format"));
435 }
436 if body == "cmake-format: on" {
437 return Some(BarrierEvent::EnableByDirective("cmake-format"));
438 }
439 if body == "cmakefmt: off" {
440 return Some(BarrierEvent::DisableByDirective("cmakefmt"));
441 }
442 if body == "cmakefmt: on" {
443 return Some(BarrierEvent::EnableByDirective("cmakefmt"));
444 }
445 if body == "fmt: off" {
446 return Some(BarrierEvent::DisableByDirective("fmt"));
447 }
448 if body == "fmt: on" {
449 return Some(BarrierEvent::EnableByDirective("fmt"));
450 }
451
452 None
453}
454
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456enum BarrierMode {
457 Enabled,
458 DisabledByDirective,
459 DisabledByFence,
460}
461
462impl BarrierMode {
463 fn as_str(self) -> &'static str {
464 match self {
465 BarrierMode::Enabled => "enabled",
466 BarrierMode::DisabledByDirective => "disabled-by-directive",
467 BarrierMode::DisabledByFence => "disabled-by-fence",
468 }
469 }
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473enum BarrierEvent<'a> {
474 DisableByDirective(&'a str),
475 EnableByDirective(&'a str),
476 Fence,
477}
478
479pub(crate) struct DebugLog<'a> {
480 lines: Option<&'a mut Vec<String>>,
481}
482
483impl<'a> DebugLog<'a> {
484 fn disabled() -> Self {
485 Self { lines: None }
486 }
487
488 fn enabled(lines: &'a mut Vec<String>) -> Self {
489 Self { lines: Some(lines) }
490 }
491
492 fn log(&mut self, message: impl Into<String>) {
493 if let Some(lines) = self.lines.as_deref_mut() {
494 lines.push(message.into());
495 }
496 }
497}
498
499fn block_dedent_before(command_name: &str) -> usize {
500 usize::from(matches_ascii_insensitive(
501 command_name,
502 &[
503 "elseif",
504 "else",
505 "endif",
506 "endforeach",
507 "endwhile",
508 "endfunction",
509 "endmacro",
510 "endblock",
511 ],
512 ))
513}
514
515fn block_indent_after(command_name: &str) -> usize {
516 usize::from(matches_ascii_insensitive(
517 command_name,
518 &[
519 "if", "foreach", "while", "function", "macro", "block", "elseif", "else",
520 ],
521 ))
522}
523
524fn matches_ascii_insensitive(input: &str, candidates: &[&str]) -> bool {
525 candidates
526 .iter()
527 .any(|candidate| input.eq_ignore_ascii_case(candidate))
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn format_parsed_file_honors_disable() {
536 let source = "set( X 1 )\n";
537 let file = parser::parse(source).unwrap();
538 let config = Config {
539 disable: true,
540 ..Config::default()
541 };
542
543 let formatted =
544 format_parsed_file(source, &file, &config, CommandRegistry::builtins()).unwrap();
545
546 assert_eq!(formatted, source);
547 }
548
549 #[test]
550 fn format_parsed_file_applies_line_endings_relative_to_source() {
551 let source = "set( X 1 )\r\n";
552 let file = parser::parse(source).unwrap();
553 let config = Config {
554 line_ending: LineEnding::Auto,
555 ..Config::default()
556 };
557
558 let formatted =
559 format_parsed_file(source, &file, &config, CommandRegistry::builtins()).unwrap();
560
561 assert_eq!(formatted, "set(X 1)\r\n");
562 }
563
564 #[test]
565 fn format_source_rejects_invalid_programmatic_regex_config() {
566 let config = Config {
567 fence_pattern: "[".to_owned(),
568 ..Config::default()
569 };
570
571 let err = format_source("set(X 1)\n", &config).unwrap_err();
572 match err {
573 Error::Config(config_err) => {
574 assert_eq!(config_err.path, PathBuf::from("<programmatic-config>"));
575 assert_eq!(config_err.details.format, "runtime");
576 assert!(config_err.details.message.contains("invalid regex"));
577 }
578 other => panic!("expected config error, got {other:?}"),
579 }
580 }
581}