use serde_yaml::Value;
use super::errors::DeclarativeError;
use super::yaml::{self, render};
use crate::contract::{DocRule, Rule};
use crate::finding::FindingLevel;
use crate::text_constraints::{
CompiledSpec, forbids_compiled, requires_compiled, text_rule_compiled,
};
use crate::text_match::{NeedleError, TextKind, TextMatchSpec, build_regex, count_bound_problem};
const SPEC_KEYS: &[&str] = &[
"pattern",
"regex",
"normalize",
"ignoreCase",
"min",
"max",
"id",
"note",
"level",
];
fn err(msg: String) -> DeclarativeError {
DeclarativeError::InvalidTextSpec(msg)
}
fn compile_match_spec(
raw: &Value,
kind: &str,
path: &str,
) -> Result<CompiledSpec, DeclarativeError> {
let Value::Mapping(map) = raw else {
return Err(err(format!(
"{path}: a match spec must be a mapping (pattern | regex, …)"
)));
};
for key in map.keys() {
let known = key.as_str().is_some_and(|k| SPEC_KEYS.contains(&k));
if !known {
return Err(err(format!(
"{path}: unknown match-spec key '{}' (allowed: {})",
key.as_str().unwrap_or("?"),
SPEC_KEYS.join(", ")
)));
}
}
let mut spec = TextMatchSpec::default();
assign_needle(map, &mut spec, path)?;
assign_scalar_fields(map, &mut spec, path)?;
assign_level(map, &mut spec, path)?;
assert_count_bound(&spec, kind, path)?;
match build_regex(&spec) {
Ok(re) => Ok((spec, re)),
Err(NeedleError::Invalid(e)) => Err(err(if spec.regex.is_some() {
format!("{path}.regex: invalid regex {}: {e}", spec.repr())
} else {
format!("{path}.pattern: {} does not compile: {e}", spec.repr())
})),
Err(NeedleError::Missing) => Err(err(format!("{path}: a match spec needs a needle"))),
}
}
fn assign_needle(
map: &serde_yaml::Mapping,
spec: &mut TextMatchSpec,
path: &str,
) -> Result<(), DeclarativeError> {
let pattern = map.get("pattern");
let regex = map.get("regex");
match (pattern, regex) {
(Some(_), Some(_)) => Err(err(format!(
"{path}: a match spec needs exactly one of 'pattern' / 'regex', not both"
))),
(None, None) => Err(err(format!(
"{path}: a match spec needs one of 'pattern' (a literal) or 'regex' (a source)"
))),
(Some(_), None) => {
spec.pattern = yaml::string(map, "pattern", path, err)?.map(str::to_string);
Ok(())
}
(None, Some(_)) => {
spec.regex = yaml::string(map, "regex", path, err)?.map(str::to_string);
Ok(())
}
}
}
fn assign_scalar_fields(
map: &serde_yaml::Mapping,
spec: &mut TextMatchSpec,
path: &str,
) -> Result<(), DeclarativeError> {
spec.normalize = yaml::boolean(map, "normalize", path, err)?;
spec.ignore_case = yaml::boolean(map, "ignoreCase", path, err)?;
spec.min = yaml::num(map, "min", path, err)?;
spec.max = yaml::num(map, "max", path, err)?;
spec.id = yaml::string(map, "id", path, err)?.map(str::to_string);
spec.note = yaml::string(map, "note", path, err)?.map(str::to_string);
Ok(())
}
fn assign_level(
map: &serde_yaml::Mapping,
spec: &mut TextMatchSpec,
path: &str,
) -> Result<(), DeclarativeError> {
if let Some(v) = map.get("level") {
spec.level = match v.as_str() {
Some("error") => Some(FindingLevel::Error),
Some("warn") => Some(FindingLevel::Warn),
_ => {
return Err(err(format!(
"{path}.level must be \"error\" or \"warn\" (got {})",
render(Some(v))
)));
}
};
}
Ok(())
}
fn assert_count_bound(
spec: &TextMatchSpec,
kind: &str,
path: &str,
) -> Result<(), DeclarativeError> {
let kind = if kind == "requires" {
TextKind::Requires
} else {
TextKind::Forbids
};
match count_bound_problem(kind, spec) {
Some(problem) => Err(err(format!("{path}: {}", problem.message))),
None => Ok(()),
}
}
fn matcher_identity(spec: &TextMatchSpec) -> String {
crate::text_match::pattern_key(spec)
}
fn compile_match_specs(
raw: &Value,
kind: &str,
path: &str,
) -> Result<Vec<CompiledSpec>, DeclarativeError> {
let Value::Sequence(entries) = raw else {
return Err(err(format!("{path}: {kind} must be a list of match specs")));
};
let specs: Vec<CompiledSpec> = entries
.iter()
.enumerate()
.map(|(i, entry)| compile_match_spec(entry, kind, &format!("{path}[{i}]")))
.collect::<Result<_, _>>()?;
let mut seen: Vec<(String, usize)> = Vec::new();
for (i, (spec, _)) in specs.iter().enumerate() {
let key = matcher_identity(spec);
if let Some((_, prev)) = seen.iter().find(|(k, _)| *k == key) {
return Err(err(format!(
"{path}[{i}]: duplicate match spec — same matcher as {path}[{prev}] (identical needle / normalize / ignoreCase)"
)));
}
seen.push((key, i));
}
Ok(specs)
}
pub fn has_text_keys(node: &serde_yaml::Mapping) -> bool {
node.get("requires").is_some() || node.get("forbids").is_some()
}
fn compile_scope_text_specs(
node: &serde_yaml::Mapping,
path: &str,
scope_label: &str,
) -> Result<(Vec<CompiledSpec>, Vec<CompiledSpec>), DeclarativeError> {
let req = match node.get("requires") {
Some(v) => compile_match_specs(v, "requires", &format!("{path}.requires"))?,
None => Vec::new(),
};
let forb = match node.get("forbids") {
Some(v) => compile_match_specs(v, "forbids", &format!("{path}.forbids"))?,
None => Vec::new(),
};
let literal_id = |spec: &TextMatchSpec| -> Option<String> {
spec.regex.is_none().then(|| matcher_identity(spec))
};
let required: Vec<String> = req.iter().map(|(s, _)| s).filter_map(literal_id).collect();
for (spec, _) in &forb {
if let Some(key) = literal_id(spec)
&& required.contains(&key)
{
return Err(err(format!(
"{path}: contradiction in {scope_label} — the literal \"{}\" is both required and forbidden",
spec.pattern.as_deref().unwrap_or("")
)));
}
}
Ok((req, forb))
}
pub fn compile_section_text_rules(
node: &serde_yaml::Mapping,
path: &str,
scope_label: &str,
) -> Result<Vec<Box<dyn Rule>>, DeclarativeError> {
let (req, forb) = compile_scope_text_specs(node, path, scope_label)?;
let mut rules: Vec<Box<dyn Rule>> = Vec::new();
if !req.is_empty() {
rules.push(requires_compiled(req));
}
if !forb.is_empty() {
rules.push(forbids_compiled(forb));
}
Ok(rules)
}
pub fn compile_body_text_rule(
node: &serde_yaml::Mapping,
path: &str,
) -> Result<Option<Box<dyn DocRule>>, DeclarativeError> {
let (req, forb) = compile_scope_text_specs(node, path, "the document")?;
if req.is_empty() && forb.is_empty() {
return Ok(None);
}
Ok(Some(text_rule_compiled(req, forb)))
}
#[cfg(test)]
mod tests {
use super::*;
fn map(yaml: &str) -> serde_yaml::Mapping {
serde_yaml::from_str(yaml).unwrap()
}
#[test]
fn section_scope_compiles_requires_then_forbids() {
let node =
map("requires:\n - pattern: outcome\nforbids:\n - pattern: TBD\n level: warn\n");
let rules = compile_section_text_rules(&node, "body.sections[0]", "Summary").unwrap();
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].id(), "text/requires");
assert_eq!(rules[1].id(), "text/forbids");
}
#[test]
fn body_root_compiles_to_one_doc_rule_or_none() {
let node = map("forbids:\n - pattern: '}scripts/'\n normalize: false\n");
assert!(compile_body_text_rule(&node, "body").unwrap().is_some());
let empty = map("sections: []\n");
assert!(compile_body_text_rule(&empty, "body").unwrap().is_none());
}
#[test]
fn vocabulary_violations_are_declarative_errors() {
let cases = [
"requires:\n - pattern: a\n wibble: 1\n", "requires:\n - pattern: a\n regex: b\n", "requires:\n - min: 1\n", "requires:\n - pattern: 3\n", "requires:\n - pattern: a\n level: report\n", "requires:\n - pattern: a\n max: 0\n", "forbids:\n - pattern: a\n max: -1\n", "requires:\n - pattern: a\n - pattern: a\n", "requires:\n - regex: '('\n", "requires:\n - regex: '(?=x)'\n", "requires:\n - regex: '\\w{1000}{1000}'\n", ];
for yaml in cases {
let node = map(yaml);
assert!(
compile_section_text_rules(&node, "p", "S").is_err(),
"expected rejection for: {yaml}"
);
}
}
#[test]
fn a_forbids_min_above_its_max_compiles() {
let node = map("forbids:\n - pattern: a\n min: 3\n max: 1\n");
assert!(compile_section_text_rules(&node, "p", "S").is_ok());
}
#[test]
fn a_needle_error_names_its_key() {
let message = |yaml: &str| match compile_section_text_rules(&map(yaml), "p", "S") {
Err(e) => e.to_string(),
Ok(_) => panic!("expected rejection for: {yaml}"),
};
assert!(
message("forbids:\n - regex: '('\n").contains("p.forbids[0].regex: invalid regex")
);
let long = "x ".repeat(20_000);
assert!(
message(&format!("forbids:\n - pattern: '{long}'\n"))
.contains("p.forbids[0].pattern: ")
);
}
#[test]
fn a_required_and_forbidden_literal_is_a_contradiction() {
let node = map("requires:\n - pattern: outcome\nforbids:\n - pattern: outcome\n");
let Err(e) = compile_section_text_rules(&node, "p", "Summary") else {
panic!("expected a DeclarativeError")
};
assert!(e.to_string().contains("contradiction in Summary"));
}
}