use std::collections::HashSet; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use oxc::allocator::Allocator; use oxc::ast::ast::{Declaration, IdentifierReference, ImportOrExportKind, Statement}; use oxc::ast::builder::AstBuilder; use oxc::ast_visit::Visit; use oxc::codegen::{Codegen, CodegenOptions}; use oxc::diagnostics::Diagnostics; use oxc::isolated_declarations::{IsolatedDeclarations, IsolatedDeclarationsOptions}; use oxc::parser::Parser; use oxc::semantic::SemanticBuilder; use oxc::span::{SPAN, SourceType}; use oxc::transformer::{TransformOptions, Transformer}; /// Compile one TypeScript source from a Cargo build script. /// /// Paths are relative to the extension crate. Outputs mirror the source path /// relative to `src/` in `OUT_DIR`: `src/main.ts` produces `main.js` and, when a /// public module is named, `main.d.ts`, while `src/public/posix.ts` produces /// `public/posix.js` and `public/posix.d.ts`. Imports are preserved, not resolved /// or bundled. Public declarations support self-contained exported functions, /// variables, interfaces, and type aliases, plus the two shapes a facade needs /// beyond that: private interfaces and type aliases referenced by exported /// signatures (kept in the ambient module, not exported), and type-only imports /// and re-exports naming a public `avril:` module (preserved verbatim). /// /// This does not type-check the source; validate erasable TypeScript separately. pub fn build(source: impl AsRef, public_module: Option<&str>) -> Result<()> { let path = source.as_ref(); println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed={}", path.display()); let output = output_path( &PathBuf::from(std::env::var_os("OUT_DIR").context("Missing OUT_DIR")?), path, )?; let source = std::fs::read_to_string(path).with_context(|| format!("Reading {}", path.display()))?; let compiled = compile(path, &source, public_module) .with_context(|| format!("Compiling {}", path.display()))?; if let Some(parent) = output.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(output.with_extension("js"), compiled.javascript)?; let declaration_path = output.with_extension("d.ts"); if let Some(declaration) = compiled.declaration { std::fs::write(declaration_path, declaration)?; } else { // OUT_DIR persists when a formerly public entry becomes internal. match std::fs::remove_file(declaration_path) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error.into()), } } Ok(()) } /// A facade may only name another public module; anything else would leak a /// private module graph into the emitted ambient declaration. fn is_public_module(source: &str) -> bool { source.starts_with("avril:") } /// Outputs mirror the source path relative to `src/`, so one crate can own /// several public modules without colliding. fn output_path(out_dir: &Path, source: &Path) -> Result { let relative = source .strip_prefix("src") .context("Source must live under src/")?; Ok(out_dir.join(relative)) } /// Identifiers a declaration mentions. Used to keep only the private types the /// public surface actually reaches into the emitted ambient module. #[derive(Default)] struct References<'a>(HashSet<&'a str>); impl<'a> Visit<'a> for References<'a> { fn visit_identifier_reference(&mut self, reference: &IdentifierReference<'a>) { self.0.insert(reference.name.as_str()); } } fn references<'a>(statement: &Statement<'a>) -> HashSet<&'a str> { let mut references = References::default(); references.visit_statement(statement); references.0 } fn private_name<'a>(statement: &Statement<'a>) -> Option<&'a str> { match statement { Statement::TSInterfaceDeclaration(interface) => Some(interface.id.name.as_str()), Statement::TSTypeAliasDeclaration(alias) => Some(alias.id.name.as_str()), _ => None, } } fn imported_name<'a>(specifier: &'a oxc::ast::ast::ImportDeclarationSpecifier<'a>) -> &'a str { use oxc::ast::ast::ImportDeclarationSpecifier::{ ImportDefaultSpecifier, ImportNamespaceSpecifier, ImportSpecifier, }; match specifier { ImportSpecifier(specifier) => specifier.local.name.as_str(), ImportDefaultSpecifier(specifier) => specifier.local.name.as_str(), ImportNamespaceSpecifier(specifier) => specifier.local.name.as_str(), } } struct Compiled { javascript: String, declaration: Option, } fn diagnostics(diagnostics: Diagnostics, source: &str) -> Result<()> { if !diagnostics.is_empty() { bail!( "{}", diagnostics .into_iter() .map(|diagnostic| diagnostic.render_with_source_code(source)) .collect::>() .join("\n") ); } Ok(()) } fn compile(path: &Path, source: &str, public_module: Option<&str>) -> Result { let allocator = Allocator::default(); let parsed = Parser::new(&allocator, source, SourceType::ts()).parse(); diagnostics(parsed.diagnostics, source)?; let mut program = parsed.program; let declaration = if let Some(module) = public_module { let mut declaration = IsolatedDeclarations::new(&allocator, IsolatedDeclarationsOptions::default()) .build(&program); diagnostics(declaration.diagnostics, source)?; // A public entry must carry its own types; private module graphs are // not bundled. Strip `declare` from the exports the surface is written // with, and the empty `export {}` marker oxc may emit; it is re-added // canonically below only when a private type survives. let mut index = 0; while index < declaration.program.body.len() { let strip = match &mut declaration.program.body[index] { Statement::ExportDeclaration(export) => { match &mut export.declaration { Declaration::FunctionDeclaration(function) => function.declare = false, Declaration::VariableDeclaration(variable) => variable.declare = false, Declaration::TSInterfaceDeclaration(interface) => interface.declare = false, Declaration::TSTypeAliasDeclaration(alias) => alias.declare = false, _ => bail!( "Unsupported public declaration for {module:?}: expected an exported function, variable, interface, or type alias" ), }; false } Statement::TSInterfaceDeclaration(interface) => { interface.declare = false; false } Statement::TSTypeAliasDeclaration(alias) => { alias.declare = false; false } Statement::ImportDeclaration(_) | Statement::ExportFromDeclaration(_) => false, Statement::ExportNamedDeclaration(export) => { if !export.specifiers.is_empty() { bail!( "Unsupported public declaration for {module:?}: expected an exported function, variable, interface, or type alias" ); } true } Statement::ExportDefaultDeclaration(_) => bail!( "Unsupported public declaration for {module:?}: a public surface exports named declarations" ), // An ambient module can only describe types. Non-exported // values and classes describe nothing a guest can reach, so // they never belong in it. _ => true, }; if strip { declaration.program.body.remove(index); } else { index += 1; } } // The ambient module carries the surface, not the implementation: drop // private declarations the exported ones never reach, then the imports // nothing retained names any more. let mut reachable: HashSet<&str> = declaration .program .body .iter() .filter(|statement| matches!(statement, Statement::ExportDeclaration(_))) .flat_map(references) .collect(); let mut privates: Vec<(&str, HashSet<&str>)> = declaration .program .body .iter() .filter_map(|statement| { private_name(statement).map(|name| (name, references(statement))) }) .collect(); loop { let mut grew = false; for (name, refs) in &privates { if reachable.contains(name) { for reference in refs { grew |= reachable.insert(reference); } } } if !grew { break; } } declaration.program.body.retain(|statement| { private_name(statement).is_none_or(|name| reachable.contains(name)) }); let used: HashSet<&str> = declaration .program .body .iter() .flat_map(references) .collect(); declaration .program .body .retain(|statement| match statement { Statement::ImportDeclaration(import) => import .specifiers .iter() .flatten() .any(|specifier| used.contains(imported_name(specifier))), _ => true, }); privates.clear(); // What remains must still be expressible by guests: type-only // references that name a public module, never a private one. for statement in &declaration.program.body { let (source, kind) = match statement { Statement::ImportDeclaration(import) => { (import.source.value.as_str(), import.import_kind) } Statement::ExportFromDeclaration(export) => { (export.source.value.as_str(), export.export_kind) } _ => continue, }; if kind != ImportOrExportKind::Type || !is_public_module(source) { bail!( "Unsupported public declaration for {module:?}: reference to {source:?} must be type-only and name a public `avril:` module" ); } } let kept_private = declaration .program .body .iter() .any(|statement| private_name(statement).is_some()); if kept_private { let ast_builder = AstBuilder::new(&allocator); declaration .program .body .push(Statement::new_export_named_declaration( SPAN, [], ImportOrExportKind::Value, &ast_builder, )); } let public = Codegen::new() .with_options(CodegenOptions { initial_indent: 1, ..Default::default() }) .build(&declaration.program) .code; let name = serde_json::to_string(module)?; Some(format!( "// Generated from {path:?}.\ndeclare module {name} {{\n{public}}}\n" )) } else { None }; let semantic = SemanticBuilder::new_compiler().build(&program); diagnostics(semantic.diagnostics, source)?; // Keep native JS syntax and runtime imports; only explicit type imports are erased. let mut options = TransformOptions::default(); options.typescript.only_remove_type_imports = true; let transformed = Transformer::new(&allocator, path, &options) .build_with_scoping(semantic.semantic.into_scoping(), &mut program); diagnostics(transformed.diagnostics, source)?; Ok(Compiled { javascript: Codegen::new().build(&program).code, declaration, }) } pub mod testing; #[cfg(test)] mod tests;