Skip to main content

meta_language/
mixed_regions.rs

1use crate::configuration::RegionDetectionPolicy;
2use crate::line_index::LineIndex;
3use crate::source::{ByteRange, SourceSpan};
4
5const TXT_LANGUAGE: &str = "txt";
6
7/// Embedded region discovered inside a mixed-language document.
8#[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    /// Language detected for the embedded region.
20    #[must_use]
21    pub fn language(&self) -> &str {
22        &self.language
23    }
24
25    /// Source span covered by the embedded region.
26    #[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}