use std::path::{Path, PathBuf};
use crate::custom::{Rule, Severity, ShapeSet};
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 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 (line, col) = index.line_col(input, m.start);
let place = crate::ReportAt {
path: name.unwrap_or(""),
line,
col,
base: index.base(),
pattern: Some(&rule.name),
rule: Some(crate::ReportRule { name: &rule.name, severity: rule.severity.name(), message: "", fix: "" }),
};
let message = self.messages[i].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>) -> RuleStream<'_> {
let groups = self
.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();
RuleStream { scan: self, name: name.map(str::to_string), groups }
}
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,
name: Option<String>,
groups: Vec<(usize, crate::HeldStream)>,
}
impl RuleStream<'_> {
#[must_use]
pub fn commits_early(&self) -> bool {
self.groups.iter().all(|(_, stream)| stream.commits_early())
}
pub fn push(&mut self, bytes: &[u8]) -> Vec<Found> {
let scan = self.scan;
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 index = LineIndex::new(stream.held()).within(Some(stream.base()), stream.lines_before());
let findings = scan.findings_at(*group, stream.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
}
#[must_use]
pub fn finish(self) -> Vec<Found> {
let RuleStream { scan, name, groups } = self;
let mut out = Vec::new();
for (group, stream) in groups {
let ended = stream.finish();
if ended.matches.is_empty() {
continue;
}
let index = LineIndex::new(&ended.held).within(Some(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> {
let mut out = Vec::new();
for arg in paths {
let path = Path::new(arg);
if path.is_dir() {
let mut found = Vec::new();
rule_files_under(path, &mut found)?;
found.sort();
if found.is_empty() {
return Err(format!("{arg}: no .trex file under it"));
}
out.extend(found);
} else {
out.push(path.to_path_buf());
}
}
Ok(out)
}
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(())
}
fn rule_files_under(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
let path = entry.path();
if path.is_dir() {
rule_files_under(&path, out)?;
} else if path.extension().is_some_and(|ext| ext == "trex") {
out.push(path);
}
}
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\":[{}]}}}},\"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")
}