Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 143 additions & 16 deletions cargo-eqts/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -985,28 +985,44 @@ fn load_metadata(library_path: &Path) -> Result<Vec<Function>> {
}

fn parse_metadata(bytes: &[u8]) -> Result<Vec<Function>> {
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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject null export kinds as missing descriptors

When schema-v3 metadata declares reactive capabilities but an export contains "kind": null, this check treats the field as present and bypasses the ambiguity guard. Serde then deserializes the null into FunctionWire.kind: None and defaults the export to ExportKind::Function, so a reactive u64 handle can be emitted through an ordinary-value loader. Treat null as missing by checking that the value is non-null or validating the deserialized descriptor explicitly.

Useful? React with 👍 / 👎.

});
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)?;
}
Expand Down Expand Up @@ -1248,6 +1264,7 @@ fn validate_identifier(value: &str) -> Result<()> {
| "import"
| "in"
| "instanceof"
| "let"
| "new"
| "null"
| "return"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:?}");
}
}
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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(),
Expand Down
34 changes: 34 additions & 0 deletions eq-ts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,4 +911,38 @@ mod tests {
let status = unsafe { __private::write_output::<u32>(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::<u32>::from_json(serde_json::json!([1, 2])).expect("array"),
vec![1, 2]
);
assert!(Vec::<u32>::from_json(serde_json::json!("no")).is_err());
}

#[test]
fn result_json_requires_ok_or_error() {
assert_eq!(
Result::<u32, String>::from_json(serde_json::json!({"ok": 7})).expect("ok"),
Ok(7)
);
assert_eq!(
Result::<u32, String>::from_json(serde_json::json!({"error": "no"})).expect("error"),
Err("no".into())
);
assert!(Result::<u32, String>::from_json(serde_json::json!({})).is_err());
assert!(Result::<u32, String>::from_json(serde_json::json!([])).is_err());
}
}
9 changes: 9 additions & 0 deletions eqts-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
12 changes: 12 additions & 0 deletions eqts-macros/tests/ui/associated_fn.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
use eqts_macros::methods;

struct Counter;

#[methods]
impl Counter {
pub fn create() -> Self {
Self
}
}

fn main() {}
5 changes: 5 additions & 0 deletions eqts-macros/tests/ui/associated_fn.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: method requires a receiver
--> tests/ui/associated_fn.rs:7:9
|
7 | pub fn create() -> Self {
| ^^^^^^^^^^^^^^^^^^^
14 changes: 14 additions & 0 deletions eqts-macros/tests/ui/async_mut_method.rs
Original file line number Diff line number Diff line change
@@ -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() {}
5 changes: 5 additions & 0 deletions eqts-macros/tests/ui/async_mut_method.stderr
Original file line number Diff line number Diff line change
@@ -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) {
| ^^^^^^^^^
8 changes: 8 additions & 0 deletions eqts-macros/tests/ui/export_arguments.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use eqts_macros::export;

#[export(name = "add")]
pub fn add(left: u32, right: u32) -> u32 {
left + right
}

fn main() {}
7 changes: 7 additions & 0 deletions eqts-macros/tests/ui/export_arguments.stderr
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions eqts-macros/tests/ui/trait_impl_methods.rs
Original file line number Diff line number Diff line change
@@ -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() {}
7 changes: 7 additions & 0 deletions eqts-macros/tests/ui/trait_impl_methods.stderr
Original file line number Diff line number Diff line change
@@ -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 | | }
| |_^
6 changes: 6 additions & 0 deletions eqts-macros/tests/ui/tuple_record.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
use eqts_macros::Record;

#[derive(Record)]
pub struct Point(pub u32, pub u32);

fn main() {}
5 changes: 5 additions & 0 deletions eqts-macros/tests/ui/tuple_record.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: Record requires named fields
--> tests/ui/tuple_record.rs:4:17
|
4 | pub struct Point(pub u32, pub u32);
| ^^^^^^^^^^^^^^^^^^
8 changes: 8 additions & 0 deletions eqts-macros/tests/ui/unsafe_fn.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use eqts_macros::export;

#[export]
pub unsafe fn value() -> u32 {
1
}

fn main() {}
5 changes: 5 additions & 0 deletions eqts-macros/tests/ui/unsafe_fn.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: eqts exports require safe Rust functions
--> tests/ui/unsafe_fn.rs:4:5
|
4 | pub unsafe fn value() -> u32 {
| ^^^^^^^^^^^^^^^^^^^^^^^^
Loading