Skip to main content

cmake_tidy_format/
lib.rs

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}