Skip to content

Commit 1ead7f8

Browse files
committed
feat: use diff instead of clang-format XML output
This avoids discrepancies with clang-format's XML output.; see cpp-linter/cpp-linter#168. Instead, we now save the clang-format fixed output to a project-specific cache folder. Then we take the diff between the formatted output and the original file's content. The resulting diff is how we determine the lines changed by clang-format. This actually lays the ground work for other improvements, specifically how to assemble changes for an auto-fix commit. Additionally, this also removes the dependency used for XML parsing .
1 parent 21b54d4 commit 1ead7f8

9 files changed

Lines changed: 102 additions & 209 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cpp-linter/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ fast-glob = "1.0.1"
2424
futures = "0.3.32"
2525
gix-imara-diff = { version = "0.2.2", features = ["unified_diff"] }
2626
log = { workspace = true }
27-
quick-xml = { version = "0.40.1", features = ["serialize"] }
2827
regex = { workspace = true }
2928
reqwest = { workspace = true }
3029
semver = { workspace = true }

cpp-linter/src/clang_tools/clang_format.rs

Lines changed: 63 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,25 @@
33
44
use std::{
55
fs,
6+
ops::Range,
7+
path::PathBuf,
68
process::Command,
79
sync::{Arc, Mutex, MutexGuard},
810
};
911

12+
use gix_imara_diff::{Diff, InternedInput};
1013
use log::Level;
11-
use serde::Deserialize;
1214

1315
// project-specific crates/modules
1416
use super::MakeSuggestions;
15-
use crate::{
16-
cli::ClangParams,
17-
common_fs::{FileObj, get_line_count_from_offset},
18-
error::ClangCaptureError,
19-
};
17+
use crate::{cli::ClangParams, common_fs::FileObj, error::ClangCaptureError};
2018

21-
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
19+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
2220
pub struct FormatAdvice {
2321
/// A list of [`Replacement`]s that clang-tidy wants to make.
24-
#[serde(rename(deserialize = "replacement"), default)]
25-
pub replacements: Vec<Replacement>,
22+
pub replacements: Vec<Range<u32>>,
2623

27-
pub patched: Option<Vec<u8>>,
24+
pub patched: PathBuf,
2825
}
2926

3027
impl MakeSuggestions for FormatAdvice {
@@ -37,21 +34,6 @@ impl MakeSuggestions for FormatAdvice {
3734
}
3835
}
3936

40-
/// A single replacement that clang-format wants to make.
41-
#[derive(Debug, PartialEq, Eq, Default, Clone, Copy, Deserialize)]
42-
pub 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-
5537
/// Get a string that summarizes the given `--style`
5638
pub fn summarize_style(style: &str) -> String {
5739
let mut char_iter = style.chars();
@@ -97,49 +79,40 @@ pub fn run_clang_format(
9779
for range in &ranges {
9880
cmd.arg(format!("--lines={}:{}", range.start(), range.end()));
9981
}
82+
let cache_path = clang_params.project_cache_dir.join("patches");
83+
let cache_format_fixes = cache_path.join(file.name.with_added_extension("format"));
84+
fs::create_dir_all(
85+
cache_format_fixes
86+
.parent()
87+
.ok_or(ClangCaptureError::UnknownCacheParentPath)?,
88+
)
89+
.map_err(ClangCaptureError::MkDirFailed)?;
10090
let file_name = file.name.to_string_lossy().to_string();
10191
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-
.map_err(|e| ClangCaptureError::FailedToRunCommand {
119-
task: format!("get fixes from clang-format {file_name}"),
120-
source: e,
121-
})?
122-
.stdout,
123-
)
124-
};
125-
cmd.arg("--output-replacements-xml");
12692
logs.push((
127-
log::Level::Info,
93+
Level::Info,
12894
format!(
129-
"Running \"{} {}\"",
95+
"Getting format fixes with \"{} {}\"",
13096
cmd.get_program().to_string_lossy(),
13197
cmd.get_args()
132-
.map(|x| x.to_string_lossy())
98+
.map(|a| a.to_string_lossy())
13399
.collect::<Vec<_>>()
134100
.join(" ")
135101
),
136102
));
137103
let output = cmd
138104
.output()
139105
.map_err(|e| ClangCaptureError::FailedToRunCommand {
140-
task: format!("Failed to get replacements from clang-format: {file_name}"),
106+
task: format!("get fixes from clang-format {file_name}"),
141107
source: e,
142108
})?;
109+
fs::write(&cache_format_fixes, &output.stdout).map_err(|e| {
110+
ClangCaptureError::WriteFileFailed {
111+
file_name: cache_format_fixes.to_string_lossy().to_string(),
112+
source: e,
113+
}
114+
})?;
115+
143116
if !output.stderr.is_empty() || !output.status.success() {
144117
logs.push((
145118
log::Level::Debug,
@@ -149,46 +122,43 @@ pub fn run_clang_format(
149122
),
150123
));
151124
}
152-
let mut format_advice = if !output.stdout.is_empty() {
153-
let xml =
154-
String::from_utf8(output.stdout).map_err(|e| ClangCaptureError::NonUtf8Output {
155-
task: format!("XML output from clang-format (for {file_name})"),
156-
source: e,
157-
})?;
158-
quick_xml::de::from_str::<FormatAdvice>(&xml).map_err(|e| {
159-
ClangCaptureError::XmlParsingFailed {
160-
file_name: file_name.clone(),
161-
source: e,
162-
}
163-
})?
164-
} else {
165-
FormatAdvice::default()
166-
};
167-
format_advice.patched = patched;
168-
if !format_advice.replacements.is_empty() {
169-
let original_contents =
170-
fs::read(&file.name).map_err(|e| ClangCaptureError::ReadFileFailed {
171-
file_name: file_name.clone(),
172-
source: e,
173-
})?;
174-
// get line and column numbers from format_advice.offset
175-
let mut filtered_replacements = Vec::new();
176-
for replacement in &mut format_advice.replacements {
177-
let line_number = get_line_count_from_offset(&original_contents, replacement.offset);
178-
replacement.line = line_number;
179-
for range in &ranges {
180-
if range.contains(&line_number) {
181-
filtered_replacements.push(*replacement);
182-
break;
183-
}
184-
}
185-
if ranges.is_empty() {
186-
// lines_changed_only is disabled
187-
filtered_replacements.push(*replacement);
188-
}
125+
126+
// use a diff between patched and original contents to get format results
127+
let original_contents =
128+
fs::read_to_string(&file.name).map_err(|e| ClangCaptureError::ReadFileFailed {
129+
file_name: file_name.clone(),
130+
source: e,
131+
})?;
132+
let patched_contents = String::from_utf8(output.stdout.to_vec()).map_err(|e| {
133+
ClangCaptureError::NonUtf8Output {
134+
task: "clang-format".to_string(),
135+
source: e,
189136
}
190-
format_advice.replacements = filtered_replacements;
191-
}
137+
})?;
138+
let input = InternedInput::new(original_contents.as_str(), patched_contents.as_str());
139+
let mut diff = Diff::compute(gix_imara_diff::Algorithm::Histogram, &input);
140+
diff.postprocess_lines(&input);
141+
let format_advice = FormatAdvice {
142+
replacements: diff
143+
.hunks()
144+
.filter_map(|hunk| {
145+
if ranges.is_empty() {
146+
Some(hunk.before)
147+
} else {
148+
// only include replacements that fall within the specified line ranges
149+
if ranges.iter().any(|range| {
150+
range.contains(&hunk.before.start)
151+
&& range.contains(&hunk.before.end.saturating_sub(1))
152+
}) {
153+
Some(hunk.before)
154+
} else {
155+
None
156+
}
157+
}
158+
})
159+
.collect(),
160+
patched: cache_format_fixes,
161+
};
192162
file.format_advice = Some(format_advice);
193163
Ok(logs)
194164
}
@@ -197,56 +167,7 @@ pub fn run_clang_format(
197167
mod tests {
198168
#![allow(clippy::unwrap_used)]
199169

200-
use super::{FormatAdvice, Replacement, summarize_style};
201-
202-
#[test]
203-
fn parse_blank_xml() {
204-
let xml = String::new();
205-
let result = quick_xml::de::from_str::<FormatAdvice>(&xml);
206-
assert!(result.is_err());
207-
}
208-
209-
#[test]
210-
fn parse_xml_no_replacements() {
211-
let xml_raw = r#"<?xml version='1.0'?>
212-
<replacements xml:space='preserve' incomplete_format='false'>
213-
</replacements>"#
214-
.as_bytes()
215-
.to_vec();
216-
let expected = FormatAdvice::default();
217-
let xml = String::from_utf8(xml_raw).unwrap();
218-
let document = quick_xml::de::from_str::<FormatAdvice>(&xml).unwrap();
219-
assert_eq!(expected, document);
220-
}
221-
222-
#[test]
223-
fn parse_xml() {
224-
let xml_raw = r#"<?xml version='1.0'?>
225-
<replacements xml:space='preserve' incomplete_format='false'>
226-
<replacement offset='113' length='5'>&#10; </replacement>
227-
<replacement offset='147' length='0'> </replacement>
228-
<replacement offset='161' length='0'></replacement>
229-
<replacement offset='165' length='19'>&#10;&#10;</replacement>
230-
</replacements>"#
231-
.as_bytes()
232-
.to_vec();
233-
234-
let expected = FormatAdvice {
235-
replacements: [113, 147, 161, 165]
236-
.iter()
237-
.map(|offset| Replacement {
238-
offset: *offset,
239-
..Default::default()
240-
})
241-
.collect(),
242-
patched: None,
243-
};
244-
245-
let xml = String::from_utf8(xml_raw).unwrap();
246-
247-
let document = quick_xml::de::from_str::<FormatAdvice>(&xml).unwrap();
248-
assert_eq!(expected, document);
249-
}
170+
use super::summarize_style;
250171

251172
fn formalize_style(style: &str, expected: &str) {
252173
assert_eq!(summarize_style(style), expected);

cpp-linter/src/clang_tools/clang_tidy.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,7 @@ mod test {
496496
format_review: false,
497497
clang_tidy_command: Some(exe_path),
498498
clang_format_command: None,
499+
project_cache_dir: PathBuf::from(".").join(".cpp-linter-cache"),
499500
};
500501
let mut file_lock = arc_file.lock().unwrap();
501502
let logs = run_clang_tidy(&mut file_lock, &clang_params)

cpp-linter/src/cli/structs.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ pub struct ClangParams {
178178
pub format_filter: Option<FileFilter>,
179179
pub tidy_review: bool,
180180
pub format_review: bool,
181+
pub project_cache_dir: PathBuf,
181182
}
182183

183184
#[cfg(feature = "bin")]
@@ -215,6 +216,8 @@ impl From<&Cli> for ClangParams {
215216
format_filter,
216217
tidy_review: args.feedback_options.tidy_review,
217218
format_review: args.feedback_options.format_review,
219+
project_cache_dir: PathBuf::from(&args.source_options.repo_root)
220+
.join(".cpp-linter-cache"),
218221
}
219222
}
220223
}

cpp-linter/src/common_fs/mod.rs

Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,8 @@ impl FileObj {
143143
) -> Result<(), FileObjError> {
144144
let original_content = fs::read_to_string(&self.name).map_err(FileObjError::ReadFile)?;
145145
let file_name = self.name.to_str().unwrap_or_default().replace("\\", "/");
146-
if let Some(advice) = &self.format_advice
147-
&& let Some(patched) = &advice.patched
148-
{
149-
let patched = String::from_utf8(patched.to_vec())
150-
.map_err(|e| FileObjError::FromUtf8Error(file_name.clone(), e))?;
146+
if let Some(advice) = &self.format_advice {
147+
let patched = fs::read_to_string(&advice.patched).map_err(FileObjError::ReadFile)?;
151148
let (diff, input) = make_patch(patched.as_str(), &original_content);
152149
advice.get_suggestions(review_comments, self, &diff, &input, summary_only);
153150
}
@@ -219,48 +216,13 @@ impl FileObj {
219216
}
220217
}
221218

222-
/// Gets the line number for a given `offset` (of bytes) from the given
223-
/// buffer `contents`.
224-
///
225-
/// The `offset` given to this function is expected to originate from
226-
/// diagnostic information provided by clang-format. Any `offset` out of
227-
/// bounds is clamped to the given `contents` buffer's length.
228-
pub fn get_line_count_from_offset(contents: &[u8], offset: u32) -> u32 {
229-
let offset = (offset as usize).min(contents.len());
230-
let lines = contents[0..offset].split(|byte| byte == &b'\n');
231-
lines.count() as u32
232-
}
233-
234219
#[cfg(test)]
235220
mod test {
236-
use std::{fs, path::PathBuf};
221+
use std::path::PathBuf;
237222

238-
use super::{FileObj, get_line_count_from_offset};
223+
use super::FileObj;
239224
use crate::cli::LinesChangedOnly;
240225

241-
// *********************** tests for translating byte offset into line/column
242-
243-
#[test]
244-
fn translate_byte_offset() {
245-
let contents = fs::read(PathBuf::from("tests/demo/demo.cpp")).unwrap();
246-
let lines = get_line_count_from_offset(&contents, 144);
247-
assert_eq!(lines, 13);
248-
}
249-
250-
#[test]
251-
fn get_line_count_edge_cases() {
252-
// Empty content
253-
assert_eq!(get_line_count_from_offset(&[], 0), 1);
254-
255-
// No newlines
256-
assert_eq!(get_line_count_from_offset(b"abc", 3), 1);
257-
258-
// Consecutive newlines
259-
assert_eq!(get_line_count_from_offset(b"a\n\nb", 3), 3);
260-
261-
// Offset beyond content length
262-
assert_eq!(get_line_count_from_offset(b"a\nb\n", 10), 3);
263-
}
264226
// *********************** tests for FileObj::get_ranges()
265227

266228
#[test]

0 commit comments

Comments
 (0)