use std::collections::HashMap;
use std::path::PathBuf;
use once_cell::sync::Lazy;
use regex::Regex;
use crate::editor::position::Position;
use crate::issue_registry::{NewIssue, Severity};
use super::types::{BodyRule, CompiledMatcher, EmitSeverity, EndCondition};
pub type CaptureMap = HashMap<String, String>;
struct ActiveBlock {
matcher_index: usize,
captures: CaptureMap,
body_index: usize,
lines_accumulated: u32,
}
pub struct MatcherEngine {
matchers: Vec<CompiledMatcher>,
active: Option<ActiveBlock>,
marker: String,
}
impl MatcherEngine {
pub fn new(mut matchers: Vec<CompiledMatcher>, marker: impl Into<String>) -> Self {
matchers.sort_by_key(|b| std::cmp::Reverse(b.priority));
Self {
matchers,
active: None,
marker: marker.into(),
}
}
pub fn process_line(&mut self, line: &str) -> Vec<NewIssue> {
let mut issues = Vec::new();
let start_winner = self.matchers.iter().enumerate().find_map(|(idx, m)| {
if m.start.is_match(line) {
Some((idx, extract_captures(&m.start, line)))
} else {
None
}
});
if let Some((sorted_idx, captures)) = start_winner {
issues.extend(
self.active.take().and_then(|old| {
emit_issue(&self.matchers[old.matcher_index], &old.captures, &self.marker)
}),
);
self.active = Some(ActiveBlock {
matcher_index: sorted_idx,
captures,
body_index: 0,
lines_accumulated: 0,
});
return issues;
}
if self.active.is_none() {
return issues; }
let is_blank_end = {
let block = self.active.as_ref().unwrap();
self.matchers[block.matcher_index].end == EndCondition::BlankLine
&& line.trim().is_empty()
};
if is_blank_end {
let old = self.active.take().unwrap();
if let Some(issue) =
emit_issue(&self.matchers[old.matcher_index], &old.captures, &self.marker)
{
issues.push(issue);
}
return issues;
}
{
let block = self.active.as_mut().unwrap();
block.lines_accumulated += 1;
}
let max_exceeded = {
let block = self.active.as_ref().unwrap();
self.matchers[block.matcher_index]
.max_lines
.is_some_and(|max| block.lines_accumulated > max)
};
if max_exceeded {
let old = self.active.take().unwrap();
if let Some(issue) =
emit_issue(&self.matchers[old.matcher_index], &old.captures, &self.marker)
{
issues.push(issue);
}
return issues;
}
let body = {
let block = self.active.as_ref().unwrap();
self.matchers[block.matcher_index].body.clone()
};
if let Some(ref mut block) = self.active {
process_body_line(block, &body, line);
}
issues
}
pub fn flush(&mut self) -> Vec<NewIssue> {
self.active
.take()
.and_then(|old| {
emit_issue(&self.matchers[old.matcher_index], &old.captures, &self.marker)
})
.into_iter()
.collect()
}
}
fn process_body_line(block: &mut ActiveBlock, body: &[BodyRule], line: &str) {
if body.is_empty() || block.body_index >= body.len() {
return; }
let max_iter = body.len() + 1;
let mut iter = 0;
while iter < max_iter && block.body_index < body.len() {
iter += 1;
let rule = &body[block.body_index];
if rule.pattern.is_match(line) {
let new_caps = extract_captures(&rule.pattern, line);
block.captures.extend(new_caps);
if rule.repeat {
} else {
block.body_index += 1;
}
return; } else if rule.optional {
block.body_index += 1;
} else {
return;
}
}
}
static PLACEHOLDER_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\{\{\s*(\w+)\s*\}\}").expect("static regex"));
pub fn render_template(template: &str, captures: &CaptureMap) -> String {
PLACEHOLDER_RE
.replace_all(template, |caps: ®ex::Captures<'_>| {
captures
.get(caps[1].trim())
.cloned()
.unwrap_or_default()
})
.into_owned()
}
pub fn extract_captures(re: &Regex, line: &str) -> CaptureMap {
let mut map = CaptureMap::new();
if let Some(caps) = re.captures(line) {
for name in re.capture_names().flatten() {
if let Some(m) = caps.name(name) {
map.insert(name.to_string(), m.as_str().to_string());
}
}
}
map
}
fn emit_issue(matcher: &CompiledMatcher, captures: &CaptureMap, marker: &str) -> Option<NewIssue> {
let message = render_template(&matcher.emit.message, captures);
if message.is_empty() {
return None;
}
let severity = match matcher.emit.severity {
EmitSeverity::Error => Severity::Error,
EmitSeverity::Warning => Severity::Warning,
EmitSeverity::Info | EmitSeverity::Hint => Severity::Info,
};
let path = matcher
.emit
.file
.as_deref()
.and_then(|tmpl| {
let s = render_template(tmpl, captures);
if s.is_empty() { None } else { Some(PathBuf::from(s)) }
});
let range = {
let line_num = matcher.emit.line.as_deref().and_then(|tmpl| {
render_template(tmpl, captures)
.parse::<usize>()
.ok()
.map(|n| n.saturating_sub(1))
});
line_num.map(|ln| {
let col = matcher
.emit
.column
.as_deref()
.and_then(|tmpl| render_template(tmpl, captures).parse::<usize>().ok())
.unwrap_or(0);
let pos = Position::new(ln, col);
(pos, pos)
})
};
Some(NewIssue {
marker: Some(marker.to_string()),
source: matcher.source.clone(),
path,
range,
message,
severity,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::log_matcher::types::{BodyRule, CompiledMatcher, EmitSeverity, EmitTemplate, EndCondition, MatcherId};
use std::sync::Arc;
fn make_matcher(
id: &str,
start_pat: &str,
body: Vec<BodyRule>,
end: EndCondition,
emit_msg: &str,
priority: u32,
) -> CompiledMatcher {
CompiledMatcher {
id: MatcherId(id.to_string()),
source: "test".to_string(),
priority,
schema_version: 1,
start: Arc::new(Regex::new(start_pat).unwrap()),
body,
max_lines: None,
end,
emit: EmitTemplate {
severity: EmitSeverity::Error,
message: emit_msg.to_string(),
file: None,
line: None,
column: None,
code: None,
},
}
}
fn body_rule(pat: &str, optional: bool, repeat: bool) -> BodyRule {
BodyRule {
pattern: Arc::new(Regex::new(pat).unwrap()),
optional,
repeat,
}
}
#[test]
fn render_template_basic() {
let mut caps = CaptureMap::new();
caps.insert("msg".to_string(), "hello world".to_string());
assert_eq!(render_template("error: {{ msg }}", &caps), "error: hello world");
}
#[test]
fn render_template_missing_key() {
let caps = CaptureMap::new();
assert_eq!(render_template("{{ missing }}", &caps), "");
}
#[test]
fn render_template_multiple() {
let mut caps = CaptureMap::new();
caps.insert("file".to_string(), "src/main.rs".to_string());
caps.insert("line".to_string(), "42".to_string());
assert_eq!(
render_template("{{ file }}:{{ line }}", &caps),
"src/main.rs:42"
);
}
#[test]
fn extract_captures_basic() {
let re = Regex::new(r"^(?P<file>.+):(?P<line>\d+)").unwrap();
let caps = extract_captures(&re, "src/main.rs:42");
assert_eq!(caps.get("file").map(|s| s.as_str()), Some("src/main.rs"));
assert_eq!(caps.get("line").map(|s| s.as_str()), Some("42"));
}
#[test]
fn extract_captures_no_match() {
let re = Regex::new(r"^(?P<file>.+):(?P<line>\d+)").unwrap();
let caps = extract_captures(&re, "no match here");
assert!(caps.is_empty());
}
#[test]
fn single_start_flush() {
let m = make_matcher(
"t.err",
r"^error: (?P<message>.+)",
vec![],
EndCondition::NextStart,
"{{ message }}",
0,
);
let mut engine = MatcherEngine::new(vec![m], "task:q:t");
let issues = engine.process_line("error: something broke");
assert!(issues.is_empty(), "not emitted yet");
let flushed = engine.flush();
assert_eq!(flushed.len(), 1);
assert_eq!(flushed[0].message, "something broke");
}
#[test]
fn multiline_block() {
let m = {
let mut m = make_matcher(
"t.err",
r"^error: (?P<message>.+)",
vec![body_rule(r"^ --> (?P<file>.+):(?P<line>\d+)", false, false)],
EndCondition::NextStart,
"{{ message }}",
0,
);
m.emit.file = Some("{{ file }}".to_string());
m.emit.line = Some("{{ line }}".to_string());
m
};
let mut engine = MatcherEngine::new(vec![m], "task:q:t");
engine.process_line("error: my error");
engine.process_line(" --> src/lib.rs:10");
let issues = engine.flush();
assert_eq!(issues.len(), 1);
let issue = &issues[0];
assert_eq!(issue.message, "my error");
assert_eq!(issue.path.as_deref(), Some(std::path::Path::new("src/lib.rs")));
assert_eq!(issue.range.map(|(s, _)| s.line), Some(9));
}
#[test]
fn next_start_terminates_block() {
let m = make_matcher(
"t.err",
r"^error: (?P<message>.+)",
vec![],
EndCondition::NextStart,
"{{ message }}",
0,
);
let mut engine = MatcherEngine::new(vec![m], "task:q:t");
let i1 = engine.process_line("error: first error");
assert!(i1.is_empty());
let i2 = engine.process_line("error: second error");
assert_eq!(i2.len(), 1, "first block emitted on second start");
assert_eq!(i2[0].message, "first error");
let flushed = engine.flush();
assert_eq!(flushed.len(), 1);
assert_eq!(flushed[0].message, "second error");
}
#[test]
fn blank_line_terminates_block() {
let m = make_matcher(
"t.warn",
r"^warning: (?P<message>.+)",
vec![],
EndCondition::BlankLine,
"{{ message }}",
0,
);
let mut engine = MatcherEngine::new(vec![m], "task:q:t");
engine.process_line("warning: some warning");
let issues = engine.process_line("");
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].message, "some warning");
assert!(engine.flush().is_empty());
}
#[test]
fn optional_body_skipped() {
let m = make_matcher(
"t.err",
r"^error: (?P<message>.+)",
vec![body_rule(r"^ --> (?P<file>.+)", true, false)], EndCondition::NextStart,
"{{ message }}",
0,
);
let mut engine = MatcherEngine::new(vec![m], "task:q:t");
engine.process_line("error: oops");
let issues = engine.flush();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].message, "oops");
}
#[test]
fn repeat_body_rule() {
let m = make_matcher(
"t.note",
r"^note: (?P<message>.+)",
vec![body_rule(r"^\s+\| (?P<detail>.+)", true, true)], EndCondition::NextStart,
"{{ message }}",
0,
);
let mut engine = MatcherEngine::new(vec![m], "task:q:t");
engine.process_line("note: context");
engine.process_line(" | line one");
engine.process_line(" | line two");
let issues = engine.flush();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].message, "context");
}
#[test]
fn priority_resolution() {
let low = {
let mut m = make_matcher(
"t.low",
r"^msg: (?P<message>.+)",
vec![],
EndCondition::NextStart,
"LOW: {{ message }}",
1,
);
m.id = MatcherId("t.low".to_string());
m
};
let high = {
let mut m = make_matcher(
"t.high",
r"^msg: (?P<message>.+)",
vec![],
EndCondition::NextStart,
"HIGH: {{ message }}",
100,
);
m.id = MatcherId("t.high".to_string());
m
};
let mut engine = MatcherEngine::new(vec![low, high], "task:q:t");
engine.process_line("msg: hello");
let issues = engine.flush();
assert_eq!(issues.len(), 1);
assert!(issues[0].message.starts_with("HIGH:"), "high-priority matcher should win");
}
#[test]
fn flush_empty() {
let mut engine = MatcherEngine::new(vec![], "task:q:t");
assert!(engine.flush().is_empty());
}
#[test]
fn emit_issue_empty_message() {
let m = make_matcher(
"t.empty",
r"^error: (?P<message>.+)",
vec![],
EndCondition::NextStart,
"{{ missing_key }}",
0,
);
let mut engine = MatcherEngine::new(vec![m], "task:q:t");
engine.process_line("error: something");
let issues = engine.flush();
assert!(issues.is_empty(), "empty rendered message should produce no issue");
}
#[test]
fn idle_line_ignored() {
let m = make_matcher(
"t.err",
r"^error: (?P<message>.+)",
vec![],
EndCondition::NextStart,
"{{ message }}",
0,
);
let mut engine = MatcherEngine::new(vec![m], "task:q:t");
let issues = engine.process_line("just some random output");
assert!(issues.is_empty());
assert!(engine.flush().is_empty());
}
#[test]
fn max_lines_cap() {
let mut m = make_matcher(
"t.err",
r"^error: (?P<message>.+)",
vec![],
EndCondition::NextStart,
"{{ message }}",
0,
);
m.max_lines = Some(2);
let mut engine = MatcherEngine::new(vec![m], "task:q:t");
engine.process_line("error: my message");
engine.process_line("body line 1");
engine.process_line("body line 2");
let issues = engine.process_line("body line 3");
assert_eq!(issues.len(), 1, "force-emit on max_lines exceeded");
assert!(engine.flush().is_empty(), "block already consumed");
}
}