use std::collections::BTreeMap;
use std::path::{Component, Path};
use serde::Serialize;
use super::{ALL_CATALOGS, CatalogEntry, Diagnostic, Label, Severity, SourceMap, Span, line_col};
pub const SCHEMA: &str = "https://json.schemastore.org/sarif-2.1.0.json";
pub const VERSION: &str = "2.1.0";
const COLUMN_KIND: &str = "unicodeCodePoints";
const SRCROOT: &str = "%SRCROOT%";
#[derive(Debug, Serialize)]
pub struct SarifLog {
#[serde(rename = "$schema")]
pub schema: &'static str,
pub version: &'static str,
pub runs: Vec<Run>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Run {
pub tool: Tool,
#[serde(skip_serializing_if = "Option::is_none")]
pub original_uri_base_ids: Option<BTreeMap<&'static str, ArtifactLocation>>,
pub results: Vec<SarifResult>,
pub column_kind: &'static str,
}
#[derive(Debug, Serialize)]
pub struct Tool {
pub driver: Driver,
}
#[derive(Debug, Serialize)]
pub struct Driver {
pub name: &'static str,
pub version: String,
pub rules: Vec<Rule>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Rule {
pub id: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<&'static str>,
pub short_description: Message,
pub default_configuration: Configuration,
}
#[derive(Debug, Serialize)]
pub struct Message {
pub text: String,
}
#[derive(Debug, Serialize)]
pub struct Configuration {
pub level: &'static str,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SarifResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_id: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_index: Option<usize>,
pub level: &'static str,
pub message: Message,
#[serde(skip_serializing_if = "Option::is_none")]
pub locations: Option<Vec<Location>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub related_locations: Vec<Location>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Location {
#[serde(skip_serializing_if = "Option::is_none")]
pub physical_location: Option<PhysicalLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<Message>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PhysicalLocation {
pub artifact_location: ArtifactLocation,
pub region: Region,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactLocation {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub uri_base_id: Option<&'static str>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Region {
pub start_line: u32,
pub start_column: u32,
pub end_line: u32,
pub end_column: u32,
}
fn sarif_level(severity: Severity) -> &'static str {
match severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "note",
}
}
fn rule(entry: &CatalogEntry) -> Rule {
Rule {
id: entry.code.as_str(),
name: entry.lint,
short_description: Message {
text: entry.summary.to_string(),
},
default_configuration: Configuration {
level: if entry.allow_by_default {
"none"
} else {
sarif_level(entry.severity)
},
},
}
}
#[derive(Debug, Default)]
struct Normalized {
prefix: Option<String>,
rooted: bool,
segments: Vec<String>,
}
impl Normalized {
fn new(path: &Path) -> Self {
let mut normalized = Self::default();
for component in path.components() {
match component {
Component::Prefix(prefix) => {
normalized.prefix = Some(prefix.as_os_str().to_string_lossy().into_owned());
}
Component::RootDir => normalized.rooted = true,
Component::CurDir => {}
Component::ParentDir => match normalized.segments.last() {
Some(last) if last != ".." => {
normalized.segments.pop();
}
_ if normalized.rooted => {}
_ => normalized.segments.push("..".to_string()),
},
Component::Normal(segment) => {
normalized
.segments
.push(segment.to_string_lossy().into_owned());
}
}
}
normalized
}
fn strip_prefix(&self, root: &Normalized) -> Option<&[String]> {
(self.prefix == root.prefix
&& self.rooted == root.rooted
&& self.segments.starts_with(&root.segments))
.then(|| &self.segments[root.segments.len()..])
}
fn uri(&self) -> String {
let mut uri = String::new();
if self.rooted {
uri.push_str("file://");
if let Some(prefix) = &self.prefix {
uri.push('/');
uri.push_str(&prefix.replace('\\', "/"));
}
uri.push('/');
}
uri.push_str(&join_segments(&self.segments));
uri
}
}
fn join_segments(segments: &[String]) -> String {
let mut uri = String::new();
for (index, segment) in segments.iter().enumerate() {
if index > 0 {
uri.push('/');
}
for byte in segment.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
uri.push(byte as char);
}
_ => uri.push_str(&format!("%{byte:02X}")),
}
}
}
uri
}
struct Root<'a> {
path: &'a Path,
normalized: Normalized,
}
fn artifact_location(path: &str, root: Option<&Root>) -> ArtifactLocation {
let path = Path::new(path);
let Some(root) = root else {
return ArtifactLocation {
uri: Normalized::new(path).uri(),
uri_base_id: None,
};
};
let full = Normalized::new(&root.path.join(path));
match full.strip_prefix(&root.normalized) {
Some(relative) => ArtifactLocation {
uri: join_segments(relative),
uri_base_id: Some(SRCROOT),
},
None => ArtifactLocation {
uri: full.uri(),
uri_base_id: None,
},
}
}
fn original_uri_base_ids(root: &Normalized) -> BTreeMap<&'static str, ArtifactLocation> {
let mut uri = root.uri();
if !uri.ends_with('/') {
uri.push('/');
}
BTreeMap::from([(
SRCROOT,
ArtifactLocation {
uri,
uri_base_id: None,
},
)])
}
fn physical_location(
span: Span,
sources: &SourceMap,
root: Option<&Root>,
) -> Option<PhysicalLocation> {
let path = sources.path(span.file)?;
let text = sources.text(span.file)?;
let start = line_col(text, span.range.start());
let end = line_col(text, span.range.end());
Some(PhysicalLocation {
artifact_location: artifact_location(path, root),
region: Region {
start_line: start.line,
start_column: start.column,
end_line: end.line,
end_column: end.column,
},
})
}
fn related_location(label: &Label, sources: &SourceMap, root: Option<&Root>) -> Location {
Location {
physical_location: physical_location(label.span, sources, root),
message: Some(Message {
text: label.message.clone(),
}),
}
}
pub fn to_sarif(
diagnostics: &[Diagnostic],
sources: &SourceMap,
root: Option<&Path>,
tool_version: &str,
) -> SarifLog {
let root = root.map(|path| Root {
path,
normalized: Normalized::new(path),
});
let root = root.as_ref();
let entries: Vec<&CatalogEntry> = ALL_CATALOGS
.iter()
.flat_map(|(_, catalog)| catalog.iter())
.collect();
let rules = entries.iter().map(|entry| rule(entry)).collect();
let results = diagnostics
.iter()
.map(|diagnostic| {
let rule_index = (!diagnostic.code.is_empty())
.then(|| {
entries
.iter()
.position(|entry| entry.code == diagnostic.code)
})
.flatten();
SarifResult {
rule_id: (!diagnostic.code.is_empty()).then(|| diagnostic.code.as_str()),
rule_index,
level: sarif_level(diagnostic.severity),
message: Message {
text: diagnostic.message.clone(),
},
locations: physical_location(diagnostic.primary, sources, root).map(|location| {
vec![Location {
physical_location: Some(location),
message: None,
}]
}),
related_locations: diagnostic
.labels
.iter()
.map(|label| related_location(label, sources, root))
.collect(),
}
})
.collect();
SarifLog {
schema: SCHEMA,
version: VERSION,
runs: vec![Run {
tool: Tool {
driver: Driver {
name: "ridl",
version: tool_version.to_string(),
rules,
},
},
original_uri_base_ids: root.map(|root| original_uri_base_ids(&root.normalized)),
results,
column_kind: COLUMN_KIND,
}],
}
}
#[cfg(test)]
mod tests {
use rowan::{TextRange, TextSize};
use std::path::Path;
use super::to_sarif;
use crate::diag::{
ALL_CATALOGS, DiagCode, Diagnostic, FileId, Label, Severity, SourceMap, Span,
};
fn span(file: FileId, start: u32, end: u32) -> Span {
Span {
file,
range: TextRange::new(TextSize::new(start), TextSize::new(end)),
}
}
#[test]
fn sarif_shape() {
let text = "package p\ninterface S {\n signal speed: Speed\n}\n";
let mut sources = SourceMap::new();
let file = sources.file_id("/ws/sensor/demo.ridl", text);
let at_signal = span(file, 33, 38); let at_interface = span(file, 20, 21); let diagnostics = vec![
Diagnostic {
code: DiagCode::RIDL_100,
severity: Severity::Warning,
message: "signal without a timing annotation".to_string(),
primary: at_signal,
labels: vec![Label {
span: at_interface,
message: "in this interface".to_string(),
}],
fixits: Vec::new(),
},
Diagnostic {
code: DiagCode::NONE,
severity: Severity::Error,
message: "expected a type".to_string(),
primary: span(file, 10, 19),
labels: Vec::new(),
fixits: Vec::new(),
},
Diagnostic {
code: DiagCode::MANI_101,
severity: Severity::Error,
message: "could not fetch `demo`".to_string(),
primary: span(FileId::DETACHED, 0, 0),
labels: Vec::new(),
fixits: Vec::new(),
},
];
let log = to_sarif(&diagnostics, &sources, Some(Path::new("/ws")), "0.0.0-test");
insta::assert_json_snapshot!("sarif_shape", log);
let value = serde_json::to_value(&log).expect("the log serializes");
assert_eq!(value["version"], "2.1.0");
assert_eq!(
value["$schema"],
"https://json.schemastore.org/sarif-2.1.0.json"
);
let run = &value["runs"][0];
assert_eq!(run["columnKind"], "unicodeCodePoints");
assert_eq!(run["tool"]["driver"]["name"], "ridl");
assert_eq!(run["tool"]["driver"]["version"], "0.0.0-test");
assert_eq!(run["originalUriBaseIds"]["%SRCROOT%"]["uri"], "file:///ws/");
let rules = run["tool"]["driver"]["rules"]
.as_array()
.expect("rules is an array");
let row_count: usize = ALL_CATALOGS.iter().map(|(_, rows)| rows.len()).sum();
assert_eq!(rules.len(), row_count);
let (index, rule) = rules
.iter()
.enumerate()
.find(|(_, rule)| rule["id"] == "RIDL-100")
.expect("a RIDL-100 rule");
assert_eq!(rule["name"], "missing-timing");
assert_eq!(rule["defaultConfiguration"]["level"], "warning");
assert!(rules.iter().all(|rule| rule.get("helpUri").is_none()));
let rule_level = |id: &str| {
rules
.iter()
.find(|rule| rule["id"] == id)
.unwrap_or_else(|| panic!("a {id} rule"))["defaultConfiguration"]["level"]
.clone()
};
assert_eq!(rule_level("TYPL-410"), "none");
assert_eq!(rule_level("TYPL-404"), "warning");
let error_rule = rules
.iter()
.find(|rule| rule["id"] == "MANI-101")
.expect("a MANI-101 rule");
assert!(error_rule.get("name").is_none(), "{error_rule}");
assert_eq!(error_rule["defaultConfiguration"]["level"], "error");
let results = run["results"].as_array().expect("results is an array");
assert_eq!(results.len(), 3);
let ridl_100 = &results[0];
assert_eq!(ridl_100["ruleId"], "RIDL-100");
assert_eq!(ridl_100["ruleIndex"], index);
assert_eq!(ridl_100["level"], "warning");
let location = &ridl_100["locations"][0]["physicalLocation"];
assert_eq!(location["artifactLocation"]["uri"], "sensor/demo.ridl");
assert_eq!(location["artifactLocation"]["uriBaseId"], "%SRCROOT%");
assert_eq!(location["region"]["startLine"], 3);
assert_eq!(location["region"]["startColumn"], 10);
assert_eq!(location["region"]["endLine"], 3);
assert_eq!(location["region"]["endColumn"], 15);
let related = ridl_100["relatedLocations"]
.as_array()
.expect("relatedLocations is an array");
assert_eq!(related.len(), 1);
assert_eq!(related[0]["message"]["text"], "in this interface");
assert_eq!(
related[0]["physicalLocation"]["artifactLocation"]["uriBaseId"],
"%SRCROOT%"
);
let uncoded = &results[1];
assert!(uncoded.get("ruleId").is_none(), "{uncoded}");
assert!(uncoded.get("ruleIndex").is_none(), "{uncoded}");
assert_eq!(uncoded["level"], "error");
let detached = &results[2];
assert_eq!(detached["ruleId"], "MANI-101");
assert!(detached.get("locations").is_none(), "{detached}");
assert!(results.iter().all(|result| result.get("fixes").is_none()));
}
fn log_for(root: Option<&Path>, path: &str) -> serde_json::Value {
let mut sources = SourceMap::new();
let file = sources.file_id(path, "package p\n");
let diagnostics = vec![Diagnostic {
code: DiagCode::RIDL_100,
severity: Severity::Warning,
message: "m".to_string(),
primary: span(file, 0, 7),
labels: Vec::new(),
fixits: Vec::new(),
}];
serde_json::to_value(to_sarif(&diagnostics, &sources, root, "0"))
.expect("the log serializes")
}
fn uri_of(root: Option<&Path>, path: &str) -> (String, Option<String>) {
let log = log_for(root, path);
let location =
&log["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"];
(
location["uri"]
.as_str()
.expect("uri is a string")
.to_string(),
location["uriBaseId"].as_str().map(str::to_string),
)
}
const SRCROOT: Option<&str> = Some("%SRCROOT%");
fn srcroot() -> Option<String> {
SRCROOT.map(str::to_string)
}
#[test]
fn a_path_outside_the_root_is_an_absolute_file_uri() {
let root = Some(Path::new("/ws"));
assert_eq!(
uri_of(root, "/elsewhere/a.ridl"),
("file:///elsewhere/a.ridl".to_string(), None)
);
assert_eq!(
uri_of(root, "/ws-other/a.ridl"),
("file:///ws-other/a.ridl".to_string(), None),
"a sibling whose name starts with the root's name is outside it"
);
}
#[test]
fn dot_and_parent_dir_components_are_normalised_lexically() {
let root = Some(Path::new("/ws"));
assert_eq!(
uri_of(root, "/ws/../other/a.ridl"),
("file:///other/a.ridl".to_string(), None)
);
assert_eq!(
uri_of(root, "/ws/sub/../a.ridl"),
("a.ridl".to_string(), srcroot())
);
assert_eq!(
uri_of(root, "/ws/./sub/a.ridl"),
("sub/a.ridl".to_string(), srcroot())
);
assert_eq!(
uri_of(Some(Path::new("/ws/sub/..")), "/ws/a.ridl"),
("a.ridl".to_string(), srcroot()),
"the root is normalised too"
);
}
#[test]
fn a_relative_path_is_joined_onto_the_root() {
let root = Some(Path::new("/ws"));
assert_eq!(
uri_of(root, "sensor/a.ridl"),
("sensor/a.ridl".to_string(), srcroot())
);
assert_eq!(uri_of(root, "./a.ridl"), ("a.ridl".to_string(), srcroot()));
assert_eq!(
uri_of(root, "../x/a.ridl"),
("file:///x/a.ridl".to_string(), None)
);
}
#[test]
fn segments_are_percent_encoded() {
let root = Some(Path::new("/my ws"));
assert_eq!(
uri_of(root, "/my ws/b c.typl"),
("b%20c.typl".to_string(), srcroot())
);
assert_eq!(
uri_of(root, "/my ws/sub/my file#1.ridl"),
("sub/my%20file%231.ridl".to_string(), srcroot())
);
assert_eq!(
uri_of(root, "/my ws/caf\u{e9}.typl"),
("caf%C3%A9.typl".to_string(), srcroot())
);
assert_eq!(
uri_of(root, "/else where/a#b.ridl"),
("file:///else%20where/a%23b.ridl".to_string(), None)
);
assert_eq!(
uri_of(root, "/my ws/100%.ridl"),
("100%25.ridl".to_string(), srcroot()),
"a literal `%` is encoded, so the URI decodes to the path"
);
assert_eq!(
uri_of(root, "/my ws/a:b.ridl"),
("a%3Ab.ridl".to_string(), srcroot()),
"`:` is encoded, so a relative URI is not read as a scheme"
);
assert_eq!(
uri_of(root, "/my ws/~u.ridl"),
("~u.ridl".to_string(), srcroot()),
"`~` is unreserved and passes"
);
let log = log_for(root, "/my ws/b c.typl");
assert_eq!(
log["runs"][0]["originalUriBaseIds"]["%SRCROOT%"]["uri"],
"file:///my%20ws/"
);
}
#[test]
fn no_root_gives_absolute_uris_and_no_base_ids() {
assert_eq!(
uri_of(None, "/ws/sensor/a.ridl"),
("file:///ws/sensor/a.ridl".to_string(), None)
);
let log = log_for(None, "/ws/sensor/a.ridl");
assert!(log["runs"][0].get("originalUriBaseIds").is_none(), "{log}");
}
#[test]
fn no_root_keeps_a_relative_path_relative() {
assert_eq!(
uri_of(None, "sensor/a.ridl"),
("sensor/a.ridl".to_string(), None)
);
assert_eq!(
uri_of(None, "../../a.ridl"),
("../../a.ridl".to_string(), None)
);
assert_eq!(
uri_of(None, "../x/../a.ridl"),
("../a.ridl".to_string(), None)
);
}
#[test]
fn a_parent_dir_under_the_filesystem_root_is_dropped() {
assert_eq!(
uri_of(None, "/../x.ridl"),
("file:///x.ridl".to_string(), None)
);
assert_eq!(
uri_of(Some(Path::new("/ws")), "/../ws/a.ridl"),
("a.ridl".to_string(), srcroot())
);
}
#[test]
fn the_filesystem_root_as_root_gives_one_trailing_slash() {
let root = Some(Path::new("/"));
let log = log_for(root, "/a.ridl");
assert_eq!(
log["runs"][0]["originalUriBaseIds"]["%SRCROOT%"]["uri"],
"file:///"
);
assert_eq!(uri_of(root, "/a.ridl"), ("a.ridl".to_string(), srcroot()));
}
#[test]
fn a_relative_root_does_not_claim_an_absolute_path() {
assert_eq!(
uri_of(Some(Path::new("ws")), "/ws/a.ridl"),
("file:///ws/a.ridl".to_string(), None)
);
}
}