use std::path::Path;
use oxc_allocator::Allocator;
use oxc_ast::ast::{Program, Statement};
use oxc_codegen::Codegen;
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
use oxc_transformer::{TransformOptions, Transformer};
pub const DECLARATIONS: &str = include_str!("../assets/apiplant.d.ts");
pub fn to_js(label: &str, source: &str) -> Result<String, String> {
let allocator = Allocator::default();
let name = format!("{label}.ts");
let path = Path::new(&name);
let source_type = SourceType::from_path(path).map_err(|e| e.to_string())?;
let parsed = Parser::new(&allocator, source, source_type).parse();
if let Some(errors) = report(parsed.diagnostics.iter()) {
return Err(errors);
}
let mut program = parsed.program;
reject_imports(source, &program)?;
let scoping = SemanticBuilder::new()
.build(&program)
.semantic
.into_scoping();
let transformed = Transformer::new(&allocator, path, &TransformOptions::default())
.build_with_scoping(scoping, &mut program);
if let Some(errors) = report(transformed.diagnostics.iter()) {
return Err(errors);
}
Ok(Codegen::new().build(&program).code)
}
fn report<'a, D: std::fmt::Display + 'a>(
diagnostics: impl Iterator<Item = &'a D>,
) -> Option<String> {
let message = diagnostics
.map(|d| d.to_string())
.collect::<Vec<_>>()
.join("\n");
(!message.is_empty()).then_some(message)
}
pub fn check_bundle(code: &str) -> Result<(), String> {
let allocator = Allocator::default();
let source_type = SourceType::mjs();
let parsed = Parser::new(&allocator, code, source_type).parse();
if let Some(errors) = report(parsed.diagnostics.iter()) {
return Err(errors);
}
reject_imports(code, &parsed.program)?;
let exports_something = parsed.program.body.iter().any(|statement| {
matches!(
statement,
Statement::ExportDefaultDeclaration(_)
| Statement::ExportNamedDeclaration(_)
| Statement::ExportAllDeclaration(_)
)
});
if !exports_something {
return Err(
"the bundle exports nothing. The isolate loads an ES module, so \
bundle with `--format=esm`; CommonJS output (`module.exports`) \
declares no exports and cannot be loaded."
.to_string(),
);
}
Ok(())
}
fn reject_imports(source: &str, program: &Program<'_>) -> Result<(), String> {
for statement in &program.body {
let (specifier, span) = match statement {
Statement::ImportDeclaration(declaration) => {
(declaration.source.value.as_str(), declaration.span)
}
Statement::ExportNamedDeclaration(declaration) => match &declaration.source {
Some(source) => (source.value.as_str(), declaration.span),
None => continue,
},
Statement::ExportAllDeclaration(declaration) => {
(declaration.source.value.as_str(), declaration.span)
}
_ => continue,
};
if specifier == crate::module::NAME {
continue;
}
return Err(format!(
"line {}: cannot import `{specifier}`.\n\
apiplant does not bundle TypeScript functions, so a function is one \
self-contained file and `apiplant` is the only module it can import \
— the host, the database, the cache and the mailer all come from \
there.",
line_of(source, span.start)
));
}
Ok(())
}
fn line_of(source: &str, offset: u32) -> usize {
source[..offset as usize]
.bytes()
.filter(|b| *b == b'\n')
.count()
+ 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn types_are_stripped_and_the_code_survives() {
let js = to_js(
"greet",
r#"
interface In { name: string }
export const manifest = [{ name: "greet", permission: "public" }];
export function greet(input: In, ctx: unknown): { hi: string } {
return { hi: input.name };
}
"#,
)
.unwrap();
assert!(js.contains("export function greet(input, ctx)"), "{js}");
assert!(!js.contains("interface"), "{js}");
}
#[test]
fn a_syntax_error_is_reported_rather_than_emitted() {
let err = to_js("broken", "export function oops( {").unwrap_err();
assert!(!err.is_empty());
}
#[test]
fn the_apiplant_module_is_importable() {
let js = to_js(
"greet",
"import { defineFunctions, db, s } from \"apiplant\";\n\
export default defineFunctions({});\n",
)
.unwrap();
assert!(js.contains("from \"apiplant\""), "{js}");
}
#[test]
fn every_other_import_is_refused_with_a_reason() {
let relative = to_js("greet", "\nimport { x } from \"./other.ts\";\n").unwrap_err();
assert!(relative.contains("does not bundle"), "{relative}");
assert!(relative.contains("line 2"), "{relative}");
let package = to_js("greet", "import zod from \"zod\";\n").unwrap_err();
assert!(package.contains("cannot import `zod`"), "{package}");
let reexport = to_js("greet", "export * from \"./other.ts\";\n").unwrap_err();
assert!(reexport.contains("cannot import"), "{reexport}");
}
#[test]
fn imports_are_recognised_by_shape_not_by_spelling() {
let js = to_js(
"greet",
"import {\n db,\n} from \"apiplant\";\n\
export const manifest = [{ name: \"g\" }];\n\
export function g() { return db.query(\"SELECT 1\"); }\n",
)
.unwrap();
assert!(js.contains("from \"apiplant\""), "{js}");
let err = to_js("greet", "import {\n x,\n} from \"./other.ts\";\n").unwrap_err();
assert!(err.contains("line 1"), "{err}");
}
#[test]
fn a_bundle_must_be_esm_with_apiplant_left_external() {
let good = "import { db } from \"apiplant\";\n\
var slugify = (s) => s.toLowerCase();\n\
export default { manifest: [], handlers: {} };\n";
assert!(check_bundle(good).is_ok());
let unbundled = "import slugify from \"slugify\";\nexport default {};\n";
let err = check_bundle(unbundled).unwrap_err();
assert!(err.contains("cannot import `slugify`"), "{err}");
let cjs = "const { db } = require(\"apiplant\");\nmodule.exports = {};\n";
let err = check_bundle(cjs).unwrap_err();
assert!(err.contains("--format=esm"), "{err}");
}
#[test]
fn the_word_import_inside_code_is_not_an_import() {
let js = to_js(
"greet",
"export const manifest = [{ name: \"g\" }];\nexport const note = \"import me\";\n",
)
.unwrap();
assert!(js.contains("import me"));
}
}