use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tower_lsp::lsp_types::{
Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range, Url,
};
use crate::models::{Finding, Severity};
pub fn to_lsp_severity(severity: Severity) -> DiagnosticSeverity {
match severity {
Severity::Critical => DiagnosticSeverity::ERROR,
Severity::High => DiagnosticSeverity::WARNING,
Severity::Medium => DiagnosticSeverity::WARNING,
Severity::Low => DiagnosticSeverity::INFORMATION,
Severity::Info => DiagnosticSeverity::HINT,
}
}
pub fn finding_to_diagnostic(finding: &Finding) -> Diagnostic {
let start_1 = finding.line_start.unwrap_or(1);
let end_1 = finding.line_end.unwrap_or(start_1);
let start_line = start_1.saturating_sub(1);
let end_line = end_1;
Diagnostic {
range: Range {
start: Position::new(start_line, 0),
end: Position::new(end_line, 0),
},
severity: Some(to_lsp_severity(finding.severity)),
code: Some(NumberOrString::String(finding.id.clone())),
source: Some("repotoire".to_string()),
message: finding.title.clone(),
..Default::default()
}
}
pub fn path_to_uri(path: &Path) -> Option<Url> {
Url::from_file_path(path)
.or_else(|_| {
path.canonicalize()
.map_err(|_| ())
.and_then(Url::from_file_path)
})
.ok()
}
#[derive(Default)]
pub struct DiagnosticMap {
map: HashMap<Url, Vec<(Finding, Diagnostic)>>,
}
impl DiagnosticMap {
pub fn new() -> Self {
Self::default()
}
pub fn set_all(&mut self, findings: &[Finding]) -> Vec<Url> {
let old_uris: std::collections::HashSet<Url> = self.map.keys().cloned().collect();
self.map.clear();
for finding in findings {
if let Some(path) = finding.affected_files.first() {
if let Some(uri) = path_to_uri(path) {
let diag = finding_to_diagnostic(finding);
self.map
.entry(uri)
.or_default()
.push((finding.clone(), diag));
}
}
}
let new_uris: std::collections::HashSet<Url> = self.map.keys().cloned().collect();
old_uris.difference(&new_uris).cloned().collect()
}
fn fingerprint(f: &Finding) -> (String, Option<PathBuf>, Option<u32>) {
(
f.detector.clone(),
f.affected_files.first().cloned(),
f.line_start,
)
}
pub fn apply_delta(
&mut self,
new_findings: &[Finding],
fixed_findings: &[Finding],
) -> Vec<Url> {
let mut changed_uris = Vec::new();
for fixed in fixed_findings {
if let Some(path) = fixed.affected_files.first() {
if let Some(uri) = path_to_uri(path) {
let fixed_fp = Self::fingerprint(fixed);
if let Some(entries) = self.map.get_mut(&uri) {
let before = entries.len();
entries.retain(|(f, _)| Self::fingerprint(f) != fixed_fp);
if entries.len() != before {
changed_uris.push(uri.clone());
}
}
if self.map.get(&uri).map(|e| e.is_empty()).unwrap_or(false) {
self.map.remove(&uri);
}
}
}
}
for finding in new_findings {
if let Some(path) = finding.affected_files.first() {
if let Some(uri) = path_to_uri(path) {
let diag = finding_to_diagnostic(finding);
self.map
.entry(uri.clone())
.or_default()
.push((finding.clone(), diag));
if !changed_uris.contains(&uri) {
changed_uris.push(uri);
}
}
}
}
changed_uris
}
pub fn get_diagnostics(&self, uri: &Url) -> Vec<Diagnostic> {
self.map
.get(uri)
.map(|entries| entries.iter().map(|(_, d)| d.clone()).collect())
.unwrap_or_default()
}
pub fn all_uris(&self) -> Vec<Url> {
self.map.keys().cloned().collect()
}
pub fn clear(&mut self) {
self.map.clear();
}
pub fn find_at(&self, uri: &Url, line_1indexed: u32) -> Vec<&Finding> {
self.map
.get(uri)
.map(|entries| {
entries
.iter()
.filter(|(f, _)| {
let start = f.line_start.unwrap_or(1);
let end = f.line_end.unwrap_or(start);
line_1indexed >= start && line_1indexed <= end
})
.map(|(f, _)| f)
.collect()
})
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_finding(id: &str, detector: &str, file: &str, line: u32, severity: Severity) -> Finding {
Finding {
id: id.to_string(),
detector: detector.to_string(),
affected_files: vec![PathBuf::from(file)],
line_start: Some(line),
severity,
title: format!("{} issue", detector),
..Default::default()
}
}
#[test]
fn severity_mapping() {
assert_eq!(to_lsp_severity(Severity::Critical), DiagnosticSeverity::ERROR);
assert_eq!(to_lsp_severity(Severity::High), DiagnosticSeverity::WARNING);
assert_eq!(to_lsp_severity(Severity::Medium), DiagnosticSeverity::WARNING);
assert_eq!(to_lsp_severity(Severity::Low), DiagnosticSeverity::INFORMATION);
assert_eq!(to_lsp_severity(Severity::Info), DiagnosticSeverity::HINT);
}
#[test]
fn finding_to_diagnostic_mapping() {
let f = make_finding("f1", "XSS", "/tmp/a.rs", 10, Severity::High);
let d = finding_to_diagnostic(&f);
assert_eq!(d.range.start.line, 9); assert_eq!(d.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(d.source, Some("repotoire".to_string()));
assert_eq!(d.message, "XSS issue");
}
#[test]
fn finding_no_line_defaults_to_zero() {
let mut f = make_finding("f1", "Arch", "/tmp/a.rs", 1, Severity::Medium);
f.line_start = None;
let d = finding_to_diagnostic(&f);
assert_eq!(d.range.start.line, 0);
}
#[test]
fn diagnostic_map_set_all() {
let mut map = DiagnosticMap::new();
let findings = vec![
make_finding("f1", "XSS", "/tmp/a.rs", 10, Severity::High),
make_finding("f2", "SQLi", "/tmp/b.rs", 20, Severity::Critical),
];
map.set_all(&findings);
assert_eq!(map.all_uris().len(), 2);
}
#[test]
fn diagnostic_map_apply_delta() {
let mut map = DiagnosticMap::new();
let initial = vec![make_finding("f1", "XSS", "/tmp/a.rs", 10, Severity::High)];
map.set_all(&initial);
let new = vec![make_finding("f2", "SQLi", "/tmp/a.rs", 20, Severity::Critical)];
let fixed = vec![make_finding("f1", "XSS", "/tmp/a.rs", 10, Severity::High)];
let changed = map.apply_delta(&new, &fixed);
let uri = Url::from_file_path("/tmp/a.rs").unwrap();
assert!(changed.contains(&uri));
let diags = map.get_diagnostics(&uri);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, "SQLi issue");
}
}