diff --git a/cargo-eqts/src/main.rs b/cargo-eqts/src/main.rs index 51de4e7..a4860ae 100644 --- a/cargo-eqts/src/main.rs +++ b/cargo-eqts/src/main.rs @@ -985,28 +985,44 @@ fn load_metadata(library_path: &Path) -> Result> { } fn parse_metadata(bytes: &[u8]) -> Result> { - let mut document: MetadataDocument = + let raw: serde_json::Value = serde_json::from_slice(bytes).context("eqts metadata is invalid JSON")?; + if raw + .get("schema_version") + .and_then(serde_json::Value::as_u64) + == Some(3) + { + let reactive_capabilities = raw + .get("capabilities") + .and_then(serde_json::Value::as_object) + .is_some_and(|capabilities| { + capabilities + .iter() + .any(|(name, value)| name != "owned_values" && value.as_bool() == Some(true)) + }); + let missing_kind = raw + .get("functions") + .and_then(serde_json::Value::as_array) + .is_some_and(|functions| { + !functions.is_empty() + && functions + .iter() + .any(|function| function.get("kind").is_none()) + }); + if reactive_capabilities && missing_kind { + bail!( + "eqts metadata schema version 3 declares reactive capabilities without per-export descriptors; cannot distinguish ordinary u64 values from reactive handles or generate parity-safe loaders" + ); + } + } + let mut document: MetadataDocument = + serde_json::from_value(raw).context("eqts metadata is invalid JSON")?; if !matches!(document.schema_version, 1..=3) { bail!( "unsupported eqts metadata schema version {}; expected 1, 2, or 3", document.schema_version ); } - if document.schema_version == 3 - && document - .functions - .iter() - .all(|function| matches!(function.kind, ExportKind::Function)) - && document - .capabilities - .iter() - .any(|(name, enabled)| *enabled && name != "owned_values") - { - bail!( - "eqts metadata schema version 3 declares reactive capabilities without per-export descriptors; cannot distinguish ordinary u64 values from reactive handles or generate parity-safe loaders" - ); - } if document.schema_version < 3 { validate_capabilities(document.capabilities)?; } @@ -1248,6 +1264,7 @@ fn validate_identifier(value: &str) -> Result<()> { | "import" | "in" | "instanceof" + | "let" | "new" | "null" | "return" @@ -2094,6 +2111,7 @@ fn deno_type(scalar: Scalar) -> &'static str { #[cfg(test)] mod tests { use super::*; + use clap::Parser; fn add_function() -> Function { Function { @@ -2181,7 +2199,7 @@ mod tests { #[test] fn invalid_identifiers_are_rejected() { - for identifier in ["", "two words", "9lives", "default"] { + for identifier in ["", "two words", "9lives", "default", "let"] { assert!(validate_identifier(identifier).is_err(), "{identifier:?}"); } } @@ -2211,6 +2229,9 @@ mod tests { #[test] fn individual_compiled_targets_are_selected() { + assert_eq!(selected_targets(Target::NodeKoffi), [Target::NodeKoffi]); + assert_eq!(selected_targets(Target::Bun), [Target::Bun]); + assert_eq!(selected_targets(Target::Deno), [Target::Deno]); assert_eq!(selected_targets(Target::NodeNapi), [Target::NodeNapi]); assert_eq!(selected_targets(Target::Wasm), [Target::Wasm]); assert_eq!(selected_targets(Target::WasmBrowser), [Target::WasmBrowser]); @@ -2311,6 +2332,112 @@ mod tests { assert!(error.to_string().contains("ordinary u64 values")); } + #[test] + fn schema_v3_explicit_function_exports_parse_with_default_capabilities() { + let functions = parse_metadata( + br#"{"schema_version":3,"capabilities":{"owned_values":true,"objects":true,"async_functions":true,"callbacks":true,"traits":true,"streams":true,"iterators":true},"functions":[{"module":"example","name":"add","symbol":"eqts_add","abi":"scalar","kind":{"kind":"function"},"parameters":[{"name":"left","ty":{"kind":"scalar","scalar":"u32"}},{"name":"right","ty":{"kind":"scalar","scalar":"u32"}}],"result":{"kind":"scalar","scalar":"u32"}}],"method_sets":[]}"#, + ) + .expect("ordinary schema v3 functions must parse when kind is explicit"); + assert_eq!(functions.len(), 1); + assert_eq!(functions[0].name, "add"); + assert!(matches!(functions[0].kind, ExportKind::Function)); + } + + #[test] + fn schema_v3_empty_inventory_parses_with_default_capabilities() { + parse_metadata( + br#"{"schema_version":3,"capabilities":{"owned_values":true,"objects":true,"async_functions":true,"callbacks":true,"traits":true,"streams":true,"iterators":true},"functions":[],"method_sets":[]}"#, + ) + .expect("empty schema v3 metadata must parse"); + } + + #[test] + fn unknown_export_kind_is_rejected() { + let error = parse_metadata( + br#"{"schema_version":3,"capabilities":{"owned_values":true},"functions":[{"module":"fixture","name":"work","symbol":"eqts_work","abi":"json","kind":"teleport","parameters":[],"result":{"kind":"scalar","scalar":"u64"}}]}"#, + ) + .expect_err("unknown export kind must fail"); + assert!(format!("{error:#}").contains("unknown export kind teleport")); + } + + #[test] + fn empty_module_is_rejected() { + let mut function = add_function(); + function.module.clear(); + let error = normalized_metadata(vec![function]).expect_err("empty module must fail"); + assert!(error.to_string().contains("empty module")); + } + + #[test] + fn invalid_symbols_are_rejected() { + for symbol in ["", "1add", "eqts-add"] { + let mut function = add_function(); + function.symbol = symbol.to_string(); + assert!(normalized_metadata(vec![function]).is_err(), "{symbol:?}"); + } + } + + #[test] + fn empty_enum_is_rejected() { + let mut function = add_function(); + function.abi = FunctionAbi::Json; + function.result = Type::Owned(OwnedType::Enum { + name: "Empty".to_string(), + variants: Vec::new(), + }); + let error = normalized_metadata(vec![function]).expect_err("empty enum must fail"); + assert!( + error + .to_string() + .contains("must contain at least one variant") + ); + } + + #[test] + fn typescript_names_camel_case_snake_identifiers() { + assert_eq!(typescript_name("add"), "add"); + assert_eq!(typescript_name("add_u64"), "addU64"); + assert_eq!(typescript_name("on_event_name"), "onEventName"); + assert_eq!(typescript_name("foo__bar"), "fooBar"); + assert_eq!(typescript_name("_leading"), "Leading"); + } + + #[test] + fn cli_build_parses_target_and_defaults() { + let cli = Cli::try_parse_from(["cargo", "eqts", "build", "--target", "bun"]) + .expect("valid CLI must parse"); + match cli.command { + Commands::Eqts { + command: + EqtsCommand::Build { + target, + release, + out_dir, + }, + } => { + assert_eq!(target, Target::Bun); + assert!(!release); + assert_eq!(out_dir, PathBuf::from("dist")); + } + } + } + + #[test] + fn cli_build_requires_a_target() { + let Err(error) = Cli::try_parse_from(["cargo", "eqts", "build"]) else { + panic!("target is required"); + }; + assert!(error.to_string().contains("required")); + } + + #[test] + fn cli_build_rejects_unknown_targets() { + let Err(error) = Cli::try_parse_from(["cargo", "eqts", "build", "--target", "jvm"]) else { + panic!("unknown target must fail"); + }; + assert!(error.to_string().contains("invalid value")); + } + fn reactive_function(kind: ExportKind) -> Function { Function { module: "fixture".to_string(), diff --git a/eq-ts/src/lib.rs b/eq-ts/src/lib.rs index 37814bb..bf6283d 100644 --- a/eq-ts/src/lib.rs +++ b/eq-ts/src/lib.rs @@ -911,4 +911,38 @@ mod tests { let status = unsafe { __private::write_output::(std::ptr::null_mut(), 42) }; assert_eq!(status, ABI_NULL_OUTPUT); } + + #[test] + fn u64_json_requires_a_decimal_string() { + assert_eq!( + u64::from_json(serde_json::json!("42")).expect("decimal string"), + 42 + ); + assert!(u64::from_json(serde_json::json!(42)).is_err()); + assert!(u64::from_json(serde_json::json!("")).is_err()); + assert!(u64::from_json(serde_json::json!("-1")).is_err()); + } + + #[test] + fn vec_json_requires_an_array() { + assert_eq!( + Vec::::from_json(serde_json::json!([1, 2])).expect("array"), + vec![1, 2] + ); + assert!(Vec::::from_json(serde_json::json!("no")).is_err()); + } + + #[test] + fn result_json_requires_ok_or_error() { + assert_eq!( + Result::::from_json(serde_json::json!({"ok": 7})).expect("ok"), + Ok(7) + ); + assert_eq!( + Result::::from_json(serde_json::json!({"error": "no"})).expect("error"), + Err("no".into()) + ); + assert!(Result::::from_json(serde_json::json!({})).is_err()); + assert!(Result::::from_json(serde_json::json!([])).is_err()); + } } diff --git a/eqts-macros/src/lib.rs b/eqts-macros/src/lib.rs index 02210f3..de7e5d9 100644 --- a/eqts-macros/src/lib.rs +++ b/eqts-macros/src/lib.rs @@ -887,4 +887,13 @@ mod tests { assert!(output.contains("serialize_maps_as_objects")); assert!(output.contains("Value :: String (message) => message")); } + + #[test] + fn camel_case_converts_snake_identifiers() { + assert_eq!(camel_case("add"), "add"); + assert_eq!(camel_case("add_u64"), "addU64"); + assert_eq!(camel_case("eqts_handle_invoke"), "eqtsHandleInvoke"); + assert_eq!(camel_case("foo__bar"), "fooBar"); + assert_eq!(camel_case("_leading"), "Leading"); + } } diff --git a/eqts-macros/tests/ui/associated_fn.rs b/eqts-macros/tests/ui/associated_fn.rs new file mode 100644 index 0000000..a78f9b5 --- /dev/null +++ b/eqts-macros/tests/ui/associated_fn.rs @@ -0,0 +1,12 @@ +use eqts_macros::methods; + +struct Counter; + +#[methods] +impl Counter { + pub fn create() -> Self { + Self + } +} + +fn main() {} diff --git a/eqts-macros/tests/ui/associated_fn.stderr b/eqts-macros/tests/ui/associated_fn.stderr new file mode 100644 index 0000000..1ae6a08 --- /dev/null +++ b/eqts-macros/tests/ui/associated_fn.stderr @@ -0,0 +1,5 @@ +error: method requires a receiver + --> tests/ui/associated_fn.rs:7:9 + | +7 | pub fn create() -> Self { + | ^^^^^^^^^^^^^^^^^^^ diff --git a/eqts-macros/tests/ui/async_mut_method.rs b/eqts-macros/tests/ui/async_mut_method.rs new file mode 100644 index 0000000..130aab3 --- /dev/null +++ b/eqts-macros/tests/ui/async_mut_method.rs @@ -0,0 +1,14 @@ +use eqts_macros::methods; + +struct Counter { + value: u32, +} + +#[methods] +impl Counter { + pub async fn bump(&mut self) { + self.value += 1; + } +} + +fn main() {} diff --git a/eqts-macros/tests/ui/async_mut_method.stderr b/eqts-macros/tests/ui/async_mut_method.stderr new file mode 100644 index 0000000..aa8cb20 --- /dev/null +++ b/eqts-macros/tests/ui/async_mut_method.stderr @@ -0,0 +1,5 @@ +error: async methods cannot use &mut self + --> tests/ui/async_mut_method.rs:9:23 + | +9 | pub async fn bump(&mut self) { + | ^^^^^^^^^ diff --git a/eqts-macros/tests/ui/export_arguments.rs b/eqts-macros/tests/ui/export_arguments.rs new file mode 100644 index 0000000..4102048 --- /dev/null +++ b/eqts-macros/tests/ui/export_arguments.rs @@ -0,0 +1,8 @@ +use eqts_macros::export; + +#[export(name = "add")] +pub fn add(left: u32, right: u32) -> u32 { + left + right +} + +fn main() {} diff --git a/eqts-macros/tests/ui/export_arguments.stderr b/eqts-macros/tests/ui/export_arguments.stderr new file mode 100644 index 0000000..8d6e364 --- /dev/null +++ b/eqts-macros/tests/ui/export_arguments.stderr @@ -0,0 +1,7 @@ +error: eqts::export takes no arguments + --> tests/ui/export_arguments.rs:3:1 + | +3 | #[export(name = "add")] + | ^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `export` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/eqts-macros/tests/ui/trait_impl_methods.rs b/eqts-macros/tests/ui/trait_impl_methods.rs new file mode 100644 index 0000000..4696920 --- /dev/null +++ b/eqts-macros/tests/ui/trait_impl_methods.rs @@ -0,0 +1,14 @@ +use eqts_macros::methods; + +trait Greeter { + fn greet(&self); +} + +struct Person; + +#[methods] +impl Greeter for Person { + fn greet(&self) {} +} + +fn main() {} diff --git a/eqts-macros/tests/ui/trait_impl_methods.stderr b/eqts-macros/tests/ui/trait_impl_methods.stderr new file mode 100644 index 0000000..b736f0d --- /dev/null +++ b/eqts-macros/tests/ui/trait_impl_methods.stderr @@ -0,0 +1,7 @@ +error: methods requires an inherent impl + --> tests/ui/trait_impl_methods.rs:10:1 + | +10 | / impl Greeter for Person { +11 | | fn greet(&self) {} +12 | | } + | |_^ diff --git a/eqts-macros/tests/ui/tuple_record.rs b/eqts-macros/tests/ui/tuple_record.rs new file mode 100644 index 0000000..dbc319b --- /dev/null +++ b/eqts-macros/tests/ui/tuple_record.rs @@ -0,0 +1,6 @@ +use eqts_macros::Record; + +#[derive(Record)] +pub struct Point(pub u32, pub u32); + +fn main() {} diff --git a/eqts-macros/tests/ui/tuple_record.stderr b/eqts-macros/tests/ui/tuple_record.stderr new file mode 100644 index 0000000..4ac1bb4 --- /dev/null +++ b/eqts-macros/tests/ui/tuple_record.stderr @@ -0,0 +1,5 @@ +error: Record requires named fields + --> tests/ui/tuple_record.rs:4:17 + | +4 | pub struct Point(pub u32, pub u32); + | ^^^^^^^^^^^^^^^^^^ diff --git a/eqts-macros/tests/ui/unsafe_fn.rs b/eqts-macros/tests/ui/unsafe_fn.rs new file mode 100644 index 0000000..f382f61 --- /dev/null +++ b/eqts-macros/tests/ui/unsafe_fn.rs @@ -0,0 +1,8 @@ +use eqts_macros::export; + +#[export] +pub unsafe fn value() -> u32 { + 1 +} + +fn main() {} diff --git a/eqts-macros/tests/ui/unsafe_fn.stderr b/eqts-macros/tests/ui/unsafe_fn.stderr new file mode 100644 index 0000000..9bbdc2a --- /dev/null +++ b/eqts-macros/tests/ui/unsafe_fn.stderr @@ -0,0 +1,5 @@ +error: eqts exports require safe Rust functions + --> tests/ui/unsafe_fn.rs:4:5 + | +4 | pub unsafe fn value() -> u32 { + | ^^^^^^^^^^^^^^^^^^^^^^^^