lang_check/prose/gap.rs
1//! The gap between two prose words, and what a markup language puts there.
2//!
3//! Extraction collects the words that carry prose. Everything between two of
4//! them is a *gap*, and a gap decides two things:
5//!
6//! - whether the words on either side belong to one prose block, answered by
7//! stripping the gap's markup and looking at what is left;
8//! - which byte ranges the checker must not see, so that a formula or a command
9//! name is not reported as a spelling mistake.
10//!
11//! Both answers come from the same question — "what token starts here?" — so a
12//! language describes its gap syntax once, as a [`Syntax`] function, and
13//! [`strip`] and [`exclusions`] derive the rest.
14//!
15//! # Adding a language
16//!
17//! Write one function of the shape [`Syntax`]: given the gap's bytes and an
18//! offset, return the token that starts there, or `None` when the byte is
19//! ordinary text. Then pass it to [`super::shared::merge_ranges`]. That is the
20//! whole surface — there is no second scanner to keep in step, which is what
21//! this module exists to prevent.
22//!
23//! A matcher is written as a `match` on `bytes[i..]` so the slice patterns
24//! carry the bounds checks, and the arms are ordered longest-prefix first
25//! (`##{` before `#{`, `\name` before `\x`).
26
27/// What the checker should see in place of a token.
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum Token {
30 /// Carries no prose but separates the words around it: math, a verbatim
31 /// span, a code span. Stripped to a space, so `a $x$ b` still reads as one
32 /// sentence rather than as `ab`.
33 Separator,
34 /// Invisible in the rendered document: a command name, an escape, a comment.
35 /// Stripped to nothing, so `\emph{a}b` reads as `ab`.
36 Elided,
37 /// Block-level structure — the words on either side are in different
38 /// paragraphs. Kept verbatim in the stripped gap so the bridge check sees
39 /// it and refuses to join them.
40 Barrier,
41}
42
43/// One recognized token: its kind and the bytes it covers.
44///
45/// `start` may lie *before* the offset the token was recognized at. LaTeX
46/// display math reaches back for the whitespace around it, so that blanking the
47/// formula does not leave a double space in the middle of a sentence.
48#[derive(Clone, Copy, Debug)]
49pub struct Match {
50 pub token: Token,
51 pub start: usize,
52 pub end: usize,
53}
54
55impl Match {
56 /// A token covering `start..end`.
57 #[must_use]
58 pub const fn new(token: Token, start: usize, end: usize) -> Self {
59 Self { token, start, end }
60 }
61
62 /// A token covering `at..end` — the common case, where the token begins
63 /// exactly where it was recognized.
64 #[must_use]
65 pub const fn at(token: Token, at: usize, end: usize) -> Self {
66 Self::new(token, at, end)
67 }
68}
69
70/// Recognizes the token starting at `bytes[i]`, or `None` when that byte is
71/// ordinary text.
72///
73/// Called only with `i < bytes.len()` and `i` on a character boundary. The
74/// returned `end` must be greater than `i`, or the scan cannot make progress.
75pub type Syntax = fn(&[u8], usize) -> Option<Match>;
76
77/// The gap with its markup removed, for the bridge check.
78///
79/// [`Token::Separator`] becomes a space, [`Token::Elided`] disappears, and
80/// [`Token::Barrier`] is copied through unchanged so the caller's bridge test
81/// rejects the gap.
82#[must_use]
83pub fn strip(gap: &str, syntax: Syntax) -> String {
84 let bytes = gap.as_bytes();
85 let mut out = String::with_capacity(gap.len());
86 let mut i = 0;
87 while i < bytes.len() {
88 let Some(found) = syntax(bytes, i) else {
89 // Ordinary text — copy the whole run in one go rather than a
90 // character at a time.
91 let start = i;
92 loop {
93 i = next_boundary(bytes, i);
94 if i >= bytes.len() || syntax(bytes, i).is_some() {
95 break;
96 }
97 }
98 out.push_str(&gap[start..i]);
99 continue;
100 };
101 match found.token {
102 Token::Separator => out.push(' '),
103 Token::Elided => {}
104 Token::Barrier => out.push_str(&gap[i..found.end]),
105 }
106 i = found.end;
107 }
108 out
109}
110
111/// The gap's tokens as exclusion ranges, offset into the document.
112///
113/// The ranges come out sorted and disjoint: a token that reaches back for the
114/// whitespace before it is clamped to where the previous token ended, so the
115/// two never overlap.
116pub fn exclusions(gap: &str, offset: usize, syntax: Syntax, out: &mut Vec<(usize, usize)>) {
117 let bytes = gap.as_bytes();
118 let mut i = 0;
119 let mut previous_end = 0;
120 while i < bytes.len() {
121 let Some(found) = syntax(bytes, i) else {
122 i = next_boundary(bytes, i);
123 continue;
124 };
125 let start = found.start.max(previous_end);
126 if start < found.end {
127 out.push((offset + start, offset + found.end));
128 previous_end = found.end;
129 }
130 i = found.end;
131 }
132}
133
134/// The next character boundary after `i`.
135///
136/// Token matchers key on ASCII, so stepping a character at a time is what keeps
137/// every slice in [`strip`] on a boundary even when the prose around the markup
138/// is not ASCII.
139const fn next_boundary(bytes: &[u8], i: usize) -> usize {
140 let mut j = i + 1;
141 while j < bytes.len() && bytes[j] & 0b1100_0000 == 0b1000_0000 {
142 j += 1;
143 }
144 j
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 /// A toy syntax: `$…$` separates, `\x` elides, `|` is a barrier.
152 fn toy(bytes: &[u8], i: usize) -> Option<Match> {
153 Some(match bytes[i..] {
154 [b'$', ..] => Match::at(
155 Token::Separator,
156 i,
157 super::super::shared::close_at(bytes, i + 1, b"$", None),
158 ),
159 [b'\\', _, ..] => Match::at(Token::Elided, i, i + 2),
160 [b'|', ..] => Match::at(Token::Barrier, i, i + 1),
161 _ => return None,
162 })
163 }
164
165 #[test]
166 fn strip_applies_one_rule_per_token_kind() {
167 assert_eq!(strip("a $x$ b", toy), "a b");
168 assert_eq!(strip("a \\q b", toy), "a b");
169 assert_eq!(strip("a | b", toy), "a | b");
170 }
171
172 #[test]
173 fn strip_keeps_non_ascii_text_intact() {
174 assert_eq!(strip("ä ö $x$ ü", toy), "ä ö ü");
175 }
176
177 #[test]
178 fn exclusions_are_sorted_and_disjoint() {
179 let mut out = Vec::new();
180 exclusions("a $x$ \\q |", 100, toy, &mut out);
181 assert_eq!(out, vec![(102, 105), (106, 108), (109, 110)]);
182 }
183
184 #[test]
185 fn exclusions_clamp_a_token_that_reaches_backwards() {
186 /// Every `!` claims the byte before it as well.
187 fn greedy(bytes: &[u8], i: usize) -> Option<Match> {
188 match bytes[i..] {
189 [b'!', ..] => Some(Match::new(Token::Separator, i.saturating_sub(1), i + 1)),
190 _ => None,
191 }
192 }
193 let mut out = Vec::new();
194 exclusions("!!", 0, greedy, &mut out);
195 assert_eq!(
196 out,
197 vec![(0, 1), (1, 2)],
198 "the second token must not reach back into the first"
199 );
200 }
201
202 #[test]
203 fn an_unterminated_token_consumes_the_rest() {
204 assert_eq!(strip("a $x", toy), "a ");
205 }
206}