Skip to main content

cratefield_text_guard/
lib.rs

1//! `cratefield-text-guard`: keep the facts when a model rewrites text
2//! (issue #836).
3//!
4//! A venture that asks a [`TextModel`](https://docs.rs/cratefield-core) to
5//! rewrite, shorten or translate somebody's text has to be sure the model
6//! did not quietly change what the text *says*: a figure, a name, a quoted
7//! sentence, a line of code. This crate does the two halves of that check
8//! and nothing else:
9//!
10//! 1. [`Guard::extract`] finds the **protected spans** of a text: numbers,
11//!    quotations, code, multi-word names, links and hashtags
12//!    ([`SpanKind`]), plus any spans the caller protects itself
13//!    ([`ExtraSpan`]).
14//! 2. [`Guard::verify`] checks a rewrite against its original and lists
15//!    every protected span that went missing or changed, and every number
16//!    the rewrite introduced ([`Violation`]).
17//!
18//! ```
19//! use cratefield_text_guard::{SpanKind, ViolationKind, extract, verify};
20//!
21//! let original = "Our Q3 revenue grew by 18% to $4.2M, Dana Okafor said.";
22//! let kinds: Vec<_> = extract(original).iter().map(|s| (s.kind, s.text.clone())).collect();
23//! assert_eq!(kinds, [
24//!     (SpanKind::Number, "Q3".to_owned()),
25//!     (SpanKind::Number, "18%".to_owned()),
26//!     (SpanKind::Number, "$4.2M".to_owned()),
27//!     (SpanKind::Name, "Dana Okafor".to_owned()),
28//! ]);
29//!
30//! assert!(verify(original, "Dana Okafor says Q3 revenue rose 18% to $4.2M.").is_ok());
31//!
32//! let violations = verify(original, "Dana Okafor says Q3 revenue rose 19% to $4.2M.").unwrap_err();
33//! assert_eq!(violations.len(), 2);
34//! assert_eq!(violations[0].kind, ViolationKind::Missing);   // 18% is gone
35//! assert_eq!(violations[1].kind, ViolationKind::Introduced); // 19% came from nowhere
36//! ```
37//!
38//! What a caller does with the violations is its own business: retry the
39//! model with the failed spans named, refuse the rewrite, or show them.
40//!
41//! # The heuristics, on purpose simple
42//!
43//! No regex engine, no language model, no dictionary: every rule below is a
44//! character scan, so its false positives and negatives can be read off the
45//! code. Spans never overlap; when two rules match the same text the
46//! earlier rule in this list wins.
47//!
48//! - **Caller spans** ([`SpanKind::Custom`]): byte ranges the caller
49//!   supplies ([`Guard::with_extra`], [`Guard::extract_with`]) for syntax
50//!   this crate does not know, such as a platform's mention markup. They
51//!   come first and beat every built-in rule they overlap; see
52//!   [Caller spans](#caller-spans).
53//! - **Code** ([`SpanKind::Code`]): a fenced block (a line opening with
54//!   three or more backticks or tildes, to the matching closing fence or
55//!   the end of the text), then inline code (a run of backticks to the next
56//!   run of the same length, within one paragraph). Must survive verbatim,
57//!   fences included.
58//! - **Quotes** ([`SpanKind::Quote`]): text between straight double quotes
59//!   (`"…"`), curly double quotes (`“…”`), German low-high quotes (`„…“`)
60//!   or guillemets (`«…»`), within one paragraph and at
61//!   most [`MAX_QUOTE_CHARS`] long. Single quotes are not quotes here: `’`
62//!   is also the apostrophe, and telling the two apart needs grammar. What
63//!   must survive is the quoted **words** ([`Span::protected`]): the quote
64//!   marks may change style, and a closing `,` `.` `;` or `:` inside the
65//!   marks may move, because typographic conventions move them.
66//! - **Links** ([`SpanKind::Url`]): `http://` or `https://` (any case)
67//!   followed by a host, or a bare `www.` followed by a host with a dot in
68//!   it, starting at a word boundary and running to whitespace or to one of
69//!   `<` `>` `"` `` ` `` or a curly quote or guillemet. Sentence punctuation
70//!   at the end (`.` `,` `;` `:` `!` `?` and closing `'` or `’`) is not part
71//!   of the link, and neither is a closing `)` or `]` without a matching
72//!   opener inside the link: `(see https://example.com/a_(b))` keeps
73//!   `https://example.com/a_(b)`. Must survive verbatim and **whole**: the
74//!   rewrite has to contain it as a complete link, so
75//!   `https://example.com/a` is not found inside `https://example.com/ab`.
76//!   Digits in a link are part of it, never numbers of their own, and a `#`
77//!   in a link is never a hashtag.
78//! - **Hashtags** ([`SpanKind::Hashtag`]): `#` followed by letters, digits
79//!   and `_` (any script), containing at least one letter, where the `#` is
80//!   not glued to a letter, digit, `_`, `#` or `&` before it. So `#launch`,
81//!   `#Q3_results` and `#café` are hashtags, while `#1` and `#123` are not
82//!   (they read as rank and issue numbers, and the digits are protected as a
83//!   number instead), `C#` is not, and a Markdown heading (`# Title`, `##
84//!   Title`) is not, because a space follows the marks. A hex colour such as
85//!   `#ff8800` *is* a hashtag by these rules. Must survive verbatim, and a
86//!   hashtag is not found inside a longer one (`#launch` in
87//!   `#launch_day`).
88//! - **Numbers** ([`SpanKind::Number`]): a token that starts at a word
89//!   boundary with an optional currency sign and a digit, continues through
90//!   digits joined by `.` `,` `:` `/` or `-` (only between digits, so a
91//!   full stop after a figure is not part of it), and keeps a `%` or `‰`
92//!   and any letters glued to the end (`$4.2M`, `18%`, `3rd`, `10x`,
93//!   `2026-10-08`). Also one to four capitals glued to digits, with an
94//!   optional hyphen (`Q3`, `FY2024`, `H1`, `GPT-4`). Spelled-out numbers
95//!   ("three million") are not numbers here. Must survive verbatim.
96//! - **Names** ([`SpanKind::Name`]): two or more capitalised words in a
97//!   row, separated by single spaces (`Dana Okafor`, `Bank of America`:
98//!   a small set of lowercase particles may sit **between** capitalised
99//!   words). A leading function word (`The`, `In`, `However`, …) is dropped
100//!   first, and a trailing possessive `'s` is not part of the name. A
101//!   single capitalised word is never a name: that would lock the first
102//!   word of every sentence. The flip side: a capitalised word that is not a
103//!   function word, followed by a capitalised word, is a name, so a
104//!   sentence opening "Ask Dana" locks "Ask Dana". Must survive verbatim.
105//!
106//! "Survive" means: occur in the rewrite at least once, at a word boundary
107//! (`18%` is not found inside `118%`). The rewrite may reorder spans and
108//! may use one fewer time than the original; it may not drop or alter one.
109//! A number the rewrite contains and the original does not is reported as
110//! [`ViolationKind::Introduced`]: a changed figure shows up as one missing
111//! and one introduced. Names, quotes, code, links and hashtags a rewrite
112//! adds are not reported — new wording is the point of a rewrite.
113//!
114//! # Caller spans
115//!
116//! A platform's own markup — a mention such as
117//! `@[Name](urn:li:organization:123)`, a template, a placeholder — is
118//! nothing this crate should know about, but a rewrite must keep it, and
119//! the digits inside it must not count as numbers. A caller locks it with
120//! [`ExtraSpan`]s, either per call ([`Guard::extract_with`],
121//! [`Guard::verify_with`]) or with an extractor function that runs on every
122//! text the guard looks at, original and rewrite alike
123//! ([`Guard::with_extra`]):
124//!
125//! ```
126//! use cratefield_text_guard::{ExtraSpan, Guard, SpanKind};
127//!
128//! /// `{{placeholder}}` templates, labelled for the caller's own errors.
129//! fn placeholders(text: &str) -> Vec<ExtraSpan> {
130//!     let mut spans = Vec::new();
131//!     let mut from = 0;
132//!     while let Some(open) = text[from..].find("{{").map(|at| from + at) {
133//!         let Some(close) = text[open..].find("}}").map(|at| open + at + 2) else { break };
134//!         spans.push(ExtraSpan::new(open..close, "placeholder"));
135//!         from = close;
136//!     }
137//!     spans
138//! }
139//!
140//! let guard = Guard::new().with_extra(placeholders);
141//! let spans = guard.extract("Hi {{name_2}}, 18% off");
142//! assert_eq!(spans[0].kind, SpanKind::Custom);
143//! assert_eq!(spans[0].label.as_deref(), Some("placeholder"));
144//! assert_eq!(spans[1].text, "18%"); // the 2 inside the template is not a number
145//!
146//! let violations = guard.verify("Hi {{name_2}}", "Hi {{name_3}}").unwrap_err();
147//! assert_eq!(violations.len(), 1); // the template is missing; 3 is not "introduced"
148//! ```
149//!
150//! Caller spans come back as [`SpanKind::Custom`] with the caller's label
151//! in [`Span::label`]. One survives when the rewrite has a caller span with
152//! the same label and the same text: the same characters that the caller's
153//! rules no longer recognise (an escaped copy of a mention, say) do not
154//! count, so [`Guard::verify_with`] needs the rewrite's caller spans too.
155//! They take precedence over every built-in span they overlap,
156//! including code. A range that is empty, out of bounds, not on a `char`
157//! boundary, or overlaps an earlier caller span is ignored; per-call spans
158//! count as earlier than the extractor's.
159
160#![forbid(unsafe_code)]
161
162use std::collections::BTreeSet;
163use std::ops::Range;
164
165use serde::{Deserialize, Serialize};
166
167/// The longest quotation, in characters, that counts as a quote. Longer
168/// "quotes" are almost always a stray or unbalanced mark pairing with one
169/// paragraphs away, and locking them would freeze most of a text.
170pub const MAX_QUOTE_CHARS: usize = 600;
171
172/// What kind of fact a [`Span`] protects.
173///
174/// Non-exhaustive: new built-in kinds are added in minor releases.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177#[non_exhaustive]
178pub enum SpanKind {
179    /// Two or more capitalised words: a person, a company, a place.
180    Name,
181    /// A figure: `18%`, `$4.2M`, `Q3`, `2026-10-08`.
182    Number,
183    /// A quotation between double quotes.
184    Quote,
185    /// Inline code or a fenced code block.
186    Code,
187    /// A link: `https://example.com/path`, `www.example.com`.
188    Url,
189    /// A hashtag: `#launch`, `#Q3_results`.
190    Hashtag,
191    /// A span the caller protected ([`ExtraSpan`]); its label is in
192    /// [`Span::label`].
193    Custom,
194}
195
196impl SpanKind {
197    /// The name used in errors, logs and JSON.
198    pub const fn name(self) -> &'static str {
199        match self {
200            Self::Name => "name",
201            Self::Number => "number",
202            Self::Quote => "quote",
203            Self::Code => "code",
204            Self::Url => "url",
205            Self::Hashtag => "hashtag",
206            Self::Custom => "custom",
207        }
208    }
209}
210
211impl std::fmt::Display for SpanKind {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.write_str(self.name())
214    }
215}
216
217/// One protected span of a text: its kind, its text, and where it is.
218///
219/// `start..end` are **byte** offsets into the text it was extracted from,
220/// on `char` boundaries, so `&text[span.range()] == span.text`.
221#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
222pub struct Span {
223    pub kind: SpanKind,
224    pub text: String,
225    pub start: usize,
226    pub end: usize,
227    /// The caller's label for a [`SpanKind::Custom`] span (`"mention"`,
228    /// say); `None` for every built-in kind, and then absent from JSON.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub label: Option<String>,
231}
232
233impl Span {
234    /// The byte range of the span in its text.
235    pub fn range(&self) -> Range<usize> {
236        self.start..self.end
237    }
238
239    /// The part of the span a rewrite must keep verbatim: the whole span,
240    /// except for a quote, where it is the quoted words without the marks
241    /// and without a closing `,` `.` `;` `:` (see the crate docs).
242    pub fn protected(&self) -> &str {
243        match self.kind {
244            SpanKind::Quote => quote_words(&self.text),
245            _ => &self.text,
246        }
247    }
248
249    /// What the span is, for an error message or a JSON answer: the
250    /// caller's label for a [`SpanKind::Custom`] span, the kind's name
251    /// otherwise.
252    pub fn kind_name(&self) -> &str {
253        match (&self.label, self.kind) {
254            (Some(label), SpanKind::Custom) => label,
255            _ => self.kind.name(),
256        }
257    }
258}
259
260/// A span the caller protects on top of the built-in kinds: a byte range
261/// of the text and a label of the caller's choosing (see
262/// [Caller spans](crate#caller-spans)).
263#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
264pub struct ExtraSpan {
265    pub range: Range<usize>,
266    pub label: String,
267}
268
269impl ExtraSpan {
270    pub fn new(range: Range<usize>, label: impl Into<String>) -> Self {
271        Self {
272            range,
273            label: label.into(),
274        }
275    }
276}
277
278/// Finds a caller's own protected spans in a text; see [`Guard::with_extra`].
279pub type Extractor = fn(&str) -> Vec<ExtraSpan>;
280
281/// How a rewrite broke a protected span.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
283#[serde(rename_all = "snake_case")]
284pub enum ViolationKind {
285    /// A span of the original that the rewrite does not contain.
286    Missing,
287    /// A number the rewrite contains that the original does not.
288    Introduced,
289}
290
291/// One protected span a rewrite did not keep, or a number it made up.
292///
293/// For [`ViolationKind::Missing`], `span` is located in the **original**;
294/// for [`ViolationKind::Introduced`], in the **rewrite**.
295#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
296pub struct Violation {
297    pub kind: ViolationKind,
298    pub span: Span,
299}
300
301/// Which span kinds to look for, and the caller's own extractor if any.
302/// [`Guard::new`] looks for every built-in kind.
303#[derive(Debug, Clone, Copy)]
304#[allow(clippy::struct_excessive_bools)] // one switch per `SpanKind`, set by name
305pub struct Guard {
306    names: bool,
307    numbers: bool,
308    quotes: bool,
309    code: bool,
310    urls: bool,
311    hashtags: bool,
312    extra: Option<Extractor>,
313}
314
315impl Default for Guard {
316    fn default() -> Self {
317        Self::new()
318    }
319}
320
321impl Guard {
322    /// A guard for names, numbers, quotes, code, links and hashtags, with
323    /// no caller spans.
324    pub const fn new() -> Self {
325        Self {
326            names: true,
327            numbers: true,
328            quotes: true,
329            code: true,
330            urls: true,
331            hashtags: true,
332            extra: None,
333        }
334    }
335
336    /// Whether links are protected (default: yes). Links are still *found*
337    /// when off, like code, so the digits and `#` inside one are not
338    /// protected as numbers and hashtags; they are just not returned.
339    #[must_use]
340    pub const fn urls(mut self, on: bool) -> Self {
341        self.urls = on;
342        self
343    }
344
345    /// Whether hashtags are protected (default: yes).
346    #[must_use]
347    pub const fn hashtags(mut self, on: bool) -> Self {
348        self.hashtags = on;
349        self
350    }
351
352    /// Runs `extractor` on every text this guard looks at — the text of
353    /// [`Guard::extract`], and both the original and the rewrite in
354    /// [`Guard::verify`] — and protects what it returns as
355    /// [`SpanKind::Custom`] spans, ahead of every built-in kind. Replaces
356    /// any extractor set before.
357    #[must_use]
358    pub const fn with_extra(mut self, extractor: Extractor) -> Self {
359        self.extra = Some(extractor);
360        self
361    }
362
363    /// Whether multi-word names are protected (default: yes).
364    #[must_use]
365    pub const fn names(mut self, on: bool) -> Self {
366        self.names = on;
367        self
368    }
369
370    /// Whether numbers are protected (default: yes).
371    #[must_use]
372    pub const fn numbers(mut self, on: bool) -> Self {
373        self.numbers = on;
374        self
375    }
376
377    /// Whether quotations are protected (default: yes).
378    #[must_use]
379    pub const fn quotes(mut self, on: bool) -> Self {
380        self.quotes = on;
381        self
382    }
383
384    /// Whether code is protected (default: yes). Code is still *found* when
385    /// off, so a quote or a number inside it is not protected on its own;
386    /// it is just not returned.
387    #[must_use]
388    pub const fn code(mut self, on: bool) -> Self {
389        self.code = on;
390        self
391    }
392
393    /// The protected spans of `text`, in order, never overlapping.
394    pub fn extract(&self, text: &str) -> Vec<Span> {
395        self.extract_with(text, &[])
396    }
397
398    /// [`Guard::extract`] with `extra` caller spans of `text` protected
399    /// first, ahead of the guard's own extractor (if any) and every
400    /// built-in kind.
401    pub fn extract_with(&self, text: &str, extra: &[ExtraSpan]) -> Vec<Span> {
402        let mut taken: Vec<Range<usize>> = Vec::new();
403        let mut spans = Vec::new();
404
405        let from_extractor = self.extra.map(|f| f(text)).unwrap_or_default();
406        for e in extra.iter().chain(&from_extractor) {
407            let r = &e.range;
408            let valid = r.start < r.end
409                && r.end <= text.len()
410                && text.is_char_boundary(r.start)
411                && text.is_char_boundary(r.end);
412            if valid && !overlaps(&taken, r) {
413                let mut custom = span(text, SpanKind::Custom, r.clone());
414                custom.label = Some(e.label.clone());
415                spans.push(custom);
416                taken.push(r.clone());
417            }
418        }
419
420        let code: Vec<_> = code_spans(text)
421            .into_iter()
422            .filter(|r| !overlaps(&taken, r))
423            .collect();
424        for range in &code {
425            if self.code {
426                spans.push(span(text, SpanKind::Code, range.clone()));
427            }
428        }
429        taken.extend(code);
430
431        if self.quotes {
432            let found = quote_spans(text, &taken);
433            taken.extend(found.iter().cloned());
434            spans.extend(found.into_iter().map(|r| span(text, SpanKind::Quote, r)));
435        }
436        // Links are always found, like code, so a figure or a `#` inside
437        // one never turns into a span of its own.
438        let urls: Vec<_> = url_spans(text)
439            .into_iter()
440            .filter(|r| !overlaps(&taken, r))
441            .collect();
442        if self.urls {
443            spans.extend(urls.iter().map(|r| span(text, SpanKind::Url, r.clone())));
444        }
445        taken.extend(urls);
446        if self.hashtags {
447            let found: Vec<_> = hashtag_spans(text)
448                .into_iter()
449                .filter(|r| !overlaps(&taken, r))
450                .collect();
451            taken.extend(found.iter().cloned());
452            spans.extend(found.into_iter().map(|r| span(text, SpanKind::Hashtag, r)));
453        }
454        if self.numbers {
455            let found: Vec<_> = number_spans(text)
456                .into_iter()
457                .filter(|r| !overlaps(&taken, r))
458                .collect();
459            taken.extend(found.iter().cloned());
460            spans.extend(found.into_iter().map(|r| span(text, SpanKind::Number, r)));
461        }
462        if self.names {
463            let found: Vec<_> = name_spans(text)
464                .into_iter()
465                .filter(|r| !overlaps(&taken, r))
466                .collect();
467            spans.extend(found.into_iter().map(|r| span(text, SpanKind::Name, r)));
468        }
469
470        spans.sort_by_key(|s| (s.start, s.end));
471        spans
472    }
473
474    /// The byte ranges of [`Guard::extract`], for a diff that keeps them
475    /// whole.
476    pub fn ranges(&self, text: &str) -> Vec<Range<usize>> {
477        self.extract(text).iter().map(Span::range).collect()
478    }
479
480    /// The byte ranges of [`Guard::extract_with`].
481    pub fn ranges_with(&self, text: &str, extra: &[ExtraSpan]) -> Vec<Range<usize>> {
482        self.extract_with(text, extra)
483            .iter()
484            .map(Span::range)
485            .collect()
486    }
487
488    /// Checks that `rewrite` kept every protected span of `original` and
489    /// introduced no number of its own.
490    ///
491    /// # Errors
492    ///
493    /// Every [`Violation`], missing ones first in the original's order,
494    /// then introduced ones in the rewrite's order. A span that occurs
495    /// several times in the original is reported once.
496    pub fn verify(&self, original: &str, rewrite: &str) -> Result<(), Vec<Violation>> {
497        self.verify_with(original, &[], rewrite, &[])
498    }
499
500    /// [`Guard::verify`] with caller spans for each side. Each of
501    /// `original_extra` survives only as one of `rewrite_extra` (or of the
502    /// guard's extractor) with the same label and text, and `rewrite_extra`
503    /// shadows what it covers in the rewrite, so the digits inside a caller
504    /// span are never an introduced number.
505    ///
506    /// # Errors
507    ///
508    /// See [`Guard::verify`].
509    pub fn verify_with(
510        &self,
511        original: &str,
512        original_extra: &[ExtraSpan],
513        rewrite: &str,
514        rewrite_extra: &[ExtraSpan],
515    ) -> Result<(), Vec<Violation>> {
516        let mut violations = Vec::new();
517        let mut seen = BTreeSet::new();
518        let rewrite_spans = self.extract_with(rewrite, rewrite_extra);
519        for span in self.extract_with(original, original_extra) {
520            let key = (span.kind, span.label.clone(), span.protected().to_owned());
521            if seen.contains(&key) {
522                continue;
523            }
524            let kept = if span.kind == SpanKind::Custom {
525                // A caller span survives as a caller span: same label, same
526                // text, found in the rewrite by the same caller rules.
527                rewrite_spans.iter().any(|kept| {
528                    kept.kind == SpanKind::Custom
529                        && kept.label == span.label
530                        && kept.text == span.text
531                })
532            } else {
533                contains_bounded(rewrite, span.protected(), span.kind)
534            };
535            if !kept {
536                violations.push(Violation {
537                    kind: ViolationKind::Missing,
538                    span,
539                });
540            }
541            seen.insert(key);
542        }
543        if self.numbers {
544            let mut introduced = BTreeSet::new();
545            for span in rewrite_spans {
546                if span.kind == SpanKind::Number
547                    && !contains_bounded(original, &span.text, SpanKind::Number)
548                    && introduced.insert(span.text.clone())
549                {
550                    violations.push(Violation {
551                        kind: ViolationKind::Introduced,
552                        span,
553                    });
554                }
555            }
556        }
557        if violations.is_empty() {
558            Ok(())
559        } else {
560            Err(violations)
561        }
562    }
563}
564
565/// [`Guard::extract`] with every kind on.
566pub fn extract(text: &str) -> Vec<Span> {
567    Guard::new().extract(text)
568}
569
570/// [`Guard::verify`] with every kind on.
571///
572/// # Errors
573///
574/// See [`Guard::verify`].
575pub fn verify(original: &str, rewrite: &str) -> Result<(), Vec<Violation>> {
576    Guard::new().verify(original, rewrite)
577}
578
579fn span(text: &str, kind: SpanKind, range: Range<usize>) -> Span {
580    Span {
581        kind,
582        text: text[range.clone()].to_owned(),
583        start: range.start,
584        end: range.end,
585        label: None,
586    }
587}
588
589fn overlaps(taken: &[Range<usize>], range: &Range<usize>) -> bool {
590    taken
591        .iter()
592        .any(|t| t.start < range.end && range.start < t.end)
593}
594
595// ---------------------------------------------------------------- code ----
596
597/// Fenced blocks first, then inline code outside them.
598fn code_spans(text: &str) -> Vec<Range<usize>> {
599    let mut spans = fenced_blocks(text);
600    let fenced = spans.clone();
601    spans.extend(
602        inline_code(text)
603            .into_iter()
604            .filter(|r| !overlaps(&fenced, r)),
605    );
606    spans.sort_by_key(|r| r.start);
607    spans
608}
609
610/// The lines of `text` with their byte offsets, newline excluded.
611fn lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
612    let mut at = 0;
613    text.split_inclusive('\n').map(move |raw| {
614        let start = at;
615        at += raw.len();
616        (start, raw.trim_end_matches(['\n', '\r']))
617    })
618}
619
620/// The fence a line opens or closes, as (marker char, run length).
621fn fence(line: &str) -> Option<(char, usize)> {
622    let trimmed = line.trim_start_matches(' ');
623    if line.len() - trimmed.len() > 3 {
624        return None;
625    }
626    let marker = trimmed.chars().next().filter(|c| *c == '`' || *c == '~')?;
627    let run = trimmed.chars().take_while(|c| *c == marker).count();
628    (run >= 3).then_some((marker, run))
629}
630
631fn fenced_blocks(text: &str) -> Vec<Range<usize>> {
632    let mut blocks = Vec::new();
633    let mut open: Option<(usize, char, usize)> = None;
634    for (start, line) in lines(text) {
635        match open {
636            None => {
637                if let Some((marker, run)) = fence(line) {
638                    let indent = line.len() - line.trim_start_matches(' ').len();
639                    open = Some((start + indent, marker, run));
640                }
641            }
642            Some((from, marker, run)) => {
643                let closes = fence(line).is_some_and(|(m, r)| {
644                    m == marker && r >= run && line.trim().chars().all(|c| c == marker)
645                });
646                if closes {
647                    blocks.push(from..start + line.trim_end().len());
648                    open = None;
649                }
650            }
651        }
652    }
653    // An unclosed fence runs to the end of the text, as in CommonMark.
654    if let Some((from, _, _)) = open {
655        blocks.push(from..text.trim_end().len().max(from));
656    }
657    blocks
658}
659
660fn inline_code(text: &str) -> Vec<Range<usize>> {
661    let bytes = text.as_bytes();
662    let mut spans = Vec::new();
663    let mut i = 0;
664    while i < bytes.len() {
665        if bytes[i] != b'`' {
666            i += 1;
667            continue;
668        }
669        let run = bytes[i..].iter().take_while(|b| **b == b'`').count();
670        let body = i + run;
671        let mut j = body;
672        let mut closed = None;
673        while j < bytes.len() {
674            if text[j..].starts_with("\n\n") || text[j..].starts_with("\r\n\r\n") {
675                break;
676            }
677            if bytes[j] == b'`' {
678                let close = bytes[j..].iter().take_while(|b| **b == b'`').count();
679                if close == run {
680                    closed = Some(j + close);
681                    break;
682                }
683                j += close;
684            } else {
685                j += 1;
686            }
687        }
688        match closed {
689            Some(end) if end > body + run => {
690                spans.push(i..end);
691                i = end;
692            }
693            _ => i = body,
694        }
695    }
696    spans
697}
698
699// -------------------------------------------------------------- quotes ----
700
701/// The quoted words of a quote span: marks and closing punctuation off.
702fn quote_words(quote: &str) -> &str {
703    let inner = quote
704        .strip_prefix(['"', '“', '„', '«'])
705        .and_then(|q| q.strip_suffix(['"', '”', '“', '»']))
706        .unwrap_or(quote);
707    inner
708        .trim()
709        .trim_end_matches([',', '.', ';', ':'])
710        .trim_end()
711}
712
713fn quote_spans(text: &str, taken: &[Range<usize>]) -> Vec<Range<usize>> {
714    let mut spans = Vec::new();
715    let mut chars = text.char_indices().peekable();
716    while let Some((i, c)) = chars.next() {
717        let close = match c {
718            '"' => '"',
719            '“' => '”',
720            '„' => '“',
721            '«' => '»',
722            _ => continue,
723        };
724        if taken.iter().any(|t| t.contains(&i)) {
725            continue;
726        }
727        let body = i + c.len_utf8();
728        let mut end = None;
729        for (count, (j, d)) in text[body..].char_indices().enumerate() {
730            if count > MAX_QUOTE_CHARS || text[body + j..].starts_with("\n\n") {
731                break;
732            }
733            if d == close {
734                end = Some(body + j + d.len_utf8());
735                break;
736            }
737        }
738        let Some(end) = end else { continue };
739        let range = i..end;
740        let words = quote_words(&text[range.clone()]);
741        if words.chars().any(char::is_alphanumeric) && !overlaps(taken, &range) {
742            // Skip the closing mark so it cannot open the next quote.
743            while chars.peek().is_some_and(|(j, _)| *j < end) {
744                chars.next();
745            }
746            spans.push(range);
747        }
748    }
749    spans
750}
751
752// --------------------------------------------------------------- links ----
753
754/// Where a link's address starts, if one starts at byte `start`: the
755/// length of its `http://`, `https://` or `www.` prefix.
756fn link_prefix(rest: &str) -> Option<usize> {
757    let starts = |prefix: &str| {
758        rest.get(..prefix.len())
759            .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
760    };
761    ["https://", "http://", "www."]
762        .into_iter()
763        .find(|prefix| starts(prefix))
764        .map(str::len)
765}
766
767/// Ends a link outright: whitespace, angle brackets, double quotes of any
768/// style, a backtick.
769fn ends_link(c: char) -> bool {
770    c.is_whitespace() || matches!(c, '<' | '>' | '"' | '`' | '“' | '”' | '„' | '«' | '»')
771}
772
773/// The end of a link starting at `start`, if one starts there. Does not
774/// check the word boundary before it.
775fn url_at(text: &str, start: usize) -> Option<usize> {
776    let rest = &text[start..];
777    let prefix = link_prefix(rest)?;
778    if !rest[prefix..].starts_with(char::is_alphanumeric) {
779        return None;
780    }
781    let mut end = prefix
782        + rest[prefix..]
783            .chars()
784            .take_while(|c| !ends_link(*c))
785            .map(char::len_utf8)
786            .sum::<usize>();
787    // Sentence punctuation and unmatched closers at the end are the
788    // sentence's, not the link's.
789    while let Some(last) = rest[..end].chars().next_back() {
790        let unmatched = |open: char| {
791            let link = &rest[..end];
792            link.matches(open).count() < link.matches(last).count()
793        };
794        let trailing = match last {
795            '.' | ',' | ';' | ':' | '!' | '?' | '\'' | '’' => true,
796            ')' => unmatched('('),
797            ']' => unmatched('['),
798            _ => false,
799        };
800        if !trailing {
801            break;
802        }
803        end -= last.len_utf8();
804    }
805    let host = &rest[prefix..end];
806    let host = &host[..host.find(['/', '?', '#']).unwrap_or(host.len())];
807    // `www.` needs a dot in the host after it, or "www.something" in prose
808    // would be a link; a scheme needs a host at all.
809    let ok = if prefix == "www.".len() {
810        host.contains('.') && !host.ends_with('.')
811    } else {
812        !host.is_empty()
813    };
814    ok.then_some(start + end)
815}
816
817fn url_spans(text: &str) -> Vec<Range<usize>> {
818    let mut spans = Vec::new();
819    let mut i = 0;
820    while i < text.len() {
821        let Some(c) = text[i..].chars().next() else {
822            break;
823        };
824        let at_boundary = before(text, i).is_none_or(|p| !is_word_char(p));
825        if at_boundary
826            && matches!(c, 'h' | 'H' | 'w' | 'W')
827            && let Some(end) = url_at(text, i)
828        {
829            spans.push(i..end);
830            i = end;
831            continue;
832        }
833        i += c.len_utf8();
834    }
835    spans
836}
837
838// ------------------------------------------------------------ hashtags ----
839
840fn is_tag_char(c: char) -> bool {
841    c.is_alphanumeric() || c == '_'
842}
843
844/// Whether a `#` may open a hashtag after this char: not glued to a word
845/// (`C#`), another `#` (`##`), or an `&` (`&#39;`).
846fn opens_hashtag(prev: Option<char>) -> bool {
847    prev.is_none_or(|p| !is_tag_char(p) && !matches!(p, '#' | '&'))
848}
849
850/// The end of a hashtag whose `#` is at `start`, if one starts there.
851fn hashtag_at(text: &str, start: usize) -> Option<usize> {
852    let body = text[start..].strip_prefix('#')?;
853    let len: usize = body
854        .chars()
855        .take_while(|c| is_tag_char(*c))
856        .map(char::len_utf8)
857        .sum();
858    // `#1` and `#123` are a rank and an issue number, not hashtags.
859    body[..len]
860        .chars()
861        .any(char::is_alphabetic)
862        .then_some(start + 1 + len)
863}
864
865fn hashtag_spans(text: &str) -> Vec<Range<usize>> {
866    let mut spans = Vec::new();
867    for (i, c) in text.char_indices() {
868        if c != '#' || spans.last().is_some_and(|r: &Range<usize>| i < r.end) {
869            continue;
870        }
871        if opens_hashtag(before(text, i))
872            && let Some(end) = hashtag_at(text, i)
873        {
874            spans.push(i..end);
875        }
876    }
877    spans
878}
879
880// ------------------------------------------------------------- numbers ----
881
882const CURRENCY: &[char] = &[
883    '$', '€', '£', '¥', '₹', '₩', '₽', '฿', '₦', '₱', '₫', '₺', '₪', '¢',
884];
885
886fn is_word_char(c: char) -> bool {
887    c.is_alphanumeric()
888}
889
890/// The char before byte `at`, if any.
891fn before(text: &str, at: usize) -> Option<char> {
892    text[..at].chars().next_back()
893}
894
895fn number_spans(text: &str) -> Vec<Range<usize>> {
896    let mut spans = Vec::new();
897    let mut i = 0;
898    while i < text.len() {
899        let Some(c) = text[i..].chars().next() else {
900            break;
901        };
902        let at_boundary = before(text, i).is_none_or(|p| !is_word_char(p));
903        if at_boundary && let Some(end) = number_at(text, i) {
904            spans.push(i..end);
905            i = end;
906            continue;
907        }
908        i += c.len_utf8();
909    }
910    spans
911}
912
913/// The end of a number token starting at `start`, if one starts there.
914fn number_at(text: &str, start: usize) -> Option<usize> {
915    let rest = &text[start..];
916    let mut chars = rest.char_indices().peekable();
917    let (_, first) = *chars.peek()?;
918
919    let digits_from = if CURRENCY.contains(&first) {
920        chars.next();
921        let (at, c) = *chars.peek()?;
922        if !c.is_ascii_digit() {
923            return None;
924        }
925        at
926    } else if first.is_ascii_uppercase() {
927        // Q3, FY2024, H1, GPT-4: one to four capitals glued to digits.
928        let caps = rest.chars().take_while(char::is_ascii_uppercase).count();
929        if caps > 4 {
930            return None;
931        }
932        let mut at = caps;
933        if rest[at..].starts_with('-') {
934            at += 1;
935        }
936        if !rest[at..].starts_with(|c: char| c.is_ascii_digit()) {
937            return None;
938        }
939        at
940    } else if first.is_ascii_digit() {
941        0
942    } else {
943        return None;
944    };
945
946    // Digits, joined by separators only when a digit follows.
947    let bytes = rest.as_bytes();
948    let mut end = digits_from;
949    loop {
950        while end < bytes.len() && bytes[end].is_ascii_digit() {
951            end += 1;
952        }
953        let joins = end + 1 < bytes.len()
954            && matches!(bytes[end], b'.' | b',' | b':' | b'/' | b'-')
955            && bytes[end + 1].is_ascii_digit();
956        if joins {
957            end += 1;
958        } else {
959            break;
960        }
961    }
962    // A percent or per-mille sign, then any letters glued on (M, bn, st, x).
963    if let Some(sign) = rest[end..]
964        .chars()
965        .next()
966        .filter(|c| matches!(c, '%' | '‰'))
967    {
968        end += sign.len_utf8();
969    }
970    end += rest[end..]
971        .chars()
972        .take_while(|c| c.is_alphanumeric())
973        .map(char::len_utf8)
974        .sum::<usize>();
975    Some(start + end)
976}
977
978// --------------------------------------------------------------- names ----
979
980/// Function words that start a sentence often and a name rarely. Dropped
981/// from the front of a run of capitalised words.
982const LEADING: &[&str] = &[
983    "A",
984    "About",
985    "After",
986    "Also",
987    "An",
988    "And",
989    "As",
990    "At",
991    "Before",
992    "But",
993    "By",
994    "Dear",
995    "During",
996    "Each",
997    "Every",
998    "For",
999    "From",
1000    "Furthermore",
1001    "He",
1002    "Her",
1003    "Here",
1004    "His",
1005    "However",
1006    "I",
1007    "If",
1008    "In",
1009    "Into",
1010    "It",
1011    "Its",
1012    "Meanwhile",
1013    "Moreover",
1014    "My",
1015    "No",
1016    "Not",
1017    "Of",
1018    "On",
1019    "Once",
1020    "Or",
1021    "Our",
1022    "She",
1023    "Since",
1024    "So",
1025    "Some",
1026    "That",
1027    "The",
1028    "Their",
1029    "Then",
1030    "There",
1031    "These",
1032    "They",
1033    "This",
1034    "Those",
1035    "Thus",
1036    "To",
1037    "Under",
1038    "Until",
1039    "We",
1040    "What",
1041    "When",
1042    "Where",
1043    "While",
1044    "Why",
1045    "With",
1046    "Yet",
1047    "You",
1048    "Your",
1049];
1050
1051/// Lowercase words that may sit between the capitalised words of a name.
1052const PARTICLES: &[&str] = &[
1053    "al", "bin", "da", "de", "del", "der", "di", "du", "la", "le", "of", "van", "von",
1054];
1055
1056/// A word: letters, with `'`, `’` or `-` allowed between letters.
1057fn word_at(text: &str, start: usize) -> Option<usize> {
1058    let mut end = start;
1059    let mut chars = text[start..].char_indices().peekable();
1060    while let Some((at, c)) = chars.next() {
1061        if c.is_alphabetic() {
1062            end = start + at + c.len_utf8();
1063        } else if !(matches!(c, '\'' | '’' | '-')
1064            && end == start + at
1065            && chars.peek().is_some_and(|(_, n)| n.is_alphabetic()))
1066        {
1067            break;
1068        }
1069    }
1070    (end > start).then_some(end)
1071}
1072
1073fn is_capitalised(word: &str) -> bool {
1074    word.chars().next().is_some_and(char::is_uppercase)
1075}
1076
1077/// Drops a trailing possessive (`'s`, `’s`, or a bare `'`/`’` after s).
1078fn without_possessive(word: &str) -> &str {
1079    for suffix in ["'s", "’s", "'S", "’S"] {
1080        if let Some(stem) = word.strip_suffix(suffix) {
1081            return stem;
1082        }
1083    }
1084    word.trim_end_matches(['\'', '’'])
1085}
1086
1087fn name_spans(text: &str) -> Vec<Range<usize>> {
1088    // Every word with its range, and whether exactly one space precedes it.
1089    let mut words: Vec<(Range<usize>, bool)> = Vec::new();
1090    let mut i = 0;
1091    while i < text.len() {
1092        let c = text[i..].chars().next().unwrap_or(' ');
1093        let at_boundary = before(text, i).is_none_or(|p| !p.is_alphanumeric());
1094        if at_boundary
1095            && c.is_alphabetic()
1096            && let Some(end) = word_at(text, i)
1097        {
1098            // Digits glued on make it not a word (Q3 is a number).
1099            if text[end..].starts_with(|n: char| n.is_alphanumeric()) {
1100                i = end;
1101                continue;
1102            }
1103            let joined = words
1104                .last()
1105                .is_some_and(|(w, _)| matches!(&text[w.end..i], " " | "\u{a0}"));
1106            words.push((i..end, joined));
1107            i = end;
1108        } else {
1109            i += c.len_utf8();
1110        }
1111    }
1112
1113    let mut spans = Vec::new();
1114    let mut k = 0;
1115    while k < words.len() {
1116        if !is_capitalised(&text[words[k].0.clone()]) {
1117            k += 1;
1118            continue;
1119        }
1120        // Grow a run: capitalised words, or a particle between two of them.
1121        let mut run = vec![k];
1122        let mut j = k + 1;
1123        while j < words.len() && words[j].1 {
1124            // A word ending in a possessive closes the name.
1125            let last = &text[words[*run.last().unwrap_or(&k)].0.clone()];
1126            if without_possessive(last) != last {
1127                break;
1128            }
1129            let word = &text[words[j].0.clone()];
1130            if is_capitalised(word) {
1131                run.push(j);
1132                j += 1;
1133            } else if PARTICLES.contains(&word)
1134                && j + 1 < words.len()
1135                && words[j + 1].1
1136                && is_capitalised(&text[words[j + 1].0.clone()])
1137            {
1138                run.push(j);
1139                run.push(j + 1);
1140                j += 2;
1141            } else {
1142                break;
1143            }
1144        }
1145        while run.first().is_some_and(|f| {
1146            let word = &text[words[*f].0.clone()];
1147            LEADING.contains(&word) || !is_capitalised(word)
1148        }) {
1149            run.remove(0);
1150        }
1151        let capitalised = run
1152            .iter()
1153            .filter(|w| is_capitalised(&text[words[**w].0.clone()]))
1154            .count();
1155        if capitalised >= 2
1156            && let (Some(first), Some(last)) = (run.first(), run.last())
1157        {
1158            let start = words[*first].0.start;
1159            let last_range = words[*last].0.clone();
1160            let end = last_range.start + without_possessive(&text[last_range]).len();
1161            spans.push(start..end);
1162        }
1163        k = j.max(k + 1);
1164    }
1165    spans
1166}
1167
1168// -------------------------------------------------------------- verify ----
1169
1170/// Whether `needle` occurs in `hay` at a word boundary for its kind.
1171fn contains_bounded(hay: &str, needle: &str, kind: SpanKind) -> bool {
1172    if needle.is_empty() {
1173        return true;
1174    }
1175    let starts_word = needle.starts_with(is_word_char);
1176    let ends_word = needle.ends_with(is_word_char);
1177    // 4.2 inside 14.2 or 1,4.2: a number runs on through a separator
1178    // that has a digit on its far side.
1179    let number = kind == SpanKind::Number;
1180    let runs_on = |sep: char, far: Option<char>| {
1181        number && matches!(sep, '.' | ',') && far.is_some_and(|d| d.is_ascii_digit())
1182    };
1183    hay.match_indices(needle).any(|(at, _)| {
1184        let end = at + needle.len();
1185        let left_ok = !starts_word
1186            || before(hay, at)
1187                .is_none_or(|p| !is_word_char(p) && !runs_on(p, before(hay, at - p.len_utf8())));
1188        let right_ok = !ends_word
1189            || hay[end..].chars().next().is_none_or(|n| {
1190                !is_word_char(n) && !runs_on(n, hay[end + n.len_utf8()..].chars().next())
1191            });
1192        match kind {
1193            // The whole link, not a prefix of a longer one.
1194            SpanKind::Url => left_ok && url_at(hay, at) == Some(end),
1195            // Not glued on either side, and not the front of a longer tag.
1196            SpanKind::Hashtag => {
1197                opens_hashtag(before(hay, at)) && !hay[end..].starts_with(is_tag_char)
1198            }
1199            _ => left_ok && right_ok,
1200        }
1201    })
1202}
1203
1204#[cfg(test)]
1205mod tests;