use std::path::PathBuf;
use crate::custom::{Rule, Severity, ShapeSet};
use crate::encoding::OffsetUnit;
use crate::files::{Edit, LineIndex};
use crate::records::{Quantifier, Query};
use crate::typed::json_string;
#[derive(Clone, Debug)]
pub struct Finding {
pub rule: usize,
pub start: usize,
pub end: usize,
pub m: crate::Match,
pub message: String,
pub fix: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Place {
pub line: usize,
pub col: usize,
pub end_line: usize,
pub end_col: usize,
}
impl Place {
#[must_use]
pub fn of(index: &LineIndex, input: &[u8], start: usize, end: usize) -> Place {
let (line, col) = index.line_col(input, start);
let (end_line, end_col) = index.line_col(input, end);
Place { line, col, end_line, end_col }
}
}
#[derive(Clone, Debug)]
pub struct Found {
pub name: Option<String>,
pub input: Vec<u8>,
pub byte_base: Option<usize>,
pub line_base: Option<usize>,
pub findings: Vec<Finding>,
}
impl Found {
#[must_use]
pub fn index(&self) -> LineIndex {
LineIndex::new(&self.input).within(self.byte_base, self.line_base)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SkippedFix {
pub rule: usize,
pub line: usize,
pub col: usize,
}
struct Group {
files: Vec<String>,
accepts: Option<ignore::overrides::Override>,
plain: Vec<usize>,
set: Option<crate::PatternSet>,
lists: bool,
records: Vec<usize>,
}
impl Group {
fn new(files: &[String]) -> Result<Group, String> {
let accepts = if files.is_empty() {
None
} else {
let mut builder = ignore::overrides::OverrideBuilder::new(".");
for glob in files {
builder.add(glob).map_err(|e| format!("files glob {glob:?}: {e}"))?;
}
Some(builder.build().map_err(|e| format!("files globs: {e}"))?)
};
Ok(Group { files: files.to_vec(), accepts, plain: Vec::new(), set: None, lists: false, records: Vec::new() })
}
fn reads(&self, name: Option<&str>) -> bool {
match (&self.accepts, name) {
(None, _) => true,
(Some(_), None) => false,
(Some(globs), Some(name)) => !globs.matched(name, false).is_ignore(),
}
}
}
pub struct RuleScan {
shapes: ShapeSet,
messages: Vec<crate::Template>,
fixes: Vec<Option<crate::Template>>,
queries: Vec<Option<Query>>,
groups: Vec<Group>,
}
impl RuleScan {
pub fn new(shapes: ShapeSet) -> Result<Self, String> {
let rules = shapes.rules();
let mut messages = Vec::with_capacity(rules.len());
let mut fixes = Vec::with_capacity(rules.len());
let mut queries = Vec::with_capacity(rules.len());
for rule in rules {
let bound = rule.pattern.capture_names();
messages.push(
crate::Template::parse_report(&rule.message, &bound)
.map_err(|e| format!("rule {}: message error at byte {}: {}", rule.name, e.pos, e.msg))?,
);
fixes.push(match &rule.fix {
Some(fix) => Some(
crate::Template::parse(fix, &bound)
.map_err(|e| format!("rule {}: fix error at byte {}: {}", rule.name, e.pos, e.msg))?,
),
None => None,
});
queries.push(rule.on_records().then(|| Query {
quantifier: Quantifier::All,
positives: vec![rule.pattern.clone()],
set: None,
negatives: rule.unless.clone(),
unit: rule.record_unit(),
}));
}
let mut groups: Vec<Group> = Vec::new();
for (i, rule) in rules.iter().enumerate() {
let k = match groups.iter().position(|g| g.files == rule.files) {
Some(k) => k,
None => {
groups.push(Group::new(&rule.files).map_err(|e| format!("rule {}: {e}", rule.name))?);
groups.len() - 1
}
};
if rule.on_records() {
groups[k].records.push(i);
} else {
groups[k].plain.push(i);
}
}
for group in &mut groups {
if group.plain.is_empty() {
continue;
}
let pats = group.plain.iter().map(|&i| rules[i].pattern.clone()).collect();
let names = group.plain.iter().map(|&i| rules[i].name.clone()).collect();
group.set = Some(crate::PatternSet::named(pats, names).under(shapes.clone()));
group.lists = group.plain.iter().any(|&i| reads_lists(rules, &messages, &fixes, i));
}
Ok(RuleScan { shapes, messages, fixes, queries, groups })
}
#[must_use]
pub fn rules(&self) -> &[Rule] {
self.shapes.rules()
}
#[must_use]
pub fn messages_read_place(&self) -> bool {
self.messages.iter().any(crate::Template::reads_place)
}
#[must_use]
pub fn messages_read_offsets(&self) -> bool {
self.messages.iter().any(crate::Template::reads_offsets)
}
#[must_use]
pub fn capture_names(&self) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for rule in self.rules() {
for name in rule.pattern.capture_names() {
if !names.contains(&name) {
names.push(name);
}
}
}
names
}
#[must_use]
pub fn findings(&self, name: Option<&str>, input: &[u8], index: &LineIndex, cap: Option<usize>) -> Vec<Finding> {
let mut out = Vec::new();
for group in &self.groups {
if !group.reads(name) {
continue;
}
if let Some(set) = &group.set {
for (k, m) in set.scan_matches(input, group.lists) {
let i = group.plain[k];
out.push(self.finding(i, m.start, m.end, m, name, input, index));
}
}
for &i in &group.records {
let Some(query) = &self.queries[i] else {
continue;
};
let lists = reads_lists(self.rules(), &self.messages, &self.fixes, i);
for hit in query.hits(input, &self.shapes, true) {
let Some(&(s, e)) = hit.spans.first() else {
continue;
};
let at = |offset: usize| u32::try_from(offset).expect("an input offset fits a span");
let span = [crate::Span { start: at(s), end: at(e) }];
let pattern = &self.rules()[i].pattern;
let resolved = if lists {
crate::captures_with_shapes_and_lists(pattern, input, &self.shapes, &span)
} else {
crate::captures_with_shapes(pattern, input, &self.shapes, &span)
};
let Some(m) = resolved.into_iter().next() else {
continue;
};
out.push(self.finding(i, hit.start, hit.end, m, name, input, index));
}
}
}
out.sort_by_key(|f| (f.start, f.end, f.rule));
if let Some(n) = cap {
out.truncate(n);
}
out
}
#[allow(clippy::too_many_arguments)]
fn finding(
&self,
i: usize,
start: usize,
end: usize,
m: crate::Match,
name: Option<&str>,
input: &[u8],
index: &LineIndex,
) -> Finding {
let rule = &self.rules()[i];
let message = &self.messages[i];
let (line, col) = if message.reads_place() { index.line_col(input, m.start) } else { (0, 0) };
let place = crate::ReportAt {
path: name.unwrap_or(""),
line,
col,
offsets: if message.reads_offsets() { index.offsets(input, m.start, m.end) } else { None },
pattern: Some(&rule.name),
rule: Some(crate::ReportRule { name: &rule.name, severity: rule.severity.name(), message: "", fix: "" }),
};
let message = message.render_report(&m, input, &place);
let fix = self.fixes[i].as_ref().map(|t| t.render(&m, input));
Finding { rule: i, start, end, m, message, fix }
}
#[must_use]
pub fn record_rules(&self) -> Vec<&str> {
self.rules().iter().filter(|r| r.on_records()).map(|r| r.name.as_str()).collect()
}
#[must_use]
pub fn stream(
&self,
name: Option<&str>,
origin: usize,
lines_before: Option<usize>,
units_before: Option<(OffsetUnit, usize)>,
) -> RuleStream<'_> {
RuleStream { scan: self, streams: Streams::new(self, name, origin, lines_before, units_before) }
}
fn findings_at(
&self,
group: usize,
held: &[u8],
index: &LineIndex,
committed: &[(usize, crate::Span)],
name: Option<&str>,
) -> Vec<Finding> {
let g = &self.groups[group];
let mut members: Vec<usize> = committed.iter().map(|&(member, _)| member).collect();
members.sort_unstable();
members.dedup();
let mut out = Vec::with_capacity(committed.len());
for member in members {
let spans: Vec<crate::Span> =
committed.iter().filter(|&&(m, _)| m == member).map(|&(_, span)| span).collect();
let i = g.plain[member];
let pattern = &self.rules()[i].pattern;
let resolved = if g.lists {
crate::captures_with_shapes_and_lists(pattern, held, &self.shapes, &spans)
} else {
crate::captures_with_shapes(pattern, held, &self.shapes, &spans)
};
for m in resolved {
out.push(self.finding(i, m.start, m.end, m, name, held, index));
}
}
out.sort_by_key(|f| (f.start, f.end, f.rule));
out
}
#[must_use]
pub fn fix_edits(&self, name: &str, input: &[u8], index: &LineIndex) -> (Vec<Edit>, Vec<SkippedFix>) {
let mut edits = Vec::new();
let mut skipped = Vec::new();
let mut last_end = 0usize;
for f in self.findings(Some(name), input, index, None) {
let Some(fix) = f.fix else {
continue;
};
if f.m.start < last_end {
let (line, col) = index.line_col(input, f.m.start);
skipped.push(SkippedFix { rule: f.rule, line, col });
continue;
}
last_end = f.m.end;
edits.push(Edit { start: f.m.start, end: f.m.end, replacement: fix.into_bytes() });
}
(edits, skipped)
}
#[must_use]
pub fn sarif(&self, found: &[Found]) -> String {
let rules: Vec<SarifRule> = self.rules().iter().map(SarifRule::of).collect();
let mut results: Vec<SarifResult> = Vec::new();
for one in found {
let index = one.index();
let Found { name, input, findings, .. } = one;
let uri = name.as_deref().map(|n| n.replace('\\', "/"));
for f in findings {
results.push(SarifResult {
rule: f.rule,
uri: uri.clone(),
at: Place::of(&index, input, f.start, f.end),
snippet: String::from_utf8_lossy(&input[f.start..f.end]).into_owned(),
message: f.message.clone(),
fix: f.fix.clone().map(|fix| (fix, Place::of(&index, input, f.m.start, f.m.end))),
});
}
}
sarif(&rules, &results)
}
#[must_use]
pub fn github_line(&self, name: Option<&str>, input: &[u8], index: &LineIndex, f: &Finding) -> String {
let rule = &self.rules()[f.rule];
github_annotation(
rule.severity,
&rule.name,
name,
Place::of(index, input, f.start, f.end),
&f.message,
)
}
#[must_use]
pub fn finding_json(&self, name: Option<&str>, input: &[u8], index: &LineIndex, f: &Finding) -> String {
use crate::report::{captures_json, json_escape};
let rule = &self.rules()[f.rule];
let place = Place::of(index, input, f.start, f.end);
let mut out = format!(
"{{\"rule\":\"{}\",\"severity\":\"{}\",\"message\":\"{}\"",
json_escape(&rule.name),
rule.severity.name(),
json_escape(&f.message)
);
if let Some(n) = name {
out.push_str(&format!(",\"path\":\"{}\"", json_escape(n)));
}
out.push_str(&format!(
",\"line\":{},\"col\":{},\"end_line\":{},\"end_col\":{},\"start\":{},\"end\":{},\"text\":\"{}\",\"captures\":{}",
place.line,
place.col,
place.end_line,
place.end_col,
index.offset(f.start),
index.offset(f.end),
json_escape(&String::from_utf8_lossy(&input[f.start..f.end])),
captures_json(input, &f.m, "", None, None)
));
if let Some(fix) = &f.fix {
out.push_str(&format!(",\"fix\":\"{}\"", json_escape(fix)));
}
if !rule.meta.is_empty() {
let members: Vec<String> = rule
.meta
.iter()
.map(|(k, v)| format!("\"{}\":\"{}\"", json_escape(k), json_escape(v)))
.collect();
out.push_str(&format!(",\"meta\":{{{}}}", members.join(",")));
}
out.push('}');
out
}
}
pub struct RuleStream<'a> {
scan: &'a RuleScan,
streams: Streams,
}
impl RuleStream<'_> {
#[must_use]
pub fn commits_early(&self) -> bool {
self.streams.commits_early()
}
pub fn push(&mut self, bytes: &[u8]) -> Vec<Found> {
self.streams.push(self.scan, bytes)
}
#[must_use]
pub fn finish(self) -> Vec<Found> {
self.streams.finish(self.scan)
}
}
pub struct SharedRuleStream {
scan: std::sync::Arc<RuleScan>,
streams: Streams,
}
impl SharedRuleStream {
#[must_use]
pub fn new(
scan: std::sync::Arc<RuleScan>,
name: Option<&str>,
origin: usize,
lines_before: Option<usize>,
units_before: Option<(OffsetUnit, usize)>,
) -> SharedRuleStream {
let streams = Streams::new(&scan, name, origin, lines_before, units_before);
SharedRuleStream { scan, streams }
}
#[must_use]
pub fn scan(&self) -> &std::sync::Arc<RuleScan> {
&self.scan
}
#[must_use]
pub fn commits_early(&self) -> bool {
self.streams.commits_early()
}
pub fn push(&mut self, bytes: &[u8]) -> Vec<Found> {
self.streams.push(&self.scan, bytes)
}
#[must_use]
pub fn finish(self) -> Vec<Found> {
self.streams.finish(&self.scan)
}
}
struct Streams {
name: Option<String>,
groups: Vec<(usize, crate::HeldStream)>,
units: Option<(OffsetUnit, usize)>,
}
impl Streams {
fn new(
scan: &RuleScan,
name: Option<&str>,
origin: usize,
lines_before: Option<usize>,
units_before: Option<(OffsetUnit, usize)>,
) -> Streams {
let groups = scan
.groups
.iter()
.enumerate()
.filter(|(_, g)| g.reads(name))
.filter_map(|(k, g)| {
g.set.as_ref().map(|set| {
(k, crate::HeldStream::new(crate::StreamScanner::over_set(set.clone()), origin, lines_before))
})
})
.collect();
Streams { name: name.map(str::to_string), groups, units: units_before }
}
fn commits_early(&self) -> bool {
self.groups.iter().all(|(_, stream)| stream.commits_early())
}
fn index_of(units: Option<(OffsetUnit, usize)>, held: &[u8], base: usize, lines_before: Option<usize>) -> LineIndex {
let index = LineIndex::new(held).within(Some(base), lines_before);
match units {
Some((unit, through)) => index.counting(held, unit, Some(through - unit.count(held))),
None => index,
}
}
fn push(&mut self, scan: &RuleScan, bytes: &[u8]) -> Vec<Found> {
if let Some((unit, through)) = self.units.as_mut() {
*through += unit.count(bytes);
}
let units = self.units;
let name = self.name.as_deref();
let mut out = Vec::new();
for (group, stream) in &mut self.groups {
let committed = stream.push(bytes);
if committed.is_empty() {
continue;
}
let held = stream.held();
let index = Streams::index_of(units, held, stream.base(), stream.lines_before());
let findings = scan.findings_at(*group, held, &index, &committed, name);
out.push(Found {
name: name.map(str::to_string),
input: stream.held().to_vec(),
byte_base: Some(stream.base()),
line_base: stream.lines_before(),
findings,
});
}
out
}
fn finish(self, scan: &RuleScan) -> Vec<Found> {
let Streams { name, groups, units } = self;
let mut out = Vec::new();
for (group, stream) in groups {
let ended = stream.finish();
if ended.matches.is_empty() {
continue;
}
let index = Streams::index_of(units, &ended.held, ended.base, ended.lines_before);
let findings = scan.findings_at(group, &ended.held, &index, &ended.matches, name.as_deref());
out.push(Found {
name: name.clone(),
input: ended.held,
byte_base: Some(ended.base),
line_base: ended.lines_before,
findings,
});
}
out
}
}
fn reads_lists(rules: &[Rule], messages: &[crate::Template], fixes: &[Option<crate::Template>], i: usize) -> bool {
rules[i].pattern.has_list_registers()
&& (messages[i].reads_lists() || fixes[i].as_ref().is_some_and(crate::Template::reads_lists))
}
pub fn rule_files(paths: &[String]) -> Result<Vec<PathBuf>, String> {
crate::declarations::pattern_files(paths).map_err(|e| e.to_string())
}
pub fn declare_rule_files(shapes: &mut ShapeSet, paths: &[String]) -> Result<(), String> {
for file in rule_files(paths)? {
shapes.declare_file(&file).map_err(|e| e.msg)?;
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct SarifRule {
pub name: String,
pub message: String,
pub pattern: String,
pub severity: Severity,
pub meta: Vec<(String, String)>,
}
impl SarifRule {
#[must_use]
pub fn of(rule: &Rule) -> SarifRule {
SarifRule {
name: rule.name.clone(),
message: rule.message.clone(),
pattern: rule.source.clone(),
severity: rule.severity,
meta: rule.meta.clone(),
}
}
}
#[derive(Clone, Debug)]
pub struct SarifResult {
pub rule: usize,
pub uri: Option<String>,
pub at: Place,
pub snippet: String,
pub message: String,
pub fix: Option<(String, Place)>,
}
#[must_use]
pub fn sarif(rules: &[SarifRule], results: &[SarifResult]) -> String {
let rule_objects: Vec<String> = rules.iter().map(sarif_rule).collect();
let result_objects: Vec<String> = results.iter().map(|r| sarif_result(rules, r)).collect();
format!(
"{{\"$schema\":\"https://json.schemastore.org/sarif-2.1.0.json\",\"version\":\"2.1.0\",\"runs\":[{{\"tool\":{{\"driver\":{{\"name\":\"trex\",\"version\":{},\"informationUri\":\"https://github.com/Variably-Constant/trex\",\"rules\":[{}]}}}},\"columnKind\":\"unicodeCodePoints\",\"results\":[{}]}}]}}",
json_string(crate::version()),
rule_objects.join(","),
result_objects.join(",")
)
}
fn sarif_rule(rule: &SarifRule) -> String {
let mut out = format!(
"{{\"id\":{},\"shortDescription\":{{\"text\":{}}},\"fullDescription\":{{\"text\":{}}},\"defaultConfiguration\":{{\"level\":\"{}\"}}",
json_string(&rule.name),
json_string(&rule.message),
json_string(&rule.pattern),
rule.severity.name()
);
if !rule.meta.is_empty() {
let mut members: Vec<String> = Vec::new();
let tags = crate::custom::tags_of(&rule.meta);
if !tags.is_empty() {
let quoted: Vec<String> = tags.iter().map(|t| json_string(t)).collect();
members.push(format!("\"tags\":[{}]", quoted.join(",")));
}
for (k, v) in &rule.meta {
if k != "tags" {
members.push(format!("{}:{}", json_string(k), json_string(v)));
}
}
out.push_str(&format!(",\"properties\":{{{}}}", members.join(",")));
}
out.push('}');
out
}
fn sarif_result(rules: &[SarifRule], r: &SarifResult) -> String {
let (name, level) = match rules.get(r.rule) {
Some(rule) => (rule.name.as_str(), rule.severity.name()),
None => ("", Severity::Warning.name()),
};
let region = format!(
"{{\"startLine\":{},\"startColumn\":{},\"endLine\":{},\"endColumn\":{},\"snippet\":{{\"text\":{}}}}}",
r.at.line,
r.at.col,
r.at.end_line,
r.at.end_col,
json_string(&r.snippet)
);
let artifact = r.uri.as_deref().map(|u| format!("\"artifactLocation\":{{\"uri\":{}}},", json_string(u)));
let mut out = format!(
"{{\"ruleId\":{},\"ruleIndex\":{},\"level\":\"{level}\",\"message\":{{\"text\":{}}},\"locations\":[{{\"physicalLocation\":{{{}\"region\":{region}}}}}]",
json_string(name),
r.rule,
json_string(&r.message),
artifact.as_deref().unwrap_or("")
);
if let (Some((fix, deleted)), Some(u)) = (&r.fix, &r.uri) {
out.push_str(&format!(
",\"fixes\":[{{\"description\":{{\"text\":{}}},\"artifactChanges\":[{{\"artifactLocation\":{{\"uri\":{}}},\"replacements\":[{{\"deletedRegion\":{{\"startLine\":{},\"startColumn\":{},\"endLine\":{},\"endColumn\":{}}},\"insertedContent\":{{\"text\":{}}}}}]}}]}}]",
json_string(name),
json_string(u),
deleted.line,
deleted.col,
deleted.end_line,
deleted.end_col,
json_string(fix)
));
}
out.push('}');
out
}
#[must_use]
pub fn github_annotation(severity: Severity, rule: &str, file: Option<&str>, at: Place, message: &str) -> String {
let mut props: Vec<String> = Vec::new();
if let Some(n) = file {
props.push(format!("file={}", github_property(&n.replace('\\', "/"))));
}
props.push(format!("line={}", at.line));
props.push(format!("col={}", at.col));
props.push(format!("endLine={}", at.end_line));
props.push(format!("endColumn={}", at.end_col));
props.push(format!("title={}", github_property(rule)));
format!("::{} {}::{}", severity.github(), props.join(","), github_data(message))
}
fn github_data(text: &str) -> String {
text.replace('%', "%25").replace('\r', "%0D").replace('\n', "%0A")
}
fn github_property(text: &str) -> String {
github_data(text).replace(':', "%3A").replace(',', "%2C")
}
#[cfg(test)]
mod tests {
use super::*;
fn placing_scan() -> RuleScan {
let mut shapes = ShapeSet::new();
shapes.declare_text("rule placed note \"${0} at ${start}..${end}\" = \"abc\"\n").expect("the rule declares");
RuleScan::new(shapes).expect("the rule scans")
}
#[test]
fn a_message_places_its_match_in_the_index_unit() {
let scan = placing_scan();
let text = "\u{E9}\u{1F600} abc\n".as_bytes();
let message = |index: &LineIndex| scan.findings(None, text, index, None)[0].message.clone();
assert_eq!(message(&LineIndex::new(text)), "abc at 7..10");
assert_eq!(message(&LineIndex::new(text).counting(text, OffsetUnit::CodePoints, Some(0))), "abc at 3..6");
assert_eq!(message(&LineIndex::new(text).counting(text, OffsetUnit::Utf16, Some(0))), "abc at 4..7");
let window = LineIndex::new(text).within(Some(50), Some(2)).counting(text, OffsetUnit::Utf16, Some(20));
assert_eq!(message(&window), "abc at 24..27");
}
#[test]
fn a_streamed_message_counts_the_units_pushed_before_it() {
let scan = placing_scan();
let pieces = ["\u{1F600}\u{1F600}\n", "\u{E9} abc\n"];
let whole: String = pieces.concat();
let ahead = whole[..whole.find("abc").expect("abc")].encode_utf16().count();
let mut stream = scan.stream(None, 0, Some(0), Some((OffsetUnit::Utf16, 0)));
let mut found = Vec::new();
for piece in pieces {
found.extend(stream.push(piece.as_bytes()));
}
found.extend(stream.finish());
let messages: Vec<String> = found.iter().flat_map(|f| &f.findings).map(|f| f.message.clone()).collect();
assert_eq!(messages, [format!("abc at {ahead}..{}", ahead + 3)]);
let mut bytes = scan.stream(None, 0, Some(0), None);
let mut found = Vec::new();
for piece in pieces {
found.extend(bytes.push(piece.as_bytes()));
}
found.extend(bytes.finish());
let at = whole.find("abc").expect("abc");
let messages: Vec<String> = found.iter().flat_map(|f| &f.findings).map(|f| f.message.clone()).collect();
assert_eq!(messages, [format!("abc at {at}..{}", at + 3)]);
}
#[test]
fn a_sarif_run_says_its_columns_count_code_points() {
let scan = placing_scan();
let text = b"x abc\n".to_vec();
let index = LineIndex::new(&text);
let findings = scan.findings(Some("f.txt"), &text, &index, None);
let found = [Found { name: Some("f.txt".into()), input: text, byte_base: Some(0), line_base: Some(0), findings }];
let doc = scan.sarif(&found);
assert!(doc.contains("]}},\"columnKind\":\"unicodeCodePoints\",\"results\":[{"), "{doc}");
assert!(sarif(&[], &[]).contains("\"columnKind\":\"unicodeCodePoints\",\"results\":[]"));
}
}