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}
13
14/// Parses language scope annotations from document text.
15///
16/// Supports the following marker formats:
17/// - `<!-- lang: xx -->` (HTML/Markdown comments)
18/// - `// @lang: xx` (line comments)
19/// - `/* @lang: xx */` (block comments)
20/// - `% @lang: xx` (LaTeX comments)
21pub struct ScopeParser;
22
23impl ScopeParser {
24    /// Extract all language scope regions from the given text.
25    ///
26    /// Returns scoped regions sorted by byte offset. Text between
27    /// the start of the document and the first marker (or with no markers
28    /// at all) is *not* included - the caller should fall back to the
29    /// default language for those ranges.
30    #[must_use]
31    pub fn parse(text: &str) -> Vec<ScopedRegion> {
32        let mut markers: Vec<(usize, String)> = Vec::new();
33
34        for (line_start, line) in line_byte_offsets(text) {
35            if let Some(lang) = Self::extract_marker(line) {
36                // The scope starts after the marker line
37                let scope_start = line_start + line.len();
38                // Skip trailing newline if present
39                let scope_start = if text.as_bytes().get(scope_start) == Some(&b'\n') {
40                    scope_start + 1
41                } else {
42                    scope_start
43                };
44                markers.push((scope_start, lang));
45            }
46        }
47
48        let mut regions = Vec::with_capacity(markers.len());
49        for (i, (start, lang)) in markers.iter().enumerate() {
50            let end = markers.get(i + 1).map_or(text.len(), |(next_start, _)| {
51                // Walk back to before the marker line
52                text[..*next_start]
53                    .rfind('\n')
54                    .map_or(*next_start, |nl_pos| {
55                        // Find the start of the marker line
56                        text[..nl_pos].rfind('\n').map_or(0, |prev_nl| prev_nl + 1)
57                    })
58            });
59
60            if end > *start {
61                regions.push(ScopedRegion {
62                    language: lang.clone(),
63                    byte_range: *start..end,
64                });
65            }
66        }
67
68        regions
69    }
70
71    /// Look up the language for a given byte offset, if it falls within a scoped region.
72    #[must_use]
73    pub fn language_at(regions: &[ScopedRegion], byte_offset: usize) -> Option<&str> {
74        regions
75            .iter()
76            .find(|r| r.byte_range.contains(&byte_offset))
77            .map(|r| r.language.as_str())
78    }
79
80    fn extract_marker(line: &str) -> Option<String> {
81        crate::text_util::in_comment(line, Self::parse_lang_directive)
82    }
83
84    fn parse_lang_directive(s: &str) -> Option<String> {
85        // Accept: "lang: xx", "@lang: xx", "lang:xx", "@lang:xx"
86        let s = s.strip_prefix('@').unwrap_or(s);
87        let s = s.strip_prefix("lang").unwrap_or_default();
88        let s = s.strip_prefix(':').unwrap_or_default();
89        let lang = s.trim();
90
91        if lang.is_empty() || lang.len() > 10 || lang.contains(' ') {
92            return None;
93        }
94
95        Some(lang.to_string())
96    }
97}
98
99/// Yields `(byte_offset_of_line_start, line_str)` for each line including the trailing `\n`.
100fn line_byte_offsets(text: &str) -> impl Iterator<Item = (usize, &str)> {
101    let mut offset = 0;
102    text.split_inclusive('\n').map(move |line| {
103        let start = offset;
104        offset += line.len();
105        (start, line)
106    })
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn html_comment_marker() {
115        let text = "English text.\n<!-- lang: fr -->\nTexte français.\n";
116        let regions = ScopeParser::parse(text);
117        assert_eq!(regions.len(), 1);
118        assert_eq!(regions[0].language, "fr");
119        let scoped_text = &text[regions[0].byte_range.clone()];
120        assert!(scoped_text.contains("Texte français"));
121    }
122
123    #[test]
124    fn line_comment_marker() {
125        let text = "English.\n// @lang: de\nDeutscher Text.\n";
126        let regions = ScopeParser::parse(text);
127        assert_eq!(regions.len(), 1);
128        assert_eq!(regions[0].language, "de");
129    }
130
131    #[test]
132    fn block_comment_marker() {
133        let text = "Hello.\n/* @lang: es */\nTexto español.\n";
134        let regions = ScopeParser::parse(text);
135        assert_eq!(regions.len(), 1);
136        assert_eq!(regions[0].language, "es");
137    }
138
139    #[test]
140    fn latex_comment_marker() {
141        let text = "English.\n% @lang: fr\nFrançais.\n";
142        let regions = ScopeParser::parse(text);
143        assert_eq!(regions.len(), 1);
144        assert_eq!(regions[0].language, "fr");
145    }
146
147    #[test]
148    fn multiple_regions() {
149        let text = "\
150English paragraph.
151<!-- lang: fr -->
152Paragraphe français.
153<!-- lang: de -->
154Deutscher Absatz.
155";
156        let regions = ScopeParser::parse(text);
157        assert_eq!(regions.len(), 2);
158        assert_eq!(regions[0].language, "fr");
159        assert_eq!(regions[1].language, "de");
160    }
161
162    #[test]
163    fn no_markers() {
164        let text = "Just plain English text with no annotations.";
165        let regions = ScopeParser::parse(text);
166        assert!(regions.is_empty());
167    }
168
169    #[test]
170    fn language_at_lookup() {
171        let text = "Hello.\n<!-- lang: fr -->\nBonjour.\n";
172        let regions = ScopeParser::parse(text);
173        // "Bonjour" starts somewhere after the marker
174        let bonjour_offset = text.find("Bonjour").unwrap();
175        assert_eq!(
176            ScopeParser::language_at(&regions, bonjour_offset),
177            Some("fr")
178        );
179        assert_eq!(ScopeParser::language_at(&regions, 0), None);
180    }
181
182    #[test]
183    fn marker_without_at_sign() {
184        let text = "Hello.\n<!-- lang: ja -->\n日本語テキスト.\n";
185        let regions = ScopeParser::parse(text);
186        assert_eq!(regions.len(), 1);
187        assert_eq!(regions[0].language, "ja");
188    }
189
190    #[test]
191    fn ignores_invalid_markers() {
192        let text = "<!-- lang: -->\n<!-- lang: this is not a lang -->\n<!-- notlang: fr -->\n";
193        let regions = ScopeParser::parse(text);
194        assert!(regions.is_empty());
195    }
196}