Skip to main content

lang_check/
text_util.rs

1//! Small shared text utilities: byte-offset–safe string handling, and the shared
2//! reading of an engine's `suggestions` list.
3
4/// Slice a `&str` at byte offsets, snapping each bound to the nearest char
5/// boundary so the operation never panics on multi-byte UTF-8.
6///
7/// `start` rounds down, `end` rounds up; both are clamped to the string length.
8#[must_use]
9pub fn safe_slice(s: &str, start: usize, end: usize) -> &str {
10    let lo = s.floor_char_boundary(start.min(s.len()));
11    let hi = s.ceil_char_boundary(end.min(s.len()));
12    &s[lo..hi]
13}
14
15/// Everything up to `end`, snapping the bound DOWN to a char boundary.
16///
17/// The one-sided counterpart to [`safe_slice`]: it floors where `safe_slice` would ceil, so the
18/// partial character at a split offset is excluded rather than included. Callers wanting the
19/// text *before* an engine-reported span want this.
20#[must_use]
21pub fn safe_prefix(s: &str, end: usize) -> &str {
22    &s[..s.floor_char_boundary(end.min(s.len()))]
23}
24
25/// Everything from `start`, snapping the bound UP to a char boundary.
26///
27/// Mirror of [`safe_prefix`] for the text *after* a span; ceils so a split character is not
28/// re-emitted as a partial tail.
29#[must_use]
30pub fn safe_suffix(s: &str, start: usize) -> &str {
31    &s[s.ceil_char_boundary(start.min(s.len()))..]
32}
33
34/// Snap a byte range outward to char boundaries, clamped to the string length.
35///
36/// The offsets form of [`safe_slice`], for callers that need the *bounds* rather than the slice —
37/// splicing with `String::replace_range`, or deciding whether an engine-reported span was
38/// well-formed by testing whether snapping moved it.
39#[must_use]
40pub fn snap_range(s: &str, start: usize, end: usize) -> (usize, usize) {
41    (
42        s.floor_char_boundary(start.min(s.len())),
43        s.ceil_char_boundary(end.min(s.len())),
44    )
45}
46
47/// Smallest edit distance between the token and any single-word suggestion.
48///
49/// Returns `None` when the engine offered nothing usable. Suggestions containing
50/// whitespace are ignored: `LanguageTool` answers `Abramsky` with `Abram sky`, a word-split
51/// proposal that is one edit away by character count but is not evidence that the token
52/// is a misspelling of a known word.
53///
54/// Shared by [`crate::names`] and [`crate::morphology`], which ask the same question of the
55/// same engine output: is there a known word this token is one slip away from?
56#[must_use]
57pub fn min_suggestion_distance(token: &str, suggestions: &[String]) -> Option<usize> {
58    let lowered = token.to_lowercase();
59    suggestions
60        .iter()
61        .filter(|s| !s.chars().any(char::is_whitespace))
62        .map(|s| strsim::damerau_levenshtein(&lowered, &s.to_lowercase()))
63        .min()
64}
65
66#[cfg(test)]
67mod tests {
68    use super::{min_suggestion_distance, safe_prefix, safe_slice, safe_suffix, snap_range};
69
70    #[test]
71    fn ascii_slice_is_exact() {
72        assert_eq!(safe_slice("hello world", 0, 5), "hello");
73        assert_eq!(safe_slice("hello world", 6, 11), "world");
74    }
75
76    #[test]
77    fn snaps_offsets_inside_multibyte_chars() {
78        // 'ö' occupies two bytes; offsets landing mid-char must widen outward.
79        let s = "Ölförderung";
80        // byte 1 is mid-'Ö' -> floors to 0; byte 4 is mid-'ö' -> ceils past it.
81        let slice = safe_slice(s, 1, 4);
82        assert!(s.starts_with(slice) || s.contains(slice));
83        assert!(slice.is_char_boundary(0));
84    }
85
86    #[test]
87    fn clamps_out_of_range_offsets() {
88        assert_eq!(safe_slice("abc", 0, 999), "abc");
89        assert_eq!(safe_slice("abc", 999, 999), "");
90    }
91
92    #[test]
93    fn prefix_and_suffix_snap_away_from_a_split_char() {
94        // 'ö' is two bytes at 1..3; byte 2 is inside it.
95        let s = "Föö";
96        assert_eq!(safe_prefix(s, 2), "F"); // floors back off the partial char
97        assert_eq!(safe_suffix(s, 2), "ö"); // ceils forward past it
98        assert_eq!(safe_prefix(s, 0), "");
99        assert_eq!(safe_suffix(s, 999), "");
100    }
101
102    #[test]
103    fn snap_range_reports_whether_it_moved() {
104        let s = "Föö";
105        // Already on boundaries — unchanged, so a caller can trust the span.
106        assert_eq!(snap_range(s, 0, 3), (0, 3));
107        // Byte 2 splits 'ö': the start floors back, the end ceils forward.
108        assert_eq!(snap_range(s, 2, 2), (1, 3));
109        assert_eq!(snap_range(s, 0, 999), (0, s.len()));
110    }
111
112    #[test]
113    fn prefix_and_suffix_partition_on_a_real_boundary() {
114        let s = "Föö";
115        assert_eq!(format!("{}{}", safe_prefix(s, 3), safe_suffix(s, 3)), s);
116    }
117
118    #[test]
119    fn word_split_suggestions_are_not_evidence_of_a_typo() {
120        let sugg = vec!["Abram sky".to_string()];
121        assert_eq!(min_suggestion_distance("Abramsky", &sugg), None);
122    }
123
124    #[test]
125    fn suggestion_distance_is_case_insensitive() {
126        let sugg = vec!["Hoar".to_string()];
127        assert_eq!(min_suggestion_distance("Hoare", &sugg), Some(1));
128        assert_eq!(min_suggestion_distance("recieve", &[]), None);
129    }
130}
131
132/// Unwrap a line's comment syntax and hand the content inside to `parse`.
133///
134/// Both directive readers -- the scope markers in [`crate::scoping`] and the
135/// `lang-check-*` directives in [`crate::ignore_rules`] -- accept the same four
136/// spellings of a comment, and differ only in what they do with the text
137/// inside. The four are `<!-- ... -->`, `// ...`, `/* ... */` and `% ...`,
138/// covering Markdown and HTML, the C-family markup languages, and LaTeX.
139///
140/// Returns `None` when the line is not a comment, or when `parse` rejects
141/// what was inside one.
142pub fn in_comment<T>(line: &str, parse: impl Fn(&str) -> Option<T>) -> Option<T> {
143    let trimmed = line.trim();
144
145    if let Some(rest) = trimmed.strip_prefix("<!--")
146        && let Some(inner) = rest.strip_suffix("-->")
147    {
148        return parse(inner.trim());
149    }
150
151    if let Some(rest) = trimmed.strip_prefix("//") {
152        return parse(rest.trim());
153    }
154
155    if let Some(rest) = trimmed.strip_prefix("/*")
156        && let Some(inner) = rest.strip_suffix("*/")
157    {
158        return parse(inner.trim());
159    }
160
161    if let Some(rest) = trimmed.strip_prefix('%') {
162        return parse(rest.trim());
163    }
164
165    None
166}
167
168/// Tracks whether a line falls inside a fenced code block.
169///
170/// A directive written inside a fence is an example of a directive, not one.
171/// The language guide demonstrates the scope marker by showing
172/// ` ```markdown ` … `<!-- lang: fr -->` … ` ``` `, and without this the
173/// marker was obeyed: everything after it in the file was checked as French,
174/// so the page documenting the feature was the page the feature broke.
175///
176/// Fences are recognised the way `CommonMark` defines them -- three or more
177/// backticks or tildes, indented no more than three spaces, closed by at
178/// least as many of the same character with nothing after them. Typst raw
179/// blocks use the same delimiters, and a format with no fences at all simply
180/// never opens one.
181#[derive(Debug, Default)]
182pub struct FenceTracker {
183    /// The character and length of the fence currently open.
184    open: Option<(u8, usize)>,
185}
186
187impl FenceTracker {
188    #[must_use]
189    pub const fn new() -> Self {
190        Self { open: None }
191    }
192
193    /// Feed the next line; returns whether it is inside a fence.
194    ///
195    /// The fence lines themselves count as inside, because a directive can
196    /// only ever be on one of them by accident.
197    pub fn consume(&mut self, line: &str) -> bool {
198        let Some((marker, run)) = fence_run(line) else {
199            return self.open.is_some();
200        };
201
202        // A closing fence matches the opener's character and is at least as
203        // long, with nothing after it. Anything else inside a fence -- a ```
204        // run within a ~~~ block, say -- is just content.
205        if let Some((open_marker, open_run)) = self.open {
206            if marker == open_marker && run >= open_run && info_string(line, marker).is_empty() {
207                self.open = None;
208            }
209        } else {
210            self.open = Some((marker, run));
211        }
212        true
213    }
214
215    /// Whether a fence is currently open.
216    #[must_use]
217    pub const fn inside(&self) -> bool {
218        self.open.is_some()
219    }
220}
221
222/// The fence character and its run length, when a line opens or closes one.
223fn fence_run(line: &str) -> Option<(u8, usize)> {
224    let indent = line.len() - line.trim_start().len();
225    // More than three spaces of indent makes it an indented code block, not a
226    // fence.
227    if indent > 3 {
228        return None;
229    }
230    let rest = line.trim_start();
231    let marker = match rest.as_bytes().first() {
232        Some(&b'`') => b'`',
233        Some(&b'~') => b'~',
234        _ => return None,
235    };
236    let run = rest.bytes().take_while(|&b| b == marker).count();
237    (run >= 3).then_some((marker, run))
238}
239
240/// Whatever follows the fence characters, trimmed.
241fn info_string(line: &str, marker: u8) -> &str {
242    line.trim_start().trim_start_matches(marker as char).trim()
243}