Skip to main content

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}