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 pub marker_range: Range<usize>,
18}
19
20pub struct ScopeParser;
28
29impl ScopeParser {
30 #[must_use]
37 pub fn parse(text: &str) -> Vec<ScopedRegion> {
38 let mut markers: Vec<(usize, String, Range<usize>)> = Vec::new();
39 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 let scope_start = line_start + line.len();
51 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 text[..*next_start]
70 .rfind('\n')
71 .map_or(*next_start, |nl_pos| {
72 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 #[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 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
117fn 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 let bonjour_offset = text.find("Bonjour").unwrap();
193 assert_eq!(
194 ScopeParser::language_at(®ions, bonjour_offset),
195 Some("fr")
196 );
197 assert_eq!(ScopeParser::language_at(®ions, 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 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 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}