use std::path::Path;
use oxc_allocator::Allocator;
use oxc_ast::ast::{Comment, Program};
use oxc_ast_visit::Visit;
use oxc_parser::Parser;
use oxc_span::SourceType;
use crate::ExportInfo;
use crate::ModuleInfo;
use crate::astro::{is_astro_file, parse_astro_to_module};
use crate::css::{is_css_file, parse_css_to_module};
use crate::html::{is_html_file, parse_html_to_module};
use crate::mdx::{is_mdx_file, parse_mdx_to_module};
use crate::sfc::{is_sfc_file, parse_sfc_to_module};
use crate::visitor::ModuleInfoExtractor;
use fallow_types::discover::FileId;
use fallow_types::extract::{ImportInfo, VisibilityTag};
pub fn parse_source_to_module(
file_id: FileId,
path: &Path,
source: &str,
content_hash: u64,
need_complexity: bool,
) -> ModuleInfo {
if is_sfc_file(path) {
return parse_sfc_to_module(file_id, path, source, content_hash);
}
if is_astro_file(path) {
return parse_astro_to_module(file_id, source, content_hash);
}
if is_mdx_file(path) {
return parse_mdx_to_module(file_id, source, content_hash);
}
if is_css_file(path) {
return parse_css_to_module(file_id, path, source, content_hash);
}
if is_html_file(path) {
return parse_html_to_module(file_id, source, content_hash);
}
let source_type = SourceType::from_path(path).unwrap_or_default();
let allocator = Allocator::default();
let parser_return = Parser::new(&allocator, source, source_type).parse();
let mut suppressions =
crate::suppress::parse_suppressions(&parser_return.program.comments, source);
let mut extractor = ModuleInfoExtractor::new();
extractor.visit_program(&parser_return.program);
let mut unused_bindings =
compute_unused_import_bindings(&parser_return.program, &extractor.imports);
let line_offsets = fallow_types::extract::compute_line_offsets(source);
let mut complexity = if need_complexity {
crate::complexity::compute_complexity(&parser_return.program, line_offsets.clone())
} else {
Vec::new()
};
let mut flag_uses = crate::flags::extract_flags(
&parser_return.program,
&line_offsets,
&[], &[], false, );
let total_extracted =
extractor.exports.len() + extractor.imports.len() + extractor.re_exports.len();
let mut used_retry = false;
if total_extracted == 0 && source.len() > 100 && !source_type.is_jsx() {
let jsx_type = if source_type.is_typescript() {
SourceType::tsx()
} else {
SourceType::jsx()
};
let allocator2 = Allocator::default();
let retry_return = Parser::new(&allocator2, source, jsx_type).parse();
let mut retry_extractor = ModuleInfoExtractor::new();
retry_extractor.visit_program(&retry_return.program);
let retry_total = retry_extractor.exports.len()
+ retry_extractor.imports.len()
+ retry_extractor.re_exports.len();
if retry_total > total_extracted {
unused_bindings =
compute_unused_import_bindings(&retry_return.program, &retry_extractor.imports);
if need_complexity {
complexity = crate::complexity::compute_complexity(
&retry_return.program,
line_offsets.clone(),
);
}
flag_uses =
crate::flags::extract_flags(&retry_return.program, &line_offsets, &[], &[], false);
suppressions =
crate::suppress::parse_suppressions(&retry_return.program.comments, source);
apply_jsdoc_visibility_tags(
&mut retry_extractor.exports,
&retry_return.program.comments,
source,
);
extract_jsdoc_import_types(
&mut retry_extractor.imports,
&retry_return.program.comments,
source,
);
extractor = retry_extractor;
used_retry = true;
}
}
if !used_retry {
apply_jsdoc_visibility_tags(
&mut extractor.exports,
&parser_return.program.comments,
source,
);
extract_jsdoc_import_types(
&mut extractor.imports,
&parser_return.program.comments,
source,
);
}
let mut info = extractor.into_module_info(file_id, content_hash, suppressions);
info.unused_import_bindings = unused_bindings;
info.line_offsets = line_offsets;
info.complexity = complexity;
info.flag_uses = flag_uses;
info
}
fn apply_jsdoc_visibility_tags(exports: &mut [ExportInfo], comments: &[Comment], source: &str) {
if exports.is_empty() || comments.is_empty() {
return;
}
let mut tag_offsets: Vec<(u32, VisibilityTag)> = Vec::new();
for comment in comments {
if comment.is_jsdoc() {
let content_span = comment.content_span();
let start = content_span.start as usize;
let end = (content_span.end as usize).min(source.len());
if start < end {
let text = &source[start..end];
let tag = if has_public_tag(text) {
VisibilityTag::Public
} else if has_internal_tag(text) {
VisibilityTag::Internal
} else if has_alpha_tag(text) {
VisibilityTag::Alpha
} else if has_beta_tag(text) {
VisibilityTag::Beta
} else if has_expected_unused_tag(text) {
VisibilityTag::ExpectedUnused
} else {
continue;
};
tag_offsets.push((comment.attached_to, tag));
}
}
}
if tag_offsets.is_empty() {
return;
}
tag_offsets.sort_unstable_by_key(|&(offset, _)| offset);
for export in exports.iter_mut() {
if export.span.start == 0 && export.span.end == 0 {
continue;
}
if let Ok(idx) = tag_offsets.binary_search_by_key(&export.span.start, |&(o, _)| o) {
export.visibility = tag_offsets[idx].1;
continue;
}
let idx = tag_offsets.partition_point(|&(o, _)| o <= export.span.start);
if idx > 0 {
let (offset, tag) = tag_offsets[idx - 1];
let offset = offset as usize;
let export_start = export.span.start as usize;
if offset < export_start && export_start <= source.len() {
let between = &source[offset..export_start];
if between.starts_with("export") && !between.contains(';') && !between.contains('}')
{
export.visibility = tag;
}
}
}
}
}
fn has_internal_tag(comment_text: &str) -> bool {
for (i, _) in comment_text.match_indices("@internal") {
let after = i + "@internal".len();
if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
return true;
}
}
false
}
fn has_beta_tag(comment_text: &str) -> bool {
for (i, _) in comment_text.match_indices("@beta") {
let after = i + "@beta".len();
if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
return true;
}
}
false
}
fn has_alpha_tag(comment_text: &str) -> bool {
for (i, _) in comment_text.match_indices("@alpha") {
let after = i + "@alpha".len();
if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
return true;
}
}
false
}
fn has_expected_unused_tag(comment_text: &str) -> bool {
for (i, _) in comment_text.match_indices("@expected-unused") {
let after = i + "@expected-unused".len();
if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
return true;
}
}
false
}
const fn is_ident_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn extract_jsdoc_import_types(imports: &mut Vec<ImportInfo>, comments: &[Comment], source: &str) {
if comments.is_empty() {
return;
}
for comment in comments {
if !comment.is_jsdoc() {
continue;
}
let content_span = comment.content_span();
let start = content_span.start as usize;
let end = (content_span.end as usize).min(source.len());
if start >= end {
continue;
}
scan_jsdoc_imports_in(&source[start..end], imports);
}
}
fn scan_jsdoc_imports_in(body: &str, imports: &mut Vec<ImportInfo>) {
let bytes = body.as_bytes();
let mut cursor = 0;
while let Some(rel) = body[cursor..].find("import(") {
let open = cursor + rel + "import(".len();
cursor = open;
if open >= bytes.len() {
break;
}
let mut i = open;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
let quote = bytes[i];
if quote != b'\'' && quote != b'"' {
continue;
}
let path_start = i + 1;
let Some(rel_close) = body[path_start..].find(quote as char) else {
break;
};
let path_end = path_start + rel_close;
let path = &body[path_start..path_end];
if path.is_empty() {
cursor = path_end + 1;
continue;
}
let mut j = path_end + 1;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j >= bytes.len() || bytes[j] != b')' {
cursor = path_end + 1;
continue;
}
j += 1;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
cursor = j;
if j >= bytes.len() || bytes[j] != b'.' {
imports.push(ImportInfo {
source: path.to_string(),
imported_name: fallow_types::extract::ImportedName::SideEffect,
local_name: String::new(),
is_type_only: true,
span: oxc_span::Span::default(),
source_span: oxc_span::Span::default(),
});
continue;
}
j += 1;
let name_start = j;
while j < bytes.len() && is_ident_char(bytes[j]) {
j += 1;
}
if name_start == j {
continue;
}
let member = &body[name_start..j];
cursor = j;
imports.push(ImportInfo {
source: path.to_string(),
imported_name: fallow_types::extract::ImportedName::Named(member.to_string()),
local_name: String::new(),
is_type_only: true,
span: oxc_span::Span::default(),
source_span: oxc_span::Span::default(),
});
}
}
fn has_public_tag(comment_text: &str) -> bool {
for (i, _) in comment_text.match_indices("@public") {
let after = i + "@public".len();
if after >= comment_text.len() || !is_ident_char(comment_text.as_bytes()[after]) {
return true;
}
}
for (i, _) in comment_text.match_indices("@api") {
let after = i + "@api".len();
if after < comment_text.len() && !is_ident_char(comment_text.as_bytes()[after]) {
let rest = comment_text[after..].trim_start();
if rest.starts_with("public") {
let after_public = "public".len();
if after_public >= rest.len() || !is_ident_char(rest.as_bytes()[after_public]) {
return true;
}
}
}
}
false
}
pub fn compute_unused_import_bindings(
program: &Program<'_>,
imports: &[ImportInfo],
) -> Vec<String> {
use oxc_semantic::SemanticBuilder;
if imports.is_empty() {
return Vec::new();
}
let semantic_ret = SemanticBuilder::new().build(program);
let semantic = semantic_ret.semantic;
let scoping = semantic.scoping();
let root_scope = scoping.root_scope_id();
let mut unused = Vec::new();
for import in imports {
if import.local_name.is_empty() {
continue;
}
let name = oxc_span::Ident::from(import.local_name.as_str());
if let Some(symbol_id) = scoping.get_binding(root_scope, name)
&& scoping.get_resolved_references(symbol_id).count() == 0
{
unused.push(import.local_name.clone());
}
}
unused
}
#[cfg(test)]
mod tests {
use super::{
has_alpha_tag, has_beta_tag, has_internal_tag, has_public_tag, scan_jsdoc_imports_in,
};
use fallow_types::extract::{ImportInfo, ImportedName};
#[test]
fn has_public_tag_matches_bare_tag() {
assert!(has_public_tag(" * @public"));
}
#[test]
fn has_public_tag_matches_api_public_variant() {
assert!(has_public_tag(" * @api public"));
}
#[test]
fn has_public_tag_rejects_partial_word() {
assert!(!has_public_tag(" * @publicly"));
}
#[test]
fn has_public_tag_rejects_at_apipublic() {
assert!(!has_public_tag(" * @apipublic"));
}
#[test]
fn has_public_tag_rejects_missing_at() {
assert!(!has_public_tag(" * public"));
}
#[test]
fn has_internal_tag_matches_bare_tag() {
assert!(has_internal_tag(" * @internal"));
}
#[test]
fn has_internal_tag_rejects_partial_word() {
assert!(!has_internal_tag(" * @internalizer"));
}
#[test]
fn has_internal_tag_rejects_missing_at() {
assert!(!has_internal_tag(" * internal"));
}
#[test]
fn has_beta_tag_matches_bare_tag() {
assert!(has_beta_tag(" * @beta"));
}
#[test]
fn has_beta_tag_rejects_partial_word() {
assert!(!has_beta_tag(" * @betaware"));
}
#[test]
fn has_beta_tag_rejects_missing_at() {
assert!(!has_beta_tag(" * beta"));
}
#[test]
fn alpha_tag_standalone() {
assert!(has_alpha_tag("@alpha"));
}
#[test]
fn alpha_tag_with_text() {
assert!(has_alpha_tag("@alpha Some description"));
}
#[test]
fn alpha_tag_not_prefix() {
assert!(!has_alpha_tag("@alphabet"));
}
#[test]
fn has_alpha_tag_rejects_missing_at() {
assert!(!has_alpha_tag(" * alpha"));
}
fn scan(body: &str) -> Vec<ImportInfo> {
let mut imports = Vec::new();
scan_jsdoc_imports_in(body, &mut imports);
imports
}
#[test]
fn scan_jsdoc_single_import_with_member() {
let imports = scan(" * @param foo {import('./types').Foo}");
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].source, "./types");
assert_eq!(
imports[0].imported_name,
ImportedName::Named("Foo".to_string())
);
assert!(imports[0].is_type_only);
assert!(imports[0].local_name.is_empty());
}
#[test]
fn scan_jsdoc_double_quoted_path() {
let imports = scan(r#" * @type {import("./types").Foo}"#);
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].source, "./types");
}
#[test]
fn scan_jsdoc_multiple_imports_in_same_body() {
let imports = scan(" * @param a {import('./a').A} @param b {import('./b').B}");
assert_eq!(imports.len(), 2);
assert_eq!(imports[0].source, "./a");
assert_eq!(imports[1].source, "./b");
}
#[test]
fn scan_jsdoc_union_annotation_captures_both_members() {
let imports = scan(" * @type {import('./a').A | import('./b').B}");
assert_eq!(imports.len(), 2);
assert_eq!(
imports[0].imported_name,
ImportedName::Named("A".to_string())
);
assert_eq!(
imports[1].imported_name,
ImportedName::Named("B".to_string())
);
}
#[test]
fn scan_jsdoc_nested_member_uses_first_segment() {
let imports = scan(" * @type {import('./types').ns.Foo}");
assert_eq!(imports.len(), 1);
assert_eq!(
imports[0].imported_name,
ImportedName::Named("ns".to_string())
);
}
#[test]
fn scan_jsdoc_parent_relative_path() {
let imports = scan(" * @type {import('../lib/types.js').Foo}");
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].source, "../lib/types.js");
}
#[test]
fn scan_jsdoc_bare_package_specifier() {
let imports = scan(" * @type {import('@scope/pkg').Client}");
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].source, "@scope/pkg");
assert_eq!(
imports[0].imported_name,
ImportedName::Named("Client".to_string())
);
}
#[test]
fn scan_jsdoc_without_member_is_side_effect() {
let imports = scan(" * @type {import('./types')}");
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].source, "./types");
assert_eq!(imports[0].imported_name, ImportedName::SideEffect);
assert!(imports[0].is_type_only);
}
#[test]
fn scan_jsdoc_empty_path_is_skipped() {
let imports = scan(" * @type {import('').Foo}");
assert!(imports.is_empty());
}
#[test]
fn scan_jsdoc_truncated_no_closing_quote_does_not_panic() {
let imports = scan(" * @type {import('./truncated");
assert!(imports.is_empty());
}
#[test]
fn scan_jsdoc_missing_closing_paren_is_skipped() {
let imports = scan(" * @type {import('./types'.Foo}");
assert!(imports.is_empty());
}
#[test]
fn scan_jsdoc_whitespace_between_paren_and_dot() {
let imports = scan(" * @type {import('./types') .Foo}");
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].source, "./types");
assert_eq!(
imports[0].imported_name,
ImportedName::Named("Foo".to_string())
);
}
#[test]
fn scan_jsdoc_whitespace_between_paren_and_quote() {
let imports = scan(" * @type {import( './types').Foo}");
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].source, "./types");
}
#[test]
fn scan_jsdoc_non_quote_after_paren_skipped() {
let imports = scan(" * @type {import(foo).Bar}");
assert!(imports.is_empty());
}
#[test]
fn scan_jsdoc_ignores_prose_with_import_word() {
let imports = scan(" * This is an important note about imports.");
assert!(imports.is_empty());
}
#[test]
fn scan_jsdoc_utf8_path_works() {
let imports = scan(" * @type {import('./héllo').Foo}");
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].source, "./héllo");
}
#[test]
fn scan_jsdoc_empty_body_is_empty() {
assert!(scan("").is_empty());
}
#[test]
fn scan_jsdoc_no_import_in_body_is_empty() {
assert!(scan(" * @param foo The foo parameter").is_empty());
}
#[test]
fn scan_jsdoc_appends_to_existing_imports() {
let mut imports = vec![ImportInfo {
source: "existing".to_string(),
imported_name: ImportedName::Default,
local_name: "existing".to_string(),
is_type_only: false,
span: oxc_span::Span::default(),
source_span: oxc_span::Span::default(),
}];
scan_jsdoc_imports_in(" * {import('./new').Foo}", &mut imports);
assert_eq!(imports.len(), 2);
assert_eq!(imports[0].source, "existing");
assert_eq!(imports[1].source, "./new");
}
#[test]
fn scan_jsdoc_ident_boundary_stops_at_bracket() {
let imports = scan(" * @type {import('./t').Abc}");
assert_eq!(imports.len(), 1);
assert_eq!(
imports[0].imported_name,
ImportedName::Named("Abc".to_string())
);
}
#[test]
fn scan_jsdoc_empty_member_name_is_skipped() {
let imports = scan(" * @type {import('./x').}");
assert!(imports.is_empty());
}
}