Skip to main content

cpp_linter/clang_tools/
clang_format.rs

1//! This module holds functionality specific to running clang-format and parsing it's
2//! output.
3
4use std::{
5    fs,
6    process::Command,
7    sync::{Arc, Mutex, MutexGuard},
8};
9
10use anyhow::{Context, Result, anyhow};
11use log::Level;
12use serde::Deserialize;
13
14// project-specific crates/modules
15use super::MakeSuggestions;
16use crate::{
17    cli::ClangParams,
18    common_fs::{FileObj, get_line_count_from_offset},
19};
20
21#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
22pub struct FormatAdvice {
23    /// A list of [`Replacement`]s that clang-tidy wants to make.
24    #[serde(rename(deserialize = "replacement"), default)]
25    pub replacements: Vec<Replacement>,
26
27    pub patched: Option<Vec<u8>>,
28}
29
30impl MakeSuggestions for FormatAdvice {
31    fn get_suggestion_help(&self, _start_line: u32, _end_line: u32) -> String {
32        String::from("### clang-format suggestions\n")
33    }
34
35    fn get_tool_name(&self) -> String {
36        "clang-format".to_string()
37    }
38}
39
40/// A single replacement that clang-format wants to make.
41#[derive(Debug, PartialEq, Eq, Default, Clone, Copy, Deserialize)]
42pub struct Replacement {
43    /// The byte offset where the replacement will start.
44    #[serde(rename = "@offset")]
45    pub offset: u32,
46
47    /// The line number described by the [`Replacement::offset`].
48    ///
49    /// This value is not provided by the XML output, but we calculate it after
50    /// deserialization.
51    #[serde(default)]
52    pub line: u32,
53}
54
55/// Get a string that summarizes the given `--style`
56pub fn summarize_style(style: &str) -> String {
57    let mut char_iter = style.chars();
58    if ["google", "chromium", "microsoft", "mozilla", "webkit"].contains(&style)
59        && let Some(first_char) = char_iter.next()
60    {
61        // capitalize the first letter
62        first_char.to_ascii_uppercase().to_string() + char_iter.as_str()
63    } else if style == "llvm" || style == "gnu" {
64        style.to_ascii_uppercase()
65    } else {
66        String::from("Custom")
67    }
68}
69
70/// Get a total count of clang-format advice from the given list of [FileObj]s.
71pub fn tally_format_advice(files: &[Arc<Mutex<FileObj>>]) -> Result<u64, String> {
72    let mut total = 0;
73    for file in files {
74        let file = file.lock().map_err(|e| e.to_string())?;
75        if let Some(advice) = &file.format_advice
76            && !advice.replacements.is_empty()
77        {
78            total += 1;
79        }
80    }
81    Ok(total)
82}
83
84/// Run clang-format for a specific `file`, then parse and return it's XML output.
85pub fn run_clang_format(
86    file: &mut MutexGuard<FileObj>,
87    clang_params: &ClangParams,
88) -> Result<Vec<(log::Level, String)>> {
89    let cmd_path = clang_params
90        .clang_format_command
91        .as_ref()
92        .ok_or(anyhow!("clang-format path unknown"))?;
93    let mut cmd = Command::new(cmd_path);
94    let mut logs = vec![];
95    cmd.args(["--style", &clang_params.style]);
96    let ranges = file.get_ranges(&clang_params.lines_changed_only);
97    for range in &ranges {
98        cmd.arg(format!("--lines={}:{}", range.start(), range.end()));
99    }
100    let file_name = file.name.to_string_lossy().to_string();
101    cmd.arg(file.name.to_path_buf().as_os_str());
102    let patched = if !clang_params.format_review {
103        None
104    } else {
105        logs.push((
106            Level::Info,
107            format!(
108                "Getting format fixes with \"{} {}\"",
109                cmd.get_program().to_string_lossy(),
110                cmd.get_args()
111                    .map(|a| a.to_string_lossy())
112                    .collect::<Vec<_>>()
113                    .join(" ")
114            ),
115        ));
116        Some(
117            cmd.output()
118                .with_context(|| format!("Failed to get fixes from clang-format: {file_name}"))?
119                .stdout,
120        )
121    };
122    cmd.arg("--output-replacements-xml");
123    logs.push((
124        log::Level::Info,
125        format!(
126            "Running \"{} {}\"",
127            cmd.get_program().to_string_lossy(),
128            cmd.get_args()
129                .map(|x| x.to_string_lossy())
130                .collect::<Vec<_>>()
131                .join(" ")
132        ),
133    ));
134    let output = cmd
135        .output()
136        .with_context(|| format!("Failed to get replacements from clang-format: {file_name}"))?;
137    if !output.stderr.is_empty() || !output.status.success() {
138        logs.push((
139            log::Level::Debug,
140            format!(
141                "clang-format raised the follow errors:\n{}",
142                String::from_utf8_lossy(&output.stderr)
143            ),
144        ));
145    }
146    let mut format_advice = if !output.stdout.is_empty() {
147        let xml = String::from_utf8(output.stdout).with_context(|| {
148            format!("XML output from clang-format was not UTF-8 encoded: {file_name}")
149        })?;
150        quick_xml::de::from_str::<FormatAdvice>(&xml).with_context(|| {
151            format!("Failed to parse XML output from clang-format for {file_name}")
152        })?
153    } else {
154        FormatAdvice::default()
155    };
156    format_advice.patched = patched;
157    if !format_advice.replacements.is_empty() {
158        let original_contents = fs::read(&file.name).with_context(|| {
159            format!(
160                "Failed to read file's original content before translating byte offsets: {file_name}",
161            )
162        })?;
163        // get line and column numbers from format_advice.offset
164        let mut filtered_replacements = Vec::new();
165        for replacement in &mut format_advice.replacements {
166            let line_number = get_line_count_from_offset(&original_contents, replacement.offset);
167            replacement.line = line_number;
168            for range in &ranges {
169                if range.contains(&line_number) {
170                    filtered_replacements.push(*replacement);
171                    break;
172                }
173            }
174            if ranges.is_empty() {
175                // lines_changed_only is disabled
176                filtered_replacements.push(*replacement);
177            }
178        }
179        format_advice.replacements = filtered_replacements;
180    }
181    file.format_advice = Some(format_advice);
182    Ok(logs)
183}
184
185#[cfg(test)]
186mod tests {
187    #![allow(clippy::unwrap_used)]
188
189    use super::{FormatAdvice, Replacement, summarize_style};
190
191    #[test]
192    fn parse_blank_xml() {
193        let xml = String::new();
194        let result = quick_xml::de::from_str::<FormatAdvice>(&xml);
195        assert!(result.is_err());
196    }
197
198    #[test]
199    fn parse_xml_no_replacements() {
200        let xml_raw = r#"<?xml version='1.0'?>
201<replacements xml:space='preserve' incomplete_format='false'>
202</replacements>"#
203            .as_bytes()
204            .to_vec();
205        let expected = FormatAdvice::default();
206        let xml = String::from_utf8(xml_raw).unwrap();
207        let document = quick_xml::de::from_str::<FormatAdvice>(&xml).unwrap();
208        assert_eq!(expected, document);
209    }
210
211    #[test]
212    fn parse_xml() {
213        let xml_raw = r#"<?xml version='1.0'?>
214<replacements xml:space='preserve' incomplete_format='false'>
215<replacement offset='113' length='5'>&#10;      </replacement>
216<replacement offset='147' length='0'> </replacement>
217<replacement offset='161' length='0'></replacement>
218<replacement offset='165' length='19'>&#10;&#10;</replacement>
219</replacements>"#
220            .as_bytes()
221            .to_vec();
222
223        let expected = FormatAdvice {
224            replacements: [113, 147, 161, 165]
225                .iter()
226                .map(|offset| Replacement {
227                    offset: *offset,
228                    ..Default::default()
229                })
230                .collect(),
231            patched: None,
232        };
233
234        let xml = String::from_utf8(xml_raw).unwrap();
235
236        let document = quick_xml::de::from_str::<FormatAdvice>(&xml).unwrap();
237        assert_eq!(expected, document);
238    }
239
240    fn formalize_style(style: &str, expected: &str) {
241        assert_eq!(summarize_style(style), expected);
242    }
243
244    #[test]
245    fn formalize_llvm_style() {
246        formalize_style("llvm", "LLVM");
247    }
248
249    #[test]
250    fn formalize_google_style() {
251        formalize_style("google", "Google");
252    }
253
254    #[test]
255    fn formalize_custom_style() {
256        formalize_style("file", "Custom");
257    }
258}