Skip to main content

omena_bridge/
source_language.rs

1use oxc_allocator::Allocator;
2use oxc_parser::Parser;
3use oxc_span::SourceType;
4use serde::Serialize;
5use std::borrow::Cow;
6
7const EDITOR_RECOVERY_IDENTIFIER: &str = "omenaEditorRecovery";
8
9pub(crate) struct RecoveredEditorSourceV0 {
10    pub source: String,
11    pub trusted_byte_end: usize,
12}
13
14/// Asks Oxc, rather than a second syntax recognizer, whether a bounded repair makes an
15/// incomplete editor buffer structurally parseable. Callers must ignore expression facts that
16/// cross `trusted_byte_end`; inserted text exists only to recover scopes and declarations that
17/// precede the incomplete expression.
18pub(crate) fn recover_panicked_editor_source(
19    source: &str,
20    source_type: SourceType,
21    diagnostic_offsets: &[usize],
22) -> Option<RecoveredEditorSourceV0> {
23    const REPAIRS: &[&str] = &[
24        EDITOR_RECOVERY_IDENTIFIER,
25        "omenaEditorRecovery)} />;",
26        "omenaEditorRecovery')} />;",
27        "omenaEditorRecovery\")} />;",
28        "omenaEditorRecovery`)} />;",
29        "omenaEditorRecovery']",
30        "omenaEditorRecovery\"]",
31        "omenaEditorRecovery`]",
32    ];
33
34    let line_ends = source
35        .match_indices('\n')
36        .map(|(index, _)| {
37            if index > 0 && source.as_bytes()[index - 1] == b'\r' {
38                index - 1
39            } else {
40                index
41            }
42        })
43        .collect::<Vec<_>>();
44    let mut insertion_points = line_ends.iter().rev().take(16).copied().collect::<Vec<_>>();
45    for diagnostic_offset in diagnostic_offsets {
46        let diagnostic_offset = (*diagnostic_offset).min(source.len());
47        if let Some(previous_line_end) = line_ends
48            .iter()
49            .copied()
50            .rev()
51            .find(|line_end| *line_end <= diagnostic_offset)
52        {
53            insertion_points.push(previous_line_end);
54        }
55        let line_end = line_ends
56            .iter()
57            .copied()
58            .find(|line_end| *line_end >= diagnostic_offset)
59            .unwrap_or(source.len());
60        insertion_points.push(line_end);
61    }
62    insertion_points.push(source.len());
63    insertion_points.sort_unstable();
64    insertion_points.dedup();
65
66    for insertion_point in insertion_points.into_iter().rev() {
67        for repair in REPAIRS {
68            let mut candidate = String::with_capacity(source.len() + repair.len());
69            candidate.push_str(&source[..insertion_point]);
70            candidate.push_str(repair);
71            candidate.push_str(&source[insertion_point..]);
72            let allocator = Allocator::default();
73            if !Parser::new(&allocator, candidate.as_str(), source_type)
74                .parse()
75                .panicked
76            {
77                return Some(RecoveredEditorSourceV0 {
78                    source: candidate,
79                    trusted_byte_end: insertion_point,
80                });
81            }
82        }
83    }
84    None
85}
86
87pub(crate) trait SourceLanguageParserV0 {
88    fn parser_id(&self) -> &'static str;
89    fn language(&self) -> &'static str;
90    fn language_aliases(&self) -> Vec<&'static str>;
91    fn projection_kind(&self) -> &'static str;
92    fn source_type(&self, source_path: &str) -> SourceType;
93    fn project<'a>(&self, source: &'a str) -> Cow<'a, str>;
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97enum SourceLanguageParserKindV0 {
98    OxcTsx,
99    VueSfcScript,
100    HtmlScript,
101    SvelteComponentScript,
102    AstroComponentScript,
103    MarkdownFencedCode,
104    ServerTemplateMarkup,
105}
106
107impl SourceLanguageParserV0 for SourceLanguageParserKindV0 {
108    fn parser_id(&self) -> &'static str {
109        match self {
110            Self::OxcTsx => "oxcTsxSourceLanguageParserV0",
111            Self::VueSfcScript => "vueSfcScriptProjectionParserV0",
112            Self::HtmlScript => "htmlScriptProjectionParserV0",
113            Self::SvelteComponentScript => "svelteComponentScriptProjectionParserV0",
114            Self::AstroComponentScript => "astroComponentScriptProjectionParserV0",
115            Self::MarkdownFencedCode => "markdownFencedCodeProjectionParserV0",
116            Self::ServerTemplateMarkup => "serverTemplateMarkupProjectionParserV0",
117        }
118    }
119
120    fn language(&self) -> &'static str {
121        match self {
122            Self::OxcTsx => "tsx",
123            Self::VueSfcScript => "vue",
124            Self::HtmlScript => "html",
125            Self::SvelteComponentScript => "svelte",
126            Self::AstroComponentScript => "astro",
127            Self::MarkdownFencedCode => "markdown",
128            Self::ServerTemplateMarkup => "server-template",
129        }
130    }
131
132    fn language_aliases(&self) -> Vec<&'static str> {
133        match self {
134            Self::OxcTsx => vec![
135                "typescriptreact",
136                "javascriptreact",
137                "typescript",
138                "javascript",
139            ],
140            Self::MarkdownFencedCode => vec!["mdx"],
141            Self::ServerTemplateMarkup => vec![
142                "liquid",
143                "twig",
144                "nunjucks",
145                "handlebars",
146                "erb",
147                "ejs",
148                "django-html",
149                "jinja",
150                "html-eex",
151                "heex",
152            ],
153            _ => Vec::new(),
154        }
155    }
156
157    fn projection_kind(&self) -> &'static str {
158        match self {
159            Self::OxcTsx => "identityOxc",
160            Self::VueSfcScript => "bytePreservingScriptBlocks",
161            Self::HtmlScript => "bytePreservingScriptBlocks",
162            Self::SvelteComponentScript => "bytePreservingScriptBlocks",
163            Self::AstroComponentScript => "bytePreservingFrontmatterAndScriptBlocks",
164            Self::MarkdownFencedCode => "bytePreservingFencedCodeBlocks",
165            Self::ServerTemplateMarkup => "bytePreservingTemplateMarkupScan",
166        }
167    }
168
169    fn source_type(&self, source_path: &str) -> SourceType {
170        match self {
171            Self::OxcTsx => {
172                SourceType::from_path(source_path).unwrap_or_else(|_| SourceType::tsx())
173            }
174            Self::VueSfcScript
175            | Self::HtmlScript
176            | Self::SvelteComponentScript
177            | Self::AstroComponentScript
178            | Self::MarkdownFencedCode
179            | Self::ServerTemplateMarkup => SourceType::tsx(),
180        }
181    }
182
183    fn project<'a>(&self, source: &'a str) -> Cow<'a, str> {
184        match self {
185            Self::OxcTsx => Cow::Borrowed(source),
186            Self::VueSfcScript => Cow::Owned(project_tag_contents_to_typescript_source(
187                source,
188                "<script",
189                "</script>",
190            )),
191            Self::HtmlScript => Cow::Owned(project_tag_contents_to_typescript_source(
192                source,
193                "<script",
194                "</script>",
195            )),
196            Self::SvelteComponentScript => Cow::Owned(project_tag_contents_to_typescript_source(
197                source,
198                "<script",
199                "</script>",
200            )),
201            Self::AstroComponentScript => {
202                Cow::Owned(project_astro_component_to_typescript_source(source))
203            }
204            Self::MarkdownFencedCode => Cow::Owned(project_markdown_to_typescript_source(source)),
205            Self::ServerTemplateMarkup => Cow::Owned(String::new()),
206        }
207    }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
211#[serde(rename_all = "camelCase")]
212pub struct SourceLanguageParserBoundarySummaryV0 {
213    pub schema_version: &'static str,
214    pub product: &'static str,
215    pub parser_count: usize,
216    pub parsers: Vec<SourceLanguageParserDescriptorV0>,
217    pub external_parser_abi_stable: bool,
218    pub ready_surfaces: Vec<&'static str>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
222#[serde(rename_all = "camelCase")]
223pub struct SourceLanguageParserDescriptorV0 {
224    pub parser_id: &'static str,
225    pub language: &'static str,
226    pub language_aliases: Vec<&'static str>,
227    pub projection_kind: &'static str,
228    pub fixture_witnessed: bool,
229}
230
231pub fn summarize_omena_bridge_source_language_parser_boundary_v0()
232-> SourceLanguageParserBoundarySummaryV0 {
233    let parsers = [
234        SourceLanguageParserKindV0::OxcTsx,
235        SourceLanguageParserKindV0::VueSfcScript,
236        SourceLanguageParserKindV0::HtmlScript,
237        SourceLanguageParserKindV0::SvelteComponentScript,
238        SourceLanguageParserKindV0::AstroComponentScript,
239        SourceLanguageParserKindV0::MarkdownFencedCode,
240        SourceLanguageParserKindV0::ServerTemplateMarkup,
241    ]
242    .into_iter()
243    .map(|parser| SourceLanguageParserDescriptorV0 {
244        parser_id: parser.parser_id(),
245        language: parser.language(),
246        language_aliases: parser.language_aliases(),
247        projection_kind: parser.projection_kind(),
248        fixture_witnessed: true,
249    })
250    .collect::<Vec<_>>();
251
252    SourceLanguageParserBoundarySummaryV0 {
253        schema_version: "0",
254        product: "omena-bridge.source-language-parser-boundary",
255        parser_count: parsers.len(),
256        parsers,
257        external_parser_abi_stable: false,
258        ready_surfaces: vec![
259            "sourceLanguageParserV0",
260            "oxcTsxParserBoundary",
261            "vueSfcScriptProjection",
262            "htmlScriptProjection",
263            "svelteComponentScriptProjection",
264            "astroComponentScriptProjection",
265            "markdownFencedCodeProjection",
266            "mdxFencedCodeProjection",
267            "serverTemplateMarkupScan",
268            "serverTemplateInterpolationScan",
269            "serverTemplateLanguageAliases",
270        ],
271    }
272}
273
274pub(crate) fn is_vue_source(source_path: &str, source_language: Option<&str>) -> bool {
275    source_language == Some("vue") || source_path.ends_with(".vue")
276}
277
278pub(crate) fn is_html_source(source_path: &str, source_language: Option<&str>) -> bool {
279    source_language == Some("html")
280        || source_path.ends_with(".html")
281        || source_path.ends_with(".htm")
282}
283
284pub(crate) fn is_svelte_source(source_path: &str, source_language: Option<&str>) -> bool {
285    source_language == Some("svelte") || source_path.ends_with(".svelte")
286}
287
288pub(crate) fn is_astro_source(source_path: &str, source_language: Option<&str>) -> bool {
289    source_language == Some("astro") || source_path.ends_with(".astro")
290}
291
292pub(crate) fn is_markdown_source(source_path: &str, source_language: Option<&str>) -> bool {
293    source_language == Some("markdown")
294        || source_language == Some("mdx")
295        || source_path.ends_with(".md")
296        || source_path.ends_with(".mdx")
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub(crate) enum ServerTemplateDelimiterFamilyV0 {
301    LiquidLike,
302    ErbLike,
303    Handlebars,
304}
305
306pub(crate) fn server_template_delimiter_family(
307    source_path: &str,
308    source_language: Option<&str>,
309) -> Option<ServerTemplateDelimiterFamilyV0> {
310    if matches!(
311        source_language,
312        Some("liquid" | "twig" | "nunjucks" | "django-html" | "jinja")
313    ) || source_path.ends_with(".liquid")
314        || source_path.ends_with(".twig")
315        || source_path.ends_with(".njk")
316        || source_path.ends_with(".nunjucks")
317    {
318        return Some(ServerTemplateDelimiterFamilyV0::LiquidLike);
319    }
320    if matches!(source_language, Some("erb" | "ejs" | "html-eex" | "heex"))
321        || source_path.ends_with(".erb")
322        || source_path.ends_with(".ejs")
323        || source_path.ends_with(".html.eex")
324        || source_path.ends_with(".heex")
325    {
326        return Some(ServerTemplateDelimiterFamilyV0::ErbLike);
327    }
328    if source_language == Some("handlebars")
329        || source_path.ends_with(".hbs")
330        || source_path.ends_with(".handlebars")
331    {
332        return Some(ServerTemplateDelimiterFamilyV0::Handlebars);
333    }
334    None
335}
336
337pub(crate) fn is_server_template_source(source_path: &str, source_language: Option<&str>) -> bool {
338    matches!(
339        source_language,
340        Some(
341            "liquid"
342                | "twig"
343                | "nunjucks"
344                | "handlebars"
345                | "erb"
346                | "ejs"
347                | "django-html"
348                | "jinja"
349                | "html-eex"
350                | "heex"
351        )
352    ) || server_template_delimiter_family(source_path, source_language).is_some()
353}
354
355fn source_language_parser_for_path(
356    source_path: &str,
357    source_language: Option<&str>,
358) -> SourceLanguageParserKindV0 {
359    if is_vue_source(source_path, source_language) {
360        SourceLanguageParserKindV0::VueSfcScript
361    } else if is_html_source(source_path, source_language) {
362        SourceLanguageParserKindV0::HtmlScript
363    } else if is_svelte_source(source_path, source_language) {
364        SourceLanguageParserKindV0::SvelteComponentScript
365    } else if is_astro_source(source_path, source_language) {
366        SourceLanguageParserKindV0::AstroComponentScript
367    } else if is_markdown_source(source_path, source_language) {
368        SourceLanguageParserKindV0::MarkdownFencedCode
369    } else if is_server_template_source(source_path, source_language) {
370        SourceLanguageParserKindV0::ServerTemplateMarkup
371    } else {
372        SourceLanguageParserKindV0::OxcTsx
373    }
374}
375
376pub(crate) fn project_source_for_language<'a>(
377    source_path: &str,
378    source: &'a str,
379    source_language: Option<&str>,
380) -> Cow<'a, str> {
381    source_language_parser_for_path(source_path, source_language).project(source)
382}
383
384pub(crate) fn source_type_for_language(
385    source_path: &str,
386    source_language: Option<&str>,
387) -> SourceType {
388    source_language_parser_for_path(source_path, source_language).source_type(source_path)
389}
390
391#[cfg(test)]
392fn project_vue_sfc_script_to_typescript_source(source: &str) -> String {
393    project_tag_contents_to_typescript_source(source, "<script", "</script>")
394}
395
396#[cfg(test)]
397fn project_html_script_to_typescript_source(source: &str) -> String {
398    project_tag_contents_to_typescript_source(source, "<script", "</script>")
399}
400
401#[cfg(test)]
402fn project_svelte_component_script_to_typescript_source(source: &str) -> String {
403    project_tag_contents_to_typescript_source(source, "<script", "</script>")
404}
405
406#[cfg(test)]
407fn project_astro_component_script_to_typescript_source(source: &str) -> String {
408    project_astro_component_to_typescript_source(source)
409}
410
411#[cfg(test)]
412fn project_markdown_fenced_code_to_typescript_source(source: &str) -> String {
413    project_markdown_to_typescript_source(source)
414}
415
416fn project_tag_contents_to_typescript_source(
417    source: &str,
418    open_tag: &str,
419    close_tag: &str,
420) -> String {
421    project_ranges_to_typescript_source(source, tag_content_ranges(source, open_tag, close_tag))
422}
423
424fn project_astro_component_to_typescript_source(source: &str) -> String {
425    let mut ranges = Vec::new();
426    if let Some(range) = astro_frontmatter_range(source) {
427        ranges.push(range);
428    }
429    ranges.extend(tag_content_ranges(source, "<script", "</script>"));
430    project_ranges_to_typescript_source(source, ranges)
431}
432
433fn project_markdown_to_typescript_source(source: &str) -> String {
434    project_ranges_to_typescript_source(source, markdown_typescript_fence_ranges(source))
435}
436
437fn markdown_typescript_fence_ranges(source: &str) -> Vec<(usize, usize)> {
438    let mut ranges = Vec::new();
439    let mut open_fence: Option<(char, usize, usize)> = None;
440    let mut offset = 0usize;
441
442    for line in source.split_inclusive('\n') {
443        let line_start = offset;
444        let line_end = offset + line.len();
445        let line_without_newline = line.trim_end_matches(['\r', '\n']);
446        let leading_spaces = line_without_newline
447            .chars()
448            .take_while(|ch| *ch == ' ')
449            .count();
450        let trimmed = line_without_newline.trim_start_matches(' ');
451        if leading_spaces <= 3 {
452            if let Some((fence_char, fence_len, content_start)) = open_fence {
453                if markdown_fence_marker(trimmed).is_some_and(|(candidate_char, candidate_len)| {
454                    candidate_char == fence_char && candidate_len >= fence_len
455                }) {
456                    ranges.push((content_start, line_start));
457                    open_fence = None;
458                }
459            } else if let Some((fence_char, fence_len)) = markdown_fence_marker(trimmed) {
460                let language = trimmed[fence_len..].trim();
461                if markdown_fence_language_is_typescript(language) {
462                    open_fence = Some((fence_char, fence_len, line_end));
463                }
464            }
465        }
466        offset = line_end;
467    }
468
469    if let Some((_, _, content_start)) = open_fence {
470        ranges.push((content_start, source.len()));
471    }
472    ranges
473}
474
475fn markdown_fence_marker(line: &str) -> Option<(char, usize)> {
476    let mut chars = line.chars();
477    let fence_char = chars.next()?;
478    if fence_char != '`' && fence_char != '~' {
479        return None;
480    }
481    let fence_len = 1 + chars.take_while(|ch| *ch == fence_char).count();
482    if fence_len >= 3 {
483        Some((fence_char, fence_len))
484    } else {
485        None
486    }
487}
488
489fn markdown_fence_language_is_typescript(language: &str) -> bool {
490    let normalized = language
491        .split_whitespace()
492        .next()
493        .unwrap_or_default()
494        .trim_start_matches('{')
495        .trim_end_matches('}')
496        .to_ascii_lowercase();
497    matches!(
498        normalized.as_str(),
499        "ts" | "tsx"
500            | "typescript"
501            | "typescriptreact"
502            | "js"
503            | "jsx"
504            | "javascript"
505            | "javascriptreact"
506    )
507}
508
509pub(crate) fn tag_content_ranges(
510    source: &str,
511    open_tag: &str,
512    close_tag: &str,
513) -> Vec<(usize, usize)> {
514    let lower = source.to_ascii_lowercase();
515    let mut cursor = 0usize;
516    let mut ranges = Vec::new();
517
518    while let Some(relative_start) = lower[cursor..].find(open_tag) {
519        let tag_start = cursor + relative_start;
520        let Some(relative_tag_end) = lower[tag_start..].find('>') else {
521            break;
522        };
523        let content_start = tag_start + relative_tag_end + 1;
524        let Some(relative_close_start) = lower[content_start..].find(close_tag) else {
525            break;
526        };
527        let content_end = content_start + relative_close_start;
528        ranges.push((content_start, content_end));
529        cursor = content_end + close_tag.len();
530    }
531    ranges
532}
533
534fn astro_frontmatter_range(source: &str) -> Option<(usize, usize)> {
535    if !source.starts_with("---") {
536        return None;
537    }
538    let content_start = source[3..].find('\n')? + 4;
539    let relative_close = source[content_start..].find("\n---")?;
540    Some((content_start, content_start + relative_close))
541}
542
543fn project_ranges_to_typescript_source(
544    source: &str,
545    ranges: impl IntoIterator<Item = (usize, usize)>,
546) -> String {
547    let mut keep = vec![false; source.len()];
548    for (start, end) in ranges {
549        for item in keep.iter_mut().take(end).skip(start) {
550            *item = true;
551        }
552    }
553
554    let mut projected = String::with_capacity(source.len());
555    for (index, ch) in source.char_indices() {
556        if ch == '\n' {
557            projected.push('\n');
558        } else if keep[index] {
559            projected.push(ch);
560        } else {
561            for _ in 0..ch.len_utf8() {
562                projected.push(' ');
563            }
564        }
565    }
566    projected
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn vue_sfc_projection_preserves_byte_offsets_and_script_text() {
575        let source = "<template>한글</template>\n<script setup lang=\"ts\">\nconst styles = useCssModule();\n</script>\n<style module>.root {}</style>\n";
576        let projected = project_vue_sfc_script_to_typescript_source(source);
577
578        assert_eq!(projected.len(), source.len());
579        assert_eq!(
580            projected.find("styles = useCssModule"),
581            source.find("styles = useCssModule")
582        );
583        assert!(!projected.contains("한글"));
584        assert!(!projected.contains(".root"));
585    }
586
587    #[test]
588    fn html_projection_preserves_script_import_offsets() {
589        let source = "<main>ignored</main>\n<script type=\"module\">\nimport styles from \"./App.module.scss\";\n</script>\n";
590        let projected = project_html_script_to_typescript_source(source);
591
592        assert_eq!(projected.len(), source.len());
593        assert_eq!(
594            projected.find("import styles"),
595            source.find("import styles")
596        );
597        assert!(!projected.contains("ignored"));
598    }
599
600    #[test]
601    fn svelte_projection_preserves_script_import_offsets() {
602        let source = "<script lang=\"ts\">\nimport styles from \"./Card.module.scss\";\nexport const root = styles.root;\n</script>\n<section>ignored</section>\n<style>.root { color: red; }</style>\n";
603        let projected = project_svelte_component_script_to_typescript_source(source);
604
605        assert_eq!(projected.len(), source.len());
606        assert_eq!(
607            projected.find("import styles"),
608            source.find("import styles")
609        );
610        assert_eq!(projected.find("styles.root"), source.find("styles.root"));
611        assert!(!projected.contains("ignored"));
612        assert!(!projected.contains("color: red"));
613    }
614
615    #[test]
616    fn astro_projection_preserves_frontmatter_and_script_import_offsets() {
617        let source = "---\nimport styles from \"./Card.module.scss\";\nconst root = styles.root;\n---\n<div class={root}>ignored</div>\n<script>\nconst local = styles.root;\n</script>\n<style>.root { color: red; }</style>\n";
618        let projected = project_astro_component_script_to_typescript_source(source);
619
620        assert_eq!(projected.len(), source.len());
621        assert_eq!(
622            projected.find("import styles"),
623            source.find("import styles")
624        );
625        assert_eq!(projected.find("const local"), source.find("const local"));
626        assert!(!projected.contains("ignored"));
627        assert!(!projected.contains("color: red"));
628    }
629
630    #[test]
631    fn markdown_projection_preserves_typescript_fenced_code_offsets() {
632        let source = "# Notes\n\nignored text\n\n```tsx\nimport styles from \"./Card.module.scss\";\nconst root = styles.root;\n```\n\n```css\n.root { color: red; }\n```\n";
633        let projected = project_markdown_fenced_code_to_typescript_source(source);
634
635        assert_eq!(projected.len(), source.len());
636        assert_eq!(
637            projected.find("import styles"),
638            source.find("import styles")
639        );
640        assert_eq!(projected.find("styles.root"), source.find("styles.root"));
641        assert!(!projected.contains("ignored text"));
642        assert!(!projected.contains("color: red"));
643    }
644
645    #[test]
646    fn source_language_parser_boundary_lists_fixture_witnessed_v0_parsers() {
647        let summary = summarize_omena_bridge_source_language_parser_boundary_v0();
648
649        assert_eq!(
650            summary.product,
651            "omena-bridge.source-language-parser-boundary"
652        );
653        assert_eq!(summary.parser_count, 7);
654        assert!(!summary.external_parser_abi_stable);
655        assert!(summary.parsers.iter().any(|parser| {
656            parser.parser_id == "oxcTsxSourceLanguageParserV0" && parser.fixture_witnessed
657        }));
658        assert!(summary.parsers.iter().any(|parser| {
659            parser.parser_id == "htmlScriptProjectionParserV0" && parser.language == "html"
660        }));
661        assert!(summary.parsers.iter().any(|parser| {
662            parser.parser_id == "svelteComponentScriptProjectionParserV0"
663                && parser.language == "svelte"
664        }));
665        assert!(summary.parsers.iter().any(|parser| {
666            parser.parser_id == "astroComponentScriptProjectionParserV0"
667                && parser.language == "astro"
668        }));
669        assert!(summary.parsers.iter().any(|parser| {
670            parser.parser_id == "markdownFencedCodeProjectionParserV0"
671                && parser.language == "markdown"
672                && parser.language_aliases == vec!["mdx"]
673        }));
674        assert!(summary.parsers.iter().any(|parser| {
675            parser.parser_id == "serverTemplateMarkupProjectionParserV0"
676                && parser.language == "server-template"
677                && parser.language_aliases.contains(&"liquid")
678                && parser.language_aliases.contains(&"heex")
679        }));
680        assert!(summary.ready_surfaces.contains(&"mdxFencedCodeProjection"));
681        assert!(
682            summary
683                .ready_surfaces
684                .contains(&"serverTemplateInterpolationScan")
685        );
686        assert!(
687            summary
688                .ready_surfaces
689                .contains(&"serverTemplateLanguageAliases")
690        );
691    }
692
693    #[test]
694    fn server_template_projection_keeps_script_extraction_inert() {
695        let projected = project_source_for_language(
696            "page.liquid",
697            r#"<main class="{{ modifier }}">content</main>"#,
698            Some("liquid"),
699        );
700
701        assert_eq!(projected.as_ref(), "");
702    }
703}