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