use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use once_cell::sync::Lazy;
use regex::Regex;
use super::message::{has_errors, Message};
use super::schema::LogMatcherDef;
use super::types::{
BodyRule, CompiledMatcher, EmitSeverity, EmitTemplate, EndCondition, MatcherId,
};
#[derive(Debug, Clone)]
pub struct CompileOptions {
pub source_file: Option<String>,
pub warn_unused_captures: bool,
pub max_schema_version: u32,
}
impl Default for CompileOptions {
fn default() -> Self {
Self {
source_file: None,
warn_unused_captures: false,
max_schema_version: 1,
}
}
}
#[derive(Debug)]
pub struct CompileResult {
pub matchers: Vec<CompiledMatcher>,
pub messages: Vec<Message>,
}
pub fn compile_matchers(
defs: Vec<LogMatcherDef>,
options: CompileOptions,
) -> Result<CompileResult, Vec<Message>> {
let src = options.source_file.as_deref().unwrap_or("unknown");
let mut messages: Vec<Message> = Vec::new();
let mut matchers: Vec<CompiledMatcher> = Vec::new();
let mut skip_indices: HashSet<usize> = HashSet::new();
let mut first_occurrence: HashMap<String, usize> = HashMap::new();
for (idx, def) in defs.iter().enumerate() {
let id_field = format!("{}:matchers[{}].id", src, idx);
if def.id.is_empty() {
messages.push(Message::error_at("matcher id must not be empty", &id_field));
skip_indices.insert(idx);
} else if let Some(&first_idx) = first_occurrence.get(&def.id) {
let first_path = format!("{}:matchers[{}].id", src, first_idx);
messages.push(
Message::error_at(
format!("duplicate matcher id: {}", def.id),
&id_field,
)
.with_related(&first_path, "first defined here"),
);
skip_indices.insert(idx);
} else {
first_occurrence.insert(def.id.clone(), idx);
}
}
for (idx, def) in defs.iter().enumerate() {
if skip_indices.contains(&idx) {
continue;
}
let field = |suffix: &str| format!("{}:matchers[{}].{}", src, idx, suffix);
let mut matcher_msgs: Vec<Message> = Vec::new();
if def.schema_version > options.max_schema_version {
matcher_msgs.push(Message::error_at(
format!(
"unsupported schema version {}; max is {}",
def.schema_version, options.max_schema_version
),
field("schema_version"),
));
}
if def.source.is_empty() {
matcher_msgs.push(Message::error_at("source must not be empty", field("source")));
}
let start_regex = compile_regex(&def.start.pattern, field("start.match"), &mut matcher_msgs);
let has_valid_end =
matches!(def.end.condition.as_str(), "next_start" | "blank_line");
let end = match def.end.condition.as_str() {
"next_start" => Some(EndCondition::NextStart),
"blank_line" => Some(EndCondition::BlankLine),
other => {
matcher_msgs.push(Message::error_at(
format!(
"unknown condition '{}'; expected next_start or blank_line",
other
),
field("end.condition"),
));
None
}
};
let mut body_rules: Vec<BodyRule> = Vec::new();
for (bidx, brule) in def.body.iter().enumerate() {
let brule_field =
|s: &str| format!("{}:matchers[{}].body[{}].{}", src, idx, bidx, s);
if brule.repeat && !has_valid_end {
matcher_msgs.push(Message::error_at(
"repeat: true requires a valid end condition",
brule_field("repeat"),
));
}
let pattern =
compile_regex(&brule.pattern, brule_field("match"), &mut matcher_msgs);
if let Some(r) = pattern {
body_rules.push(BodyRule {
pattern: r,
optional: brule.optional,
repeat: brule.repeat,
});
}
}
if def.emit.message.is_empty() {
matcher_msgs.push(Message::error_at(
"emit.message is required",
field("emit.message"),
));
}
let severity =
parse_severity(&def.emit.severity, field("emit.severity"), &mut matcher_msgs);
if options.warn_unused_captures {
let all_names: HashSet<String> = {
let mut names = HashSet::new();
if let Some(r) = &start_regex {
collect_capture_names(r, &mut names);
}
for rule in &body_rules {
collect_capture_names(&rule.pattern, &mut names);
}
names
};
let emit = &def.emit;
let template_fields: &[(&str, Option<&String>)] = &[
("emit.message", Some(&emit.message)),
("emit.file", emit.file.as_ref()),
("emit.line", emit.line.as_ref()),
("emit.column", emit.column.as_ref()),
("emit.code", emit.code.as_ref()),
];
for (fname, tmpl) in template_fields.iter() {
if let Some(t) = tmpl {
for group_name in extract_template_refs(t) {
if !all_names.contains(&group_name) {
matcher_msgs.push(Message::warning_at(
format!(
"template '{{{{ {} }}}}' references capture group not found in any regex",
group_name
),
field(fname),
));
}
}
}
}
}
messages.extend(matcher_msgs.iter().cloned());
if !has_errors(&matcher_msgs)
&& let (Some(start), Some(end), Some(severity)) = (start_regex, end, severity)
{
matchers.push(CompiledMatcher {
id: MatcherId(def.id.clone()),
source: def.source.clone(),
priority: def.priority,
schema_version: def.schema_version,
start,
body: body_rules,
max_lines: def.max_lines,
end,
emit: EmitTemplate {
severity,
message: def.emit.message.clone(),
file: def.emit.file.clone(),
line: def.emit.line.clone(),
column: def.emit.column.clone(),
code: def.emit.code.clone(),
},
});
}
}
if has_errors(&messages) {
Err(messages)
} else {
Ok(CompileResult { matchers, messages })
}
}
fn compile_regex(
pattern: &str,
field_path: impl Into<String>,
messages: &mut Vec<Message>,
) -> Option<Arc<Regex>> {
match Regex::new(pattern) {
Ok(r) => Some(Arc::new(r)),
Err(e) => {
messages.push(Message::error_at(
format!("invalid regex: {}", e),
field_path,
));
None
}
}
}
fn parse_severity(
s: &str,
field_path: impl Into<String>,
messages: &mut Vec<Message>,
) -> Option<EmitSeverity> {
match s {
"error" => Some(EmitSeverity::Error),
"warning" => Some(EmitSeverity::Warning),
"info" => Some(EmitSeverity::Info),
"hint" => Some(EmitSeverity::Hint),
other => {
messages.push(Message::error_at(
format!(
"unknown severity '{}'; expected error, warning, info, or hint",
other
),
field_path,
));
None
}
}
}
fn collect_capture_names(regex: &Regex, out: &mut HashSet<String>) {
for name in regex.capture_names().flatten() {
out.insert(name.to_string());
}
}
static TEMPLATE_REF_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\{\{\s*(\w+)\s*\}\}").expect("static regex"));
fn extract_template_refs(template: &str) -> Vec<String> {
TEMPLATE_REF_RE
.captures_iter(template)
.map(|c| c[1].to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::log_matcher::schema::{BodyRuleDef, EmitDef, EndDef, StartDef};
fn minimal_def(id: &str) -> LogMatcherDef {
LogMatcherDef {
id: id.to_string(),
source: "test".to_string(),
priority: 0,
schema_version: 1,
start: StartDef {
pattern: "^test".to_string(),
},
body: vec![],
max_lines: None,
end: EndDef {
condition: "next_start".to_string(),
},
emit: EmitDef {
severity: "error".to_string(),
message: "test message".to_string(),
file: None,
line: None,
column: None,
code: None,
},
}
}
fn compile_one(def: LogMatcherDef) -> Result<CompileResult, Vec<Message>> {
compile_matchers(vec![def], CompileOptions::default())
}
#[test]
fn valid_matcher_compiles() {
let result = compile_one(minimal_def("my.matcher")).expect("should succeed");
assert_eq!(result.matchers.len(), 1);
assert_eq!(result.matchers[0].id.0, "my.matcher");
assert_eq!(result.matchers[0].priority, 0);
assert_eq!(result.matchers[0].schema_version, 1);
assert!(result.messages.is_empty());
}
#[test]
fn empty_defs_succeeds() {
let result = compile_matchers(vec![], CompileOptions::default()).expect("should succeed");
assert!(result.matchers.is_empty());
assert!(result.messages.is_empty());
}
#[test]
fn invalid_start_regex_rejected_with_field_path() {
let mut def = minimal_def("bad.regex");
def.start.pattern = "^error(unclosed".to_string();
let msgs = compile_one(def).expect_err("should fail");
let ref_paths: Vec<&str> = msgs
.iter()
.filter_map(|m| m.reference.as_ref())
.map(|r| r.filename.as_str())
.collect();
assert!(
ref_paths.iter().any(|p| p.contains("start.match")),
"expected start.match in refs, got: {:?}",
ref_paths
);
assert!(msgs.iter().any(|m| m.text.contains("invalid regex")));
}
#[test]
fn invalid_body_regex_rejected() {
let mut def = minimal_def("bad.body");
def.body.push(BodyRuleDef {
pattern: "^(bad".to_string(),
optional: false,
repeat: false,
});
let msgs = compile_one(def).expect_err("should fail");
assert!(msgs.iter().any(|m| m
.reference
.as_ref()
.is_some_and(|r| r.filename.contains("body[0].match"))));
}
#[test]
fn missing_emit_message_rejected() {
let mut def = minimal_def("no.message");
def.emit.message = String::new();
let msgs = compile_one(def).expect_err("should fail");
assert!(msgs.iter().any(|m| m
.reference
.as_ref()
.is_some_and(|r| r.filename.contains("emit.message"))));
assert!(msgs.iter().any(|m| m.text.contains("emit.message is required")));
}
#[test]
fn multiple_matchers_compile() {
let defs = vec![
minimal_def("a.matcher"),
minimal_def("b.matcher"),
minimal_def("c.matcher"),
];
let result = compile_matchers(defs, CompileOptions::default()).expect("should succeed");
assert_eq!(result.matchers.len(), 3);
}
#[test]
fn duplicate_id_error_has_related_ref() {
let defs = vec![minimal_def("dup.id"), minimal_def("dup.id")];
let msgs = compile_matchers(defs, CompileOptions::default()).expect_err("should fail");
let dup_msg = msgs
.iter()
.find(|m| m.text.contains("duplicate matcher id"))
.expect("should have duplicate error");
assert!(
!dup_msg.related.is_empty(),
"expected related reference for duplicate"
);
assert!(dup_msg.related[0].label.contains("first defined here"));
}
#[test]
fn first_of_duplicate_still_compiles() {
let defs = vec![minimal_def("dup"), minimal_def("dup")];
let msgs = compile_matchers(defs, CompileOptions::default()).expect_err("should fail");
let dup_errors: Vec<_> = msgs
.iter()
.filter(|m| m.text.contains("duplicate matcher id: dup"))
.collect();
assert_eq!(dup_errors.len(), 1, "expected exactly one duplicate error");
}
#[test]
fn defaults_applied() {
let result = compile_one(minimal_def("defaults")).expect("should succeed");
let m = &result.matchers[0];
assert_eq!(m.priority, 0);
assert_eq!(m.schema_version, 1);
assert!(m.body.is_empty());
assert!(m.max_lines.is_none());
}
#[test]
fn schema_version_too_high_rejected() {
let mut def = minimal_def("future");
def.schema_version = 99;
let msgs = compile_one(def).expect_err("should fail");
assert!(msgs.iter().any(|m| m
.reference
.as_ref()
.is_some_and(|r| r.filename.contains("schema_version"))));
assert!(msgs.iter().any(|m| m.text.contains("unsupported schema version")));
}
#[test]
fn unknown_end_condition_rejected() {
let mut def = minimal_def("bad.end");
def.end.condition = "timeout".to_string();
let msgs = compile_one(def).expect_err("should fail");
assert!(msgs.iter().any(|m| m
.reference
.as_ref()
.is_some_and(|r| r.filename.contains("end.condition"))));
}
#[test]
fn blank_line_end_condition_compiles() {
let mut def = minimal_def("blank.end");
def.end.condition = "blank_line".to_string();
let result = compile_one(def).expect("should succeed");
assert_eq!(result.matchers[0].end, crate::log_matcher::types::EndCondition::BlankLine);
}
#[test]
fn unknown_severity_rejected() {
let mut def = minimal_def("bad.sev");
def.emit.severity = "fatal".to_string();
let msgs = compile_one(def).expect_err("should fail");
assert!(msgs.iter().any(|m| m.text.contains("unknown severity")));
}
#[test]
fn repeat_without_end_condition_rejected() {
let mut def = minimal_def("bad.repeat");
def.end.condition = "bad_cond".to_string();
def.body.push(BodyRuleDef {
pattern: "^.*".to_string(),
optional: false,
repeat: true,
});
let msgs = compile_one(def).expect_err("should fail");
assert!(msgs
.iter()
.any(|m| m.text.contains("repeat: true requires a valid end condition")));
}
#[test]
fn unknown_capture_warning_emitted() {
let mut def = minimal_def("warn.captures");
def.start.pattern = "^error (?P<message>.+)".to_string();
def.emit.message = "{{ unknown_group }}".to_string();
let result = compile_matchers(
vec![def],
CompileOptions {
warn_unused_captures: true,
..Default::default()
},
)
.expect("should succeed with warnings");
assert!(
result
.messages
.iter()
.any(|m| m.text.contains("unknown_group")),
"expected warning about unknown_group"
);
}
#[test]
fn no_warning_for_known_capture() {
let mut def = minimal_def("known.capture");
def.start.pattern = "^error (?P<message>.+)".to_string();
def.emit.message = "{{ message }}".to_string();
let result = compile_matchers(
vec![def],
CompileOptions {
warn_unused_captures: true,
..Default::default()
},
)
.expect("should succeed");
assert!(
!result.messages.iter().any(|m| m.text.contains("message")),
"should not warn about known capture group"
);
}
#[test]
fn source_file_embedded_in_references() {
let mut def = minimal_def("ref.test");
def.start.pattern = "unclosed[".to_string();
let msgs = compile_matchers(
vec![def],
CompileOptions {
source_file: Some("my-extension.yaml".to_string()),
..Default::default()
},
)
.expect_err("should fail");
assert!(msgs
.iter()
.any(|m| m.reference.as_ref().is_some_and(|r| r.filename.contains("my-extension.yaml"))));
}
#[test]
fn all_errors_collected_across_matchers() {
let mut def1 = minimal_def("broken.one");
def1.start.pattern = "unclosed[".to_string();
let mut def2 = minimal_def("broken.two");
def2.emit.message = String::new();
let msgs =
compile_matchers(vec![def1, def2], CompileOptions::default()).expect_err("should fail");
let has_broken_one = msgs.iter().any(|m| {
m.reference
.as_ref()
.is_some_and(|r| r.filename.contains("matchers[0]"))
});
let has_broken_two = msgs.iter().any(|m| {
m.reference
.as_ref()
.is_some_and(|r| r.filename.contains("matchers[1]"))
});
assert!(has_broken_one, "expected error for matchers[0]");
assert!(has_broken_two, "expected error for matchers[1]");
}
}