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}