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