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}