use rowan::TextRange;
use serde::Deserialize;
use super::{
ExternalLinterParser, LinterError, ParseContext, line_col_to_offset,
map_concatenated_offset_to_original_with_end_boundary,
};
use crate::linter::diagnostics::{Diagnostic, DiagnosticNoteKind, DiagnosticOrigin, Location};
#[derive(Debug, Deserialize)]
struct JarlOutput {
diagnostics: Vec<JarlDiagnostic>,
#[allow(dead_code)]
errors: Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct JarlDiagnostic {
message: JarlMessage,
#[allow(dead_code)]
filename: String,
range: [usize; 2],
location: JarlLocation,
fix: JarlFix,
}
#[derive(Debug, Deserialize)]
struct JarlMessage {
name: String,
body: String,
#[allow(dead_code)]
suggestion: Option<String>,
}
#[derive(Debug, Deserialize)]
struct JarlLocation {
row: usize,
column: usize,
}
#[derive(Debug, Deserialize)]
struct JarlFix {
content: String,
start: usize,
end: usize,
to_skip: bool,
}
pub(crate) struct JarlParser;
impl ExternalLinterParser for JarlParser {
const NAME: &'static str = "jarl";
fn parse(ctx: &ParseContext<'_>) -> Result<Vec<Diagnostic>, LinterError> {
use crate::linter::diagnostics::{Edit, Fix};
let output: JarlOutput = serde_json::from_str(ctx.output)
.map_err(|e| LinterError::ParseError(format!("invalid jarl JSON: {}", e)))?;
let mut diagnostics = Vec::new();
for jarl_diag in output.diagnostics {
let line = jarl_diag.location.row;
let column = jarl_diag.location.column + 1;
let range_len = jarl_diag.range[1].saturating_sub(jarl_diag.range[0]);
let start_offset = line_col_to_offset(ctx.original_input, line, column)
.unwrap_or(ctx.original_input.len());
let end_offset = start_offset
.saturating_add(range_len)
.min(ctx.original_input.len());
let range = TextRange::new((start_offset as u32).into(), (end_offset as u32).into());
let location = Location {
line,
column,
range,
};
let fix = if let Some(mappings) = ctx.mappings {
if !jarl_diag.fix.to_skip {
if let (Some(fix_start), Some(fix_end)) = (
map_concatenated_offset_to_original_with_end_boundary(
jarl_diag.fix.start,
mappings,
),
map_concatenated_offset_to_original_with_end_boundary(
jarl_diag.fix.end,
mappings,
),
) {
Some(Fix {
message: format!("Apply suggested fix: {}", jarl_diag.fix.content),
edits: vec![Edit {
range: TextRange::new(
(fix_start as u32).into(),
(fix_end as u32).into(),
),
replacement: jarl_diag.fix.content.clone(),
}],
})
} else {
None
}
} else {
None
}
} else {
None
};
let mut diagnostic =
Diagnostic::warning(location, jarl_diag.message.name, jarl_diag.message.body)
.with_origin(DiagnosticOrigin::External);
if let Some(suggestion) = jarl_diag.message.suggestion.as_ref()
&& !suggestion.trim().is_empty()
{
diagnostic = diagnostic.with_note(DiagnosticNoteKind::Help, suggestion.clone());
}
diagnostics.push(if let Some(fix) = fix {
diagnostic.with_fix(fix)
} else {
diagnostic
});
}
Ok(diagnostics)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linter::external_linters::ParseContext;
#[test]
fn maps_jarl_suggestion_to_help_note() {
let ctx = ParseContext {
output: r#"{"diagnostics":[{"message":{"name":"any_is_na","body":"Prefer anyNA()","suggestion":"Use anyNA(x) instead"},"filename":"x.R","range":[0,5],"location":{"row":1,"column":0},"fix":{"content":"anyNA(x)","start":0,"end":5,"to_skip":true}}],"errors":[]}"#,
linted_input: "any(is.na(x))\n",
original_input: "any(is.na(x))\n",
mappings: None,
};
let diagnostics = JarlParser::parse(&ctx).unwrap();
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].notes.len(), 1);
assert_eq!(diagnostics[0].notes[0].kind, DiagnosticNoteKind::Help);
assert_eq!(diagnostics[0].notes[0].message, "Use anyNA(x) instead");
}
}