1use std::ops::Range;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct ScopedRegion {
8 pub language: String,
10 pub byte_range: Range<usize>,
12}
13
14pub struct ScopeParser;
22
23impl ScopeParser {
24 #[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 let scope_start = line_start + line.len();
38 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 text[..*next_start]
53 .rfind('\n')
54 .map_or(*next_start, |nl_pos| {
55 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 #[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 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
99fn 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 let bonjour_offset = text.find("Bonjour").unwrap();
175 assert_eq!(
176 ScopeParser::language_at(®ions, bonjour_offset),
177 Some("fr")
178 );
179 assert_eq!(ScopeParser::language_at(®ions, 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}