diff --git a/Cargo.lock b/Cargo.lock index 261be8e..c567aa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1555,6 +1555,8 @@ name = "l3_fn_build" version = "0.0.1" dependencies = [ "anyhow", + "serde", + "serde_json", "swc", "swc_common", "swc_ecma_ast", diff --git a/Cargo.toml b/Cargo.toml index a9c5c50..c278387 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ members = [ [workspace.dependencies] anyhow = "1.0.92" +serde = { version = "1.0.213", features = ["derive"] } +serde_json = "1.0.132" swc = "4.0.0" swc_common = { version = "3.0.0", features = ["concurrent"] } swc_ecma_ast = "3.0.0" @@ -37,7 +39,7 @@ crossterm = "0.28.1" lazy_static = "1.5.0" notify = { version = "7.0.0", default-features = false, features = ["macos_fsevent"] } regex = "1.11.1" -serde_json = "1.0.132" +serde_json = { workspace = true } sha2 = "0.10.8" swc = { workspace = true } swc_common = { workspace = true } diff --git a/fn_build/Cargo.toml b/fn_build/Cargo.toml index 7e0716d..8e4a4aa 100644 --- a/fn_build/Cargo.toml +++ b/fn_build/Cargo.toml @@ -16,4 +16,6 @@ thiserror = "1.0.67" tokio = { workspace = true } [dev-dependencies] +serde = { workspace = true } +serde_json = { workspace = true } temp-dir = { workspace = true } diff --git a/fn_build/fixtures/swc/nodejs/js/http_route/build.json b/fn_build/fixtures/swc/nodejs/js/http_route/build.json new file mode 100644 index 0000000..ddcb65a --- /dev/null +++ b/fn_build/fixtures/swc/nodejs/js/http_route/build.json @@ -0,0 +1,3 @@ +{ + "path": "routes/data/lambda.js" +} diff --git a/fn_build/fixtures/swc/nodejs/js/http_route/expect_debug.json b/fn_build/fixtures/swc/nodejs/js/http_route/expect_debug.json new file mode 100644 index 0000000..a9874d3 --- /dev/null +++ b/fn_build/fixtures/swc/nodejs/js/http_route/expect_debug.json @@ -0,0 +1,6 @@ +[ + { + "path": "routes/data/lambda.js", + "result": "Identical" + } +] diff --git a/fn_build/fixtures/swc/nodejs/js/http_route/expect_release.json b/fn_build/fixtures/swc/nodejs/js/http_route/expect_release.json new file mode 100644 index 0000000..85dcbcf --- /dev/null +++ b/fn_build/fixtures/swc/nodejs/js/http_route/expect_release.json @@ -0,0 +1,8 @@ +[ + { + "path": "routes/data/lambda.js", + "result": { + "Content": "export const DELETE=()=>{console.log(\"delete\")};" + } + } +] diff --git a/fn_build/fixtures/swc/nodejs/js/http_route/routes/data/lambda.js b/fn_build/fixtures/swc/nodejs/js/http_route/routes/data/lambda.js new file mode 100644 index 0000000..5f18dde --- /dev/null +++ b/fn_build/fixtures/swc/nodejs/js/http_route/routes/data/lambda.js @@ -0,0 +1,3 @@ +export const DELETE = () => { + console.log('delete') +} diff --git a/fn_build/src/lib.rs b/fn_build/src/lib.rs index bb2c32c..6c35cdf 100644 --- a/fn_build/src/lib.rs +++ b/fn_build/src/lib.rs @@ -1,18 +1,44 @@ mod result; +mod spec; mod swc; #[cfg(test)] mod parse_test; -use crate::result::{FnBuildError, FnBuildResult, FnSource}; -use crate::swc::parse_js_fn; -use std::path::PathBuf; +#[cfg(test)] +mod testing; + +use crate::result::{FnBuild, FnBuildError, FnBuildResult, FnSource}; +use crate::spec::{FnBuildSpec, FnParseSpec}; +use crate::swc::{build_js_fn, parse_js_fn}; + +pub async fn build_fn(build_spec: FnBuildSpec) -> FnBuildResult { + debug_assert!(build_spec.entrypoint.is_relative()); + debug_assert!(build_spec.entrypoint.parent().is_some()); + debug_assert!(build_spec.output.is_absolute()); + debug_assert!(build_spec.output.is_dir()); + debug_assert!(build_spec.project_dir.is_absolute()); + debug_assert!(build_spec.project_dir.is_dir()); + match build_spec.entrypoint.extension() { + None => Err(FnBuildError::InvalidFileType), + Some(extension) => match extension.to_string_lossy().as_ref() { + "js" | "mjs" => build_js_fn(build_spec).await, + "py" => todo!(), + "ts" => todo!(), + &_ => Err(FnBuildError::InvalidFileType), + }, + } +} -pub async fn parse_fn(entrypoint: PathBuf) -> FnBuildResult { - match entrypoint.extension() { +pub async fn parse_fn(parse_spec: FnParseSpec) -> FnBuildResult { + debug_assert!(parse_spec.entrypoint.is_relative()); + debug_assert!(parse_spec.entrypoint.parent().is_some()); + debug_assert!(parse_spec.project_dir.is_absolute()); + debug_assert!(parse_spec.project_dir.is_dir()); + match parse_spec.entrypoint.extension() { None => Err(FnBuildError::InvalidFileType), Some(extension) => match extension.to_string_lossy().as_ref() { - "js" | "mjs" => parse_js_fn(entrypoint).await, + "js" | "mjs" => parse_js_fn(parse_spec).await, "py" => todo!(), "ts" => todo!(), &_ => Err(FnBuildError::InvalidFileType), diff --git a/fn_build/src/parse_test.rs b/fn_build/src/parse_test.rs index f7719cd..27742aa 100644 --- a/fn_build/src/parse_test.rs +++ b/fn_build/src/parse_test.rs @@ -1,5 +1,37 @@ -use crate::{parse_fn, FnBuildError}; +use crate::spec::{BuildMode, FnBuildSpec}; +use crate::{build_fn, parse_fn, FnBuildError}; use std::path::PathBuf; +use temp_dir::TempDir; + +#[tokio::test] +async fn build_fn_errors_for_invalid_extension() { + let build_dir = TempDir::new().unwrap(); + let build_spec = FnBuildSpec { + entrypoint: PathBuf::from("README.md"), + mode: BuildMode::Debug, + output: build_dir.path().to_path_buf(), + project_dir: PathBuf::from("fixtures/swc/nodejs/js/http_route"), + }; + match build_fn(build_spec).await { + Err(FnBuildError::InvalidFileType) => {} + _ => panic!(), + }; +} + +#[tokio::test] +async fn build_fn_errors_without_extension() { + let build_dir = TempDir::new().unwrap(); + let build_spec = FnBuildSpec { + entrypoint: PathBuf::from("README.md"), + mode: BuildMode::Debug, + output: build_dir.path().to_path_buf(), + project_dir: PathBuf::from("fixtures/swc/nodejs/js/http_route"), + }; + match build_fn(build_spec).await { + Err(FnBuildError::InvalidFileType) => {} + _ => panic!(), + }; +} #[tokio::test] async fn parse_fn_errors_for_invalid_extension() { diff --git a/fn_build/src/result.rs b/fn_build/src/result.rs index 87bcb38..11d84b3 100644 --- a/fn_build/src/result.rs +++ b/fn_build/src/result.rs @@ -1,3 +1,4 @@ +use crate::spec::FnBuildOutput; use std::path::PathBuf; #[derive(Clone)] @@ -19,6 +20,13 @@ pub struct FnSource { pub path: PathBuf, } +pub struct FnBuild { + #[allow(unused)] + pub entrypoint: FnSource, + #[allow(unused)] + pub output: FnBuildOutput, +} + #[derive(thiserror::Error, Debug)] pub enum FnBuildError { #[error("entrypoint file type is unsupported")] diff --git a/fn_build/src/spec.rs b/fn_build/src/spec.rs new file mode 100644 index 0000000..3c3965c --- /dev/null +++ b/fn_build/src/spec.rs @@ -0,0 +1,26 @@ +use std::path::PathBuf; + +#[derive(Clone, Eq, Hash, PartialEq)] +pub enum BuildMode { + Debug, + Release, +} + +pub type FnBuildOutput = PathBuf; + +// pub enum FnBuildOutput { +// Archive(PathBuf), +// Directory(PathBuf), +// } + +pub struct FnBuildSpec { + pub entrypoint: PathBuf, + pub mode: BuildMode, + pub output: FnBuildOutput, + pub project_dir: PathBuf, +} + +pub struct FnParseSpec { + pub entrypoint: PathBuf, + pub project_dir: PathBuf, +} diff --git a/fn_build/src/swc/compiler.rs b/fn_build/src/swc/compiler.rs index 90e1818..b29077f 100644 --- a/fn_build/src/swc/compiler.rs +++ b/fn_build/src/swc/compiler.rs @@ -53,6 +53,19 @@ impl SwcCompiler { } } + pub fn minify_js(self, path: &Path) -> CompileResult { + self.source_with_compiler(path, |compiler, handler, source_file| { + compiler + .minify( + source_file, + handler, + &Default::default(), + Default::default(), + ) + .map(|transform_output| transform_output.code) + }) + } + pub fn parse_es_module(self, path: &Path) -> CompileResult { self.source_with_compiler(path, |compiler, handler, source_file| { compiler @@ -71,7 +84,7 @@ impl SwcCompiler { }) } - pub fn source_with_compiler(self, p: &Path, f: F) -> CompileResult + fn source_with_compiler(self, p: &Path, f: F) -> CompileResult where F: FnOnce(&Compiler, &Handler, Arc) -> Result, { @@ -82,7 +95,7 @@ impl SwcCompiler { self.with_compiler(|compiler, handler| f(compiler, handler, source_file)) } - pub fn with_compiler(self, f: F) -> CompileResult + fn with_compiler(self, f: F) -> CompileResult where F: FnOnce(&Compiler, &Handler) -> Result, { diff --git a/fn_build/src/swc/mod.rs b/fn_build/src/swc/mod.rs index cb14731..9159a56 100644 --- a/fn_build/src/swc/mod.rs +++ b/fn_build/src/swc/mod.rs @@ -1,18 +1,50 @@ -use crate::result::{FnBuildError, FnSource, ModuleImport}; +use crate::result::{FnBuild, FnBuildError, FnSource, ModuleImport}; +use crate::spec::{BuildMode, FnBuildSpec, FnParseSpec}; use crate::swc::compiler::SwcCompiler; use crate::swc::visitors::ImportVisitor; -use std::path::PathBuf; +use std::fs; use swc_ecma_visit::FoldWith; mod compiler; mod visitors; +#[cfg(test)] +mod swc_test; + #[cfg(test)] mod visitors_test; -pub async fn parse_js_fn(path: PathBuf) -> Result { +pub async fn build_js_fn(build_spec: FnBuildSpec) -> Result { + let output_file = build_spec.output.join(&build_spec.entrypoint); + fs::create_dir_all(output_file.parent().unwrap()).expect("mkdir -p"); + match build_spec.mode { + BuildMode::Debug => { + fs::copy( + build_spec.project_dir.join(&build_spec.entrypoint), + output_file, + ) + .expect("cp"); + } + BuildMode::Release => { + let js_path = build_spec.project_dir.join(&build_spec.entrypoint); + let minified_js = SwcCompiler::new().minify_js(&js_path).unwrap(); + fs::write(output_file, minified_js).unwrap(); + } + } + Ok(FnBuild { + entrypoint: FnSource { + imports: Vec::new(), + path: build_spec.entrypoint, + }, + output: build_spec.output, + }) +} + +pub async fn parse_js_fn(parse_spec: FnParseSpec) -> Result { let compiler = SwcCompiler::new(); - let module = compiler.parse_es_module(&path).unwrap(); + let module = compiler + .parse_es_module(&parse_spec.project_dir.join(&parse_spec.entrypoint)) + .unwrap(); let mut visitor = ImportVisitor::new(); module.fold_with(&mut visitor); let imports = visitor diff --git a/fn_build/src/swc/swc_test.rs b/fn_build/src/swc/swc_test.rs new file mode 100644 index 0000000..ba6d6f9 --- /dev/null +++ b/fn_build/src/swc/swc_test.rs @@ -0,0 +1,22 @@ +use crate::spec::FnBuildSpec; +use crate::swc::build_js_fn; +use crate::testing::{run_fixtures, BuildProcess, BuildProcessResult}; +use std::path::PathBuf; +use std::sync::Arc; + +struct JavaScriptBuild {} + +impl BuildProcess for JavaScriptBuild { + fn build(&self, build_spec: FnBuildSpec) -> BuildProcessResult { + Box::pin(build_js_fn(build_spec)) + } +} + +#[tokio::test] +pub async fn test_nodejs_js_fixtures() { + run_fixtures( + Arc::new(Box::new(JavaScriptBuild {})), + PathBuf::from("fixtures/swc/nodejs/js"), + ) + .await; +} diff --git a/fn_build/src/testing.rs b/fn_build/src/testing.rs new file mode 100644 index 0000000..e91dd65 --- /dev/null +++ b/fn_build/src/testing.rs @@ -0,0 +1,170 @@ +use crate::result::{FnBuild, FnBuildError}; +use crate::spec::{BuildMode, FnBuildSpec}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::{env, fs}; +use temp_dir::TempDir; + +#[derive(Deserialize, Serialize)] +pub struct BuildFunction { + pub path: PathBuf, +} + +#[derive(Deserialize, Serialize)] +pub struct BuildFile { + pub path: PathBuf, + pub result: BuildResult, +} + +impl BuildFile { + fn read_json(fixture_dir: &Path, build_mode: &BuildMode) -> Option> { + let expect_filename = match build_mode { + BuildMode::Debug => "expect_debug.json", + BuildMode::Release => "expect_release.json", + }; + let expect_path = fixture_dir.join(expect_filename); + if expect_path.is_file() { + Some(serde_json::from_str(fs::read_to_string(expect_path).unwrap().as_str()).unwrap()) + } else { + None + } + } +} + +#[derive(Deserialize, Serialize)] +pub enum BuildResult { + Content(String), + Identical, +} + +pub type BuildProcessResult = Pin> + Send>>; + +pub trait BuildProcess { + fn build(&self, build_spec: FnBuildSpec) -> BuildProcessResult; +} + +fn collect_fixture_dirs(p: PathBuf) -> Vec { + debug_assert!(p.is_relative()); + let mut dirs = Vec::new(); + for dir_read_result in fs::read_dir(env::current_dir().unwrap().join(p)).unwrap() { + let dir_read = dir_read_result.unwrap(); + let dir_read_path = dir_read.path(); + if dir_read_path.is_dir() { + dirs.push(dir_read_path); + } + } + dirs +} + +pub async fn run_fixtures(build_process: Arc>, fixture_root_dir: PathBuf) { + for dir in collect_fixture_dirs(fixture_root_dir) { + let test_fixture = TestFixture::new(build_process.clone(), dir); + if let Err(err) = test_fixture.test().await { + panic!("{err}"); + } + } +} + +pub struct TestFixture { + build_process: Arc>, + fixture_dir: PathBuf, + leak_build_dir: bool, +} + +impl TestFixture { + pub fn new(build_process: Arc>, fixture_dir: PathBuf) -> Self { + debug_assert!(fixture_dir.is_absolute()); + debug_assert!(fixture_dir.is_dir()); + Self { + build_process, + fixture_dir, + leak_build_dir: false, + } + } + + pub async fn test(&self) -> Result<(), anyhow::Error> { + let temp_build_dir = TempDir::new()?; + let build_dir_root = temp_build_dir.path().to_path_buf(); + if self.leak_build_dir { + temp_build_dir.leak(); + println!( + "{} {}", + self.fixture_dir.to_string_lossy(), + build_dir_root.to_string_lossy() + ); + } + for (build_mode, build_files) in self.read_expected_build_files() { + let build_dir = build_dir_root.join(match build_mode { + BuildMode::Debug => "debug", + BuildMode::Release => "release", + }); + fs::create_dir(&build_dir)?; + self.build(build_dir.clone(), build_mode.clone()).await?; + self.expect(build_dir.clone(), build_files)?; + } + Ok(()) + } + + async fn build(&self, build_dir: PathBuf, build_mode: BuildMode) -> Result<(), anyhow::Error> { + let build_fn_json = fs::read_to_string(self.fixture_dir.join("build.json"))?; + let build_fn: BuildFunction = serde_json::from_str(build_fn_json.as_str())?; + self.build_process + .build(FnBuildSpec { + entrypoint: build_fn.path, + mode: build_mode, + output: build_dir, + project_dir: self.fixture_dir.clone(), + }) + .await?; + Ok(()) + } + + fn expect(&self, build_dir: PathBuf, build_files: Vec) -> Result<(), anyhow::Error> { + for build_file in build_files { + let built_file_path = build_dir.join(&build_file.path); + let built_content = fs::read_to_string(&built_file_path).expect( + format!( + "failed reading build output file {}", + built_file_path.to_string_lossy() + ) + .as_str(), + ); + let expected_content = match build_file.result { + BuildResult::Content(expected_content) => expected_content, + BuildResult::Identical => { + let original_file_path = self.fixture_dir.join(&build_file.path); + fs::read_to_string(&original_file_path).expect( + format!( + "failed reading original file {}", + original_file_path.to_string_lossy() + ) + .as_str(), + ) + } + }; + assert_eq!( + expected_content, + built_content, + "{} did not match expected content in build dir {}", + build_file.path.to_string_lossy(), + build_dir.to_string_lossy(), + ); + } + Ok(()) + } + + fn read_expected_build_files(&self) -> HashMap> { + let mut result = HashMap::new(); + for build_mode in &[BuildMode::Debug, BuildMode::Release] { + if let Some(build_files) = BuildFile::read_json(&self.fixture_dir, &build_mode) { + result.insert(build_mode.clone(), build_files); + } + } + debug_assert!(!result.is_empty()); + result + } +}