Skip to main content

markdown_contract/
text_match.rs

1//! The text-match core and the `text/*` findings, ported from the TS `text-match.ts`:
2//!
3//! 1. [`build_regex`] compiles a spec's needle once: a literal `pattern` (any whitespace
4//!    run matches any whitespace run under `normalize`, the default; exact under
5//!    `normalize: false`) or a `regex` source, with `ignoreCase` folding.
6//! 2. [`match_text`] counts every occurrence and pins each to its 1-based line and
7//!    column. The column counts UTF-16 units, as JS string indexing does.
8//! 3. [`build_text_findings`] maps a match result and the entry's `min` / `max` to
9//!    `text/requires`, `text/forbids` and `text/count` findings.
10//! 4. [`synthesize_text_id`] gives the stable id `text/<kind>/<scopeKey>/<patternHash>`.
11//!    The hash is the TS `shortHash` exactly (FNV-1a 32-bit over UTF-16 units, in
12//!    base 36), because the corpus goldens pin these ids.
13//!
14//! `regex` sources use the `regex` crate's dialect, not JS's: there is no lookaround or
15//! backreference, `\w`, `\d` and `\b` are Unicode-aware, matching works on code points
16//! rather than UTF-16 units, and `ignoreCase` folds case by Unicode rules.
17
18use regex::{Regex, RegexBuilder};
19
20use crate::finding::{Finding, FindingLevel, SourcePos};
21use crate::js;
22use crate::registry::{Ctx, FindingSpec};
23
24// ── The match spec & result ───────────────────────────────────────────────────────────
25
26/// One match spec — the closed text-match vocabulary. Exactly
27/// one of `pattern` (a literal substring) or `regex` (a regex source) supplies the
28/// needle; `normalize` / `ignore_case` tune matching; `min` / `max` bound the count;
29/// `id` pins identity; `note` / `level` shape the finding.
30#[derive(Debug, Clone, Default)]
31pub struct TextMatchSpec {
32    /// a literal substring to find
33    pub pattern: Option<String>,
34    /// a regular expression to find
35    pub regex: Option<String>,
36    /// collapse whitespace runs before matching, so prose line-wrapping is tolerated (default true)
37    pub normalize: Option<bool>,
38    /// match without regard to case (default false)
39    pub ignore_case: Option<bool>,
40    /// the fewest hits allowed
41    pub min: Option<f64>,
42    /// the most hits allowed
43    pub max: Option<f64>,
44    /// an explicit, author-supplied finding id — pins identity across pattern edits
45    pub id: Option<String>,
46    /// author rationale, appended to the finding message
47    pub note: Option<String>,
48    /// finding severity — overrides the registry default (`error` | `warn`)
49    pub level: Option<FindingLevel>,
50}
51
52impl TextMatchSpec {
53    /// The needle as it reads in a message: `"literal"` or `/source/`.
54    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/// The result of [`match_text`]: the occurrence count and each hit's source position.
64#[derive(Debug, Clone, PartialEq, Eq, Default)]
65pub(crate) struct TextMatchResult {
66    pub(crate) count: usize,
67    /// One entry per hit, in document order: the 1-based line and column of its start.
68    pub(crate) positions: Vec<SourcePos>,
69}
70
71/// Whether a text constraint requires or forbids its needle.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub(crate) enum TextKind {
74    Requires,
75    Forbids,
76}
77
78/// The kind part of a text finding id: the presence and absence findings, or `count`
79/// when a `min` or `max` is not met.
80#[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
97// ── The pure matcher ──────────────────────────────────────────────────────────────────
98
99/// Escape every JS regex metacharacter in a literal so it matches itself (the TS
100/// `escapeRegex` set — also safe Rust regex escapes).
101fn 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
115/// A literal pattern as a regex source in which each whitespace run matches any
116/// whitespace run, so a phrase wrapped across lines still matches. Whitespace is JS `\s`.
117fn 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/// Why a spec's needle could not be compiled.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub(crate) enum NeedleError {
127    /// The spec has neither `pattern` nor `regex`.
128    Missing,
129    /// The `regex` crate rejected the source (a syntax error or the size limit).
130    Invalid(String),
131}
132
133/// Compile a spec's needle, the one place every caller builds it. `Ok(None)` is an
134/// empty needle, which never matches.
135pub(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
157/// [`build_regex`] for a builder: a contract-authoring error panics, as the TS builders
158/// throw.
159///
160/// # Panics
161/// When the spec has no needle or its `regex` does not compile.
162pub(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
169/// Maps increasing byte offsets in `text` to 1-based positions, scanning forward once.
170/// The column counts UTF-16 units, as the TS `positionMapper` does.
171struct 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
193/// Every occurrence of `re` in `text`, as JS's global `exec` loop finds them in BMP text:
194/// after an empty match the search moves on one character. Before a character outside
195/// the BMP, JS steps one UTF-16 unit and counts one more empty match; this does not.
196/// `None` (an empty needle) never matches.
197pub(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
226// ── Stable finding-id synthesis ───────────────────────────────────────────────────────
227
228/// A short, stable, deterministic hash: FNV-1a 32-bit over the string's UTF-16 code
229/// units (the TS `charCodeAt` walk), rendered base36 — the exact TS `shortHash`.
230fn 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
253/// The canonical identity of a spec's *matcher* — the needle plus the flags that change
254/// what matches. The count bound is deliberately NOT part of the key.
255pub(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
277/// The stable finding id for one text-constraint entry:
278/// `text/<kind>/<scopeKey>/<patternHash>` — stable across entry reordering, unique
279/// across scopes. An entry with an explicit `id` gets exactly that id back.
280pub(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
295// ── The finding-builder ───────────────────────────────────────────────────────────────
296
297/// A human label for the scope a finding fires in — `doc` renders as `document`.
298fn 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
309/// The count bound the builder evaluates against, with the kind's defaults applied.
310fn 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/// Why an entry's count bound is refused when the contract is built: an id and a
318/// message.
319#[derive(Debug, Clone, PartialEq)]
320pub(crate) struct CountBoundProblem {
321    pub(crate) id: &'static str,
322    pub(crate) message: String,
323}
324
325/// The one count-bound check, shared by the code builders ([`crate::text_constraints`])
326/// and the declarative compiler, so both refuse the same entries; mirrors the TS
327/// `countBoundProblem`. `None` when the bound is fine.
328///
329///   - `requires`: a `max` below the entry's minimum (`min`, default and at least 1)
330///     means absence, which is a `forbids` check (`contract/text-requires-purity`).
331///   - `forbids`: only `max` is checked when matching, so only a negative `max`, which
332///     no count can meet, is refused (`contract/text-count-bound`). A `min` on a
333///     `forbids` entry is ignored.
334pub(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
360/// The inputs to [`build_text_findings`]: one entry's match result plus its scope.
361pub(crate) struct TextFindingInput<'a> {
362    pub(crate) kind: TextKind,
363    pub(crate) spec: &'a TextMatchSpec,
364    pub(crate) result: TextMatchResult,
365    /// The scope's stable key for the finding id (`doc` for the whole document).
366    pub(crate) scope_key: &'a str,
367    /// The scope's name in messages (defaults from `scope_key`).
368    pub(crate) scope: Option<&'a str>,
369    /// The scope's heading position, where a miss or count finding pins; `None` is
370    /// document-level.
371    pub(crate) scope_pos: Option<SourcePos>,
372}
373
374/// Turn one entry's match result into its findings:
375/// a `requires` miss at the scope heading (document-level when unscoped), a
376/// `forbids` hit PER occurrence at the offending line, a bound violation as one
377/// `text/count` at the heading. Each message carries the spec's `note`; `level` rides
378/// from the spec when set, else the registry default.
379pub(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    // JS's global exec loop: an empty match counts, then the search moves on one
487    // character, so `x*` over "ab" matches at 0, 1 and 2.
488    #[test]
489    fn empty_matches_count_as_in_js() {
490        assert_eq!(run("ab", &regex("x*")).count, 3);
491        assert_eq!(run("ab", &regex("a*")).count, 3);
492        assert_eq!(run("é", &regex("")).count, 0); // an empty needle never matches
493        assert_eq!(run("éa", &regex("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        // U+FEFF is JS whitespace; U+0085 is not.
505        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(&regex("(")),
513            Err(NeedleError::Invalid(_))
514        ));
515        assert!(matches!(
516            build_regex(&regex(r"(?=x)")),
517            Err(NeedleError::Invalid(_))
518        ));
519        // Past the compiled-size limit.
520        assert!(matches!(
521            build_regex(&regex(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    // Contract first: a literal matches, counts, and pins 1-based line/col.
531    #[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    // The load-bearing contract: hashes must equal the TS engine's — the corpus goldens
564    // pin these exact ids.
565    #[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", &registry);
615        // A requires miss pins the scope heading.
616        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        // Forbids emits one finding per hit at the offending line.
636        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        // A shortfall against min: 2 is one text/count at the heading.
653        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    // NaN is decided as in the TS twin: a NaN `max` is accepted, a NaN `min` floors at 1.
677    #[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}