use regex::{Regex, RegexBuilder};
use crate::finding::{Finding, FindingLevel, SourcePos};
use crate::js;
use crate::registry::{Ctx, FindingSpec};
#[derive(Debug, Clone, Default)]
pub struct TextMatchSpec {
pub pattern: Option<String>,
pub regex: Option<String>,
pub normalize: Option<bool>,
pub ignore_case: Option<bool>,
pub min: Option<f64>,
pub max: Option<f64>,
pub id: Option<String>,
pub note: Option<String>,
pub level: Option<FindingLevel>,
}
impl TextMatchSpec {
pub(crate) fn repr(&self) -> String {
if let Some(regex) = &self.regex {
format!("/{regex}/")
} else {
format!("\"{}\"", self.pattern.as_deref().unwrap_or(""))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct TextMatchResult {
pub(crate) count: usize,
pub(crate) positions: Vec<SourcePos>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TextKind {
Requires,
Forbids,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TextFindingKind {
Requires,
Forbids,
Count,
}
impl TextFindingKind {
fn as_str(&self) -> &'static str {
match self {
TextFindingKind::Requires => "requires",
TextFindingKind::Forbids => "forbids",
TextFindingKind::Count => "count",
}
}
}
fn escape_regex(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(
c,
'.' | '*' | '+' | '?' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']' | '\\'
) {
out.push('\\');
}
out.push(c);
}
out
}
fn normalized_pattern_source(pattern: &str) -> String {
js::words(pattern)
.map(escape_regex)
.collect::<Vec<_>>()
.join(&format!("{}+", js::SPACE_CLASS))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum NeedleError {
Missing,
Invalid(String),
}
pub(crate) fn build_regex(spec: &TextMatchSpec) -> Result<Option<Regex>, NeedleError> {
let source = if let Some(regex) = &spec.regex {
regex.clone()
} else if let Some(pattern) = &spec.pattern {
if spec.normalize.unwrap_or(true) {
normalized_pattern_source(pattern)
} else {
escape_regex(pattern)
}
} else {
return Err(NeedleError::Missing);
};
if source.is_empty() {
return Ok(None);
}
RegexBuilder::new(&source)
.case_insensitive(spec.ignore_case.unwrap_or(false))
.build()
.map(Some)
.map_err(|e| NeedleError::Invalid(e.to_string()))
}
pub(crate) fn build_regex_or_panic(spec: &TextMatchSpec) -> Option<Regex> {
build_regex(spec).unwrap_or_else(|e| match e {
NeedleError::Missing => panic!("a match spec must supply one of `pattern` or `regex`"),
NeedleError::Invalid(e) => panic!("invalid regex {}: {e}", spec.repr()),
})
}
struct PositionMapper<'a> {
text: &'a str,
scanned: usize,
line: u32,
col: u32,
}
impl PositionMapper<'_> {
fn at(&mut self, offset: usize) -> SourcePos {
for c in self.text[self.scanned..offset].chars() {
if c == '\n' {
self.line += 1;
self.col = 0;
} else {
self.col += c.len_utf16() as u32;
}
}
self.scanned = offset;
SourcePos::at(self.line, self.col + 1)
}
}
pub(crate) fn match_text(text: &str, re: Option<&Regex>) -> TextMatchResult {
let Some(re) = re else {
return TextMatchResult::default();
};
let mut mapper = PositionMapper {
text,
scanned: 0,
line: 1,
col: 0,
};
let mut positions = Vec::new();
let mut start = 0;
while start <= text.len() {
let Some(m) = re.find_at(text, start) else {
break;
};
positions.push(mapper.at(m.start()));
start = if m.end() > m.start() {
m.end()
} else {
m.end() + text[m.end()..].chars().next().map_or(1, char::len_utf8)
};
}
TextMatchResult {
count: positions.len(),
positions,
}
}
fn short_hash(s: &str) -> String {
let mut h: u32 = 0x811c9dc5;
for unit in s.encode_utf16() {
h ^= unit as u32;
h = h.wrapping_mul(0x0100_0193);
}
to_base36(h)
}
fn to_base36(mut n: u32) -> String {
const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
if n == 0 {
return "0".into();
}
let mut out = Vec::new();
while n > 0 {
out.push(DIGITS[(n % 36) as usize]);
n /= 36;
}
out.reverse();
String::from_utf8(out).expect("base36 digits are ascii")
}
pub(crate) fn pattern_key(spec: &TextMatchSpec) -> String {
let fold = if spec.ignore_case.unwrap_or(false) {
"i"
} else {
""
};
if let Some(regex) = &spec.regex {
return format!("regex|{regex}|{fold}");
}
let normalize = spec.normalize.unwrap_or(true);
let pattern = spec.pattern.as_deref().unwrap_or("");
let needle = if normalize {
js::words(pattern).collect::<Vec<_>>().join(" ")
} else {
pattern.to_string()
};
format!(
"pattern|{}|{needle}|{fold}",
if normalize { "n" } else { "x" }
)
}
pub(crate) fn synthesize_text_id(
kind: TextFindingKind,
scope_key: &str,
spec: &TextMatchSpec,
) -> String {
if let Some(id) = &spec.id {
return id.clone();
}
format!(
"text/{}/{scope_key}/{}",
kind.as_str(),
short_hash(&pattern_key(spec))
)
}
fn scope_label<'a>(scope_key: &'a str, scope: Option<&'a str>) -> &'a str {
if let Some(scope) = scope {
return scope;
}
if scope_key == "doc" {
"document"
} else {
scope_key
}
}
fn bounds_for(kind: TextKind, spec: &TextMatchSpec) -> (f64, Option<f64>) {
match kind {
TextKind::Requires => (spec.min.unwrap_or(1.0), spec.max),
TextKind::Forbids => (spec.min.unwrap_or(0.0), Some(spec.max.unwrap_or(0.0))),
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct CountBoundProblem {
pub(crate) id: &'static str,
pub(crate) message: String,
}
pub(crate) fn count_bound_problem(
kind: TextKind,
spec: &TextMatchSpec,
) -> Option<CountBoundProblem> {
let max = spec.max?;
match kind {
TextKind::Requires => {
let floor = spec.min.unwrap_or(1.0).max(1.0);
(max < floor).then(|| CountBoundProblem {
id: "contract/text-requires-purity",
message: format!(
"requires({}): a requires entry may not express absence (max {max} < {floor}); use forbids(...) for an absence check",
spec.repr()
),
})
}
TextKind::Forbids => (max < 0.0).then(|| CountBoundProblem {
id: "contract/text-count-bound",
message: format!(
"forbids({}): max ({max}) is below 0, so the count bound can never be met",
spec.repr()
),
}),
}
}
pub(crate) struct TextFindingInput<'a> {
pub(crate) kind: TextKind,
pub(crate) spec: &'a TextMatchSpec,
pub(crate) result: TextMatchResult,
pub(crate) scope_key: &'a str,
pub(crate) scope: Option<&'a str>,
pub(crate) scope_pos: Option<SourcePos>,
}
pub(crate) fn build_text_findings(input: TextFindingInput<'_>, ctx: &Ctx) -> Vec<Finding> {
let (min, max) = bounds_for(input.kind, input.spec);
let repr = input.spec.repr();
let note = input
.spec
.note
.as_deref()
.map(|n| format!(" — {n}"))
.unwrap_or_default();
let count = input.result.count;
let count_finding = |expected: String| -> Finding {
let mut spec = FindingSpec::new(
synthesize_text_id(TextFindingKind::Count, input.scope_key, input.spec),
format!("{repr} found {count} times, expected {expected}{note}"),
);
if let Some(level) = input.spec.level {
spec = spec.level(level);
}
if let Some(pos) = input.scope_pos {
spec = spec.pos(pos);
}
ctx.finding(spec)
};
match input.kind {
TextKind::Requires => {
if (count as f64) < min {
if min == 1.0 {
let mut spec = FindingSpec::new(
synthesize_text_id(TextFindingKind::Requires, input.scope_key, input.spec),
format!(
"required phrase {repr} not found in {}{note}",
scope_label(input.scope_key, input.scope)
),
);
if let Some(level) = input.spec.level {
spec = spec.level(level);
}
if let Some(pos) = input.scope_pos {
spec = spec.pos(pos);
}
return vec![ctx.finding(spec)];
}
return vec![count_finding(format!("at least {}", js::num(min)))];
}
if let Some(max) = max
&& (count as f64) > max
{
return vec![count_finding(format!("at most {}", js::num(max)))];
}
vec![]
}
TextKind::Forbids => {
let max = max.unwrap_or(0.0);
if (count as f64) <= max {
return vec![];
}
if max == 0.0 {
return input
.result
.positions
.iter()
.map(|pos| {
let mut spec = FindingSpec::new(
synthesize_text_id(
TextFindingKind::Forbids,
input.scope_key,
input.spec,
),
format!("forbidden phrase {repr} present{note}"),
);
if let Some(level) = input.spec.level {
spec = spec.level(level);
}
ctx.finding(spec.pos(*pos))
})
.collect();
}
vec![count_finding(format!("at most {}", js::num(max)))]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::Registry;
fn pattern(p: &str) -> TextMatchSpec {
TextMatchSpec {
pattern: Some(p.into()),
..Default::default()
}
}
fn run(text: &str, spec: &TextMatchSpec) -> TextMatchResult {
match_text(text, build_regex(spec).unwrap().as_ref())
}
fn regex(source: &str) -> TextMatchSpec {
TextMatchSpec {
regex: Some(source.into()),
..Default::default()
}
}
#[test]
fn empty_matches_count_as_in_js() {
assert_eq!(run("ab", ®ex("x*")).count, 3);
assert_eq!(run("ab", ®ex("a*")).count, 3);
assert_eq!(run("é", ®ex("")).count, 0); assert_eq!(run("éa", ®ex("x?")).count, 3);
}
#[test]
fn columns_count_utf16_units_across_lines() {
let r = run("😀 x\n y x", &pattern("x"));
assert_eq!(r.positions, vec![SourcePos::at(1, 4), SourcePos::at(2, 4)]);
}
#[test]
fn normalize_uses_js_whitespace() {
assert_eq!(run("a\u{FEFF}b", &pattern("a b")).count, 1);
assert_eq!(run("a\u{85}b", &pattern("a b")).count, 0);
}
#[test]
fn a_needle_the_regex_crate_rejects_is_an_error() {
assert!(matches!(
build_regex(®ex("(")),
Err(NeedleError::Invalid(_))
));
assert!(matches!(
build_regex(®ex(r"(?=x)")),
Err(NeedleError::Invalid(_))
));
assert!(matches!(
build_regex(®ex(r"\w{1000}{1000}")),
Err(NeedleError::Invalid(_))
));
assert_eq!(
build_regex(&TextMatchSpec::default()).unwrap_err(),
NeedleError::Missing
);
}
#[test]
fn literal_pattern_counts_and_positions() {
let r = run("alpha beta\ngamma beta", &pattern("beta"));
assert_eq!(r.count, 2);
assert_eq!(r.positions, vec![SourcePos::at(1, 7), SourcePos::at(2, 7)]);
}
#[test]
fn normalize_lets_a_wrapped_phrase_match() {
let spec = pattern("the decision outcome");
assert_eq!(run("we name the decision\noutcome here", &spec).count, 1);
let exact = TextMatchSpec {
normalize: Some(false),
..pattern("the decision outcome")
};
assert_eq!(run("we name the decision\noutcome here", &exact).count, 0);
}
#[test]
fn ignore_case_folds_and_regex_sources_pass_through() {
let spec = TextMatchSpec {
ignore_case: Some(true),
..pattern("done")
};
assert_eq!(run("DONE and Done", &spec).count, 2);
let re = TextMatchSpec {
regex: Some("LEASE-(CONFLICT|MISSING) ref=".into()),
..Default::default()
};
assert_eq!(run("saw LEASE-CONFLICT ref=42", &re).count, 1);
}
#[test]
fn synthesized_ids_match_the_ts_goldens() {
assert_eq!(
synthesize_text_id(TextFindingKind::Requires, "summary", &pattern("outcome")),
"text/requires/summary/1tc7itx"
);
assert_eq!(
synthesize_text_id(
TextFindingKind::Forbids,
"doc",
&TextMatchSpec {
normalize: Some(false),
..pattern("}scripts/")
}
),
"text/forbids/doc/o9pijh"
);
assert_eq!(
synthesize_text_id(TextFindingKind::Count, "checklist", &pattern("DONE")),
"text/count/checklist/9ms6i7"
);
assert_eq!(
synthesize_text_id(
TextFindingKind::Requires,
"failureModes",
&TextMatchSpec {
regex: Some("LEASE-(CONFLICT|MISSING) ref=".into()),
..Default::default()
}
),
"text/requires/failureModes/17j7bdw"
);
}
#[test]
fn explicit_id_wins_over_synthesis() {
let spec = TextMatchSpec {
id: Some("my/id".into()),
..pattern("x")
};
assert_eq!(
synthesize_text_id(TextFindingKind::Requires, "s", &spec),
"my/id"
);
}
#[test]
fn requires_miss_forbids_hits_and_count_violations() {
let registry = Registry::default();
let ctx = Ctx::new("doc.md", ®istry);
let out = build_text_findings(
TextFindingInput {
kind: TextKind::Requires,
spec: &pattern("outcome"),
result: run("nothing here", &pattern("outcome")),
scope_key: "summary",
scope: Some("Summary"),
scope_pos: Some(SourcePos::at(1, 1)),
},
&ctx,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].id, "text/requires/summary/1tc7itx");
assert_eq!(
out[0].message,
"required phrase \"outcome\" not found in Summary"
);
assert_eq!(out[0].pos, Some(SourcePos::at(1, 1)));
let spec = pattern("TODO");
let out = build_text_findings(
TextFindingInput {
kind: TextKind::Forbids,
spec: &spec,
result: run("TODO a\nb TODO", &spec),
scope_key: "doc",
scope: None,
scope_pos: None,
},
&ctx,
);
assert_eq!(out.len(), 2);
assert_eq!(out[0].pos, Some(SourcePos::at(1, 1)));
assert_eq!(out[1].pos, Some(SourcePos::at(2, 3)));
let spec = TextMatchSpec {
min: Some(2.0),
..pattern("DONE")
};
let out = build_text_findings(
TextFindingInput {
kind: TextKind::Requires,
spec: &spec,
result: run("DONE once", &spec),
scope_key: "checklist",
scope: Some("Checklist"),
scope_pos: Some(SourcePos::at(1, 1)),
},
&ctx,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].id, "text/count/checklist/9ms6i7");
assert_eq!(
out[0].message,
"\"DONE\" found 1 times, expected at least 2"
);
}
#[test]
fn count_bound_problem_decides_nan_as_the_ts_twin() {
let with = |min: Option<f64>, max: Option<f64>| TextMatchSpec {
min,
max,
..pattern("x")
};
assert_eq!(
count_bound_problem(TextKind::Requires, &with(None, Some(f64::NAN))),
None
);
assert_eq!(
count_bound_problem(TextKind::Forbids, &with(None, Some(f64::NAN))),
None
);
assert_eq!(
count_bound_problem(TextKind::Requires, &with(Some(f64::NAN), Some(0.0)))
.map(|p| p.message),
Some(
"requires(\"x\"): a requires entry may not express absence (max 0 < 1); use forbids(...) for an absence check"
.to_string()
)
);
assert_eq!(
count_bound_problem(TextKind::Forbids, &with(None, Some(-1.0))).map(|p| p.id),
Some("contract/text-count-bound")
);
}
}