Skip to main content

lang_check/
scoping.rs

1use std::ops::Range;
2
3/// A region of text with an explicitly annotated natural language.
4///
5/// Parsed from scope markers like `<!-- lang: fr -->` or `// @lang: de`.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct ScopedRegion {
8    /// BCP-47 language tag (e.g. "fr", "de", "en-US").
9    pub language: String,
10    /// Byte range this scope covers (from the marker to the next marker or EOF).
11    pub byte_range: Range<usize>,
12    /// The marker line itself, which is what declared the language.
13    ///
14    /// Reported against rather than the prose when nothing can read the
15    /// language: the marker is the thing to change, and the passage is only
16    /// where the consequence lands.
17    pub marker_range: Range<usize>,
18}
19
20/// Parses language scope annotations from document text.
21///
22/// Supports the following marker formats:
23/// - `<!-- lang: xx -->` (HTML/Markdown comments)
24/// - `// @lang: xx` (line comments)
25/// - `/* @lang: xx */` (block comments)
26/// - `% @lang: xx` (LaTeX comments)
27pub struct ScopeParser;
28
29impl ScopeParser {
30    /// Extract all language scope regions from the given text.
31    ///
32    /// Returns scoped regions sorted by byte offset. Text between
33    /// the start of the document and the first marker (or with no markers
34    /// at all) is *not* included - the caller should fall back to the
35    /// default language for those ranges.
36    #[must_use]
37    pub fn parse(text: &str) -> Vec<ScopedRegion> {
38        let mut markers: Vec<(usize, String, Range<usize>)> = Vec::new();
39        // A marker inside a fenced block is an example of a marker. The
40        // language guide shows one in a ```markdown fence, and obeying it
41        // switched the rest of that page to French.
42        let mut fences = crate::text_util::FenceTracker::new();
43
44        for (line_start, line) in line_byte_offsets(text) {
45            if fences.consume(line) {
46                continue;
47            }
48            if let Some(lang) = Self::extract_marker(line) {
49                // The scope starts after the marker line
50                let scope_start = line_start + line.len();
51                // Skip trailing newline if present
52                let scope_start = if text.as_bytes().get(scope_start) == Some(&b'\n') {
53                    scope_start + 1
54                } else {
55                    scope_start
56                };
57                markers.push((
58                    scope_start,
59                    lang,
60                    line_start..line_start + line.trim_end().len(),
61                ));
62            }
63        }
64
65        let mut regions = Vec::with_capacity(markers.len());
66        for (i, (start, lang, marker)) in markers.iter().enumerate() {
67            let end = markers.get(i + 1).map_or(text.len(), |(next_start, _, _)| {
68                // Walk back to before the marker line
69                text[..*next_start]
70                    .rfind('\n')
71                    .map_or(*next_start, |nl_pos| {
72                        // Find the start of the marker line
73                        text[..nl_pos].rfind('\n').map_or(0, |prev_nl| prev_nl + 1)
74                    })
75            });
76
77            if end > *start {
78                regions.push(ScopedRegion {
79                    language: lang.clone(),
80                    byte_range: *start..end,
81                    marker_range: marker.clone(),
82                });
83            }
84        }
85
86        regions
87    }
88
89    /// Look up the language for a given byte offset, if it falls within a scoped region.
90    #[must_use]
91    pub fn language_at(regions: &[ScopedRegion], byte_offset: usize) -> Option<&str> {
92        regions
93            .iter()
94            .find(|r| r.byte_range.contains(&byte_offset))
95            .map(|r| r.language.as_str())
96    }
97
98    fn extract_marker(line: &str) -> Option<String> {
99        crate::text_util::in_comment(line, Self::parse_lang_directive)
100    }
101
102    fn parse_lang_directive(s: &str) -> Option<String> {
103        // Accept: "lang: xx", "@lang: xx", "lang:xx", "@lang:xx"
104        let s = s.strip_prefix('@').unwrap_or(s);
105        let s = s.strip_prefix("lang").unwrap_or_default();
106        let s = s.strip_prefix(':').unwrap_or_default();
107        let lang = s.trim();
108
109        if lang.is_empty() || lang.len() > 10 || lang.contains(' ') {
110            return None;
111        }
112
113        Some(lang.to_string())
114    }
115}
116
117/// Yields `(byte_offset_of_line_start, line_str)` for each line including the trailing `\n`.
118fn line_byte_offsets(text: &str) -> impl Iterator<Item = (usize, &str)> {
119    let mut offset = 0;
120    text.split_inclusive('\n').map(move |line| {
121        let start = offset;
122        offset += line.len();
123        (start, line)
124    })
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn html_comment_marker() {
133        let text = "English text.\n<!-- lang: fr -->\nTexte français.\n";
134        let regions = ScopeParser::parse(text);
135        assert_eq!(regions.len(), 1);
136        assert_eq!(regions[0].language, "fr");
137        let scoped_text = &text[regions[0].byte_range.clone()];
138        assert!(scoped_text.contains("Texte français"));
139    }
140
141    #[test]
142    fn line_comment_marker() {
143        let text = "English.\n// @lang: de\nDeutscher Text.\n";
144        let regions = ScopeParser::parse(text);
145        assert_eq!(regions.len(), 1);
146        assert_eq!(regions[0].language, "de");
147    }
148
149    #[test]
150    fn block_comment_marker() {
151        let text = "Hello.\n/* @lang: es */\nTexto español.\n";
152        let regions = ScopeParser::parse(text);
153        assert_eq!(regions.len(), 1);
154        assert_eq!(regions[0].language, "es");
155    }
156
157    #[test]
158    fn latex_comment_marker() {
159        let text = "English.\n% @lang: fr\nFrançais.\n";
160        let regions = ScopeParser::parse(text);
161        assert_eq!(regions.len(), 1);
162        assert_eq!(regions[0].language, "fr");
163    }
164
165    #[test]
166    fn multiple_regions() {
167        let text = "\
168English paragraph.
169<!-- lang: fr -->
170Paragraphe français.
171<!-- lang: de -->
172Deutscher Absatz.
173";
174        let regions = ScopeParser::parse(text);
175        assert_eq!(regions.len(), 2);
176        assert_eq!(regions[0].language, "fr");
177        assert_eq!(regions[1].language, "de");
178    }
179
180    #[test]
181    fn no_markers() {
182        let text = "Just plain English text with no annotations.";
183        let regions = ScopeParser::parse(text);
184        assert!(regions.is_empty());
185    }
186
187    #[test]
188    fn language_at_lookup() {
189        let text = "Hello.\n<!-- lang: fr -->\nBonjour.\n";
190        let regions = ScopeParser::parse(text);
191        // "Bonjour" starts somewhere after the marker
192        let bonjour_offset = text.find("Bonjour").unwrap();
193        assert_eq!(
194            ScopeParser::language_at(&regions, bonjour_offset),
195            Some("fr")
196        );
197        assert_eq!(ScopeParser::language_at(&regions, 0), None);
198    }
199
200    #[test]
201    fn marker_without_at_sign() {
202        let text = "Hello.\n<!-- lang: ja -->\n日本語テキスト.\n";
203        let regions = ScopeParser::parse(text);
204        assert_eq!(regions.len(), 1);
205        assert_eq!(regions[0].language, "ja");
206    }
207
208    #[test]
209    fn a_marker_inside_a_fence_is_an_example_and_not_a_directive() {
210        // Lifted from docs/guide/languages.md, which documents the marker by
211        // showing one. Before this the page switched itself to French at the
212        // fence and stayed there, so every later paragraph was checked
213        // against a French dictionary.
214        let text =
215            "Intro paragraph.\n\n```markdown\n<!-- lang: fr -->\n```\n\nStill English here.\n";
216        assert!(
217            ScopeParser::parse(text).is_empty(),
218            "a fenced marker must not open a scope"
219        );
220    }
221
222    #[test]
223    fn a_marker_outside_a_fence_still_applies_after_one() {
224        let text =
225            "```markdown\n<!-- lang: de -->\n```\n<!-- lang: fr -->\nCeci est fran\u{e7}ais.\n";
226        let regions = ScopeParser::parse(text);
227        assert_eq!(regions.len(), 1, "{regions:?}");
228        assert_eq!(regions[0].language, "fr");
229        assert!(text[regions[0].byte_range.clone()].contains("Ceci"));
230    }
231
232    #[test]
233    fn a_tilde_fence_closes_the_block_a_tilde_fence_opened() {
234        let text = "~~~\n<!-- lang: fr -->\n~~~\n<!-- lang: de -->\nDeutscher Text.\n";
235        let regions = ScopeParser::parse(text);
236        assert_eq!(regions.len(), 1, "{regions:?}");
237        assert_eq!(regions[0].language, "de");
238    }
239
240    #[test]
241    fn a_backtick_run_does_not_close_a_tilde_fence() {
242        // Inside a ~~~ block, ``` is content. Treating it as a close would
243        // let the rest of the block escape and be read as directives.
244        let text = "~~~\n```\n<!-- lang: fr -->\n~~~\nEnglish again.\n";
245        assert!(
246            ScopeParser::parse(text).is_empty(),
247            "the marker is still fenced"
248        );
249    }
250
251    #[test]
252    fn ignores_invalid_markers() {
253        let text = "<!-- lang: -->\n<!-- lang: this is not a lang -->\n<!-- notlang: fr -->\n";
254        let regions = ScopeParser::parse(text);
255        assert!(regions.is_empty());
256    }
257}