1use std::collections::HashMap;
2
3use crate::configuration::{ParseConfiguration, RegionDetectionPolicy};
4use crate::language_catalog::canonical_language_name;
5use crate::line_index::LineIndex;
6use crate::link_network::{LinkId, LinkNetwork, LinkType};
7use crate::source::{ByteRange, SourceSpan};
8
9const TXT_LANGUAGE: &str = "txt";
10
11#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct EmbeddedRegion {
14 language: String,
15 span: SourceSpan,
16}
17
18impl EmbeddedRegion {
19 pub(crate) const fn new(language: String, span: SourceSpan) -> Self {
20 Self { language, span }
21 }
22
23 #[must_use]
25 pub fn language(&self) -> &str {
26 &self.language
27 }
28
29 #[must_use]
31 pub const fn span(&self) -> SourceSpan {
32 self.span
33 }
34}
35
36#[must_use]
52pub fn detect_embedded_regions_in_tree(
53 network: &LinkNetwork,
54 document: LinkId,
55 text: &str,
56 language: &str,
57 policy: RegionDetectionPolicy,
58) -> Vec<EmbeddedRegion> {
59 let lines = LineIndex::new(text);
60 let host = canonical_language_name(language);
61 if host == Some(TXT_LANGUAGE) {
62 return vec![region_for(&lines, TXT_LANGUAGE.to_string(), 0, text.len())];
63 }
64 if !matches!(host, Some("HTML" | "Markdown")) {
65 return Vec::new();
66 }
67 let tree = HostTree::new(network, text);
68 let mut found = Vec::new();
69 let mut stack = vec![document];
70 while let Some(node) = stack.pop() {
71 if host == Some("HTML") {
72 tree.visit_html(node, &mut found);
73 } else {
74 tree.visit_markdown(node, policy, &mut found);
75 }
76 stack.extend(tree.children(node).iter().rev());
77 }
78 found.sort_by_key(|(_, start, _)| *start);
79 found
80 .into_iter()
81 .map(|(language, start, end)| region_for(&lines, language, start, end))
82 .collect()
83}
84
85#[must_use]
92pub fn detect_embedded_regions(
93 text: &str,
94 language: &str,
95 policy: RegionDetectionPolicy,
96) -> Vec<EmbeddedRegion> {
97 let host = canonical_language_name(language);
98 if host == Some(TXT_LANGUAGE) {
99 let lines = LineIndex::new(text);
100 return vec![region_for(&lines, TXT_LANGUAGE.to_string(), 0, text.len())];
101 }
102 let Some(host @ ("HTML" | "Markdown")) = host else {
103 return Vec::new();
104 };
105 let configuration = ParseConfiguration::default().with_region_detection_policy(policy);
106 let network = LinkNetwork::parse(text, host, configuration);
107 let document = network.links().find(|link| {
108 link.metadata().link_type() == Some(LinkType::Document)
109 && link.metadata().language() == Some(host)
110 });
111 document.map_or_else(Vec::new, |document| {
112 detect_embedded_regions_in_tree(&network, document.id(), text, host, policy)
113 })
114}
115
116type FoundRegion = (String, usize, usize);
117
118struct HostTree<'a> {
121 network: &'a LinkNetwork,
122 text: &'a str,
123 children: HashMap<LinkId, Vec<LinkId>>,
124}
125
126impl<'a> HostTree<'a> {
127 fn new(network: &'a LinkNetwork, text: &'a str) -> Self {
128 let mut children: HashMap<LinkId, Vec<LinkId>> = HashMap::new();
129 for link in network.links() {
130 if link.metadata().link_type() == Some(LinkType::Syntax)
131 && let [parent] = link.references()
132 {
133 children.entry(*parent).or_default().push(link.id());
134 }
135 }
136 Self {
137 network,
138 text,
139 children,
140 }
141 }
142
143 fn children(&self, node: LinkId) -> &[LinkId] {
144 self.children.get(&node).map_or(&[], Vec::as_slice)
145 }
146
147 fn kind(&self, node: LinkId) -> Option<&str> {
148 self.network.link(node)?.metadata().term()
149 }
150
151 fn range(&self, node: LinkId) -> (usize, usize) {
152 self.network
153 .link(node)
154 .and_then(|link| link.metadata().span())
155 .map_or((0, 0), |span| {
156 (span.byte_range().start(), span.byte_range().end())
157 })
158 }
159
160 fn text(&self, node: LinkId) -> &'a str {
161 let (start, end) = self.range(node);
162 &self.text[start..end]
163 }
164
165 fn child_of_kind(&self, node: LinkId, kind: &str) -> Option<LinkId> {
166 self.children(node)
167 .iter()
168 .copied()
169 .find(|child| self.kind(*child) == Some(kind))
170 }
171
172 fn visit_html(&self, node: LinkId, found: &mut Vec<FoundRegion>) {
173 let content = self.child_of_kind(node, "raw_text");
174 match (self.kind(node), content) {
175 (Some("script_element"), Some(content)) => {
176 let type_value = self.attribute_value(node, "type");
177 if let Some(language) = script_language(type_value.unwrap_or_default()) {
178 let (start, end) = self.range(content);
179 found.push((language.to_string(), start, end));
180 }
181 }
182 (Some("style_element"), Some(content)) => {
183 let essence = mime_essence(self.attribute_value(node, "type").unwrap_or_default());
184 if essence.is_empty() || essence == "text/css" {
185 let (start, end) = self.range(content);
186 found.push(("CSS".to_string(), start, end));
187 }
188 }
189 (Some("attribute"), _) if self.attribute_name(node).as_deref() == Some("style") => {
190 if let Some(value) = self.attribute_value_node(node) {
191 let (start, end) = self.range(value);
192 found.push(("CSS".to_string(), start, end));
193 }
194 }
195 _ => {}
196 }
197 }
198
199 fn visit_markdown(
200 &self,
201 node: LinkId,
202 policy: RegionDetectionPolicy,
203 found: &mut Vec<FoundRegion>,
204 ) {
205 match self.kind(node) {
206 Some("fenced_code_block") => {
207 if let Some(content) = self.child_of_kind(node, "code_fence_content") {
208 let tag = self
209 .child_of_kind(node, "info_string")
210 .and_then(|info| self.child_of_kind(info, "language"))
211 .map_or("", |tag| self.text(tag));
212 if let Some(language) = fence_language(tag, self.text(content), policy) {
213 let (start, end) = self.range(content);
214 found.push((language, start, end));
215 }
216 }
217 }
218 Some("html_block") => {
219 let (start, end) = self.range(node);
220 found.push(("HTML".to_string(), start, end));
221 }
222 _ => {}
223 }
224 let tags: Vec<LinkId> = self
225 .children(node)
226 .iter()
227 .copied()
228 .filter(|child| self.kind(*child) == Some("html_tag"))
229 .collect();
230 let texts: Vec<&str> = tags.iter().map(|tag| self.text(*tag)).collect();
231 for (first, last) in pair_html_tags(&texts) {
232 found.push((
233 "HTML".to_string(),
234 self.range(tags[first]).0,
235 self.range(tags[last]).1,
236 ));
237 }
238 }
239
240 fn attribute_name(&self, attribute: LinkId) -> Option<String> {
241 self.child_of_kind(attribute, "attribute_name")
242 .map(|name| self.text(name).to_ascii_lowercase())
243 }
244
245 fn attribute_value_node(&self, attribute: LinkId) -> Option<LinkId> {
246 self.child_of_kind(attribute, "attribute_value")
247 .or_else(|| {
248 self.child_of_kind(attribute, "quoted_attribute_value")
249 .and_then(|quoted| self.child_of_kind(quoted, "attribute_value"))
250 })
251 }
252
253 fn attribute_value(&self, element: LinkId, name: &str) -> Option<&'a str> {
255 let start_tag = self.child_of_kind(element, "start_tag")?;
256 let attribute = self.children(start_tag).iter().copied().find(|child| {
257 self.kind(*child) == Some("attribute")
258 && self.attribute_name(*child).as_deref() == Some(name)
259 })?;
260 Some(
261 self.attribute_value_node(attribute)
262 .map_or("", |value| self.text(value)),
263 )
264 }
265}
266
267const HTML_VOID_ELEMENTS: &[&str] = &[
269 "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track",
270 "wbr",
271];
272
273struct InlineTag {
274 name: Option<String>,
275 opening: bool,
276 closing: bool,
277}
278
279fn pair_html_tags(texts: &[&str]) -> Vec<(usize, usize)> {
283 let tags: Vec<InlineTag> = texts
284 .iter()
285 .map(|text| {
286 let rest = &text[1.min(text.len())..];
287 let closing = rest.starts_with('/');
288 let rest = rest.strip_prefix('/').unwrap_or(rest);
289 let name: String = rest
290 .chars()
291 .take_while(|character| character.is_ascii_alphanumeric() || *character == '-')
292 .collect();
293 if !text.starts_with('<') || !name.starts_with(|c: char| c.is_ascii_alphabetic()) {
294 return InlineTag {
295 name: None,
296 opening: false,
297 closing: false,
298 };
299 }
300 let name = name.to_ascii_lowercase();
301 let opening =
302 !closing && !text.ends_with("/>") && !HTML_VOID_ELEMENTS.contains(&name.as_str());
303 InlineTag {
304 name: Some(name),
305 opening,
306 closing,
307 }
308 })
309 .collect();
310 let mut groups = Vec::new();
311 let mut index = 0;
312 while index < tags.len() {
313 let mut last = index;
314 if tags[index].opening {
315 let mut depth = 0_usize;
316 for (next, tag) in tags.iter().enumerate().skip(index) {
317 if tag.name != tags[index].name {
318 continue;
319 }
320 if tag.opening {
321 depth += 1;
322 }
323 if tag.closing {
324 depth -= 1;
325 }
326 if depth == 0 {
327 last = next;
328 break;
329 }
330 }
331 }
332 groups.push((index, last));
333 index = last + 1;
334 }
335 groups
336}
337
338const JAVASCRIPT_SCRIPT_TYPES: &[&str] = &[
340 "",
341 "module",
342 "application/ecmascript",
343 "application/javascript",
344 "application/x-ecmascript",
345 "application/x-javascript",
346 "text/ecmascript",
347 "text/javascript",
348 "text/javascript1.0",
349 "text/javascript1.1",
350 "text/javascript1.2",
351 "text/javascript1.3",
352 "text/javascript1.4",
353 "text/javascript1.5",
354 "text/jscript",
355 "text/livescript",
356 "text/x-ecmascript",
357 "text/x-javascript",
358];
359
360#[must_use]
367pub fn script_language(type_attribute: &str) -> Option<&'static str> {
368 let essence = mime_essence(type_attribute);
369 if JAVASCRIPT_SCRIPT_TYPES.contains(&essence.as_str()) {
370 return Some("JavaScript");
371 }
372 if essence == "importmap"
373 || essence == "speculationrules"
374 || essence.ends_with("/json")
375 || essence.ends_with("+json")
376 {
377 return Some("JSON");
378 }
379 let (_, subtype) = essence.split_once('/')?;
380 canonical_language_name(subtype.strip_prefix("x-").unwrap_or(subtype))
381}
382
383fn mime_essence(type_attribute: &str) -> String {
384 type_attribute
385 .split(';')
386 .next()
387 .unwrap_or_default()
388 .trim()
389 .to_ascii_lowercase()
390}
391
392fn fence_language(tag: &str, content: &str, policy: RegionDetectionPolicy) -> Option<String> {
393 let named = (!tag.is_empty()).then(|| canonical_language_name(tag).unwrap_or(tag).to_string());
394 let sniffed = || {
395 let language = sniff_language(content).unwrap_or(TXT_LANGUAGE);
396 canonical_language_name(language)
397 .unwrap_or(language)
398 .to_string()
399 };
400 match policy {
401 RegionDetectionPolicy::NameDriven => named,
402 RegionDetectionPolicy::ContentDriven => Some(sniffed()),
403 RegionDetectionPolicy::Both => Some(named.unwrap_or_else(sniffed)),
404 }
405}
406
407#[must_use]
410pub fn sniff_language(content: &str) -> Option<&'static str> {
411 let trimmed = content.trim_start();
412 let upper = trimmed.to_ascii_uppercase();
413
414 if trimmed.contains("fn main") {
415 Some("rust")
416 } else if trimmed.starts_with("def ") {
417 Some("Python")
418 } else if trimmed.starts_with('<') {
419 Some("HTML")
420 } else if trimmed.contains("function ")
421 || trimmed.contains("const ")
422 || trimmed.contains("let ")
423 {
424 Some("JavaScript")
425 } else if upper.starts_with("SELECT ") {
426 Some("sql-ansi")
427 } else {
428 None
429 }
430}
431
432fn region_for(lines: &LineIndex, language: String, start: usize, end: usize) -> EmbeddedRegion {
433 EmbeddedRegion::new(
434 language,
435 SourceSpan::new(
436 ByteRange::new(start, end),
437 lines.byte_point(start),
438 lines.byte_point(end),
439 ),
440 )
441}