1use regex::{Regex, RegexBuilder};
19
20use crate::finding::{Finding, FindingLevel, SourcePos};
21use crate::js;
22use crate::registry::{Ctx, FindingSpec};
23
24#[derive(Debug, Clone, Default)]
31pub struct TextMatchSpec {
32 pub pattern: Option<String>,
34 pub regex: Option<String>,
36 pub normalize: Option<bool>,
38 pub ignore_case: Option<bool>,
40 pub min: Option<f64>,
42 pub max: Option<f64>,
44 pub id: Option<String>,
46 pub note: Option<String>,
48 pub level: Option<FindingLevel>,
50}
51
52impl TextMatchSpec {
53 pub(crate) fn repr(&self) -> String {
55 if let Some(regex) = &self.regex {
56 format!("/{regex}/")
57 } else {
58 format!("\"{}\"", self.pattern.as_deref().unwrap_or(""))
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Default)]
65pub(crate) struct TextMatchResult {
66 pub(crate) count: usize,
67 pub(crate) positions: Vec<SourcePos>,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub(crate) enum TextKind {
74 Requires,
75 Forbids,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub(crate) enum TextFindingKind {
82 Requires,
83 Forbids,
84 Count,
85}
86
87impl TextFindingKind {
88 fn as_str(&self) -> &'static str {
89 match self {
90 TextFindingKind::Requires => "requires",
91 TextFindingKind::Forbids => "forbids",
92 TextFindingKind::Count => "count",
93 }
94 }
95}
96
97fn escape_regex(s: &str) -> String {
102 let mut out = String::with_capacity(s.len());
103 for c in s.chars() {
104 if matches!(
105 c,
106 '.' | '*' | '+' | '?' | '^' | '$' | '{' | '}' | '(' | ')' | '|' | '[' | ']' | '\\'
107 ) {
108 out.push('\\');
109 }
110 out.push(c);
111 }
112 out
113}
114
115fn normalized_pattern_source(pattern: &str) -> String {
118 js::words(pattern)
119 .map(escape_regex)
120 .collect::<Vec<_>>()
121 .join(&format!("{}+", js::SPACE_CLASS))
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub(crate) enum NeedleError {
127 Missing,
129 Invalid(String),
131}
132
133pub(crate) fn build_regex(spec: &TextMatchSpec) -> Result<Option<Regex>, NeedleError> {
136 let source = if let Some(regex) = &spec.regex {
137 regex.clone()
138 } else if let Some(pattern) = &spec.pattern {
139 if spec.normalize.unwrap_or(true) {
140 normalized_pattern_source(pattern)
141 } else {
142 escape_regex(pattern)
143 }
144 } else {
145 return Err(NeedleError::Missing);
146 };
147 if source.is_empty() {
148 return Ok(None);
149 }
150 RegexBuilder::new(&source)
151 .case_insensitive(spec.ignore_case.unwrap_or(false))
152 .build()
153 .map(Some)
154 .map_err(|e| NeedleError::Invalid(e.to_string()))
155}
156
157pub(crate) fn build_regex_or_panic(spec: &TextMatchSpec) -> Option<Regex> {
163 build_regex(spec).unwrap_or_else(|e| match e {
164 NeedleError::Missing => panic!("a match spec must supply one of `pattern` or `regex`"),
165 NeedleError::Invalid(e) => panic!("invalid regex {}: {e}", spec.repr()),
166 })
167}
168
169struct PositionMapper<'a> {
172 text: &'a str,
173 scanned: usize,
174 line: u32,
175 col: u32,
176}
177
178impl PositionMapper<'_> {
179 fn at(&mut self, offset: usize) -> SourcePos {
180 for c in self.text[self.scanned..offset].chars() {
181 if c == '\n' {
182 self.line += 1;
183 self.col = 0;
184 } else {
185 self.col += c.len_utf16() as u32;
186 }
187 }
188 self.scanned = offset;
189 SourcePos::at(self.line, self.col + 1)
190 }
191}
192
193pub(crate) fn match_text(text: &str, re: Option<&Regex>) -> TextMatchResult {
198 let Some(re) = re else {
199 return TextMatchResult::default();
200 };
201 let mut mapper = PositionMapper {
202 text,
203 scanned: 0,
204 line: 1,
205 col: 0,
206 };
207 let mut positions = Vec::new();
208 let mut start = 0;
209 while start <= text.len() {
210 let Some(m) = re.find_at(text, start) else {
211 break;
212 };
213 positions.push(mapper.at(m.start()));
214 start = if m.end() > m.start() {
215 m.end()
216 } else {
217 m.end() + text[m.end()..].chars().next().map_or(1, char::len_utf8)
218 };
219 }
220 TextMatchResult {
221 count: positions.len(),
222 positions,
223 }
224}
225
226fn short_hash(s: &str) -> String {
231 let mut h: u32 = 0x811c9dc5;
232 for unit in s.encode_utf16() {
233 h ^= unit as u32;
234 h = h.wrapping_mul(0x0100_0193);
235 }
236 to_base36(h)
237}
238
239fn to_base36(mut n: u32) -> String {
240 const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
241 if n == 0 {
242 return "0".into();
243 }
244 let mut out = Vec::new();
245 while n > 0 {
246 out.push(DIGITS[(n % 36) as usize]);
247 n /= 36;
248 }
249 out.reverse();
250 String::from_utf8(out).expect("base36 digits are ascii")
251}
252
253pub(crate) fn pattern_key(spec: &TextMatchSpec) -> String {
256 let fold = if spec.ignore_case.unwrap_or(false) {
257 "i"
258 } else {
259 ""
260 };
261 if let Some(regex) = &spec.regex {
262 return format!("regex|{regex}|{fold}");
263 }
264 let normalize = spec.normalize.unwrap_or(true);
265 let pattern = spec.pattern.as_deref().unwrap_or("");
266 let needle = if normalize {
267 js::words(pattern).collect::<Vec<_>>().join(" ")
268 } else {
269 pattern.to_string()
270 };
271 format!(
272 "pattern|{}|{needle}|{fold}",
273 if normalize { "n" } else { "x" }
274 )
275}
276
277pub(crate) fn synthesize_text_id(
281 kind: TextFindingKind,
282 scope_key: &str,
283 spec: &TextMatchSpec,
284) -> String {
285 if let Some(id) = &spec.id {
286 return id.clone();
287 }
288 format!(
289 "text/{}/{scope_key}/{}",
290 kind.as_str(),
291 short_hash(&pattern_key(spec))
292 )
293}
294
295fn scope_label<'a>(scope_key: &'a str, scope: Option<&'a str>) -> &'a str {
299 if let Some(scope) = scope {
300 return scope;
301 }
302 if scope_key == "doc" {
303 "document"
304 } else {
305 scope_key
306 }
307}
308
309fn bounds_for(kind: TextKind, spec: &TextMatchSpec) -> (f64, Option<f64>) {
311 match kind {
312 TextKind::Requires => (spec.min.unwrap_or(1.0), spec.max),
313 TextKind::Forbids => (spec.min.unwrap_or(0.0), Some(spec.max.unwrap_or(0.0))),
314 }
315}
316
317#[derive(Debug, Clone, PartialEq)]
320pub(crate) struct CountBoundProblem {
321 pub(crate) id: &'static str,
322 pub(crate) message: String,
323}
324
325pub(crate) fn count_bound_problem(
335 kind: TextKind,
336 spec: &TextMatchSpec,
337) -> Option<CountBoundProblem> {
338 let max = spec.max?;
339 match kind {
340 TextKind::Requires => {
341 let floor = spec.min.unwrap_or(1.0).max(1.0);
342 (max < floor).then(|| CountBoundProblem {
343 id: "contract/text-requires-purity",
344 message: format!(
345 "requires({}): a requires entry may not express absence (max {max} < {floor}); use forbids(...) for an absence check",
346 spec.repr()
347 ),
348 })
349 }
350 TextKind::Forbids => (max < 0.0).then(|| CountBoundProblem {
351 id: "contract/text-count-bound",
352 message: format!(
353 "forbids({}): max ({max}) is below 0, so the count bound can never be met",
354 spec.repr()
355 ),
356 }),
357 }
358}
359
360pub(crate) struct TextFindingInput<'a> {
362 pub(crate) kind: TextKind,
363 pub(crate) spec: &'a TextMatchSpec,
364 pub(crate) result: TextMatchResult,
365 pub(crate) scope_key: &'a str,
367 pub(crate) scope: Option<&'a str>,
369 pub(crate) scope_pos: Option<SourcePos>,
372}
373
374pub(crate) fn build_text_findings(input: TextFindingInput<'_>, ctx: &Ctx) -> Vec<Finding> {
380 let (min, max) = bounds_for(input.kind, input.spec);
381 let repr = input.spec.repr();
382 let note = input
383 .spec
384 .note
385 .as_deref()
386 .map(|n| format!(" — {n}"))
387 .unwrap_or_default();
388 let count = input.result.count;
389
390 let count_finding = |expected: String| -> Finding {
391 let mut spec = FindingSpec::new(
392 synthesize_text_id(TextFindingKind::Count, input.scope_key, input.spec),
393 format!("{repr} found {count} times, expected {expected}{note}"),
394 );
395 if let Some(level) = input.spec.level {
396 spec = spec.level(level);
397 }
398 if let Some(pos) = input.scope_pos {
399 spec = spec.pos(pos);
400 }
401 ctx.finding(spec)
402 };
403
404 match input.kind {
405 TextKind::Requires => {
406 if (count as f64) < min {
407 if min == 1.0 {
408 let mut spec = FindingSpec::new(
409 synthesize_text_id(TextFindingKind::Requires, input.scope_key, input.spec),
410 format!(
411 "required phrase {repr} not found in {}{note}",
412 scope_label(input.scope_key, input.scope)
413 ),
414 );
415 if let Some(level) = input.spec.level {
416 spec = spec.level(level);
417 }
418 if let Some(pos) = input.scope_pos {
419 spec = spec.pos(pos);
420 }
421 return vec![ctx.finding(spec)];
422 }
423 return vec![count_finding(format!("at least {}", js::num(min)))];
424 }
425 if let Some(max) = max
426 && (count as f64) > max
427 {
428 return vec![count_finding(format!("at most {}", js::num(max)))];
429 }
430 vec![]
431 }
432 TextKind::Forbids => {
433 let max = max.unwrap_or(0.0);
434 if (count as f64) <= max {
435 return vec![];
436 }
437 if max == 0.0 {
438 return input
439 .result
440 .positions
441 .iter()
442 .map(|pos| {
443 let mut spec = FindingSpec::new(
444 synthesize_text_id(
445 TextFindingKind::Forbids,
446 input.scope_key,
447 input.spec,
448 ),
449 format!("forbidden phrase {repr} present{note}"),
450 );
451 if let Some(level) = input.spec.level {
452 spec = spec.level(level);
453 }
454 ctx.finding(spec.pos(*pos))
455 })
456 .collect();
457 }
458 vec![count_finding(format!("at most {}", js::num(max)))]
459 }
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::registry::Registry;
467
468 fn pattern(p: &str) -> TextMatchSpec {
469 TextMatchSpec {
470 pattern: Some(p.into()),
471 ..Default::default()
472 }
473 }
474
475 fn run(text: &str, spec: &TextMatchSpec) -> TextMatchResult {
476 match_text(text, build_regex(spec).unwrap().as_ref())
477 }
478
479 fn regex(source: &str) -> TextMatchSpec {
480 TextMatchSpec {
481 regex: Some(source.into()),
482 ..Default::default()
483 }
484 }
485
486 #[test]
489 fn empty_matches_count_as_in_js() {
490 assert_eq!(run("ab", ®ex("x*")).count, 3);
491 assert_eq!(run("ab", ®ex("a*")).count, 3);
492 assert_eq!(run("é", ®ex("")).count, 0); assert_eq!(run("éa", ®ex("x?")).count, 3);
494 }
495
496 #[test]
497 fn columns_count_utf16_units_across_lines() {
498 let r = run("😀 x\n y x", &pattern("x"));
499 assert_eq!(r.positions, vec![SourcePos::at(1, 4), SourcePos::at(2, 4)]);
500 }
501
502 #[test]
503 fn normalize_uses_js_whitespace() {
504 assert_eq!(run("a\u{FEFF}b", &pattern("a b")).count, 1);
506 assert_eq!(run("a\u{85}b", &pattern("a b")).count, 0);
507 }
508
509 #[test]
510 fn a_needle_the_regex_crate_rejects_is_an_error() {
511 assert!(matches!(
512 build_regex(®ex("(")),
513 Err(NeedleError::Invalid(_))
514 ));
515 assert!(matches!(
516 build_regex(®ex(r"(?=x)")),
517 Err(NeedleError::Invalid(_))
518 ));
519 assert!(matches!(
521 build_regex(®ex(r"\w{1000}{1000}")),
522 Err(NeedleError::Invalid(_))
523 ));
524 assert_eq!(
525 build_regex(&TextMatchSpec::default()).unwrap_err(),
526 NeedleError::Missing
527 );
528 }
529
530 #[test]
532 fn literal_pattern_counts_and_positions() {
533 let r = run("alpha beta\ngamma beta", &pattern("beta"));
534 assert_eq!(r.count, 2);
535 assert_eq!(r.positions, vec![SourcePos::at(1, 7), SourcePos::at(2, 7)]);
536 }
537
538 #[test]
539 fn normalize_lets_a_wrapped_phrase_match() {
540 let spec = pattern("the decision outcome");
541 assert_eq!(run("we name the decision\noutcome here", &spec).count, 1);
542 let exact = TextMatchSpec {
543 normalize: Some(false),
544 ..pattern("the decision outcome")
545 };
546 assert_eq!(run("we name the decision\noutcome here", &exact).count, 0);
547 }
548
549 #[test]
550 fn ignore_case_folds_and_regex_sources_pass_through() {
551 let spec = TextMatchSpec {
552 ignore_case: Some(true),
553 ..pattern("done")
554 };
555 assert_eq!(run("DONE and Done", &spec).count, 2);
556 let re = TextMatchSpec {
557 regex: Some("LEASE-(CONFLICT|MISSING) ref=".into()),
558 ..Default::default()
559 };
560 assert_eq!(run("saw LEASE-CONFLICT ref=42", &re).count, 1);
561 }
562
563 #[test]
566 fn synthesized_ids_match_the_ts_goldens() {
567 assert_eq!(
568 synthesize_text_id(TextFindingKind::Requires, "summary", &pattern("outcome")),
569 "text/requires/summary/1tc7itx"
570 );
571 assert_eq!(
572 synthesize_text_id(
573 TextFindingKind::Forbids,
574 "doc",
575 &TextMatchSpec {
576 normalize: Some(false),
577 ..pattern("}scripts/")
578 }
579 ),
580 "text/forbids/doc/o9pijh"
581 );
582 assert_eq!(
583 synthesize_text_id(TextFindingKind::Count, "checklist", &pattern("DONE")),
584 "text/count/checklist/9ms6i7"
585 );
586 assert_eq!(
587 synthesize_text_id(
588 TextFindingKind::Requires,
589 "failureModes",
590 &TextMatchSpec {
591 regex: Some("LEASE-(CONFLICT|MISSING) ref=".into()),
592 ..Default::default()
593 }
594 ),
595 "text/requires/failureModes/17j7bdw"
596 );
597 }
598
599 #[test]
600 fn explicit_id_wins_over_synthesis() {
601 let spec = TextMatchSpec {
602 id: Some("my/id".into()),
603 ..pattern("x")
604 };
605 assert_eq!(
606 synthesize_text_id(TextFindingKind::Requires, "s", &spec),
607 "my/id"
608 );
609 }
610
611 #[test]
612 fn requires_miss_forbids_hits_and_count_violations() {
613 let registry = Registry::default();
614 let ctx = Ctx::new("doc.md", ®istry);
615 let out = build_text_findings(
617 TextFindingInput {
618 kind: TextKind::Requires,
619 spec: &pattern("outcome"),
620 result: run("nothing here", &pattern("outcome")),
621 scope_key: "summary",
622 scope: Some("Summary"),
623 scope_pos: Some(SourcePos::at(1, 1)),
624 },
625 &ctx,
626 );
627 assert_eq!(out.len(), 1);
628 assert_eq!(out[0].id, "text/requires/summary/1tc7itx");
629 assert_eq!(
630 out[0].message,
631 "required phrase \"outcome\" not found in Summary"
632 );
633 assert_eq!(out[0].pos, Some(SourcePos::at(1, 1)));
634
635 let spec = pattern("TODO");
637 let out = build_text_findings(
638 TextFindingInput {
639 kind: TextKind::Forbids,
640 spec: &spec,
641 result: run("TODO a\nb TODO", &spec),
642 scope_key: "doc",
643 scope: None,
644 scope_pos: None,
645 },
646 &ctx,
647 );
648 assert_eq!(out.len(), 2);
649 assert_eq!(out[0].pos, Some(SourcePos::at(1, 1)));
650 assert_eq!(out[1].pos, Some(SourcePos::at(2, 3)));
651
652 let spec = TextMatchSpec {
654 min: Some(2.0),
655 ..pattern("DONE")
656 };
657 let out = build_text_findings(
658 TextFindingInput {
659 kind: TextKind::Requires,
660 spec: &spec,
661 result: run("DONE once", &spec),
662 scope_key: "checklist",
663 scope: Some("Checklist"),
664 scope_pos: Some(SourcePos::at(1, 1)),
665 },
666 &ctx,
667 );
668 assert_eq!(out.len(), 1);
669 assert_eq!(out[0].id, "text/count/checklist/9ms6i7");
670 assert_eq!(
671 out[0].message,
672 "\"DONE\" found 1 times, expected at least 2"
673 );
674 }
675
676 #[test]
678 fn count_bound_problem_decides_nan_as_the_ts_twin() {
679 let with = |min: Option<f64>, max: Option<f64>| TextMatchSpec {
680 min,
681 max,
682 ..pattern("x")
683 };
684 assert_eq!(
685 count_bound_problem(TextKind::Requires, &with(None, Some(f64::NAN))),
686 None
687 );
688 assert_eq!(
689 count_bound_problem(TextKind::Forbids, &with(None, Some(f64::NAN))),
690 None
691 );
692 assert_eq!(
693 count_bound_problem(TextKind::Requires, &with(Some(f64::NAN), Some(0.0)))
694 .map(|p| p.message),
695 Some(
696 "requires(\"x\"): a requires entry may not express absence (max 0 < 1); use forbids(...) for an absence check"
697 .to_string()
698 )
699 );
700 assert_eq!(
701 count_bound_problem(TextKind::Forbids, &with(None, Some(-1.0))).map(|p| p.id),
702 Some("contract/text-count-bound")
703 );
704 }
705}