#![warn(missing_docs)]
mod array_comments;
mod cache;
mod collect;
mod commands;
mod comments;
mod components;
mod config;
mod custom_elements;
mod declaration;
mod error;
mod finder;
mod jsdoc;
mod marko;
mod mdx;
mod members;
mod model;
mod semantic;
mod sfc;
mod sharing;
mod signature_refs;
mod style;
mod template;
mod tsrx;
use skif_core::HashSet;
use std::cell::RefCell;
use std::path::Path;
use oxc_allocator::Allocator;
use oxc_ast_visit::Visit;
use oxc_parser::{ParseOptions as OxcParseOptions, Parser};
use oxc_span::SourceType;
use skif_core::LineIndex;
pub use array_comments::{
ConfigComments, ElementComment, PathSegment, PropertyComment, extract_array_comments, extract_config_comments,
};
pub use cache::{
CACHE_SCHEMA, CACHE_SCHEMA_DIGEST, CachedModule, MemoryModuleCache, ModuleCache, ModuleKey, ParseCoalescer,
cache_version, parse_cached,
};
pub use components::{
RegisteredComponent, camelize, capitalize, is_native_tag, registered_components, registered_name, resolved_names,
};
pub use config::{
ConfigValue, ExtractedConfig, FILE_DIR, ImportedCall, ModuleLoader, OWN_SPREAD, TEMPLATE_SUBSTITUTION,
UNKNOWN_SPREAD, command_in_call_dir, command_line_call, extract_command_calls, extract_config,
extract_config_object, extract_config_object_with, extract_constructor_options, extract_imported_calls,
extract_method_calls, extract_property_calls, extract_property_values, file_call_options, literal_command,
place_file_dir,
};
pub use declaration::{DECLARATION_SUFFIXES, is_declaration_file};
pub use error::SyntaxError;
pub use model::{
Export, ExportKind, Import, ImportKind, ImportedKind, ImportedName, Member, MockCall, ModuleInfo, ParseIssue,
ParseOptions, Registration, Removal, RemovalKind, coalesce_ranges, is_source_query,
};
pub use style::{is_stylesheet, parse_stylesheet};
pub fn parse_module(path: &Path, source: &str, options: &ParseOptions) -> Result<ModuleInfo, SyntaxError> {
let lines = LineIndex::new(source);
let extension = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match extension.as_str() {
"vue" | "svelte" | "astro" => parse_component(&extension, source, &lines, *options),
"mdx" => Ok(parse_mdx(source, &lines, *options)),
"tsrx" => Ok(parse_tsrx(source, &lines, *options)),
"marko" => Ok(parse_marko(source, &lines, *options)),
_ => {
let source_type = source_type_for(path).ok_or_else(|| unsupported(&extension))?;
Ok(parse_block(source, 0, source_type, &lines, *options, None))
}
}
}
fn source_type_for(path: &Path) -> Option<SourceType> {
let source_type = SourceType::from_path(path).ok()?;
Some(if source_type.is_javascript() {
source_type.with_jsx(true)
} else {
source_type
})
}
fn unsupported(extension: &str) -> SyntaxError {
SyntaxError::Unsupported {
extension: extension.to_string(),
}
}
const RETAINED_ARENA_BYTES: usize = 16 * 1024 * 1024;
thread_local! {
static ARENA: RefCell<Allocator> = RefCell::new(Allocator::default());
}
pub(crate) fn with_arena<R>(f: impl FnOnce(&Allocator) -> R) -> R {
ARENA.with(|arena| {
let Ok(mut allocator) = arena.try_borrow_mut() else {
return f(&Allocator::default());
};
allocator.reset();
let result = f(&allocator);
if allocator.capacity() > RETAINED_ARENA_BYTES {
*allocator = Allocator::default();
}
result
})
}
fn parse_block(
src: &str,
base: u32,
source_type: SourceType,
lines: &LineIndex,
options: ParseOptions,
uses: Option<&mut members::Uses>,
) -> ModuleInfo {
with_arena(|allocator| parse_block_in(allocator, src, base, source_type, lines, options, uses))
}
fn parse_block_in(
allocator: &Allocator,
src: &str,
base: u32,
source_type: SourceType,
lines: &LineIndex,
options: ParseOptions,
uses: Option<&mut members::Uses>,
) -> ModuleInfo {
let parser_options = OxcParseOptions {
preserve_parens: false,
allow_return_outside_function: true,
..OxcParseOptions::default()
};
let ret = Parser::new(allocator, src, source_type)
.with_options(parser_options)
.parse();
let parse_diagnostics: Vec<ParseIssue> = ret
.diagnostics
.errors()
.map(|diagnostic| {
let (offset, len) = diagnostic
.labels
.as_slice()
.first()
.map_or((0, 0), |l| (l.offset(), l.len()));
ParseIssue {
message: diagnostic.message.to_string(),
span: lines.span(
base.saturating_add(offset),
base.saturating_add(offset).saturating_add(len),
),
}
})
.collect();
if ret.fatal_error {
return ModuleInfo {
parse_diagnostics,
fatal: true,
..ModuleInfo::default()
};
}
let ctx = collect::Ctx { src, base, lines };
let mut info = ModuleInfo {
parse_diagnostics,
..ModuleInfo::default()
};
let collected = collect::collect(&ctx, &ret.program, &mut info);
let mut finder = finder::Finder::new(&ctx, &mut info, &ret.program);
finder.visit_program(&ret.program);
let cjs_shorthand = finder.into_cjs_shorthand();
info.imports.sort_by_key(|i| i.span.start);
let analysis = semantic::Analysis::new(&ret.program);
if options.member_accesses {
let excluded: HashSet<(u32, u32)> = collected.reexport_spans.iter().copied().collect();
members::track(&ret.program, &analysis, &mut info, &excluded, base);
if let Some(uses) = uses {
*uses = members::uses(&ret.program, &analysis);
}
}
if options.in_file_references {
mark_in_file_references(
&ret.program,
&analysis,
&collected.export_clause_spans,
&cjs_shorthand,
&mut info,
);
signature_refs::mark(&ret.program, &mut info);
members::mark_local_reads(&ret.program, &analysis, &mut info);
}
info.directives = ret
.program
.directives
.iter()
.map(|d| d.directive.as_str().to_string())
.collect();
custom_elements::mark(&ret.program, &analysis, &mut info);
comments::collect(&ctx, &ret.program, &mut info);
info.commands = commands::child_process_commands(&ret.program, &info.imports);
if src.contains("resolveComponent") {
info.template_tags = components::resolved_components(&ret.program);
info.template_tags.sort();
info.template_tags.dedup();
}
info
}
fn parse_component(
extension: &str,
source: &str,
lines: &LineIndex,
options: ParseOptions,
) -> Result<ModuleInfo, SyntaxError> {
let blocks = sfc::blocks(source, extension == "astro");
let mut parts = Vec::new();
let mut all_uses = Vec::new();
for block in blocks.scripts.iter().filter(|block| block.parse) {
let name = match (block.typescript, block.jsx) {
(true, true) => "block.tsx",
(true, false) => "block.ts",
(false, true) => "block.jsx",
(false, false) => "block.js",
};
let source_type = source_type_for(Path::new(name)).ok_or_else(|| unsupported(extension))?;
let base = u32::try_from(block.start).unwrap_or(u32::MAX);
let mut uses = members::Uses::default();
let mut part = parse_block(
&source[block.start..block.end],
base,
source_type,
lines,
options,
Some(&mut uses),
);
if extension == "svelte" && block.context == sfc::ScriptContext::Instance {
part.exports.clear();
}
parts.push(part);
all_uses.push(uses);
}
if options.member_accesses {
for (index, part) in parts.iter_mut().enumerate() {
let mut others = members::Uses::default();
for (other, uses) in all_uses.iter().enumerate() {
if other != index {
members::merge_uses(&mut others, uses);
}
}
members::credit(part, &others);
}
}
let mut info = ModuleInfo::default();
for part in parts {
info.merge(part);
}
add_style_and_source_imports(&mut info, &blocks, source, lines);
if options.member_accesses {
let flavor = match extension {
"vue" => template::Flavor::Vue,
"svelte" => template::Flavor::Svelte,
_ => template::Flavor::Astro,
};
let mut uses = members::Uses::default();
let mut tags = std::collections::BTreeSet::new();
for (start, end) in blocks.markup(source) {
tags.extend(template::scan(&source[start..end], flavor, &mut uses));
}
members::credit(&mut info, &uses);
if flavor == template::Flavor::Vue {
tags.extend(std::mem::take(&mut info.template_tags));
info.template_tags = tags.into_iter().collect();
}
}
Ok(info)
}
fn parse_mdx(source: &str, lines: &LineIndex, options: ParseOptions) -> ModuleInfo {
let extracted = mdx::extract(source);
let source_type = SourceType::jsx();
let mut info = parse_block(&extracted.esm, 0, source_type, lines, options, None);
if options.member_accesses {
let mut markup = members::Uses::default();
for (start, end) in extracted.markup {
let text = mdx::blank_inline_code(&source[start..end]);
template::scan(&text, template::Flavor::Astro, &mut markup);
}
members::credit(&mut info, &markup);
}
info
}
fn parse_tsrx(source: &str, lines: &LineIndex, options: ParseOptions) -> ModuleInfo {
let mut info = parse_block(&tsrx::extract(source), 0, SourceType::tsx(), lines, options, None);
for name in info.imports.iter_mut().flat_map(|import| &mut import.names) {
name.escapes = true;
}
info
}
fn parse_marko(source: &str, lines: &LineIndex, options: ParseOptions) -> ModuleInfo {
let blocks = sfc::Blocks {
styles: marko::styles(source),
..sfc::Blocks::default()
};
let mut code = source.as_bytes().to_vec();
for style in &blocks.styles {
for byte in &mut code[style.start..style.end] {
if !matches!(*byte, b'\n' | b'\r') {
*byte = b' ';
}
}
}
let code = String::from_utf8(code).unwrap_or_default();
let mut info = parse_tsrx(&code, lines, options);
info.imports.retain(|import| !import.specifier.starts_with('<'));
add_style_and_source_imports(&mut info, &blocks, source, lines);
info
}
fn add_style_and_source_imports(info: &mut ModuleInfo, blocks: &sfc::Blocks, source: &str, lines: &LineIndex) {
for style in &blocks.styles {
let path = format!("block.{}", style.extension);
let base = u32::try_from(style.start).unwrap_or(u32::MAX);
for mut import in parse_stylesheet(Path::new(&path), &source[style.start..style.end]).imports {
import.span = lines.span(
base.saturating_add(import.span.start),
base.saturating_add(import.span.end),
);
info.imports.push(import);
}
}
for reference in &blocks.sources {
info.imports.push(Import {
specifier: reference.specifier.clone(),
kind: ImportKind::SideEffect,
names: Vec::new(),
call: None,
span: lines.span(
u32::try_from(reference.start).unwrap_or(u32::MAX),
u32::try_from(reference.end).unwrap_or(u32::MAX),
),
});
}
info.imports.sort_by_key(|import| import.span.start);
}
fn mark_in_file_references(
program: &oxc_ast::ast::Program<'_>,
analysis: &semantic::Analysis<'_>,
clause_spans: &[oxc_span::Span],
cjs_shorthand: &[(String, oxc_span::Span)],
info: &mut ModuleInfo,
) {
let cjs_names: HashSet<&str> = cjs_shorthand.iter().map(|(name, _)| name.as_str()).collect();
let binding = |export: &Export| -> Option<String> {
match export.kind {
ExportKind::Default => None,
ExportKind::ReExport | ExportKind::StarReExport => export.local_name.clone(),
_ if export.local_name.is_none() && export.from.is_none() => {
cjs_names.contains(export.name.as_str()).then(|| export.name.clone())
}
_ => export.local_name.clone(),
}
};
let bindings: Vec<Option<String>> = info.exports.iter().map(binding).collect();
let wanted: HashSet<&str> = bindings.iter().flatten().map(String::as_str).collect();
let excluded: Vec<oxc_span::Span> = clause_spans
.iter()
.copied()
.chain(cjs_shorthand.iter().map(|(_, span)| *span))
.collect();
let referenced = semantic::referenced_locals(program, analysis, &wanted, &excluded);
for (export, local) in info.exports.iter_mut().zip(&bindings) {
let used = local.as_deref().is_some_and(|l| referenced.contains(l));
export.referenced_in_file = used;
if used {
info.in_file_references.push(export.name.clone());
}
}
info.in_file_references.sort();
info.in_file_references.dedup();
}
pub(crate) fn js_string(value: oxc_str::JSStr<'_>) -> String {
value
.as_str()
.map_or_else(|| value.display().to_string(), str::to_owned)
}
#[cfg(test)]
mod tests {
use super::{ParseOptions, Path, parse_module, with_arena};
#[test]
fn parses_in_a_reused_or_nested_arena_give_the_same_facts() {
let source =
"import { a } from 'a';\nimport * as ns from 'ns';\nexport const b = a.c + ns.d;\nexport { b as e };\n";
let parse = || parse_module(Path::new("x.ts"), source, &ParseOptions::default()).ok();
let first = parse();
let nested = with_arena(|outer| {
let held = outer.alloc_str("held");
let inner = parse();
assert_eq!(held, "held");
inner
});
let again = parse();
assert!(first.as_ref().is_some_and(|info| info.exports.len() == 2));
assert_eq!(nested, first);
assert_eq!(again, first);
}
}