Skip to main content

omena_bridge/
source_language.rs

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