meta_language/
mixed_regions.rs1use crate::configuration::RegionDetectionPolicy;
2use crate::line_index::LineIndex;
3use crate::source::{ByteRange, SourceSpan};
4
5const TXT_LANGUAGE: &str = "txt";
6
7#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct EmbeddedRegion {
10 language: String,
11 span: SourceSpan,
12}
13
14impl EmbeddedRegion {
15 pub(crate) const fn new(language: String, span: SourceSpan) -> Self {
16 Self { language, span }
17 }
18
19 #[must_use]
21 pub fn language(&self) -> &str {
22 &self.language
23 }
24
25 #[must_use]
27 pub const fn span(&self) -> SourceSpan {
28 self.span
29 }
30}
31
32pub(crate) fn detect_embedded_regions(
33 text: &str,
34 language: &str,
35 policy: RegionDetectionPolicy,
36) -> Vec<EmbeddedRegion> {
37 let mut regions = Vec::new();
38 let lines = LineIndex::new(text);
39 match language.to_ascii_lowercase().as_str() {
40 TXT_LANGUAGE => regions.push(region_for(&lines, TXT_LANGUAGE.to_string(), 0, text.len())),
41 "markdown" => {
42 regions.extend(detect_markdown_fenced_regions(text, &lines, policy));
43 regions.extend(detect_markdown_html_regions(text, &lines));
44 }
45 "html" => {
46 regions.extend(detect_html_element_regions(
47 text,
48 &lines,
49 "script",
50 "JavaScript",
51 ));
52 regions.extend(detect_html_element_regions(text, &lines, "style", "CSS"));
53 regions.extend(detect_html_style_attributes(text, &lines));
54 }
55 _ => {}
56 }
57 regions
58}
59
60fn detect_markdown_fenced_regions(
61 text: &str,
62 lines: &LineIndex,
63 policy: RegionDetectionPolicy,
64) -> Vec<EmbeddedRegion> {
65 let mut regions = Vec::new();
66 let mut offset = 0;
67 let mut open_fence: Option<(String, usize)> = None;
68
69 for line in text.split_inclusive('\n') {
70 let trimmed = line.trim_end_matches(['\r', '\n']).trim_start();
71 if let Some((language_tag, content_start)) = open_fence.take() {
72 if trimmed.starts_with("```") {
73 if let Some(language) = region_language_from_tag_or_content(
74 &language_tag,
75 &text[content_start..offset],
76 policy,
77 ) {
78 regions.push(region_for(lines, language, content_start, offset));
79 }
80 } else {
81 open_fence = Some((language_tag, content_start));
82 }
83 } else if let Some(rest) = trimmed.strip_prefix("```") {
84 let language_tag = rest
85 .split_whitespace()
86 .next()
87 .unwrap_or_default()
88 .to_string();
89 open_fence = Some((language_tag, offset + line.len()));
90 }
91 offset += line.len();
92 }
93
94 if let Some((language_tag, content_start)) = open_fence {
95 if let Some(language) =
96 region_language_from_tag_or_content(&language_tag, &text[content_start..], policy)
97 {
98 regions.push(region_for(lines, language, content_start, text.len()));
99 }
100 }
101
102 regions
103}
104
105fn region_language_from_tag_or_content(
106 language_tag: &str,
107 content: &str,
108 policy: RegionDetectionPolicy,
109) -> Option<String> {
110 match policy {
111 RegionDetectionPolicy::NameDriven => {
112 (!language_tag.is_empty()).then(|| language_tag.to_string())
113 }
114 RegionDetectionPolicy::ContentDriven => {
115 Some(sniff_language(content).unwrap_or(TXT_LANGUAGE).to_string())
116 }
117 RegionDetectionPolicy::Both => {
118 if language_tag.is_empty() {
119 Some(sniff_language(content).unwrap_or(TXT_LANGUAGE).to_string())
120 } else {
121 Some(language_tag.to_string())
122 }
123 }
124 }
125}
126
127fn detect_markdown_html_regions(text: &str, lines: &LineIndex) -> Vec<EmbeddedRegion> {
128 let mut regions = Vec::new();
129 let mut search_start = 0;
130
131 while let Some(relative_start) = text[search_start..].find('<') {
132 let start = search_start + relative_start;
133 let Some(next) = text[start + 1..].chars().next() else {
134 break;
135 };
136 if !next.is_ascii_alphabetic() {
137 search_start = start + 1;
138 continue;
139 }
140
141 let Some(close) = text[start..].find('>') else {
142 break;
143 };
144 let first_tag_end = start + close + 1;
145 let tag_name = text[start + 1..first_tag_end - 1]
146 .split_whitespace()
147 .next()
148 .unwrap_or_default()
149 .trim_matches('/')
150 .to_ascii_lowercase();
151 if tag_name.is_empty() {
152 search_start = first_tag_end;
153 continue;
154 }
155
156 let closing_tag = format!("</{tag_name}>");
157 let region_end = text[first_tag_end..]
158 .to_ascii_lowercase()
159 .find(&closing_tag)
160 .map_or(first_tag_end, |relative_end| {
161 first_tag_end + relative_end + closing_tag.len()
162 });
163 regions.push(region_for(lines, "HTML".to_string(), start, region_end));
164 search_start = region_end;
165 }
166
167 regions
168}
169
170fn detect_html_element_regions(
171 text: &str,
172 lines: &LineIndex,
173 element: &str,
174 language: &str,
175) -> Vec<EmbeddedRegion> {
176 let mut regions = Vec::new();
177 let lower = text.to_ascii_lowercase();
178 let open = format!("<{element}");
179 let close = format!("</{element}>");
180 let mut search_start = 0;
181
182 while let Some(relative_start) = lower[search_start..].find(&open) {
183 let start = search_start + relative_start;
184 let Some(open_end_relative) = lower[start..].find('>') else {
185 break;
186 };
187 let content_start = start + open_end_relative + 1;
188 let Some(close_relative) = lower[content_start..].find(&close) else {
189 break;
190 };
191 let content_end = content_start + close_relative;
192 regions.push(region_for(
193 lines,
194 language.to_string(),
195 content_start,
196 content_end,
197 ));
198 search_start = content_end + close.len();
199 }
200
201 regions
202}
203
204fn detect_html_style_attributes(text: &str, lines: &LineIndex) -> Vec<EmbeddedRegion> {
205 let mut regions = Vec::new();
206 let lower = text.to_ascii_lowercase();
207 let mut search_start = 0;
208
209 while let Some(relative_start) = lower[search_start..].find("style=\"") {
210 let value_start = search_start + relative_start + "style=\"".len();
211 let Some(value_end_relative) = text[value_start..].find('"') else {
212 break;
213 };
214 let value_end = value_start + value_end_relative;
215 regions.push(region_for(lines, "CSS".to_string(), value_start, value_end));
216 search_start = value_end + 1;
217 }
218
219 regions
220}
221
222fn sniff_language(content: &str) -> Option<&'static str> {
223 let trimmed = content.trim_start();
224 let upper = trimmed.to_ascii_uppercase();
225
226 if trimmed.contains("fn main") {
227 Some("rust")
228 } else if trimmed.starts_with("def ") {
229 Some("Python")
230 } else if trimmed.starts_with('<') {
231 Some("HTML")
232 } else if trimmed.contains("function ")
233 || trimmed.contains("const ")
234 || trimmed.contains("let ")
235 {
236 Some("JavaScript")
237 } else if upper.starts_with("SELECT ") {
238 Some("sql-ansi")
239 } else {
240 None
241 }
242}
243
244fn region_for(lines: &LineIndex, language: String, start: usize, end: usize) -> EmbeddedRegion {
245 EmbeddedRegion::new(
246 language,
247 SourceSpan::new(
248 ByteRange::new(start, end),
249 lines.char_point(start),
250 lines.char_point(end),
251 ),
252 )
253}