architecture_component!(Diagnostic);
use serde::{Deserialize, Serialize};
use std::io::{self, BufWriter, Write as _};
use std::path::{Path, PathBuf};
use strum::VariantArray;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, VariantArray)]
pub enum Language {
Python,
Rust,
}
impl Language {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Python => "python",
Self::Rust => "rust",
}
}
#[must_use]
pub fn from_path(path: &Path) -> Option<Self> {
match path.extension()?.to_str()? {
"py" => Some(Self::Python),
"rs" => Some(Self::Rust),
_ => None,
}
}
}
impl std::fmt::Display for Language {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Python => "Python",
Self::Rust => "Rust",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
#[serde(transparent)]
pub struct RuleName(pub &'static str);
impl std::fmt::Display for RuleName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ViolationMessage {
pub summary: String,
pub rationale: String,
pub suggestion: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub explanation_hint: Option<String>,
}
impl ViolationMessage {
#[must_use]
pub fn new(
summary: impl Into<String>,
rationale: impl Into<String>,
suggestion: impl Into<String>,
) -> Self {
Self {
summary: summary.into(),
rationale: rationale.into(),
suggestion: suggestion.into(),
explanation_hint: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LanguageText {
pub base: Option<&'static str>,
pub overrides: &'static [(Language, &'static str)],
}
impl LanguageText {
#[must_use]
pub const fn new(
base: Option<&'static str>,
overrides: &'static [(Language, &'static str)],
) -> Self {
Self { base, overrides }
}
#[must_use]
pub const fn from_static(base: &'static str) -> Self {
Self {
base: Some(base),
overrides: &[],
}
}
#[must_use]
pub fn resolve_for_lang(&self, lang: Language) -> &'static str {
self.overrides
.iter()
.find(|&&(override_lang, _)| override_lang == lang)
.map(|&(_, text)| text)
.or(self.base)
.unwrap_or_default()
}
#[must_use]
pub fn render_for_lang(&self, lang: Language, params: &[(&str, &str)]) -> String {
Self::interpolate(self.resolve_for_lang(lang), params)
}
#[must_use]
pub fn render(&self, params: &[(&str, &str)]) -> String {
Self::interpolate(self.base.unwrap_or_default(), params)
}
fn interpolate(template: &str, params: &[(&str, &str)]) -> String {
let mut result = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
result.push_str(&rest[..open]);
let after_open = &rest[open + 1..];
let substitution = after_open.find('}').and_then(|close| {
let key = &after_open[..close];
params
.iter()
.find(|&&(placeholder, _)| placeholder == key)
.map(|&(_, replacement)| (replacement, close))
});
if let Some((replacement, close)) = substitution {
result.push_str(replacement);
rest = &after_open[close + 1..];
} else {
result.push('{');
rest = after_open;
}
}
result.push_str(rest);
result
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ViolationTemplate {
pub summary: LanguageText,
pub rationale: LanguageText,
pub suggestion: LanguageText,
}
impl ViolationTemplate {
#[must_use]
pub const fn new(
summary: LanguageText,
rationale: LanguageText,
suggestion: LanguageText,
) -> Self {
Self {
summary,
rationale,
suggestion,
}
}
#[must_use]
pub const fn from_static(
summary: &'static str,
rationale: &'static str,
suggestion: &'static str,
) -> Self {
Self {
summary: LanguageText::from_static(summary),
rationale: LanguageText::from_static(rationale),
suggestion: LanguageText::from_static(suggestion),
}
}
#[must_use]
pub fn render(&self, params: &[(&str, &str)]) -> ViolationMessage {
ViolationMessage {
summary: self.summary.render(params),
rationale: self.rationale.render(params),
suggestion: self.suggestion.render(params),
explanation_hint: None,
}
}
#[must_use]
pub fn render_for_lang(&self, lang: Language, params: &[(&str, &str)]) -> ViolationMessage {
ViolationMessage {
summary: self.summary.render_for_lang(lang, params),
rationale: self.rationale.render_for_lang(lang, params),
suggestion: self.suggestion.render_for_lang(lang, params),
explanation_hint: None,
}
}
}
macro_rules! violation_template {
(@text { $($lang:ident => $text:expr),+ $(,)? }) => {
$crate::diagnostic::LanguageText::new(
None,
&[$(($crate::diagnostic::Language::$lang, $text)),+],
)
};
(@text $base:expr) => {
$crate::diagnostic::LanguageText::from_static($base)
};
(
summary: $summary:tt,
rationale: $rationale:tt,
suggestion: $suggestion:tt $(,)?
) => {
$crate::diagnostic::ViolationTemplate::new(
$crate::diagnostic::violation_template!(@text $summary),
$crate::diagnostic::violation_template!(@text $rationale),
$crate::diagnostic::violation_template!(@text $suggestion),
)
};
}
pub(crate) use violation_template;
pub const VCS_CONTEXT_NAME: &str = "VCS_Context";
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum LocationContext {
File(PathBuf),
Virtual(String),
}
impl std::fmt::Display for LocationContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::File(path) => write!(f, "{}", path.display()),
Self::Virtual(name) => write!(f, "{name}"),
}
}
}
impl Serialize for LocationContext {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::File(path) => serializer.serialize_str(&path.to_string_lossy()),
Self::Virtual(name) => serializer.serialize_str(name),
}
}
}
#[derive(
Debug, Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
pub struct SourceSpan {
pub start: usize,
pub end: usize,
}
impl SourceSpan {
#[must_use]
pub const fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
#[must_use]
pub const fn from_range(range: std::ops::Range<usize>) -> Self {
Self {
start: range.start,
end: range.end,
}
}
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.start <= other.start && other.end <= self.end
}
}
#[derive(Debug, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct SourceLocation {
pub context: LocationContext,
pub span: SourceSpan,
pub line: usize,
pub column: usize,
}
impl SourceLocation {
#[must_use]
pub fn file_span(path: impl Into<PathBuf>, span: SourceSpan, coord: LineColumn) -> Self {
Self {
context: LocationContext::File(path.into()),
span,
line: coord.line,
column: coord.column,
}
}
#[must_use]
pub fn virtual_span(name: impl Into<String>, content: &str, span: SourceSpan) -> Self {
let coord = LineIndex::new(content).lookup(span.start);
Self {
context: LocationContext::Virtual(name.into()),
span,
line: coord.line,
column: coord.column,
}
}
#[must_use]
pub fn format_header(&self) -> String {
format!("{}:{}:{}", self.context, self.line, self.column)
}
}
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub rule_name: RuleName,
pub message: ViolationMessage,
pub location: SourceLocation,
}
impl Ord for Diagnostic {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.location
.cmp(&other.location)
.then_with(|| self.rule_name.cmp(&other.rule_name))
.then_with(|| self.message.cmp(&other.message))
}
}
impl PartialOrd for Diagnostic {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Diagnostic {
#[must_use]
pub const fn new(
rule_name: RuleName,
message: ViolationMessage,
location: SourceLocation,
) -> Self {
Self {
rule_name,
message,
location,
}
}
}
#[derive(Debug, Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq)]
pub struct LineColumn {
pub line: usize,
pub column: usize,
}
pub(crate) struct LineIndex {
inner: line_index::LineIndex,
}
impl LineIndex {
#[must_use]
pub(crate) fn new(content: &str) -> Self {
Self {
inner: line_index::LineIndex::new(content),
}
}
#[must_use]
pub(crate) fn lookup(&self, offset: usize) -> LineColumn {
let line_col = self.inner.line_col(line_index::TextSize::from(
u32::try_from(offset).unwrap_or(u32::MAX),
));
LineColumn {
line: line_col.line as usize + 1,
column: line_col.col as usize + 1,
}
}
#[must_use]
pub(crate) fn line(&self, offset: usize) -> usize {
self.lookup(offset).line
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormat {
Plain,
Json,
}
pub fn print_diagnostics(diagnostics: &[Diagnostic], format: OutputFormat) -> anyhow::Result<()> {
if format == OutputFormat::Json {
let mut sorted: Vec<&Diagnostic> = diagnostics.iter().collect();
sorted.sort_unstable();
println!("{}", serde_json::to_string_pretty(&sorted)?);
} else {
let mut stdout = BufWriter::new(io::stdout().lock());
stdout.write_all(format_plain(diagnostics).as_bytes())?;
stdout.flush()?;
}
Ok(())
}
#[must_use]
pub fn format_plain(diagnostics: &[Diagnostic]) -> String {
use std::fmt::Write as _;
let mut sorted: Vec<&Diagnostic> = diagnostics.iter().collect();
sorted.sort_unstable();
let mut output = String::new();
for diagnostic in sorted {
let location_header = diagnostic.location.format_header();
let hint_line = diagnostic
.message
.explanation_hint
.as_ref()
.map(|hint| format!(" Or: {hint}\n"))
.unwrap_or_default();
let _ = indoc::writedoc!(
output,
"
{}: [{}] {}
Rationale: {}
Suggestion: {}
{}
",
location_header,
diagnostic.rule_name,
diagnostic.message.summary,
diagnostic.message.rationale,
diagnostic.message.suggestion,
hint_line
);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_language_text_resolve_and_render() {
const ADVICE: LanguageText = LanguageText::new(
Some("Parameterize variations for {func}"),
&[(Language::Python, "Use @pytest.mark.parametrize for {func}")],
);
assert_eq!(
ADVICE.resolve_for_lang(Language::Python),
"Use @pytest.mark.parametrize for {func}"
);
assert_eq!(
ADVICE.render_for_lang(Language::Python, &[("func", "test_math")]),
"Use @pytest.mark.parametrize for test_math"
);
assert_eq!(
ADVICE.render_for_lang(Language::Rust, &[("func", "test_math")]),
"Parameterize variations for test_math"
);
assert_eq!(
ADVICE.render(&[("func", "test_math")]),
"Parameterize variations for test_math"
);
}
#[rstest::rstest]
#[case::value_not_rescanned("{a} {b}", &[("a", "{b}"), ("b", "X")], "{b} X")]
#[case::unknown_placeholder_kept("{a} {missing}", &[("a", "X")], "X {missing}")]
#[case::unclosed_brace_kept("{a} {", &[("a", "X")], "X {")]
fn test_language_text_interpolation(
#[case] template: &'static str,
#[case] params: &[(&str, &str)],
#[case] expected: &str,
) {
assert_eq!(LanguageText::from_static(template).render(params), expected);
}
#[test]
fn test_violation_template_macro() {
const TEMPLATE: ViolationTemplate = violation_template! {
summary: "Function `{func}` too long",
rationale: "Long functions are hard to read",
suggestion: {
Python => "Refactor `{func}` with helper functions",
Rust => "Extract logic from `{func}` into sub-functions",
},
};
assert_eq!(
TEMPLATE.render_for_lang(Language::Python, &[("func", "process_data")]),
ViolationMessage::new(
"Function `process_data` too long",
"Long functions are hard to read",
"Refactor `process_data` with helper functions",
)
);
assert_eq!(
TEMPLATE.render_for_lang(Language::Rust, &[("func", "process_data")]),
ViolationMessage::new(
"Function `process_data` too long",
"Long functions are hard to read",
"Extract logic from `process_data` into sub-functions",
)
);
}
#[test]
fn test_source_location_file_span() {
let loc = SourceLocation::file_span(
"src/main.rs",
SourceSpan::new(16, 31),
LineColumn { line: 2, column: 5 },
);
assert_eq!(loc.format_header(), "src/main.rs:2:5");
assert_eq!(
serde_json::to_value(&loc).ok(),
Some(serde_json::json!({
"context": "src/main.rs",
"span": { "start": 16, "end": 31 },
"line": 2,
"column": 5,
}))
);
}
#[test]
fn test_source_location_virtual_span() {
let virtual_loc = SourceLocation::virtual_span(
VCS_CONTEXT_NAME,
"echo ok\njj edit main",
SourceSpan::new(8, 20),
);
assert_eq!(
virtual_loc,
SourceLocation {
context: LocationContext::Virtual(VCS_CONTEXT_NAME.to_string()),
span: SourceSpan::new(8, 20),
line: 2,
column: 1,
}
);
assert_eq!(virtual_loc.format_header(), "VCS_Context:2:1");
}
#[test]
fn test_diagnostic_canonical_ordering() {
let message_a = ViolationMessage::new("Summary A", "Rationale A", "Suggestion A");
let message_b = ViolationMessage::new("Summary B", "Rationale B", "Suggestion B");
let diag1 = Diagnostic::new(
RuleName("rule-b"),
message_a.clone(),
SourceLocation::file_span(
"src/b.rs",
SourceSpan::new(10, 20),
LineColumn { line: 2, column: 1 },
),
);
let diag2 = Diagnostic::new(
RuleName("rule-a"),
message_a,
SourceLocation::file_span(
"src/a.rs",
SourceSpan::new(5, 15),
LineColumn { line: 1, column: 1 },
),
);
let diag3 = Diagnostic::new(
RuleName("rule-a"),
message_b,
SourceLocation::file_span(
"src/a.rs",
SourceSpan::new(5, 15),
LineColumn { line: 1, column: 1 },
),
);
let mut diagnostics = vec![diag1.clone(), diag3.clone(), diag2.clone()];
diagnostics.sort_unstable();
assert_eq!(diagnostics, vec![diag2, diag3, diag1]);
}
}