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 `&` (`'`).
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;