1use cmake_tidy_ast::TextRange;
2use cmake_tidy_config::FormatConfiguration;
3use cmake_tidy_lexer::{Token, TokenKind, tokenize};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct FormatResult {
7 pub output: String,
8 pub changed: bool,
9}
10
11#[must_use]
12pub fn format_source(source: &str) -> FormatResult {
13 format_source_with_options(source, &FormatConfiguration::default())
14}
15
16#[must_use]
17pub fn format_source_with_options(source: &str, options: &FormatConfiguration) -> FormatResult {
18 let initial_protected_ranges = protected_ranges(source);
19 let normalized_parens = normalize_space_before_paren(
20 source,
21 &initial_protected_ranges,
22 options.space_before_paren,
23 );
24 let normalized_protected_ranges = protected_ranges(&normalized_parens);
25 let output = normalize_lines(&normalized_parens, &normalized_protected_ranges, options);
26 let changed = output != source;
27 FormatResult { output, changed }
28}
29
30fn protected_ranges(source: &str) -> Vec<TextRange> {
31 let tokens = tokenize(source);
32 let mut ranges = tokens
33 .iter()
34 .filter_map(|token| match token.kind {
35 TokenKind::BracketArgument(_) => Some(token.range),
36 _ => None,
37 })
38 .collect::<Vec<_>>();
39 ranges.extend(format_disabled_ranges(source, &tokens));
40 merge_ranges(ranges)
41}
42
43fn format_disabled_ranges(source: &str, tokens: &[Token]) -> Vec<TextRange> {
44 let mut ranges = Vec::new();
45 let mut disabled_start = None;
46
47 for token in tokens {
48 let TokenKind::Comment(text) = &token.kind else {
49 continue;
50 };
51
52 if text.trim() == "# cmake-format: off" {
53 if disabled_start.is_none() {
54 disabled_start = Some(line_start(source, token.range.start));
55 }
56 } else if text.trim() == "# cmake-format: on"
57 && let Some(start) = disabled_start.take()
58 {
59 ranges.push(TextRange::new(start, line_end(source, token.range.end)));
60 }
61 }
62
63 if let Some(start) = disabled_start {
64 ranges.push(TextRange::new(start, source.len()));
65 }
66
67 ranges
68}
69
70fn merge_ranges(mut ranges: Vec<TextRange>) -> Vec<TextRange> {
71 if ranges.len() < 2 {
72 return ranges;
73 }
74
75 ranges.sort_by_key(|range| (range.start, range.end));
76 let mut merged: Vec<TextRange> = Vec::with_capacity(ranges.len());
77
78 for range in ranges {
79 if let Some(last) = merged.last_mut()
80 && range.start <= last.end
81 {
82 last.end = last.end.max(range.end);
83 continue;
84 }
85
86 merged.push(range);
87 }
88
89 merged
90}
91
92fn line_start(source: &str, offset: usize) -> usize {
93 let bytes = source.as_bytes();
94 let mut start = offset.min(bytes.len());
95
96 while start > 0 && !matches!(bytes[start - 1], b'\n' | b'\r') {
97 start -= 1;
98 }
99
100 start
101}
102
103fn line_end(source: &str, offset: usize) -> usize {
104 let bytes = source.as_bytes();
105 let mut end = offset.min(bytes.len());
106
107 while end < bytes.len() && !matches!(bytes[end], b'\n' | b'\r') {
108 end += 1;
109 }
110
111 if bytes.get(end) == Some(&b'\r') && bytes.get(end + 1) == Some(&b'\n') {
112 end + 2
113 } else if bytes.get(end).is_some() {
114 end + 1
115 } else {
116 end
117 }
118}
119
120fn normalize_space_before_paren(
121 source: &str,
122 protected_ranges: &[TextRange],
123 enabled: bool,
124) -> String {
125 let tokens = tokenize(source);
126 let mut output = String::with_capacity(source.len());
127 let mut offset = 0;
128 let mut index = 0;
129
130 while index < tokens.len() {
131 if index + 2 < tokens.len() {
132 let first = &tokens[index];
133 let second = &tokens[index + 1];
134 let third = &tokens[index + 2];
135
136 if matches!(first.kind, TokenKind::Identifier(_))
137 && matches!(second.kind, TokenKind::Whitespace(_))
138 && matches!(third.kind, TokenKind::LeftParen)
139 && !source[second.range.start..second.range.end].contains(['\n', '\r'])
140 && !overlaps_protected_range(
141 TextRange::new(first.range.start, third.range.end),
142 protected_ranges,
143 )
144 {
145 output.push_str(&source[offset..second.range.start]);
146 if enabled {
147 output.push(' ');
148 }
149 offset = second.range.end;
150 index += 2;
151 continue;
152 }
153 }
154
155 if index + 1 < tokens.len()
156 && matches!(tokens[index].kind, TokenKind::Identifier(_))
157 && matches!(tokens[index + 1].kind, TokenKind::LeftParen)
158 && enabled
159 && !overlaps_protected_range(
160 TextRange::new(tokens[index].range.start, tokens[index + 1].range.end),
161 protected_ranges,
162 )
163 {
164 let insert_at = tokens[index + 1].range.start;
165 output.push_str(&source[offset..insert_at]);
166 output.push(' ');
167 offset = insert_at;
168 index += 1;
169 continue;
170 }
171
172 index += 1;
173 }
174
175 output.push_str(&source[offset..]);
176 output
177}
178
179fn normalize_lines(
180 source: &str,
181 protected_ranges: &[TextRange],
182 options: &FormatConfiguration,
183) -> String {
184 let bytes = source.as_bytes();
185 let mut kept_lines = Vec::new();
186 let mut offset = 0;
187 let mut blank_run = 0;
188
189 while offset < bytes.len() {
190 let mut line_end = offset;
191 while line_end < bytes.len() && !matches!(bytes[line_end], b'\n' | b'\r') {
192 line_end += 1;
193 }
194
195 let newline_end = if line_end == bytes.len() {
196 line_end
197 } else if bytes[line_end] == b'\r' && bytes.get(line_end + 1) == Some(&b'\n') {
198 line_end + 2
199 } else {
200 line_end + 1
201 };
202
203 let trim_end = trimmed_line_end(bytes, offset, line_end);
204 let line_range = TextRange::new(offset, line_end);
205 let line_protected = overlaps_protected_range(line_range, protected_ranges);
206 let preserve_trailing = trim_end != line_end
207 && overlaps_protected_range(TextRange::new(trim_end, line_end), protected_ranges);
208
209 let content = if trim_end != line_end && !preserve_trailing {
210 &source[offset..trim_end]
211 } else {
212 &source[offset..line_end]
213 };
214 let newline = &source[line_end..newline_end];
215 let is_blank = !line_protected && content.is_empty();
216
217 if is_blank {
218 blank_run += 1;
219 if blank_run <= options.max_blank_lines {
220 kept_lines.push((content.to_owned(), newline.to_owned(), line_protected));
221 }
222 } else {
223 blank_run = 0;
224 kept_lines.push((content.to_owned(), newline.to_owned(), line_protected));
225 }
226
227 offset = newline_end;
228 }
229
230 while kept_lines
231 .last()
232 .is_some_and(|(content, _newline, protected)| !*protected && content.is_empty())
233 {
234 kept_lines.pop();
235 }
236
237 let preferred_newline = preferred_newline(source);
238 let mut output = String::with_capacity(source.len());
239 for (index, (content, newline, _protected)) in kept_lines.iter().enumerate() {
240 output.push_str(content);
241
242 let is_last = index + 1 == kept_lines.len();
243 if is_last {
244 if !newline.is_empty() && options.final_newline {
245 output.push_str(preferred_newline);
246 }
247 } else if newline.is_empty() {
248 output.push_str(preferred_newline);
249 } else {
250 output.push_str(newline);
251 }
252 }
253
254 let protected_to_eof = protected_ranges
255 .iter()
256 .any(|range| range.end == source.len());
257 if options.final_newline
258 && !protected_to_eof
259 && (kept_lines.is_empty() || (!output.ends_with('\n') && !output.ends_with("\r\n")))
260 {
261 output.push_str(preferred_newline);
262 }
263
264 output
265}
266
267fn trimmed_line_end(bytes: &[u8], line_start: usize, line_end: usize) -> usize {
268 let mut trim_end = line_end;
269 while trim_end > line_start && matches!(bytes[trim_end - 1], b' ' | b'\t') {
270 trim_end -= 1;
271 }
272 trim_end
273}
274
275fn overlaps_protected_range(range: TextRange, protected_ranges: &[TextRange]) -> bool {
276 protected_ranges
277 .iter()
278 .any(|protected| range.start < protected.end && protected.start < range.end)
279}
280
281fn preferred_newline(source: &str) -> &str {
282 if source.contains("\r\n") {
283 "\r\n"
284 } else {
285 "\n"
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use cmake_tidy_config::FormatConfiguration;
292
293 use super::{format_source, format_source_with_options};
294
295 #[test]
296 fn trims_trailing_spaces() {
297 let result = format_source("project(example) \nadd_subdirectory(src)\t\n");
298 assert_eq!(result.output, "project(example)\nadd_subdirectory(src)\n");
299 assert!(result.changed);
300 }
301
302 #[test]
303 fn preserves_trailing_spaces_inside_multiline_bracket_arguments() {
304 let source = "message([=[\nfirst line \nsecond line\t\n]=])\n";
305 let result = format_source(source);
306 assert_eq!(result.output, source);
307 assert!(!result.changed);
308 }
309
310 #[test]
311 fn removes_space_before_paren() {
312 let result = format_source("message (STATUS \"hi\")\n");
313 assert_eq!(result.output, "message(STATUS \"hi\")\n");
314 assert!(result.changed);
315 }
316
317 #[test]
318 fn can_enforce_space_before_paren() {
319 let result = format_source_with_options(
320 "message(STATUS \"hi\")\n",
321 &FormatConfiguration {
322 space_before_paren: true,
323 ..FormatConfiguration::default()
324 },
325 );
326 assert_eq!(result.output, "message (STATUS \"hi\")\n");
327 assert!(result.changed);
328 }
329
330 #[test]
331 fn ensures_single_final_newline_and_trims_eof_blank_lines() {
332 let result = format_source("project(example)\n\n\n");
333 assert_eq!(result.output, "project(example)\n");
334 assert!(result.changed);
335 }
336
337 #[test]
338 fn respects_max_blank_lines_setting() {
339 let result = format_source_with_options(
340 "project(example)\n\n\n\nadd_subdirectory(src)\n",
341 &FormatConfiguration {
342 max_blank_lines: 2,
343 ..FormatConfiguration::default()
344 },
345 );
346 assert_eq!(
347 result.output,
348 "project(example)\n\n\nadd_subdirectory(src)\n"
349 );
350 }
351
352 #[test]
353 fn preserves_disabled_regions_verbatim() {
354 let source = concat!(
355 "project(example)\n",
356 "# cmake-format: off\n",
357 "message (STATUS \"hi\") \n",
358 "\n",
359 "\n",
360 "# cmake-format: on\n",
361 "add_subdirectory(src) \n",
362 );
363 let result = format_source(source);
364 assert_eq!(
365 result.output,
366 concat!(
367 "project(example)\n",
368 "# cmake-format: off\n",
369 "message (STATUS \"hi\") \n",
370 "\n",
371 "\n",
372 "# cmake-format: on\n",
373 "add_subdirectory(src)\n",
374 )
375 );
376 assert!(result.changed);
377 }
378
379 #[test]
380 fn preserves_unterminated_disabled_region_to_eof() {
381 let source = concat!(
382 "project(example)\n",
383 "# cmake-format: off\n",
384 "message (STATUS \"hi\") "
385 );
386 let result = format_source(source);
387 assert_eq!(result.output, source);
388 assert!(!result.changed);
389 }
390}