1pub(crate) mod comment;
11pub(crate) mod node;
12
13use crate::config::{Config, LineEnding};
14use crate::error::{Error, Result};
15use crate::parser::{self, ast::File, ast::Statement};
16use crate::spec::registry::CommandRegistry;
17
18pub fn format_source(source: &str, config: &Config) -> Result<String> {
30 format_source_with_registry(source, config, CommandRegistry::builtins())
31}
32
33pub fn format_source_with_debug(source: &str, config: &Config) -> Result<(String, Vec<String>)> {
36 format_source_with_registry_debug(source, config, CommandRegistry::builtins())
37}
38
39pub fn format_source_with_registry(
59 source: &str,
60 config: &Config,
61 registry: &CommandRegistry,
62) -> Result<String> {
63 if config.disable {
64 return Ok(source.to_owned());
65 }
66 let formatted = format_source_impl(source, config, registry, &mut DebugLog::disabled())?.0;
67 Ok(apply_line_ending(source, &formatted, config.line_ending))
68}
69
70pub fn format_source_with_registry_debug(
72 source: &str,
73 config: &Config,
74 registry: &CommandRegistry,
75) -> Result<(String, Vec<String>)> {
76 if config.disable {
77 return Ok((source.to_owned(), Vec::new()));
78 }
79 let mut lines = Vec::new();
80 let mut debug = DebugLog::enabled(&mut lines);
81 let (formatted, _) = format_source_impl(source, config, registry, &mut debug)?;
82 Ok((
83 apply_line_ending(source, &formatted, config.line_ending),
84 lines,
85 ))
86}
87
88pub fn format_file(file: &File, config: &Config, registry: &CommandRegistry) -> Result<String> {
106 format_file_with_debug(file, config, registry, &mut DebugLog::disabled())
107}
108
109fn format_file_with_debug(
110 file: &File,
111 config: &Config,
112 registry: &CommandRegistry,
113 debug: &mut DebugLog<'_>,
114) -> Result<String> {
115 let patterns = config.compiled_patterns();
116 let mut output = String::new();
117 let mut previous_was_content = false;
118 let mut block_depth = 0usize;
119
120 for statement in &file.statements {
121 match statement {
122 Statement::Command(command) => {
123 block_depth = block_depth.saturating_sub(block_dedent_before(&command.name));
124
125 if previous_was_content {
126 output.push('\n');
127 }
128
129 output.push_str(&node::format_command(
130 command,
131 config,
132 &patterns,
133 registry,
134 block_depth,
135 debug,
136 )?);
137
138 if let Some(trailing) = &command.trailing_comment {
139 let comment_lines = comment::format_comment_lines(
140 trailing,
141 config,
142 &patterns,
143 0,
144 config.line_width,
145 );
146 if let Some(first) = comment_lines.first() {
147 output.push(' ');
148 output.push_str(first);
149 }
150 }
151
152 previous_was_content = true;
153 block_depth += block_indent_after(&command.name);
154 }
155 Statement::TemplatePlaceholder(placeholder) => {
156 if previous_was_content {
157 output.push('\n');
158 }
159
160 output.push_str(placeholder);
161 previous_was_content = true;
162 }
163 Statement::BlankLines(count) => {
164 let newline_count = if previous_was_content {
165 count + 1
166 } else {
167 *count
168 };
169 let newline_count = newline_count.min(config.max_empty_lines + 1);
170 for _ in 0..newline_count {
171 output.push('\n');
172 }
173 previous_was_content = false;
174 }
175 Statement::Comment(c) => {
176 if previous_was_content {
177 output.push('\n');
178 }
179
180 let indent = config.indent_str().repeat(block_depth);
181 let comment_lines = comment::format_comment_lines(
182 c,
183 config,
184 &patterns,
185 indent.chars().count(),
186 config.line_width,
187 );
188 for (index, line) in comment_lines.iter().enumerate() {
189 if index > 0 {
190 output.push('\n');
191 }
192 output.push_str(&indent);
193 output.push_str(line);
194 }
195 previous_was_content = true;
196 }
197 }
198 }
199
200 if !output.ends_with('\n') {
201 output.push('\n');
202 }
203
204 if config.require_valid_layout {
205 for (i, line) in output.split('\n').enumerate() {
206 if line.is_empty() {
208 continue;
209 }
210 let width = line.chars().count();
211 if width > config.line_width {
212 return Err(Error::LayoutTooWide {
213 line_no: i + 1,
214 width,
215 limit: config.line_width,
216 });
217 }
218 }
219 }
220
221 Ok(output)
222}
223
224fn apply_line_ending(source: &str, formatted: &str, line_ending: LineEnding) -> String {
229 let use_crlf = match line_ending {
230 LineEnding::Unix => false,
231 LineEnding::Windows => true,
232 LineEnding::Auto => {
233 source.contains("\r\n")
235 }
236 };
237 if use_crlf {
238 formatted.replace('\n', "\r\n")
239 } else {
240 formatted.to_owned()
241 }
242}
243
244fn format_source_impl(
245 source: &str,
246 config: &Config,
247 registry: &CommandRegistry,
248 debug: &mut DebugLog<'_>,
249) -> Result<(String, usize)> {
250 let mut output = String::new();
251 let mut enabled_chunk = String::new();
252 let mut total_statements = 0usize;
253 let mut mode = BarrierMode::Enabled;
254 let mut enabled_chunk_start_line = 1usize;
255 let mut saw_barrier = false;
256
257 for (line_index, line) in source.split_inclusive('\n').enumerate() {
258 let line_no = line_index + 1;
259 match detect_barrier(line) {
260 Some(BarrierEvent::DisableByDirective(kind)) => {
261 let statements = flush_enabled_chunk(
262 &mut output,
263 &mut enabled_chunk,
264 config,
265 registry,
266 debug,
267 enabled_chunk_start_line,
268 saw_barrier,
269 )?;
270 total_statements += statements;
271 debug.log(format!(
272 "formatter: disabled formatting at line {line_no} via {kind}: off"
273 ));
274 output.push_str(line);
275 mode = BarrierMode::DisabledByDirective;
276 saw_barrier = true;
277 }
278 Some(BarrierEvent::EnableByDirective(kind)) => {
279 let statements = flush_enabled_chunk(
280 &mut output,
281 &mut enabled_chunk,
282 config,
283 registry,
284 debug,
285 enabled_chunk_start_line,
286 saw_barrier,
287 )?;
288 total_statements += statements;
289 debug.log(format!(
290 "formatter: enabled formatting at line {line_no} via {kind}: on"
291 ));
292 output.push_str(line);
293 if matches!(mode, BarrierMode::DisabledByDirective) {
294 mode = BarrierMode::Enabled;
295 }
296 saw_barrier = true;
297 }
298 Some(BarrierEvent::Fence) => {
299 let statements = flush_enabled_chunk(
300 &mut output,
301 &mut enabled_chunk,
302 config,
303 registry,
304 debug,
305 enabled_chunk_start_line,
306 saw_barrier,
307 )?;
308 total_statements += statements;
309 let next_mode = if matches!(mode, BarrierMode::DisabledByFence) {
310 BarrierMode::Enabled
311 } else {
312 BarrierMode::DisabledByFence
313 };
314 debug.log(format!(
315 "formatter: toggled fence region at line {line_no} -> {}",
316 next_mode.as_str()
317 ));
318 output.push_str(line);
319 mode = next_mode;
320 saw_barrier = true;
321 }
322 None => {
323 if matches!(mode, BarrierMode::Enabled) {
324 if enabled_chunk.is_empty() {
325 enabled_chunk_start_line = line_no;
326 }
327 enabled_chunk.push_str(line);
328 } else {
329 output.push_str(line);
330 }
331 }
332 }
333 }
334
335 total_statements += flush_enabled_chunk(
336 &mut output,
337 &mut enabled_chunk,
338 config,
339 registry,
340 debug,
341 enabled_chunk_start_line,
342 saw_barrier,
343 )?;
344 Ok((output, total_statements))
345}
346
347fn flush_enabled_chunk(
348 output: &mut String,
349 enabled_chunk: &mut String,
350 config: &Config,
351 registry: &CommandRegistry,
352 debug: &mut DebugLog<'_>,
353 chunk_start_line: usize,
354 barrier_context: bool,
355) -> Result<usize> {
356 if enabled_chunk.is_empty() {
357 return Ok(0);
358 }
359
360 let file = match parser::parse(enabled_chunk) {
361 Ok(file) => file,
362 Err(Error::Parse(source)) => {
363 return Err(Error::ParseContext {
364 display_name: "<source>".to_owned(),
365 source_text: enabled_chunk.clone().into_boxed_str(),
366 start_line: chunk_start_line,
367 barrier_context,
368 source,
369 });
370 }
371 Err(err) => return Err(err),
372 };
373 let statement_count = file.statements.len();
374 debug.log(format!(
375 "formatter: formatting enabled chunk with {statement_count} statement(s) starting at source line {chunk_start_line}"
376 ));
377 let formatted = format_file_with_debug(&file, config, registry, debug)?;
378 output.push_str(&formatted);
379 enabled_chunk.clear();
380 Ok(statement_count)
381}
382
383fn detect_barrier(line: &str) -> Option<BarrierEvent<'_>> {
384 let trimmed = line.trim_start();
385 if !trimmed.starts_with('#') {
386 return None;
387 }
388
389 let body = trimmed[1..].trim_start().trim_end();
390 if body.starts_with("~~~") {
391 return Some(BarrierEvent::Fence);
392 }
393
394 if body == "cmake-format: off" {
395 return Some(BarrierEvent::DisableByDirective("cmake-format"));
396 }
397 if body == "cmake-format: on" {
398 return Some(BarrierEvent::EnableByDirective("cmake-format"));
399 }
400 if body == "cmakefmt: off" {
401 return Some(BarrierEvent::DisableByDirective("cmakefmt"));
402 }
403 if body == "cmakefmt: on" {
404 return Some(BarrierEvent::EnableByDirective("cmakefmt"));
405 }
406 if body == "fmt: off" {
407 return Some(BarrierEvent::DisableByDirective("fmt"));
408 }
409 if body == "fmt: on" {
410 return Some(BarrierEvent::EnableByDirective("fmt"));
411 }
412
413 None
414}
415
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417enum BarrierMode {
418 Enabled,
419 DisabledByDirective,
420 DisabledByFence,
421}
422
423impl BarrierMode {
424 fn as_str(self) -> &'static str {
425 match self {
426 BarrierMode::Enabled => "enabled",
427 BarrierMode::DisabledByDirective => "disabled-by-directive",
428 BarrierMode::DisabledByFence => "disabled-by-fence",
429 }
430 }
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434enum BarrierEvent<'a> {
435 DisableByDirective(&'a str),
436 EnableByDirective(&'a str),
437 Fence,
438}
439
440pub(crate) struct DebugLog<'a> {
441 lines: Option<&'a mut Vec<String>>,
442}
443
444impl<'a> DebugLog<'a> {
445 fn disabled() -> Self {
446 Self { lines: None }
447 }
448
449 fn enabled(lines: &'a mut Vec<String>) -> Self {
450 Self { lines: Some(lines) }
451 }
452
453 fn log(&mut self, message: impl Into<String>) {
454 if let Some(lines) = self.lines.as_deref_mut() {
455 lines.push(message.into());
456 }
457 }
458}
459
460fn block_dedent_before(command_name: &str) -> usize {
461 usize::from(matches_ascii_insensitive(
462 command_name,
463 &[
464 "elseif",
465 "else",
466 "endif",
467 "endforeach",
468 "endwhile",
469 "endfunction",
470 "endmacro",
471 "endblock",
472 ],
473 ))
474}
475
476fn block_indent_after(command_name: &str) -> usize {
477 usize::from(matches_ascii_insensitive(
478 command_name,
479 &[
480 "if", "foreach", "while", "function", "macro", "block", "elseif", "else",
481 ],
482 ))
483}
484
485fn matches_ascii_insensitive(input: &str, candidates: &[&str]) -> bool {
486 candidates
487 .iter()
488 .any(|candidate| input.eq_ignore_ascii_case(candidate))
489}