Try to render code snippets with annotations

This commit is contained in:
Vasilios Syrakis 2025-12-22 12:24:15 +11:00
parent 6e94d19624
commit d32b2ed46f
5 changed files with 183 additions and 48 deletions

View file

@ -18,9 +18,15 @@ exit_code: 1
<testsuites name="ruff" tests="1" failures="1" errors="0">
<testsuite name="[TMP]/input.py" tests="1" disabled="0" errors="0" failures="1" package="org.ruff">
<testcase name="org.ruff.unformatted" classname="[TMP]/input" line="1" column="1">
<failure message="File would be reformatted">
line 1, col 1, File would be reformatted
<failure message="File would be reformatted"[TMP]/input.py:1:1
|
1 |
| ^
2 | from test import say_hy
3 |
4 | if __name__ == &quot;__main__&quot;:
5 | say_hy(&quot;dear Ruff contributor&quot;)
|
</failure>
</testcase>

View file

@ -20,23 +20,38 @@ exit_code: 1
<testsuites name="ruff" tests="3" failures="3" errors="0">
<testsuite name="[TMP]/input.py" tests="3" disabled="0" errors="0" failures="3" package="org.ruff">
<testcase name="org.ruff.F401" classname="[TMP]/input" line="1" column="8">
<failure message="`os` imported but unused">
line 1, col 8, `os` imported but unused
Help: Remove unused import: `os`
<failure message="`os` imported but unused"[TMP]/input.py:1:8
|
1 | import os # F401
| ^^
2 | x = y # F821
3 | match 42: # invalid-syntax
4 | case _: ...
|
help: Remove unused import: `os`
</failure>
</testcase>
<testcase name="org.ruff.F821" classname="[TMP]/input" line="2" column="5">
<failure message="Undefined name `y`">
line 2, col 5, Undefined name `y`
<failure message="Undefined name `y`"[TMP]/input.py:2:5
|
1 | import os # F401
2 | x = y # F821
| ^
3 | match 42: # invalid-syntax
4 | case _: ...
|
</failure>
</testcase>
<testcase name="org.ruff.invalid-syntax" classname="[TMP]/input" line="3" column="1">
<failure message="Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)">
line 3, col 1, Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)
<failure message="Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)"[TMP]/input.py:3:1
|
1 | import os # F401
2 | x = y # F821
3 | match 42: # invalid-syntax
| ^^^^^
4 | case _: ...
|
</failure>
</testcase>

View file

@ -3,6 +3,7 @@ use std::{collections::BTreeMap, ops::Deref};
use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite, XmlString};
use ruff_annotate_snippets::{Level, Renderer, Snippet};
use ruff_source_file::LineColumn;
use crate::diagnostic::{Diagnostic, SecondaryCode, render::FileResolver};
@ -49,14 +50,14 @@ impl<'a> JunitRenderer<'a> {
.extra
.insert(XmlString::new("package"), XmlString::new("org.ruff"));
let indent = " ".repeat(4 * 4);
for diagnostic in diagnostics {
let DiagnosticWithLocation {
diagnostic,
start_location: location,
} = diagnostic;
let indent = " ".repeat(4 * 4);
let mut sub_diags = diagnostic
let mut output = diagnostic
.sub_diagnostics()
.iter()
.map(|sub_diagnostic| {
@ -64,7 +65,8 @@ impl<'a> JunitRenderer<'a> {
// parent and formats it for rendering into the XML structure
let message = sub_diagnostic.concise_message().to_string();
let severity = sub_diagnostic.inner.severity;
let severity =
format!("{:?}", sub_diagnostic.inner.severity).to_lowercase();
let mut annotations = vec![];
// Add function/method/etc definition location
@ -99,23 +101,17 @@ impl<'a> JunitRenderer<'a> {
);
}
annotations.push(format!(
"{indent}{severity:?}: {sub_message} → {path}{sub_diag_loc}",
"{indent}{severity}: {sub_message} → {path}{sub_diag_loc}",
));
}
if annotations.is_empty() {
format!("{indent}{severity:?}: {message}")
} else {
annotations.join("\n")
annotations.push(format!("{indent}{severity}: {message}"));
}
annotations.join("\n")
})
.collect::<Vec<String>>()
.join("\n");
if !sub_diags.is_empty() {
// Add some space between the body and sub_diags if there are some
sub_diags = format!("\n\n{sub_diags}");
}
let code = diagnostic
.secondary_code()
.map_or_else(|| diagnostic.name(), SecondaryCode::as_str);
@ -124,8 +120,12 @@ impl<'a> JunitRenderer<'a> {
let classname = Path::new(filename).with_extension("");
case.set_classname(classname.to_str().unwrap());
case.status.set_message(diagnostic.body());
case.status.set_description(format!(
"\n{snippet}\n{output}\n{after_indent}",
snippet = self.render_snippet(diagnostic, Some(indent.len())),
after_indent = " ".repeat(4 * 3), // <failure> tag closes one indent less
));
let mut diagnostic_loc = String::new();
if let Some(location) = location {
case.extra.insert(
XmlString::new("line"),
@ -135,17 +135,8 @@ impl<'a> JunitRenderer<'a> {
XmlString::new("column"),
XmlString::new(location.column.to_string()),
);
diagnostic_loc = format!(
"line {row}, col {col}, ",
row = location.line,
col = location.column,
);
}
case.status.set_description(format!(
"\n{indent}{diagnostic_loc}{body}{sub_diags}\n{after_indent}",
after_indent = " ".repeat(4 * 3), // <failure> tag closes one indent less
body = diagnostic.body(),
));
test_suite.add_test_case(case);
}
report.add_test_suite(test_suite);
@ -155,6 +146,79 @@ impl<'a> JunitRenderer<'a> {
let adapter = FmtAdapter { fmt: f };
report.serialize(adapter).map_err(|_| std::fmt::Error)
}
fn render_snippet(&self, diagnostic: &Diagnostic, indentation: Option<usize>) -> String {
let (source_text, filename) = if let Some(span) = diagnostic.primary_span_ref() {
let file = span.file();
let source = file.diagnostic_source(self.resolver);
let filename = match file {
crate::diagnostic::UnifiedFile::Ty(file) => self.resolver.path(*file),
crate::diagnostic::UnifiedFile::Ruff(file) => file.name(),
};
(
source.as_source_code().text().to_string(),
filename.to_string(),
)
} else {
return String::new();
};
let mut snippet = Snippet::source(&source_text)
.line_start(1)
.origin(&filename);
let mut annotations = vec![];
if let Some(primary) = diagnostic.primary_annotation() {
if let Some(range) = primary.get_span().range() {
annotations.push(
Level::Error
.span(range.into())
// Message next to the location of the problem
.label(primary.get_message().unwrap_or_default()),
);
}
}
for secondary in diagnostic.secondary_annotations() {
if let Some(range) = secondary.get_span().range() {
annotations.push(
Level::Info
.span(range.into())
// Message at related location involved in the problem
.label(secondary.get_message().unwrap_or_default()),
);
}
}
for sub in diagnostic.sub_diagnostics() {
if let Some(primary) = sub.primary_annotation() {
if let Some(range) = primary.get_span().range() {
annotations.push(
Level::Help
.span(range.into())
// Help message goes here usually
.label(primary.get_message().unwrap_or_default()),
);
}
}
}
snippet = snippet.annotations(annotations);
let message = Level::Error.title(diagnostic.body()).snippet(snippet);
let renderer = Renderer::plain();
let rendered = renderer.render(message).to_string();
if let Some(indentation) = indentation {
let indent = " ".repeat(indentation);
rendered
.lines()
.map(|line| format!("{indent}{line}"))
.collect::<Vec<_>>()
.join("\n")
} else {
rendered
}
}
}
// TODO(brent) this and `group_diagnostics_by_filename` are also used by the `grouped` output

View file

@ -7,24 +7,58 @@ expression: env.render_diagnostics(&diagnostics)
<testsuite name="/fib.py" tests="2" disabled="0" errors="0" failures="2" package="org.ruff">
<testcase name="org.ruff.F401" classname="/fib" line="1" column="8">
<failure message="`os` imported but unused">
line 1, col 8, `os` imported but unused
Help: Remove unused import: `os`
error: `os` imported but unused
--&gt; /fib.py:1:8
|
1 | import os
| ^^
2 |
3 |
4 | def fibonacci(n):
5 | &quot;&quot;&quot;Compute the nth number in the Fibonacci sequence.&quot;&quot;&quot;
6 | x = 1
7 | if n == 0:
8 | return 0
9 | elif n == 1:
10 | return 1
11 | else:
12 | return fibonacci(n - 1) + fibonacci(n - 2)
|
help: Remove unused import: `os`
</failure>
</testcase>
<testcase name="org.ruff.F841" classname="/fib" line="6" column="5">
<failure message="Local variable `x` is assigned to but never used">
line 6, col 5, Local variable `x` is assigned to but never used
Help: Remove assignment to unused variable `x`
error: Local variable `x` is assigned to but never used
--&gt; /fib.py:6:5
|
1 | import os
2 |
3 |
4 | def fibonacci(n):
5 | &quot;&quot;&quot;Compute the nth number in the Fibonacci sequence.&quot;&quot;&quot;
6 | x = 1
| ^
7 | if n == 0:
8 | return 0
9 | elif n == 1:
10 | return 1
11 | else:
12 | return fibonacci(n - 1) + fibonacci(n - 2)
|
help: Remove assignment to unused variable `x`
</failure>
</testcase>
</testsuite>
<testsuite name="/undef.py" tests="1" disabled="0" errors="0" failures="1" package="org.ruff">
<testcase name="org.ruff.F821" classname="/undef" line="1" column="4">
<failure message="Undefined name `a`">
line 1, col 4, Undefined name `a`
error: Undefined name `a`
--&gt; /undef.py:1:4
|
1 | if a == 1: pass
| ^
|
</failure>
</testcase>

View file

@ -7,15 +7,31 @@ expression: env.render_diagnostics(&diagnostics)
<testsuite name="/syntax_errors.py" tests="2" disabled="0" errors="0" failures="2" package="org.ruff">
<testcase name="org.ruff.invalid-syntax" classname="/syntax_errors" line="1" column="15">
<failure message="Expected one or more symbol names after import">
line 1, col 15, Expected one or more symbol names after import
error: Expected one or more symbol names after import
--&gt; /syntax_errors.py:1:15
|
1 | from os import
| ^
2 |
3 | if call(foo
4 | def bar():
5 | pass
|
</failure>
</testcase>
<testcase name="org.ruff.invalid-syntax" classname="/syntax_errors" line="3" column="12">
<failure message="Expected &apos;)&apos;, found newline">
line 3, col 12, Expected &apos;)&apos;, found newline
error: Expected &apos;)&apos;, found newline
--&gt; /syntax_errors.py:3:12
|
1 | from os import
2 |
3 | if call(foo
| ^
4 | def bar():
5 | pass
|
</failure>
</testcase>