use std::path::Path;
use std::sync::LazyLock;
use oxc_allocator::Allocator;
use oxc_ast_visit::Visit;
use oxc_parser::Parser;
use oxc_span::{SourceType, Span};
use rustc_hash::FxHashSet;
use crate::asset_url::normalize_asset_url;
use crate::html::is_remote_url;
use crate::sfc::{SfcScript, SourceRegion};
use crate::source_map::ExtractionResult;
use crate::visitor::ModuleInfoExtractor;
use crate::{ImportInfo, ImportedName, ModuleInfo};
use fallow_types::discover::FileId;
static ASTRO_FRONTMATTER_RE: LazyLock<regex::Regex> =
LazyLock::new(|| crate::static_regex(r"(?s)\A\s*---[ \t]*\r?\n(?P<body>.*?\r?\n)---"));
static SCRIPT_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
crate::static_regex(
r#"(?is)<script\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</script>"#,
)
});
static SCRIPT_OPEN_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
crate::static_regex(r#"(?is)<script\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>"#)
});
static STYLE_BLOCK_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
crate::static_regex(
r#"(?is)<style\b(?P<attrs>(?:[^>"']|"[^"]*"|'[^']*')*)>(?P<body>[\s\S]*?)</style>"#,
)
});
static SRC_ATTR_RE: LazyLock<regex::Regex> =
LazyLock::new(|| crate::static_regex(r#"(?i)(?:^|\s)src\s*=\s*["'](?P<src>[^"']+)["']"#));
static HTML_COMMENT_RE: LazyLock<regex::Regex> =
LazyLock::new(|| crate::static_regex(r"(?s)<!--.*?-->"));
static TEMPLATE_IDENT_RE: LazyLock<regex::Regex> =
LazyLock::new(|| crate::static_regex(r"[A-Za-z_$][A-Za-z0-9_$]*"));
static TEMPLATE_TAG_RE: LazyLock<regex::Regex> =
LazyLock::new(|| crate::static_regex(r"</?\s*([A-Za-z_$][A-Za-z0-9_$]*)"));
static DEFINE_VARS_RE: LazyLock<regex::Regex> =
LazyLock::new(|| crate::static_regex(r"define:vars\s*="));
fn collect_astro_template_used_names(template: &str) -> FxHashSet<String> {
let mut used = FxHashSet::default();
if template.is_empty() {
return used;
}
let mut masked: Vec<(usize, usize)> = Vec::new();
masked.extend(
SCRIPT_BLOCK_RE
.find_iter(template)
.map(|m| (m.start(), m.end())),
);
masked.extend(
STYLE_BLOCK_RE
.find_iter(template)
.map(|m| (m.start(), m.end())),
);
masked.extend(
HTML_COMMENT_RE
.find_iter(template)
.map(|m| (m.start(), m.end())),
);
let is_masked = |pos: usize| masked.iter().any(|&(s, e)| pos >= s && pos < e);
for cap in TEMPLATE_TAG_RE.captures_iter(template) {
if let Some(root) = cap.get(1)
&& !is_masked(root.start())
{
used.insert(root.as_str().to_string());
}
}
collect_brace_expression_idents(template, &is_masked, &mut used);
collect_define_vars_idents(template, &mut used);
used
}
fn brace_body_end(bytes: &[u8], open: usize) -> usize {
let len = bytes.len();
let mut depth = 1usize;
let mut j = open + 1;
let mut quote: Option<u8> = None;
while j < len && depth > 0 {
let c = bytes[j];
if let Some(q) = quote {
if c == b'\\' {
j += 1; } else if c == q {
quote = None;
}
} else {
match c {
b'"' | b'\'' | b'`' => quote = Some(c),
b'{' => depth += 1,
b'}' => depth -= 1,
_ => {}
}
}
j += 1;
}
if depth == 0 { j - 1 } else { len }
}
fn collect_define_vars_idents(template: &str, used: &mut FxHashSet<String>) {
let bytes = template.as_bytes();
for m in DEFINE_VARS_RE.find_iter(template) {
let mut k = m.end();
while k < bytes.len() && bytes[k].is_ascii_whitespace() {
k += 1;
}
if k >= bytes.len() || bytes[k] != b'{' {
continue;
}
let end = brace_body_end(bytes, k);
if let Some(region) = template.get(k + 1..end) {
for token in TEMPLATE_IDENT_RE.find_iter(region) {
used.insert(token.as_str().to_string());
}
}
}
}
fn collect_brace_expression_idents(
template: &str,
is_masked: &impl Fn(usize) -> bool,
used: &mut FxHashSet<String>,
) {
let bytes = template.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] != b'{' || is_masked(i) {
i += 1;
continue;
}
let start = i + 1;
let region_end = brace_body_end(bytes, i);
if let Some(region) = template.get(start..region_end) {
for token in TEMPLATE_IDENT_RE.find_iter(region) {
used.insert(token.as_str().to_string());
}
}
i = region_end.max(start);
}
}
fn collect_template_expression_regions(template: &str) -> Vec<String> {
let mut regions = Vec::new();
if template.is_empty() {
return regions;
}
let mut masked: Vec<(usize, usize)> = Vec::new();
masked.extend(
SCRIPT_BLOCK_RE
.find_iter(template)
.map(|m| (m.start(), m.end())),
);
masked.extend(
STYLE_BLOCK_RE
.find_iter(template)
.map(|m| (m.start(), m.end())),
);
masked.extend(
HTML_COMMENT_RE
.find_iter(template)
.map(|m| (m.start(), m.end())),
);
let is_masked = |pos: usize| masked.iter().any(|&(s, e)| pos >= s && pos < e);
let bytes = template.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] != b'{' || is_masked(i) {
i += 1;
continue;
}
let start = i + 1;
let region_end = brace_body_end(bytes, i);
if let Some(region) = template.get(start..region_end) {
let trimmed = region.trim();
if !trimmed.is_empty() {
regions.push(trimmed.to_string());
}
}
i = region_end.max(start);
}
regions
}
fn extend_template_expression_member_accesses(
info: &mut ModuleInfo,
template: &str,
frontmatter_array_element_types: &rustc_hash::FxHashMap<String, String>,
) {
if frontmatter_array_element_types.is_empty() {
return;
}
for region in collect_template_expression_regions(template) {
let wrapped = format!("({region});");
let allocator = Allocator::default();
let parser_return = Parser::new(&allocator, &wrapped, SourceType::tsx()).parse();
if parser_return.panicked {
continue;
}
let mut extractor = ModuleInfoExtractor::new();
extractor.seed_array_binding_element_types(frontmatter_array_element_types);
extractor.visit_program(&parser_return.program);
info.member_accesses
.extend(extractor.take_resolved_iteration_member_accesses());
}
}
pub fn extract_astro_frontmatter(source: &str) -> Option<SfcScript> {
ASTRO_FRONTMATTER_RE.captures(source).map(|cap| {
let body_match = cap.name("body");
SfcScript {
body: body_match.map_or("", |m| m.as_str()).to_string(),
is_typescript: true, is_jsx: false,
byte_offset: body_match.map_or(0, |m| m.start()),
src: None,
src_span: None,
is_setup: false,
is_context_module: false,
generic_attr: None,
}
})
}
#[must_use]
pub fn extract_astro_template_regions(source: &str) -> Vec<SourceRegion> {
let mut ranges = Vec::new();
if let Some(frontmatter) = ASTRO_FRONTMATTER_RE.find(source) {
ranges.push((frontmatter.start(), frontmatter.end()));
}
ranges.extend(
SCRIPT_BLOCK_RE
.find_iter(source)
.map(|m| (m.start(), m.end())),
);
ranges.extend(
STYLE_BLOCK_RE
.find_iter(source)
.map(|m| (m.start(), m.end())),
);
ranges.extend(
HTML_COMMENT_RE
.find_iter(source)
.map(|m| (m.start(), m.end())),
);
ranges.sort_unstable_by_key(|(start, _)| *start);
ranges_to_gaps(source, &ranges)
}
#[must_use]
pub fn extract_astro_style_regions(source: &str) -> Vec<SourceRegion> {
let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
.find_iter(source)
.map(|m| (m.start(), m.end()))
.collect();
STYLE_BLOCK_RE
.captures_iter(source)
.filter(|cap| {
let start = cap.get(0).map_or(0, |m| m.start());
!comment_ranges
.iter()
.any(|&(cs, ce)| start >= cs && start < ce)
})
.filter_map(|cap| {
let body = cap.name("body")?;
let text = body.as_str();
if text.trim().is_empty() {
return None;
}
Some(SourceRegion {
body: text.to_string(),
byte_offset: body.start(),
})
})
.collect()
}
fn ranges_to_gaps(source: &str, ranges: &[(usize, usize)]) -> Vec<SourceRegion> {
let mut regions = Vec::new();
let mut cursor = 0;
for &(start, end) in ranges {
if start > cursor {
push_region(source, cursor, start, &mut regions);
}
cursor = cursor.max(end);
}
if cursor < source.len() {
push_region(source, cursor, source.len(), &mut regions);
}
regions
}
fn push_region(source: &str, start: usize, end: usize, regions: &mut Vec<SourceRegion>) {
let Some(body) = source.get(start..end) else {
return;
};
if body.trim().is_empty() {
return;
}
regions.push(SourceRegion {
body: body.to_string(),
byte_offset: start,
});
}
pub(crate) fn is_astro_file(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| ext == "astro")
}
pub(crate) fn parse_astro_to_module(
file_id: FileId,
source: &str,
content_hash: u64,
need_complexity: bool,
) -> ModuleInfo {
let parsed_suppressions = crate::suppress::parse_suppressions_from_source(source);
let line_offsets = fallow_types::extract::compute_line_offsets(source);
let frontmatter = extract_astro_frontmatter(source);
let template_offset = frontmatter
.as_ref()
.map_or(0, |script| script.byte_offset + script.body.len());
let template = source.get(template_offset..).unwrap_or("");
let template_used = collect_astro_template_used_names(template);
let frontmatter_offset = frontmatter.as_ref().map_or(0, |script| script.byte_offset);
let (mut extractor, semantic_usage, props_harvest, frontmatter_complexity) =
if let Some(script) = frontmatter.as_ref() {
let source_type = SourceType::ts();
let allocator = Allocator::default();
let parser_return = Parser::new(&allocator, &script.body, source_type).parse();
let mut extractor = ModuleInfoExtractor::new();
extractor.visit_program(&parser_return.program);
let extraction = ExtractionResult::contiguous(&script.body, script.byte_offset);
extractor.remap_spans_with(|span| extraction.remap_span(span));
let semantic_usage = crate::parse::compute_semantic_usage(
&parser_return.program,
&extractor.imports,
&template_used,
);
let props_harvest = crate::sfc_props::harvest_astro_props(&parser_return.program);
let frontmatter_complexity = if need_complexity {
compute_astro_frontmatter_complexity(
&parser_return.program,
&script.body,
script.byte_offset,
&line_offsets,
)
} else {
Vec::new()
};
(
extractor,
semantic_usage,
props_harvest,
frontmatter_complexity,
)
} else {
(
ModuleInfoExtractor::new(),
crate::parse::SemanticUsage::default(),
crate::sfc_props::DefinePropsHarvest::default(),
Vec::new(),
)
};
extend_imports_from_template(&mut extractor.imports, template, template_offset);
let frontmatter_array_element_types = extractor.array_binding_element_types().clone();
let mut info = extractor.into_module_info(file_id, content_hash, parsed_suppressions);
extend_template_expression_member_accesses(
&mut info,
template,
&frontmatter_array_element_types,
);
info.unused_import_bindings = semantic_usage.import_binding_usage.unused;
info.type_referenced_import_bindings = semantic_usage.import_binding_usage.type_referenced;
info.value_referenced_import_bindings = semantic_usage.import_binding_usage.value_referenced;
apply_astro_props(
&mut info,
props_harvest,
&template_used,
template,
frontmatter_offset,
);
info.complexity.extend(frontmatter_complexity);
if need_complexity {
info.complexity
.extend(crate::template_complexity::compute_astro_template_complexity(source));
}
info.line_offsets = line_offsets;
info
}
fn compute_astro_frontmatter_complexity(
program: &oxc_ast::ast::Program<'_>,
body: &str,
body_byte_offset: usize,
source_line_offsets: &[u32],
) -> Vec<fallow_types::extract::FunctionComplexity> {
let body_line_offsets = fallow_types::extract::compute_line_offsets(body);
let mut complexity = crate::complexity::compute_complexity(program, body, &body_line_offsets);
let (body_start_line, body_start_col) = fallow_types::extract::byte_offset_to_line_col(
source_line_offsets,
u32::try_from(body_byte_offset).unwrap_or(u32::MAX),
);
for function in &mut complexity {
function.line = body_start_line + function.line.saturating_sub(1);
if function.line == body_start_line {
function.col += body_start_col;
}
}
complexity
}
fn apply_astro_props(
info: &mut ModuleInfo,
harvest: crate::sfc_props::DefinePropsHarvest,
template_used: &FxHashSet<String>,
template: &str,
frontmatter_offset: usize,
) {
if harvest.has_unharvestable_props {
info.has_unharvestable_props = true;
}
if harvest.has_props_attrs_fallthrough || template.contains("...Astro.props") {
info.has_props_attrs_fallthrough = true;
}
for mut prop in harvest.props {
prop.span_start = prop
.span_start
.saturating_add(u32::try_from(frontmatter_offset).unwrap_or(u32::MAX));
prop.used_in_template =
template_used.contains(&prop.local) || template_used.contains(&prop.name);
info.component_props.push(prop);
}
}
fn extend_imports_from_template(
imports: &mut Vec<ImportInfo>,
template: &str,
template_offset: usize,
) {
if template.is_empty() {
return;
}
let comment_ranges: Vec<(usize, usize)> = HTML_COMMENT_RE
.find_iter(template)
.map(|m| (m.start(), m.end()))
.collect();
extend_processed_script_src_imports(imports, template, template_offset, &comment_ranges);
extend_inline_script_imports(imports, template, template_offset, &comment_ranges);
}
fn pos_in_comment(comment_ranges: &[(usize, usize)], pos: usize) -> bool {
comment_ranges
.iter()
.any(|&(start, end)| pos >= start && pos < end)
}
fn extend_processed_script_src_imports(
imports: &mut Vec<ImportInfo>,
template: &str,
template_offset: usize,
comment_ranges: &[(usize, usize)],
) {
for cap in SCRIPT_OPEN_RE.captures_iter(template) {
let Some(open) = cap.get(0) else {
continue;
};
if pos_in_comment(comment_ranges, open.start()) {
continue;
}
let attrs = cap.name("attrs").map_or("", |m| m.as_str());
if let Some((raw, source_span)) = processed_script_src_with_span(attrs, cap.name("attrs")) {
let tag_span = Span::new(
(template_offset + open.start()) as u32,
(template_offset + open.end()) as u32,
);
imports.push(ImportInfo {
source: normalize_asset_url(raw),
imported_name: ImportedName::SideEffect,
local_name: String::new(),
is_type_only: false,
from_style: false,
span: tag_span,
source_span: Span::new(
template_offset as u32 + source_span.start,
template_offset as u32 + source_span.end,
),
});
}
}
}
fn extend_inline_script_imports(
imports: &mut Vec<ImportInfo>,
template: &str,
template_offset: usize,
comment_ranges: &[(usize, usize)],
) {
for cap in SCRIPT_BLOCK_RE.captures_iter(template) {
let Some(open) = cap.get(0) else {
continue;
};
if pos_in_comment(comment_ranges, open.start()) {
continue;
}
let attrs = cap.name("attrs").map_or("", |m| m.as_str());
if !attrs.trim().is_empty() {
continue;
}
let Some(body_match) = cap.name("body") else {
continue;
};
let body = body_match.as_str();
if body.trim().is_empty() {
continue;
}
let allocator = Allocator::default();
let parser_return = Parser::new(&allocator, body, SourceType::ts()).parse();
let mut inline_extractor = ModuleInfoExtractor::new();
inline_extractor.visit_program(&parser_return.program);
let extraction = ExtractionResult::contiguous(body, template_offset + body_match.start());
inline_extractor.remap_spans_with(|span| extraction.remap_span(span));
imports.append(&mut inline_extractor.imports);
}
}
fn processed_script_src_with_span<'a>(
attrs: &'a str,
attrs_match: Option<regex::Match<'_>>,
) -> Option<(&'a str, Span)> {
let cap = SRC_ATTR_RE.captures(attrs)?;
let src_match = cap.name("src")?;
let src = src_match.as_str().trim();
if src.is_empty() || is_remote_url(src) {
return None;
}
let without_src = SRC_ATTR_RE.replace(attrs, "");
let extra_attrs = without_src.trim();
let extra_attrs = extra_attrs.strip_suffix('/').unwrap_or(extra_attrs).trim();
if !extra_attrs.is_empty() {
return None;
}
let attrs_start = attrs_match.map_or(0, |m| m.start());
Some((
src,
Span::new(
(attrs_start + src_match.start()) as u32,
(attrs_start + src_match.end()) as u32,
),
))
}
#[cfg(all(test, not(miri)))]
mod tests {
use super::*;
#[test]
fn is_astro_file_positive() {
assert!(is_astro_file(Path::new("Layout.astro")));
}
#[test]
fn is_astro_file_rejects_vue() {
assert!(!is_astro_file(Path::new("App.vue")));
}
#[test]
fn is_astro_file_rejects_ts() {
assert!(!is_astro_file(Path::new("utils.ts")));
}
#[test]
fn is_astro_file_rejects_mdx() {
assert!(!is_astro_file(Path::new("post.mdx")));
}
#[test]
fn extracts_frontmatter_body() {
let source = "---\nimport Layout from '../layouts/Layout.astro';\nconst title = 'Hi';\n---\n<Layout />";
let script = extract_astro_frontmatter(source);
assert!(script.is_some());
let script = script.unwrap();
assert!(script.body.contains("import Layout"));
assert!(script.body.contains("const title"));
}
#[test]
fn frontmatter_is_always_typescript() {
let source = "---\nconst x = 1;\n---\n<div />";
let script = extract_astro_frontmatter(source).unwrap();
assert!(script.is_typescript);
}
#[test]
fn frontmatter_is_not_jsx() {
let source = "---\nconst x = 1;\n---\n<div />";
let script = extract_astro_frontmatter(source).unwrap();
assert!(!script.is_jsx);
}
#[test]
fn frontmatter_has_no_src() {
let source = "---\nconst x = 1;\n---\n<div />";
let script = extract_astro_frontmatter(source).unwrap();
assert!(script.src.is_none());
}
#[test]
fn no_frontmatter_returns_none() {
let source = "<div>No frontmatter here</div>";
assert!(extract_astro_frontmatter(source).is_none());
}
#[test]
fn no_frontmatter_just_html() {
let source = "<html><body><h1>Hello</h1></body></html>";
assert!(extract_astro_frontmatter(source).is_none());
}
#[test]
fn empty_frontmatter() {
let source = "---\n\n---\n<div />";
let script = extract_astro_frontmatter(source);
assert!(script.is_some());
let body = script.unwrap().body;
assert!(body.trim().is_empty());
}
#[test]
fn only_first_frontmatter_pair() {
let source = "---\nconst first = true;\n---\n<div />\n---\nconst second = true;\n---\n";
let script = extract_astro_frontmatter(source);
assert!(script.is_some());
let body = script.unwrap().body;
assert!(body.contains("first"));
assert!(!body.contains("second"));
}
#[test]
fn byte_offset_points_to_body() {
let source = "---\nconst x = 1;\n---\n<div />";
let script = extract_astro_frontmatter(source).unwrap();
let offset = script.byte_offset;
assert!(source[offset..].starts_with("const x = 1;"));
}
#[test]
fn leading_whitespace_before_frontmatter() {
let source = " \n---\nconst x = 1;\n---\n<div />";
let script = extract_astro_frontmatter(source);
assert!(script.is_some());
assert!(script.unwrap().body.contains("const x = 1;"));
}
#[test]
fn frontmatter_with_type_annotations() {
let source = "---\ninterface Props { title: string; }\nconst { title } = Astro.props as Props;\n---\n<h1>{title}</h1>";
let script = extract_astro_frontmatter(source);
assert!(script.is_some());
let body = script.unwrap().body;
assert!(body.contains("interface Props"));
assert!(body.contains("Astro.props"));
}
#[test]
fn frontmatter_with_multiline_imports() {
let source = "---\nimport {\n Component,\n Fragment\n} from 'react';\n---\n<Component />";
let script = extract_astro_frontmatter(source).unwrap();
assert!(script.body.contains("Component"));
assert!(script.body.contains("Fragment"));
}
#[test]
fn frontmatter_with_crlf_line_endings() {
let source = "---\r\nexport const x = 1;\r\n---\r\n<div />";
let script = extract_astro_frontmatter(source);
assert!(script.is_some());
assert!(script.unwrap().body.contains("export const x = 1;"));
}
#[test]
fn frontmatter_not_at_start_returns_none() {
let source = "<div />\n---\nconst x = 1;\n---\n";
assert!(extract_astro_frontmatter(source).is_none());
}
#[test]
fn frontmatter_dashes_in_body_not_confused() {
let source = "---\nconst x = '---';\nconst y = 2;\n---\n<div />";
let script = extract_astro_frontmatter(source);
assert!(script.is_some());
let body = script.unwrap().body;
assert!(body.contains("const x = '---';"));
}
#[test]
fn parse_astro_to_module_no_frontmatter() {
let info = parse_astro_to_module(FileId(0), "<div>Hello</div>", 42, false);
assert!(info.imports.is_empty());
assert!(info.exports.is_empty());
assert_eq!(info.content_hash, 42);
assert_eq!(info.file_id, FileId(0));
}
#[test]
fn parse_astro_to_module_with_imports() {
let source = "---\nimport { ref } from 'vue';\nconst x = ref(0);\n---\n<div />";
let info = parse_astro_to_module(FileId(1), source, 99, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "vue");
assert_eq!(info.file_id, FileId(1));
assert_eq!(info.content_hash, 99);
}
#[test]
fn parse_astro_to_module_has_line_offsets() {
let source = "---\nconst x = 1;\n---\n<div />";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert!(!info.line_offsets.is_empty());
}
#[test]
fn parse_astro_to_module_has_suppressions() {
let source = "---\n// fallow-ignore-file\nconst x = 1;\n---\n<div />";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert!(!info.suppressions.is_empty());
assert_eq!(info.suppressions[0].line, 0);
}
#[test]
fn is_astro_file_rejects_svelte() {
assert!(!is_astro_file(Path::new("Component.svelte")));
}
#[test]
fn is_astro_file_rejects_no_extension() {
assert!(!is_astro_file(Path::new("Makefile")));
}
#[test]
fn parse_astro_template_script_src_relative() {
let source = "---\nconst x = 1;\n---\n<script src=\"./client.ts\"></script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "./client.ts");
}
#[test]
fn parse_astro_template_script_src_parent_relative() {
let source = "---\n---\n<script src=\"../scripts/foo.ts\"></script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "../scripts/foo.ts");
}
#[test]
fn parse_astro_template_script_src_bare_normalized() {
let source = "---\n---\n<script src=\"client.ts\"></script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "./client.ts");
}
#[test]
fn parse_astro_template_script_src_skips_remote() {
let source = "---\n---\n<script src=\"https://cdn.example.com/lib.js\"></script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert!(info.imports.is_empty());
}
#[test]
fn parse_astro_template_script_src_multiline_attrs() {
let source = "---\n---\n<script\n src=\"./client.ts\"\n></script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "./client.ts");
}
#[test]
fn parse_astro_template_script_src_with_extra_attrs_is_unprocessed() {
let source = "---\n---\n<script type=\"module\" src=\"./client.ts\"></script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert!(info.imports.is_empty());
}
#[test]
fn parse_astro_template_inline_script_import() {
let source = "---\n---\n<script>\n import '../scripts/bar';\n</script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "../scripts/bar");
assert!(matches!(
info.imports[0].imported_name,
crate::ImportedName::SideEffect
));
}
#[test]
fn parse_astro_template_inline_script_named_import() {
let source = "---\n---\n<script>\n import { foo } from '../utils';\n foo();\n</script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "../utils");
}
#[test]
fn parse_astro_template_inline_script_typescript_syntax() {
let source = "---\n---\n<script>\n import { foo } from '../utils';\n const x: number = foo();\n</script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "../utils");
}
#[test]
fn parse_astro_template_inline_script_with_attributes_is_unprocessed() {
let source = "---\n---\n<script is:inline>\n import '../scripts/bar';\n</script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert!(info.imports.is_empty());
}
#[test]
fn parse_astro_template_type_module_inline_script_is_unprocessed() {
let source = "---\n---\n<script type=\"module\">\n import '../scripts/bar';\n</script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert!(info.imports.is_empty());
}
#[test]
fn parse_astro_template_skips_inline_body_when_src_present() {
let source = "---\n---\n<script src=\"./client.ts\">import 'should-be-ignored';</script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "./client.ts");
}
#[test]
fn parse_astro_template_combined_src_and_inline() {
let source = "---\nconst title = \"Hi\";\n---\n\
<html><body>\n\
<h1>{title}</h1>\n\
<script src=\"../scripts/foo.ts\"></script>\n\
<script>\n import '../scripts/bar';\n</script>\n\
</body></html>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
let sources: Vec<&str> = info.imports.iter().map(|i| i.source.as_str()).collect();
assert!(sources.contains(&"../scripts/foo.ts"));
assert!(sources.contains(&"../scripts/bar"));
}
#[test]
fn parse_astro_template_multiple_inline_scripts() {
let source = "---\n---\n\
<script>\n import '../a';\n</script>\n\
<script>\n import '../b';\n</script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
let sources: Vec<&str> = info.imports.iter().map(|i| i.source.as_str()).collect();
assert!(sources.contains(&"../a"));
assert!(sources.contains(&"../b"));
}
#[test]
fn parse_astro_template_skips_commented_out_script_src() {
let source = "---\n---\n<!-- <script src=\"./old.ts\"></script> -->\n<script src=\"./new.ts\"></script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "./new.ts");
}
#[test]
fn parse_astro_template_skips_commented_out_inline_script() {
let source = "---\n---\n<!-- <script>\n import '../old';\n</script> -->\n<script>\n import '../new';\n</script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
let sources: Vec<&str> = info.imports.iter().map(|i| i.source.as_str()).collect();
assert!(sources.contains(&"../new"));
assert!(!sources.contains(&"../old"));
}
#[test]
fn parse_astro_template_no_frontmatter_with_script() {
let source = "<html><body><script src=\"./client.ts\"></script></body></html>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "./client.ts");
}
#[test]
fn parse_astro_template_empty_inline_script_is_skipped() {
let source = "---\n---\n<script></script>";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert!(info.imports.is_empty());
}
#[test]
fn parse_astro_template_does_not_double_count_frontmatter_imports() {
let source = "---\nimport Layout from '../Layout.astro';\n---\n<Layout />";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert_eq!(info.imports.len(), 1);
assert_eq!(info.imports[0].source, "../Layout.astro");
}
#[test]
fn astro_template_map_callback_element_type() {
let source = "---\n\
import { Util } from './Util'\n\
const utils: Util[] = [new Util()]\n\
---\n\
<div>\n\
{utils.map((util) => <p>{util.getter()}</p>)}\n\
</div>\n";
let info = parse_astro_to_module(FileId(0), source, 0, false);
let has_util_getter = info
.member_accesses
.iter()
.any(|access| access.object == "Util" && access.member == "getter");
assert!(
has_util_getter,
"expected Util.getter from the template .map callback, found: {:?}",
info.member_accesses
);
}
#[test]
fn astro_template_map_does_not_credit_unrelated_member() {
let source = "---\n\
import { Util } from './Util'\n\
const utils: Util[] = [new Util()]\n\
---\n\
<div>\n\
{utils.map((util) => <p>{util.getter()}</p>)}\n\
</div>\n";
let info = parse_astro_to_module(FileId(0), source, 0, false);
let has_unused = info
.member_accesses
.iter()
.any(|access| access.object == "Util" && access.member == "unusedMethod");
assert!(
!has_unused,
"Util.unusedMethod is never accessed and must not be credited, found: {:?}",
info.member_accesses
);
}
#[test]
fn astro_template_map_without_typed_array_credits_nothing() {
let source = "---\n\
import { Util } from './Util'\n\
const utils = getUtils()\n\
---\n\
<div>\n\
{utils.map((util) => <p>{util.getter()}</p>)}\n\
</div>\n";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert!(
!info
.member_accesses
.iter()
.any(|access| access.object == "Util"),
"an untyped receiver must not credit any Util member, found: {:?}",
info.member_accesses
);
}
}