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::template_expression_scan::{
TemplateScanMode, guarded_import_locals, import_declaration_ranges, merge_ranges,
narrowable_import_locals, pos_in_ranges, record_unexplained_mentions,
record_unexplained_script_mentions, recorded_member_pairs, scan_template_usage,
};
use crate::visitor::{
ModuleInfoExtractor, extend_member_accesses, extend_whole_object_uses, push_member_tag_accesses,
};
use crate::{ImportInfo, ImportedName, MemberAccess, 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 TEMPLATE_MEMBER_TAG_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
crate::static_regex(r"<\s*([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+)")
});
static DEFINE_VARS_RE: LazyLock<regex::Regex> =
LazyLock::new(|| crate::static_regex(r"define:vars\s*="));
fn build_masked_ranges(template: &str) -> Vec<(usize, usize)> {
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())),
);
merge_ranges(masked)
}
fn collect_astro_template_used_names(
template: &str,
masked: &[(usize, usize)],
) -> FxHashSet<String> {
let mut used = FxHashSet::default();
if template.is_empty() {
return used;
}
let is_masked = |pos: usize| pos_in_ranges(masked, pos);
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,
masked: &[(usize, usize)],
) -> Vec<(usize, usize)> {
let mut regions = Vec::new();
if template.is_empty() {
return regions;
}
let bytes = template.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] != b'{' || pos_in_ranges(masked, i) {
i += 1;
continue;
}
let start = i + 1;
let region_end = brace_body_end(bytes, i);
if region_end > start {
regions.push((start, region_end));
}
i = region_end.max(start);
}
regions
}
fn collect_template_member_tag_accesses(
template: &str,
masked: &[(usize, usize)],
) -> Vec<MemberAccess> {
let mut accesses = Vec::new();
if template.is_empty() {
return accesses;
}
for cap in TEMPLATE_MEMBER_TAG_RE.captures_iter(template) {
if let Some(name) = cap.get(1)
&& !pos_in_ranges(masked, name.start())
{
push_member_tag_accesses(&mut accesses, name.as_str());
}
}
accesses
}
fn extend_template_expression_usage(
info: &mut ModuleInfo,
template: &str,
regions: &[(usize, usize)],
frontmatter_array_element_types: &rustc_hash::FxHashMap<String, String>,
) -> Vec<(usize, usize)> {
let mut parsed_regions = Vec::with_capacity(regions.len());
if regions.is_empty() {
return parsed_regions;
}
let import_locals: FxHashSet<&str> = info
.imports
.iter()
.map(|import| import.local_name.as_str())
.collect();
let mut accesses = Vec::new();
let mut whole_object_uses = Vec::new();
let mut extractor = ModuleInfoExtractor::new();
extractor.seed_array_binding_element_types(frontmatter_array_element_types);
for &(start, end) in regions {
let Some(region) = template.get(start..end) else {
continue;
};
let region = region.trim();
if region.is_empty() {
continue;
}
let wrapped = format!("({region});");
let allocator = Allocator::default();
let parser_return = Parser::new(&allocator, &wrapped, SourceType::tsx()).parse();
if parser_return.panicked || !parser_return.errors.is_empty() {
continue;
}
extractor.visit_program(&parser_return.program);
scan_template_usage(
region,
&import_locals,
TemplateScanMode::AstroParsedRegion,
&mut accesses,
&mut whole_object_uses,
);
parsed_regions.push((start, end));
}
let (visited_accesses, visited_whole_object_uses) = extractor.take_template_expression_usage();
accesses.extend(visited_accesses);
whole_object_uses.extend(visited_whole_object_uses);
extend_member_accesses(info, accesses);
extend_whole_object_uses(info, whole_object_uses);
parsed_regions
}
fn extend_unexplained_template_mentions(
info: &mut ModuleInfo,
template: &str,
masked: &[(usize, usize)],
parsed_regions: Vec<(usize, usize)>,
) {
let guarded = guarded_import_locals(&info.imports);
if guarded.is_empty() || template.is_empty() {
return;
}
let mut explained = parsed_regions;
explained.extend(
TEMPLATE_TAG_RE
.captures_iter(template)
.filter_map(|cap| cap.get(1))
.filter(|root| !pos_in_ranges(masked, root.start()))
.map(|root| (root.start(), root.end())),
);
let explained = merge_ranges(explained);
let mut whole_object_uses = Vec::new();
record_unexplained_mentions(template, &guarded, &explained, &mut whole_object_uses);
extend_whole_object_uses(info, whole_object_uses);
}
fn extend_unexplained_frontmatter_mentions(info: &mut ModuleInfo, frontmatter: Option<&SfcScript>) {
let Some(script) = frontmatter else {
return;
};
let mut whole_object_uses = Vec::new();
{
let guarded = narrowable_import_locals(&info.imports);
if guarded.is_empty() || script.body.is_empty() {
return;
}
let recorded = recorded_member_pairs(&info.member_accesses, &guarded);
let excluded = import_declaration_ranges(&info.imports);
record_unexplained_script_mentions(
&script.body,
script.byte_offset,
&guarded,
&excluded,
&recorded,
&mut whole_object_uses,
);
}
extend_whole_object_uses(info, whole_object_uses);
}
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")
}
struct AstroFrontmatterAnalysis {
extractor: ModuleInfoExtractor,
semantic_usage: crate::parse::SemanticUsage,
props_harvest: crate::sfc_props::DefinePropsHarvest,
complexity: Vec<fallow_types::extract::FunctionComplexity>,
}
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 masked = build_masked_ranges(template);
let template_used = collect_astro_template_used_names(template, &masked);
let frontmatter_offset = frontmatter.as_ref().map_or(0, |script| script.byte_offset);
let AstroFrontmatterAnalysis {
mut extractor,
semantic_usage,
props_harvest,
complexity: frontmatter_complexity,
} = analyze_astro_frontmatter(
frontmatter.as_ref(),
&template_used,
need_complexity,
&line_offsets,
);
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_member_accesses(
&mut info,
collect_template_member_tag_accesses(template, &masked),
);
let parsed_regions = extend_template_expression_usage(
&mut info,
template,
&collect_template_expression_regions(template, &masked),
&frontmatter_array_element_types,
);
extend_unexplained_template_mentions(&mut info, template, &masked, parsed_regions);
extend_unexplained_frontmatter_mentions(&mut info, frontmatter.as_ref());
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;
crate::parse::append_declaration_merge_facts(
&mut info.semantic_facts,
semantic_usage.declaration_merges,
frontmatter_offset as u32,
);
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 analyze_astro_frontmatter(
frontmatter: Option<&SfcScript>,
template_used: &FxHashSet<String>,
need_complexity: bool,
line_offsets: &[u32],
) -> AstroFrontmatterAnalysis {
let Some(script) = frontmatter else {
return empty_astro_frontmatter_analysis();
};
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 semantic_usage = crate::parse::compute_semantic_usage_for_extractor(
&parser_return.program,
&mut extractor,
template_used,
);
let extraction = ExtractionResult::contiguous(&script.body, script.byte_offset);
extractor.remap_spans_with(|span| extraction.remap_span(span));
let props_harvest = crate::sfc_props::harvest_astro_props(&parser_return.program);
let complexity = if need_complexity {
compute_astro_frontmatter_complexity(
&parser_return.program,
&script.body,
script.byte_offset,
line_offsets,
)
} else {
Vec::new()
};
AstroFrontmatterAnalysis {
extractor,
semantic_usage,
props_harvest,
complexity,
}
}
fn empty_astro_frontmatter_analysis() -> AstroFrontmatterAnalysis {
AstroFrontmatterAnalysis {
extractor: ModuleInfoExtractor::new(),
semantic_usage: crate::parse::SemanticUsage::default(),
props_harvest: crate::sfc_props::DefinePropsHarvest::default(),
complexity: Vec::new(),
}
}
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,
is_type_only_star: 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
);
}
const MASKED_MIX_TEMPLATE: &str = concat!(
"<!-- <Widget/> and {Sidebar} -->\n",
"<Header title={fmt(x)} />\n",
"<!-- <script>{Buried}</script> -->\n",
"<script><Hidden/>{Ghost}</script>\n",
"<style>a { color: {red}; }</style>\n",
"<Footer>{items.map((i) => i.label)}</Footer>\n",
);
#[test]
fn astro_masked_ranges_match_linear_reference() {
let template = MASKED_MIX_TEMPLATE;
let mut raw: Vec<(usize, usize)> = Vec::new();
raw.extend(
SCRIPT_BLOCK_RE
.find_iter(template)
.map(|m| (m.start(), m.end())),
);
raw.extend(
STYLE_BLOCK_RE
.find_iter(template)
.map(|m| (m.start(), m.end())),
);
raw.extend(
HTML_COMMENT_RE
.find_iter(template)
.map(|m| (m.start(), m.end())),
);
let linear = |pos: usize| raw.iter().any(|&(s, e)| pos >= s && pos < e);
let merged = build_masked_ranges(template);
assert!(
merged.len() < raw.len(),
"expected overlapping ranges to merge, raw={raw:?} merged={merged:?}"
);
for pos in 0..template.len() {
assert_eq!(
pos_in_ranges(&merged, pos),
linear(pos),
"masking decision differs at byte {pos}"
);
}
}
#[test]
fn astro_template_used_names_masks_comments_credits_braces() {
let masked = build_masked_ranges(MASKED_MIX_TEMPLATE);
let used = collect_astro_template_used_names(MASKED_MIX_TEMPLATE, &masked);
for name in ["Header", "Footer", "fmt", "x", "items", "map", "i", "label"] {
assert!(
used.contains(name),
"expected {name} to be credited, got {used:?}"
);
}
for name in ["Widget", "Sidebar", "Buried", "Hidden", "Ghost", "red"] {
assert!(
!used.contains(name),
"expected {name} to stay masked, got {used:?}"
);
}
}
fn template_member_accesses(source: &str) -> Vec<(String, String)> {
parse_astro_to_module(FileId(0), source, 0, false)
.member_accesses
.iter()
.map(|access| (access.object.clone(), access.member.clone()))
.collect()
}
#[test]
fn astro_template_member_tag_records_member_access() {
let source = "---\nimport * as SC from './style';\n---\n<SC.Card />\n";
let info = parse_astro_to_module(FileId(0), source, 0, false);
assert!(
info.member_accesses
.iter()
.any(|access| access.object == "SC" && access.member == "Card"),
"<SC.Card /> should record SC.Card; got {:?}",
info.member_accesses
);
assert!(
!info.unused_import_bindings.iter().any(|b| b == "SC"),
"the dotted tag root must still credit the SC import binding; got {:?}",
info.unused_import_bindings
);
}
#[test]
fn astro_template_nested_member_tag_records_each_level() {
let accesses = template_member_accesses(
"---\nimport * as A from './widgets';\n---\n<A.B.C label=\"x\" />\n",
);
assert!(
accesses.contains(&("A".to_string(), "B".to_string())),
"<A.B.C /> should record A.B for namespace crediting; got {accesses:?}"
);
assert!(
accesses.contains(&("A.B".to_string(), "C".to_string())),
"<A.B.C /> should record the full A.B.C path; got {accesses:?}"
);
}
#[test]
fn astro_template_paired_member_tag_records_single_access() {
let accesses = template_member_accesses(
"---\nimport * as SC from './style';\n---\n<SC.Layout>\n <p>text</p>\n</SC.Layout>\n",
);
let count = accesses
.iter()
.filter(|(object, member)| object == "SC" && member == "Layout")
.count();
assert_eq!(
count, 1,
"paired tags should record exactly one SC.Layout access; got {accesses:?}"
);
}
fn template_whole_object_uses(source: &str) -> Vec<String> {
parse_astro_to_module(FileId(0), source, 0, false)
.whole_object_uses
.to_vec()
}
#[test]
fn astro_template_member_tag_in_masked_region_records_no_access_but_keeps_mark_all() {
let source = "---\nimport * as SC from './style';\n---\n\
<!-- <SC.Commented /> -->\n\
<script>const x = '<SC.Scripted />';</script>\n\
<style>/* <SC.Styled /> */</style>\n\
<SC.Visible />\n";
let accesses = template_member_accesses(source);
assert_eq!(
accesses,
vec![("SC".to_string(), "Visible".to_string())],
"only the unmasked member tag should record; got {accesses:?}"
);
assert_eq!(
template_whole_object_uses(source),
vec!["SC".to_string()],
"a masked mention must keep SC on the mark-all path"
);
}
#[test]
fn astro_template_expression_member_access_records() {
let accesses = template_member_accesses(
"---\nimport * as NS from './icons';\nimport Callout from './Callout.astro';\n---\n\
<NS.Star />\n<Callout icon={NS.Moon} />\n<p>{NS.Sun()}</p>\n",
);
for member in ["Star", "Moon", "Sun"] {
assert!(
accesses.contains(&("NS".to_string(), member.to_string())),
"NS.{member} should be recorded from the markup; got {accesses:?}"
);
}
}
#[test]
fn astro_template_whole_namespace_pass_records_whole_object_use() {
let info = parse_astro_to_module(
FileId(0),
"---\nimport * as NS from './icons';\nimport Callout from './Callout.astro';\n\
const local = 1;\n---\n<NS.Star />\n<Callout all={NS} count={local} />\n",
0,
false,
);
assert!(
info.whole_object_uses.iter().any(|name| name == "NS"),
"all={{NS}} should record a whole-object use; got {:?}",
info.whole_object_uses
);
assert!(
!info.whole_object_uses.iter().any(|name| name == "local"),
"a frontmatter local is not an import binding; got {:?}",
info.whole_object_uses
);
}
#[test]
fn astro_template_member_tag_inside_expression_records_once() {
let accesses = template_member_accesses(
"---\nimport * as EX from './ex';\nconst flag = true;\n---\n{flag && <EX.Cond />}\n",
);
let count = accesses
.iter()
.filter(|(object, member)| object == "EX" && member == "Cond")
.count();
assert_eq!(
count, 1,
"a tag inside an expression region should record exactly once; got {accesses:?}"
);
}
#[test]
fn astro_template_parsed_expression_uses_visitor_semantics() {
let info = parse_astro_to_module(
FileId(0),
"---\nimport * as NS from './icons';\n---\n\
<NS.Star />\n<p>{\"NS.Ghost\"}</p>\n<p>{Object.keys(NS).length}</p>\n",
0,
false,
);
assert!(
!info
.member_accesses
.iter()
.any(|access| access.object == "NS" && access.member == "Ghost"),
"a string literal is not a member access; got {:?}",
info.member_accesses
);
assert!(
info.whole_object_uses.iter().any(|name| name == "NS"),
"Object.keys(NS) should record a whole-object use; got {:?}",
info.whole_object_uses
);
}
#[test]
fn astro_template_unparsed_expression_region_keeps_binding_on_mark_all() {
let source = "---\nimport * as NS from './icons';\n---\n<NS.Star />\n<p>{NS.Moon(}</p>\n";
let accesses = template_member_accesses(source);
assert!(
!accesses.contains(&("NS".to_string(), "Moon".to_string())),
"an unparsed region records no access; got {accesses:?}"
);
assert_eq!(
template_whole_object_uses(source),
vec!["NS".to_string()],
"an unparsed region must keep NS on the mark-all path"
);
}
#[test]
fn astro_template_masked_tag_directive_mention_keeps_binding_on_mark_all() {
let source = "---\n\
import * as SV from './sv';\nimport * as SD from './sd';\n\
import * as SH from './sh';\nimport * as SW from './sw';\n\
import styles from './card.module.css';\n\
---\n\
<SV.Star /><SD.Star /><SH.Star /><SW.Star />\n\
<div class={styles.root}></div>\n\
<style define:vars={{ accent: SV.accent }}>div { color: var(--accent); }</style>\n\
<script define:vars={{ moon: SD.Moon }}>console.log(moon);</script>\n\
<script is:inline set:html={SH.code}></script>\n\
<script define:vars={{ SW }}>console.log(SW);</script>\n\
<style define:vars={{ spare: styles.spare }}></style>\n";
let accesses = template_member_accesses(source);
for root in ["SV", "SD", "SH", "SW"] {
assert!(
accesses.contains(&(root.to_string(), "Star".to_string())),
"{root}.Star should still record from the dotted tag; got {accesses:?}"
);
}
assert!(
accesses.contains(&("styles".to_string(), "root".to_string())),
"styles.root should record from the markup expression; got {accesses:?}"
);
let mut whole = template_whole_object_uses(source);
whole.sort();
assert_eq!(
whole,
vec![
"SD".to_string(),
"SH".to_string(),
"SV".to_string(),
"SW".to_string(),
"styles".to_string(),
],
"every binding mentioned on a masked opening tag must keep mark-all"
);
}
#[test]
fn astro_template_unstructured_mention_keeps_binding_on_mark_all() {
let source = "---\n\
import * as CM from './cm';\nimport * as TX from './tx';\n\
import * as AT from './at';\nimport * as SB from './sb';\n\
---\n\
<CM.Star /><TX.Star /><AT.Star /><SB.Star />\n\
<!-- {CM.Moon} -->\n\
<p>see TX.Moon for details</p>\n\
<div data-icon=\"AT.Moon\"></div>\n\
<script>SB.Moon();</script>\n";
let mut whole = template_whole_object_uses(source);
whole.sort();
assert_eq!(
whole,
vec![
"AT".to_string(),
"CM".to_string(),
"SB".to_string(),
"TX".to_string(),
],
"got {whole:?}"
);
}
#[test]
fn astro_template_fully_understood_mentions_record_no_whole_object_use() {
let source = "---\n\
import * as NS from './icons';\nimport Card from './Card.astro';\n\
import styles from './card.module.css';\nconst flag = true;\n\
---\n\
<NS.Star />\n<NS.Moon>\n <Card icon={NS.Sun} class={styles.root} />\n</NS.Moon>\n\
{flag && <NS.Cond />}\n<p>{NS.helper()}</p>\n<p class={`x ${styles.spare}`}>{`${NS.Tpl}`}</p>\n";
let whole = template_whole_object_uses(source);
assert!(
whole.is_empty(),
"fully understood mentions must not record a whole-object use; got {whole:?}"
);
let accesses = template_member_accesses(source);
for member in ["Star", "Moon", "Sun", "Cond", "helper", "Tpl"] {
assert!(
accesses.contains(&("NS".to_string(), member.to_string())),
"NS.{member} should be recorded; got {accesses:?}"
);
}
for class in ["root", "spare"] {
assert!(
accesses.contains(&("styles".to_string(), class.to_string())),
"styles.{class} should be recorded; got {accesses:?}"
);
}
}
#[test]
fn astro_frontmatter_bare_mention_keeps_narrowable_binding_on_mark_all() {
let source = "---\n\
import * as AL from './al';\nimport * as AC from './ac';\n\
import * as CA from './ca';\nimport * as OA from './oa';\n\
import * as AR from './ar';\nimport * as PP from './pp';\n\
import * as FD from './fd';\nimport styles from './card.module.css';\n\
import { Util } from './util';\nimport Callout from './Callout.astro';\n\
const N = AL;\nconst keys = Object.keys(AL);\n\
const rec = AC as Record<string, unknown>;\n\
const pick = (o: object) => o;\nconst picked = pick(CA);\n\
const merged = Object.assign({}, OA);\n\
const list = [AR];\n\
const props = { all: PP, classes: styles };\n\
const moon = FD.Moon;\n\
const util: Util = new Util();\nconst value = util.getter;\n\
---\n\
<AL.Star /><AC.Star /><CA.Star /><OA.Star /><AR.Star /><PP.Star /><FD.Star />\n\
<p class={styles.root}>{N.Moon}{keys.length}{rec.Moon}{picked}{merged.Moon}{list.length}{moon}{value}</p>\n\
<Callout {...props} />\n";
let mut whole = template_whole_object_uses(source);
whole.sort();
assert_eq!(
whole,
vec!["AC", "AL", "AR", "CA", "OA", "PP", "styles"]
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>(),
"every bare frontmatter mention of a narrowable binding must keep it on mark-all, once"
);
let accesses = template_member_accesses(source);
assert!(
accesses.contains(&("FD".to_string(), "Moon".to_string())),
"the dotted-only frontmatter access should be recorded; got {accesses:?}"
);
}
}