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