diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 3794ea18d1f..ea6ed08be0c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -262,6 +262,54 @@ jobs: - name: Test bytecode output run: cargo insta test -p insta-bytecode + ffi-tests: + name: Test FFI + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + needs: + - fmt + - typos + - clippy + - docs + - msrv + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + - os: ubuntu-26.04-arm + - os: ubuntu-latest + steps: + - name: Set environment + # Setting `RUSTFLAGS` overrides any flags set on .cargo/config.toml, so we need to + # set the target flags instead which are cumulative. + # Track https://github.com/rust-lang/cargo/issues/5376 + run: | + target=$(rustc -vV | awk '/^host/ { print $2 }' | tr '[:lower:]' '[:upper:]' | tr '-' '_') + echo "CARGO_TARGET_${target}_RUSTFLAGS=$W_FLAGS" >> $GITHUB_ENV + + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + + - name: Check generated C headers + run: | + cargo run --locked --profile ci -p diplomat-gen + git status --short --untracked-files=all -- ffi/boa_ffi/bindings/c/ + test -z "$(git status --porcelain --untracked-files=all -- ffi/boa_ffi/bindings/c/)" + + - name: Test C bindings + run: ./ffi/boa_ffi/tests/c/run.sh --ci + cross-tests: name: Test runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 69fa66c9b35..ba50166be6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,17 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "annotate-snippets" +version = "0.12.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + [[package]] name = "anstream" version = "1.0.0" @@ -130,6 +141,58 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "askama" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b8246bcbf8eb97abef10c2d92166449680d41d55c0fc6978a91dec2e3619608" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9670bc84a28bb3da91821ef74226949ab63f1265aff7c751634f1dd0e6f97c" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash 2.1.3", + "serde", + "serde_derive", + "syn 2.0.119", +] + +[[package]] +name = "askama_macros" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0756b45480437dded0565dfc568af62ccce146fb6cfe902e808ba86e445f44f" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0af3691ba3af77949c0b5a3925444b85cb58a0184cc7fec16c68ba2e7be868" +dependencies = [ + "rustc-hash 2.1.3", + "serde", + "serde_derive", + "unicode-ident", + "winnow 1.0.5", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -282,6 +345,15 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.13.2" @@ -463,6 +535,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "boa_ffi" +version = "0.22.0" +dependencies = [ + "boa_engine", + "diplomat", + "diplomat-runtime", +] + [[package]] name = "boa_gc" version = "0.22.0" @@ -1194,6 +1275,67 @@ dependencies = [ "thousands", ] +[[package]] +name = "diplomat" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b37a6cea72eb6df5143d3e091d4c34cb0cf4046dd1ddcc082988886fe1f809" +dependencies = [ + "diplomat_core", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "diplomat-gen" +version = "0.22.0" +dependencies = [ + "diplomat-tool", +] + +[[package]] +name = "diplomat-runtime" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9d981121ba76150987fda68185b37853c0b928a55dfdc28897348cd068440be" + +[[package]] +name = "diplomat-tool" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f71ad0ab8cf95b7179a192b1e07c3a5e8b7e6471e279d05101a12a1e77d8025c" +dependencies = [ + "askama", + "clap", + "colored", + "diplomat_core", + "heck", + "indenter", + "itertools 0.14.0", + "quote", + "serde", + "syn 3.0.9", + "syn-inline-mod", + "toml 0.8.23", +] + +[[package]] +name = "diplomat_core" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b18e806f9ecb92df828d080efe47984ae1193958295d4a84fb8b32c9a1aa1b" +dependencies = [ + "annotate-snippets", + "either", + "proc-macro2", + "quote", + "serde", + "smallvec", + "strck", + "syn 3.0.9", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -3728,6 +3870,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strck" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42316e70da376f3d113a68d138a60d8a9883c604fe97942721ec2068dab13a9f" +dependencies = [ + "unicode-ident", +] + [[package]] name = "string-interner" version = "0.19.0" @@ -3802,6 +3953,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn-inline-mod" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d5cd09c54d02e69217d362ee45b62821932cb16778a4513a722364ba4cb098" +dependencies = [ + "proc-macro2", + "syn 3.0.9", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4168,6 +4329,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", + "toml_write", "winnow 0.7.15", ] @@ -4192,6 +4354,12 @@ dependencies = [ "winnow 1.0.5", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.2.0+spec-1.1.0" diff --git a/Cargo.toml b/Cargo.toml index caf09f501de..2b7c7485344 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,9 @@ bitflags = "2.13.2" clap = "4.6.7" colored = "3.1.1" cow-utils = "0.1.3" +diplomat = { version = "0.16.0", default-features = false } +diplomat-runtime = { version = ">= 0.15.2, < 0.17.0", default-features = false } +diplomat-tool = { version = "0.16.0", default-features = false } fast-float2 = "0.2.4" hashbrown = "0.17.1" http = { version = "1.5.0" } diff --git a/ffi/boa_ffi/.gitattributes b/ffi/boa_ffi/.gitattributes new file mode 100644 index 00000000000..49065d790bd --- /dev/null +++ b/ffi/boa_ffi/.gitattributes @@ -0,0 +1 @@ +bindings/** linguist-generated=true diff --git a/ffi/boa_ffi/Cargo.toml b/ffi/boa_ffi/Cargo.toml new file mode 100644 index 00000000000..34d9603ee3e --- /dev/null +++ b/ffi/boa_ffi/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "boa_ffi" +description = "C bindings for the Boa JavaScript engine" +publish = false +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +boa_engine.workspace = true +diplomat.workspace = true +diplomat-runtime.workspace = true + +[lints] +workspace = true diff --git a/ffi/boa_ffi/README.md b/ffi/boa_ffi/README.md new file mode 100644 index 00000000000..bbc55feb6f2 --- /dev/null +++ b/ffi/boa_ffi/README.md @@ -0,0 +1,167 @@ +# Boa C bindings + +`boa_ffi` exposes Boa through opaque C handles generated by Diplomat. The current +API supports context lifecycle and value handles with type inspection and +number/null/undefined construction, plus structured error construction and +inspection. It does not yet execute JavaScript. The API is experimental and may change. + +## Build and test + +Use the Rust toolchain required by the workspace. From the repository root: + +```sh +cargo build --locked -p boa_ffi +cargo test --locked -p boa_ffi +./ffi/boa_ffi/tests/c/run.sh +``` + +The library is `target/debug/libboa_ffi.dylib` on macOS or +`target/debug/libboa_ffi.so` on Linux with Cargo's default configuration. Include +`bindings/c/BoaContext.h`, `bindings/c/JsValue.h` and `bindings/c/JsError.h` as +needed in C programs and link against this library. + +The C test runner requires a native macOS or Linux environment and a C11 compiler +(`cc`, or an executable specified by `CC`). By default it builds for the Rust +host explicitly in `target//debug/`, honors `CARGO_TARGET_DIR`, and runs +against that build's library; `--ci` reuses Cargo's `ci` profile build. Windows +support is not covered by this runner. + +Rust tests cover initialization failure, context cleanup, value types, numeric +round trips, retained error data and inspection without JavaScript execution. +Object retention is checked across garbage collection. The C tests exercise +context/value/error lifecycle, numeric edge cases and message encoding through +the generated headers and shared library. They check results explicitly, so +defining `NDEBUG` does not disable the tests. A dedicated CI job runs header +regeneration and the C tests across macOS and Linux. + +## Regenerate headers + +```sh +cargo run --locked -p diplomat-gen +``` + +Diplomat's version requirements live in the workspace `Cargo.toml`, and +`Cargo.lock` fixes the resolved versions, so no globally installed +`diplomat-tool` is needed. Regeneration replaces all of `bindings/c/`, including +removing obsolete headers. Do not edit those files by hand. Include regenerated +headers with changes to the Rust bridge. + +## Context ownership + +```c +#include "BoaContext.h" + +int main(void) { + BoaContext *context = boa_BoaContext_new(); + if (context == NULL) { + return 1; + } + boa_BoaContext_destroy(context); + return 0; +} +``` + +- `boa_BoaContext_new()` returns one owned handle, or `NULL` on an ordinary engine + initialization error. It uses Boa's fallible context builder. +- Destroy each successful result exactly once with `boa_BoaContext_destroy()`. + Its argument must be a live, non-null context pointer returned by this API. + `boa_BoaContext_destroy(NULL)` is invalid. Do not use C's `free()` on a handle. +- Copying a pointer creates an alias, not another owned handle. After destruction, + every alias is invalid. +- Use and destroy a context on the thread that created it. Calls on the same + handle must not overlap. Multiple independent contexts can coexist on one thread. +- `NULL` does not represent recovery from arbitrary Rust panics or allocation + failure. These failures may abort the process; this API does not catch panics. + +## Value handles + +`boa_JsValue_from_number(double)`, `boa_JsValue_from_null()` and +`boa_JsValue_from_undefined()` each return one owned, non-null value handle. They +require no context, and these primitive values remain usable after a context +is destroyed. Allocation failures and unexpected Rust panics may abort the process. + +```c +JsValue *value = boa_JsValue_from_number(7.0); +boa_JsValue_as_number_result number = boa_JsValue_as_number(value); +if (number.is_ok) { + printf("%g\n", number.ok); +} +boa_JsValue_destroy(value); +``` + +`boa_JsValue_get_type()` borrows the handle and returns its `JsValueType`. It reports +the ECMAScript language type: null and undefined are distinct, and functions +have the object type. This differs from JavaScript's `typeof` operator. +The underlying handle retains the original Boa value, including object identity. + +`boa_JsValue_as_number()` borrows the handle and returns a tagged optional double. +Check `is_ok` before reading `ok`; only number values have a numeric payload. +Null and undefined produce an absent result without coercion. NaN is a present +number, as are infinities and signed zeros. NaN payload bits are not guaranteed. + +All value calls, including destruction, must stay on the creating thread. Getters +require a live, non-null value handle and do not consume it. Destroy each owned +handle exactly once with `boa_JsValue_destroy()`, and invalidate every pointer alias +afterward. `boa_JsValue_destroy(NULL)` and C's `free()` are invalid for these handles. + +## Errors + +`JsError` retains Boa's original error and its stored representation: + +- `Native` has a native category and UTF-8 message. +- `Opaque` contains an arbitrary thrown value, including JavaScript Error objects. +- `Engine` is an engine failure that JavaScript cannot catch. + +`boa_JsError_get_representation()` and the getters inspect stored data without running +JavaScript. They do not read object properties or normalize opaque Error objects +into native errors. Engine details, cause/aggregate traversal and normalization +are not exposed yet; their underlying data remains in the error handle. + +`boa_JsError_from_native()` copies the supplied UTF-8 message, using its explicit byte +length, including any embedded NUL. It requires no context and returns an owned +handle for each known category. Aggregate construction starts with no members. +The reserved `Other` category is for inspecting unrecognized native categories; +passing `Other` or malformed UTF-8 to the constructor returns `NULL`. Allocation +failures and unexpected panics may abort. Invalid enum discriminants and invalid +non-empty pointers violate the caller's contract. + +```c +#include +#include "JsError.h" + +int main(void) { + const char message[] = "invalid input"; + JsError *error = boa_JsError_from_native(JsNativeErrorKind_Type, + (DiplomatStringView){.data = message, .len = sizeof(message) - 1}); + if (error == NULL) { + return 1; + } + boa_JsError_as_native_result info = boa_JsError_as_native(error); + if (!info.is_ok) { + boa_JsError_destroy(error); + return 1; + } + fwrite(info.ok.message.data, 1, info.ok.message.len, stdout); + fputc('\n', stdout); + boa_JsError_destroy(error); + return 0; +} +``` + +`boa_JsError_as_native()` returns a tagged optional category/message pair. Check +`is_ok` before reading `ok`; an empty message is present, while an opaque or engine +error has no native details. The message bytes are borrowed from the error and +remain valid until that error is destroyed. Use the byte length, not `strlen()` +or `%s`. Do not free the message or retain its view after destroying the error. + +`boa_JsError_from_opaque()` borrows a value and retains a clone; the caller keeps the +input handle and may destroy it independently. `boa_JsError_as_opaque()` returns a +new owned value handle for the stored payload, or `NULL` for native/engine errors. +Null and undefined payloads return present handles with their respective types. +Each returned payload must be destroyed with `boa_JsValue_destroy()`, independently +of the error. Object identity is preserved. Keep a context alive while using +resources that depend on it, including retained objects. + +Use and destroy errors on their creating thread. Getters require live, non-null +handles. Destroy each owned error exactly once with `boa_JsError_destroy()`; pointer +aliases then become invalid. `boa_JsError_destroy(NULL)` and C's `free()` are invalid. diff --git a/ffi/boa_ffi/bindings/c/BoaContext.d.h b/ffi/boa_ffi/bindings/c/BoaContext.d.h new file mode 100644 index 00000000000..da7027fa890 --- /dev/null +++ b/ffi/boa_ffi/bindings/c/BoaContext.d.h @@ -0,0 +1,19 @@ +#ifndef BoaContext_D_H +#define BoaContext_D_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + + + + + +typedef struct BoaContext BoaContext; + + + + +#endif // BoaContext_D_H diff --git a/ffi/boa_ffi/bindings/c/BoaContext.h b/ffi/boa_ffi/bindings/c/BoaContext.h new file mode 100644 index 00000000000..17c3e785646 --- /dev/null +++ b/ffi/boa_ffi/bindings/c/BoaContext.h @@ -0,0 +1,26 @@ +#ifndef BoaContext_H +#define BoaContext_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + + +#include "BoaContext.d.h" + + + + + + +BoaContext* boa_BoaContext_new(void); + +void boa_BoaContext_destroy(BoaContext* self); + + + + + +#endif // BoaContext_H diff --git a/ffi/boa_ffi/bindings/c/JsError.d.h b/ffi/boa_ffi/bindings/c/JsError.d.h new file mode 100644 index 00000000000..940324001f8 --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsError.d.h @@ -0,0 +1,19 @@ +#ifndef JsError_D_H +#define JsError_D_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + + + + + +typedef struct JsError JsError; + + + + +#endif // JsError_D_H diff --git a/ffi/boa_ffi/bindings/c/JsError.h b/ffi/boa_ffi/bindings/c/JsError.h new file mode 100644 index 00000000000..bf386bc0227 --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsError.h @@ -0,0 +1,39 @@ +#ifndef JsError_H +#define JsError_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + +#include "JsErrorRepresentation.d.h" +#include "JsNativeErrorInfo.d.h" +#include "JsNativeErrorKind.d.h" +#include "JsValue.d.h" + +#include "JsError.d.h" + + + + + + +JsError* boa_JsError_from_native(JsNativeErrorKind kind, DiplomatStringView message); + +JsError* boa_JsError_from_opaque(const JsValue* value); + +JsErrorRepresentation boa_JsError_get_representation(const JsError* self); + +typedef struct boa_JsError_as_native_result {union {JsNativeErrorInfo ok; }; bool is_ok;} boa_JsError_as_native_result; +boa_JsError_as_native_result boa_JsError_as_native(const JsError* self); + +JsValue* boa_JsError_as_opaque(const JsError* self); + +void boa_JsError_destroy(JsError* self); + + + + + +#endif // JsError_H diff --git a/ffi/boa_ffi/bindings/c/JsErrorRepresentation.d.h b/ffi/boa_ffi/bindings/c/JsErrorRepresentation.d.h new file mode 100644 index 00000000000..56528795aac --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsErrorRepresentation.d.h @@ -0,0 +1,24 @@ +#ifndef JsErrorRepresentation_D_H +#define JsErrorRepresentation_D_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + + + + + +typedef enum JsErrorRepresentation { + JsErrorRepresentation_Native = 0, + JsErrorRepresentation_Opaque = 1, + JsErrorRepresentation_Engine = 2, +} JsErrorRepresentation; + +typedef struct JsErrorRepresentation_option {union { JsErrorRepresentation ok; }; bool is_ok; } JsErrorRepresentation_option; + + + +#endif // JsErrorRepresentation_D_H diff --git a/ffi/boa_ffi/bindings/c/JsErrorRepresentation.h b/ffi/boa_ffi/bindings/c/JsErrorRepresentation.h new file mode 100644 index 00000000000..5d1d6060792 --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsErrorRepresentation.h @@ -0,0 +1,22 @@ +#ifndef JsErrorRepresentation_H +#define JsErrorRepresentation_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + + +#include "JsErrorRepresentation.d.h" + + + + +// No Content + + + + + +#endif // JsErrorRepresentation_H diff --git a/ffi/boa_ffi/bindings/c/JsNativeErrorInfo.d.h b/ffi/boa_ffi/bindings/c/JsNativeErrorInfo.d.h new file mode 100644 index 00000000000..d99823f2071 --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsNativeErrorInfo.d.h @@ -0,0 +1,24 @@ +#ifndef JsNativeErrorInfo_D_H +#define JsNativeErrorInfo_D_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + +#include "JsNativeErrorKind.d.h" + + + + +typedef struct JsNativeErrorInfo { + JsNativeErrorKind kind; + DiplomatStringView message; +} JsNativeErrorInfo; + +typedef struct JsNativeErrorInfo_option {union { JsNativeErrorInfo ok; }; bool is_ok; } JsNativeErrorInfo_option; + + + +#endif // JsNativeErrorInfo_D_H diff --git a/ffi/boa_ffi/bindings/c/JsNativeErrorInfo.h b/ffi/boa_ffi/bindings/c/JsNativeErrorInfo.h new file mode 100644 index 00000000000..033b902777a --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsNativeErrorInfo.h @@ -0,0 +1,22 @@ +#ifndef JsNativeErrorInfo_H +#define JsNativeErrorInfo_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + + +#include "JsNativeErrorInfo.d.h" + + + + +// No Content + + + + + +#endif // JsNativeErrorInfo_H diff --git a/ffi/boa_ffi/bindings/c/JsNativeErrorKind.d.h b/ffi/boa_ffi/bindings/c/JsNativeErrorKind.d.h new file mode 100644 index 00000000000..7f3078d3b10 --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsNativeErrorKind.d.h @@ -0,0 +1,30 @@ +#ifndef JsNativeErrorKind_D_H +#define JsNativeErrorKind_D_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + + + + + +typedef enum JsNativeErrorKind { + JsNativeErrorKind_Error = 0, + JsNativeErrorKind_Eval = 1, + JsNativeErrorKind_Range = 2, + JsNativeErrorKind_Reference = 3, + JsNativeErrorKind_Syntax = 4, + JsNativeErrorKind_Type = 5, + JsNativeErrorKind_Uri = 6, + JsNativeErrorKind_Aggregate = 7, + JsNativeErrorKind_Other = 8, +} JsNativeErrorKind; + +typedef struct JsNativeErrorKind_option {union { JsNativeErrorKind ok; }; bool is_ok; } JsNativeErrorKind_option; + + + +#endif // JsNativeErrorKind_D_H diff --git a/ffi/boa_ffi/bindings/c/JsNativeErrorKind.h b/ffi/boa_ffi/bindings/c/JsNativeErrorKind.h new file mode 100644 index 00000000000..dc99c54e0e9 --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsNativeErrorKind.h @@ -0,0 +1,22 @@ +#ifndef JsNativeErrorKind_H +#define JsNativeErrorKind_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + + +#include "JsNativeErrorKind.d.h" + + + + +// No Content + + + + + +#endif // JsNativeErrorKind_H diff --git a/ffi/boa_ffi/bindings/c/JsValue.d.h b/ffi/boa_ffi/bindings/c/JsValue.d.h new file mode 100644 index 00000000000..30d353ff7b4 --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsValue.d.h @@ -0,0 +1,19 @@ +#ifndef JsValue_D_H +#define JsValue_D_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + + + + + +typedef struct JsValue JsValue; + + + + +#endif // JsValue_D_H diff --git a/ffi/boa_ffi/bindings/c/JsValue.h b/ffi/boa_ffi/bindings/c/JsValue.h new file mode 100644 index 00000000000..df1963cc4cf --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsValue.h @@ -0,0 +1,36 @@ +#ifndef JsValue_H +#define JsValue_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + +#include "JsValueType.d.h" + +#include "JsValue.d.h" + + + + + + +JsValue* boa_JsValue_from_number(double number); + +JsValue* boa_JsValue_from_null(void); + +JsValue* boa_JsValue_from_undefined(void); + +JsValueType boa_JsValue_get_type(const JsValue* self); + +typedef struct boa_JsValue_as_number_result {union {double ok; }; bool is_ok;} boa_JsValue_as_number_result; +boa_JsValue_as_number_result boa_JsValue_as_number(const JsValue* self); + +void boa_JsValue_destroy(JsValue* self); + + + + + +#endif // JsValue_H diff --git a/ffi/boa_ffi/bindings/c/JsValueType.d.h b/ffi/boa_ffi/bindings/c/JsValueType.d.h new file mode 100644 index 00000000000..6a2b37dbe7b --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsValueType.d.h @@ -0,0 +1,29 @@ +#ifndef JsValueType_D_H +#define JsValueType_D_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + + + + + +typedef enum JsValueType { + JsValueType_Undefined = 0, + JsValueType_Null = 1, + JsValueType_Boolean = 2, + JsValueType_Number = 3, + JsValueType_String = 4, + JsValueType_Symbol = 5, + JsValueType_BigInt = 6, + JsValueType_Object = 7, +} JsValueType; + +typedef struct JsValueType_option {union { JsValueType ok; }; bool is_ok; } JsValueType_option; + + + +#endif // JsValueType_D_H diff --git a/ffi/boa_ffi/bindings/c/JsValueType.h b/ffi/boa_ffi/bindings/c/JsValueType.h new file mode 100644 index 00000000000..f78398854e1 --- /dev/null +++ b/ffi/boa_ffi/bindings/c/JsValueType.h @@ -0,0 +1,22 @@ +#ifndef JsValueType_H +#define JsValueType_H + +#include +#include +#include +#include +#include "diplomat_runtime.h" + + +#include "JsValueType.d.h" + + + + +// No Content + + + + + +#endif // JsValueType_H diff --git a/ffi/boa_ffi/bindings/c/diplomat_runtime.h b/ffi/boa_ffi/bindings/c/diplomat_runtime.h new file mode 100644 index 00000000000..e5d6b298d73 --- /dev/null +++ b/ffi/boa_ffi/bindings/c/diplomat_runtime.h @@ -0,0 +1,82 @@ +#ifndef DIPLOMAT_RUNTIME_C_H +#define DIPLOMAT_RUNTIME_C_H + +#include +#include +#include +#include + +// These come from `uchar.h`, which is not available on all platforms. +// Redefining them in C is no problem, however in >C++11 they are fundamental +// types, which don't like being redefined. +#if !(__cplusplus >= 201100) +// https://en.cppreference.com/w/c/string/multibyte/char16_t +typedef uint_least16_t char16_t; +// https://en.cppreference.com/w/c/string/multibyte/char32_t +typedef uint_least32_t char32_t; +#endif + +static_assert(sizeof(char) == sizeof(uint8_t), "your architecture's `char` is not 8 bits"); +static_assert(sizeof(char16_t) == sizeof(uint16_t), "your architecture's `char16_t` is not 16 bits"); +static_assert(sizeof(char32_t) == sizeof(uint32_t), "your architecture's `char32_t` is not 32 bits"); + +typedef struct DiplomatWrite { + void* context; + char* buf; + size_t len; + size_t cap; + bool grow_failed; + void (*flush)(struct DiplomatWrite*); + bool (*grow)(struct DiplomatWrite*, size_t); +} DiplomatWrite; + +bool diplomat_is_str(const char* buf, size_t len); + +#define MAKE_SLICES(name, c_ty) \ + typedef struct Diplomat##name##View { \ + const c_ty* data; \ + size_t len; \ + } Diplomat##name##View; \ + typedef struct Diplomat##name##ViewMut { \ + c_ty* data; \ + size_t len; \ + } Diplomat##name##ViewMut; \ + typedef struct Diplomat##name##Array { \ + const c_ty* data; \ + size_t len; \ + } Diplomat##name##Array; + +#define MAKE_SLICES_AND_OPTIONS(name, c_ty) \ + MAKE_SLICES(name, c_ty) \ + typedef struct Option##name {union { c_ty ok; }; bool is_ok; } Option##name; \ + typedef struct Option##name##View {union { Diplomat##name##View ok; }; bool is_ok; } Option##name##View; \ + typedef struct Option##name##ViewMut {union { Diplomat##name##ViewMut ok; }; bool is_ok; } Option##name##ViewMut; \ + typedef struct Option##name##Array {union { Diplomat##name##Array ok; }; bool is_ok; } Option##name##Array; \ + +MAKE_SLICES_AND_OPTIONS(I8, int8_t) +MAKE_SLICES_AND_OPTIONS(U8, uint8_t) +MAKE_SLICES_AND_OPTIONS(I16, int16_t) +MAKE_SLICES_AND_OPTIONS(U16, uint16_t) +MAKE_SLICES_AND_OPTIONS(I32, int32_t) +MAKE_SLICES_AND_OPTIONS(U32, uint32_t) +MAKE_SLICES_AND_OPTIONS(I64, int64_t) +MAKE_SLICES_AND_OPTIONS(U64, uint64_t) +MAKE_SLICES_AND_OPTIONS(Isize, intptr_t) +MAKE_SLICES_AND_OPTIONS(Usize, size_t) +MAKE_SLICES_AND_OPTIONS(F32, float) +MAKE_SLICES_AND_OPTIONS(F64, double) +MAKE_SLICES_AND_OPTIONS(Bool, bool) +MAKE_SLICES_AND_OPTIONS(Char, char32_t) +MAKE_SLICES_AND_OPTIONS(String, char) +MAKE_SLICES_AND_OPTIONS(String16, char16_t) +MAKE_SLICES_AND_OPTIONS(Strings, DiplomatStringView) +MAKE_SLICES_AND_OPTIONS(Strings16, DiplomatString16View) + +DiplomatWrite diplomat_simple_write(char* buf, size_t buf_size); + +DiplomatWrite* diplomat_buffer_write_create(size_t cap); +char* diplomat_buffer_write_get_bytes(DiplomatWrite* t); +size_t diplomat_buffer_write_len(DiplomatWrite* t); +void diplomat_buffer_write_destroy(DiplomatWrite* t); + +#endif diff --git a/ffi/boa_ffi/src/lib.rs b/ffi/boa_ffi/src/lib.rs new file mode 100644 index 00000000000..3a738b815b3 --- /dev/null +++ b/ffi/boa_ffi/src/lib.rs @@ -0,0 +1,549 @@ +//! C bindings for the Boa JavaScript engine. +//! +//! The C API and its ownership rules are documented in the crate's README. +//! Rust applications should use `boa_engine` directly. +#![cfg_attr( + not(test), + forbid(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing) +)] + +/// Entry points used by the generated C bindings. +#[diplomat::bridge] +#[diplomat::abi_rename = "boa_{0}"] +#[allow( + clippy::unnecessary_box_returns, + reason = "Diplomat transfers opaque handle ownership to C through boxed pointers" +)] +pub mod ffi { + use boa_engine::{ + Context, JsError as BoaJsError, JsNativeError as BoaJsNativeError, + JsNativeErrorKind as BoaJsNativeErrorKind, JsValue as BoaJsValue, context::ContextBuilder, + value::Type, + }; + use diplomat_runtime::{DiplomatOption, DiplomatStr, DiplomatUtf8StrSlice}; + + /// An owned Boa context, exposed as an opaque C handle. + /// + /// Keep the handle on the thread that created it. Destroy it exactly once + /// with `boa_BoaContext_destroy`; copying the pointer does not copy ownership. + /// The destructor requires a live, non-null handle. Do not call `free` on it. + #[derive(Debug)] + #[diplomat::opaque_mut] + pub struct BoaContext { + _context: Context, + } + + impl BoaContext { + /// Creates a context, or returns null if engine initialization fails. + /// + /// A successful call transfers ownership to the caller. Multiple contexts + /// can coexist on one thread. Unexpected Rust panics and allocation + /// failures may abort the process rather than return null. + #[must_use] + pub fn new() -> Option> { + Self::from_builder(Context::builder()) + } + + fn from_builder(builder: ContextBuilder) -> Option> { + builder + .build() + .ok() + .map(|context| Box::new(Self { _context: context })) + } + } + + /// The ECMAScript language type of a value. + /// + /// Unlike JavaScript's `typeof`, this distinguishes null from objects. + /// Functions have the object type. + #[derive(Debug, PartialEq, Eq)] + #[diplomat::enum_convert(Type)] + pub enum JsValueType { + /// The undefined type. + Undefined, + /// The null type. + Null, + /// The boolean type. + Boolean, + /// The number type. + Number, + /// The string type. + String, + /// The symbol type. + Symbol, + /// The bigint type. + BigInt, + /// The object type, including functions. + Object, + } + + /// An owned JavaScript value, exposed as an opaque C handle. + /// + /// Keep it on its creating thread and destroy it exactly once with + /// `boa_JsValue_destroy`. Getters borrow a live, non-null handle without + /// transferring ownership. Do not call `free` on it. + #[derive(Debug)] + #[diplomat::opaque] + pub struct JsValue(BoaJsValue); + + impl JsValue { + /// Creates an owned number value without requiring a context. + #[must_use] + pub fn from_number(number: f64) -> Box { + Box::new(Self(BoaJsValue::from(number))) + } + + /// Creates an owned null value without requiring a context. + #[must_use] + pub fn from_null() -> Box { + Box::new(Self(BoaJsValue::null())) + } + + /// Creates an owned undefined value without requiring a context. + #[must_use] + pub fn from_undefined() -> Box { + Box::new(Self(BoaJsValue::undefined())) + } + + /// Returns the language type without conversion or JavaScript execution. + #[must_use] + pub fn get_type(&self) -> JsValueType { + self.0.get_type().into() + } + + /// Returns the number, or an absent result for any other type. + /// + /// No conversion is performed. In C, check `is_ok` before reading `ok`. + /// `NaN` is a number and produces a present result. + #[must_use] + pub fn as_number(&self) -> DiplomatOption { + self.0.as_number().into() + } + } + + /// The stored representation of an error, without normalization. + #[derive(Debug, PartialEq, Eq)] + pub enum JsErrorRepresentation { + /// A native Boa error with a category and message. + Native, + /// An arbitrary thrown JavaScript value, including Error objects. + Opaque, + /// An engine failure that JavaScript cannot catch. + Engine, + } + + /// The category of a native Boa error. + #[derive(Debug, PartialEq, Eq)] + pub enum JsNativeErrorKind { + /// A generic error. + Error, + /// An evaluation error. + Eval, + /// A range error. + Range, + /// A reference error. + Reference, + /// A syntax error. + Syntax, + /// A type error. + Type, + /// A URI error. + Uri, + /// An aggregate error. + Aggregate, + /// A native category not recognized by these bindings; inspection only. + Other, + } + + /// Native error details borrowed from a live error handle. + pub struct JsNativeErrorInfo<'a> { + /// The native category. + pub kind: JsNativeErrorKind, + /// UTF-8 bytes, valid until the error is destroyed. Do not free them. + pub message: DiplomatUtf8StrSlice<'a>, + } + + /// An owned Boa error, retaining its original representation and data. + /// + /// Keep it on its creating thread and destroy it exactly once with + /// `boa_JsError_destroy`. Getters borrow a live, non-null handle. + #[derive(Debug)] + #[diplomat::opaque] + pub struct JsError(BoaJsError); + + impl JsError { + /// Creates a native error, copying the UTF-8 message, including any NULs. + /// + /// Returns null in C for `Other` or malformed UTF-8. Aggregate errors start + /// with no members. No context is required. Allocation failures and panics + /// may abort. + #[must_use] + pub fn from_native(kind: JsNativeErrorKind, message: &DiplomatStr) -> Option> { + let message = std::str::from_utf8(message).ok()?; + let error = match kind { + JsNativeErrorKind::Error => BoaJsNativeError::error(), + JsNativeErrorKind::Eval => BoaJsNativeError::eval(), + JsNativeErrorKind::Range => BoaJsNativeError::range(), + JsNativeErrorKind::Reference => BoaJsNativeError::reference(), + JsNativeErrorKind::Syntax => BoaJsNativeError::syntax(), + JsNativeErrorKind::Type => BoaJsNativeError::typ(), + JsNativeErrorKind::Uri => BoaJsNativeError::uri(), + JsNativeErrorKind::Aggregate => BoaJsNativeError::aggregate(Vec::new()), + JsNativeErrorKind::Other => return None, + }; + let error = error.with_message(message.to_owned()); + Some(Box::new(Self(error.into()))) + } + + /// Retains a borrowed value in a new owned opaque error. + /// + /// The caller keeps the input handle. Object identity is preserved. + #[must_use] + pub fn from_opaque(value: &JsValue) -> Box { + Box::new(Self(BoaJsError::from_opaque(value.0.clone()))) + } + + /// Returns the stored representation without executing JavaScript. + #[must_use] + pub fn get_representation(&self) -> JsErrorRepresentation { + if self.0.as_native().is_some() { + JsErrorRepresentation::Native + } else if self.0.as_opaque().is_some() { + JsErrorRepresentation::Opaque + } else { + JsErrorRepresentation::Engine + } + } + + /// Borrows native details, or returns an absent result for other representations. + /// + /// In C, check `is_ok` before reading `ok`. The message view is valid only + /// while this error is alive. No JavaScript properties are read. + #[must_use] + #[allow( + clippy::elidable_lifetime_names, + reason = "Diplomat requires a named lifetime for this borrowed return type" + )] + pub fn as_native<'a>(&'a self) -> Option> { + self.0.as_native().map(|error| JsNativeErrorInfo { + kind: match error.kind() { + BoaJsNativeErrorKind::Error => JsNativeErrorKind::Error, + BoaJsNativeErrorKind::Eval => JsNativeErrorKind::Eval, + BoaJsNativeErrorKind::Range => JsNativeErrorKind::Range, + BoaJsNativeErrorKind::Reference => JsNativeErrorKind::Reference, + BoaJsNativeErrorKind::Syntax => JsNativeErrorKind::Syntax, + BoaJsNativeErrorKind::Type => JsNativeErrorKind::Type, + BoaJsNativeErrorKind::Uri => JsNativeErrorKind::Uri, + BoaJsNativeErrorKind::Aggregate(_) => JsNativeErrorKind::Aggregate, + _ => JsNativeErrorKind::Other, + }, + message: error.message().into(), + }) + } + + /// Returns a new owned handle for an opaque payload, or null in C otherwise. + /// + /// Null and undefined payloads are present. Destroy each returned handle + /// with `boa_JsValue_destroy`, independently of this error. No JavaScript runs. + #[must_use] + pub fn as_opaque(&self) -> Option> { + self.0 + .as_opaque() + .map(|value| Box::new(JsValue(value.clone()))) + } + } + + #[cfg(test)] + mod tests { + use super::{ + BoaContext, BoaJsError, BoaJsNativeError, BoaJsNativeErrorKind, BoaJsValue, Context, + JsError, JsErrorRepresentation, JsNativeErrorKind, JsValue, JsValueType, + }; + use boa_engine::{ + Source, + error::{EngineError, PanicError, RuntimeLimitError}, + gc, js_string, + }; + + #[test] + fn initialization_failure_returns_none() { + let context = BoaContext::new().expect("default context should initialize"); + + // A blocking context cannot share a thread with this live context. + assert!(BoaContext::from_builder(Context::builder().can_block(true)).is_none()); + + drop(context); + + // Destruction must release the engine's per-thread context state. + let blocking = BoaContext::from_builder(Context::builder().can_block(true)) + .expect("blocking context should initialize after the conflict is gone"); + drop(blocking); + } + + #[test] + fn contexts_can_coexist_and_be_destroyed_in_any_order() { + let first = BoaContext::new().expect("first context should initialize"); + let second = BoaContext::new().expect("second context should initialize"); + drop(first); + + let third = BoaContext::new().expect("third context should initialize"); + drop(second); + drop(third); + + let blocking = Context::builder() + .can_block(true) + .build() + .expect("all nonblocking contexts should have been released"); + drop(blocking); + } + + #[test] + fn numbers_round_trip_without_coercion() { + for number in [ + 0.0, + -0.0, + 7.0, + -7.25, + f64::from(i32::MAX), + f64::from(i32::MAX) + 1.0, + f64::from(i32::MIN), + f64::from(i32::MIN) - 1.0, + 9_007_199_254_740_991.0, + f64::MAX, + f64::MIN_POSITIVE, + f64::from_bits(1), + f64::INFINITY, + f64::NEG_INFINITY, + f64::NAN, + ] { + let value = JsValue::from_number(number); + assert_eq!(value.get_type(), JsValueType::Number); + let actual = value.as_number().into_option().expect("number is present"); + if number.is_nan() { + assert!(actual.is_nan()); + } else { + assert_eq!(actual.to_bits(), number.to_bits(), "{number:?}"); + } + } + } + + #[test] + fn null_and_undefined_are_distinct_non_numbers() { + for (value, expected_type) in [ + (JsValue::from_null(), JsValueType::Null), + (JsValue::from_undefined(), JsValueType::Undefined), + ] { + assert_eq!(value.get_type(), expected_type); + assert!(value.as_number().into_option().is_none()); + } + } + + #[test] + fn type_query_handles_every_boa_value_type() { + let mut context = Context::builder() + .build() + .expect("context should initialize"); + for (source, expected_type) in [ + ("undefined", JsValueType::Undefined), + ("null", JsValueType::Null), + ("true", JsValueType::Boolean), + ("7", JsValueType::Number), + ("'text'", JsValueType::String), + ("Symbol('test')", JsValueType::Symbol), + ("7n", JsValueType::BigInt), + ("({})", JsValueType::Object), + ("(() => 7)", JsValueType::Object), + ] { + let value = JsValue( + context + .eval(Source::from_bytes(source)) + .expect("valid source"), + ); + assert_eq!(value.get_type(), expected_type, "{source}"); + assert_eq!( + value.as_number().into_option().is_some(), + expected_type == JsValueType::Number, + "{source}" + ); + } + } + + #[test] + fn retained_object_keeps_its_identity_and_contents() { + let mut context = Context::builder() + .build() + .expect("context should initialize"); + let original = JsValue( + context + .eval(Source::from_bytes("({ payload: 7 })")) + .expect("object should be created"), + ); + let retained = Box::new(JsValue(original.0.clone())); + assert!(BoaJsValue::same_value(&original.0, &retained.0)); + drop(original); + gc::force_collect(); + + assert_eq!(retained.get_type(), JsValueType::Object); + assert!(retained.as_number().into_option().is_none()); + let payload = retained + .0 + .as_object() + .expect("object should be retained") + .get(js_string!("payload"), &mut context) + .expect("property should be readable"); + assert_eq!(payload.as_number(), Some(7.0)); + } + + #[test] + fn native_errors_preserve_categories_and_messages() { + for (kind, expected) in [ + (JsNativeErrorKind::Error, BoaJsNativeErrorKind::Error), + (JsNativeErrorKind::Eval, BoaJsNativeErrorKind::Eval), + (JsNativeErrorKind::Range, BoaJsNativeErrorKind::Range), + ( + JsNativeErrorKind::Reference, + BoaJsNativeErrorKind::Reference, + ), + (JsNativeErrorKind::Syntax, BoaJsNativeErrorKind::Syntax), + (JsNativeErrorKind::Type, BoaJsNativeErrorKind::Type), + (JsNativeErrorKind::Uri, BoaJsNativeErrorKind::Uri), + ( + JsNativeErrorKind::Aggregate, + BoaJsNativeErrorKind::Aggregate(Vec::new()), + ), + ] { + for message in ["", "invalid \u{1f980} input", "before\0after"] { + let error = + JsError::from_native(kind, message.as_bytes()).expect("known category"); + assert_eq!(error.get_representation(), JsErrorRepresentation::Native); + let info = error.as_native().expect("native details"); + assert_eq!(info.kind, kind); + assert_eq!(info.message.as_bytes(), message.as_bytes()); + assert_eq!(error.0.as_native().expect("native error").kind(), &expected); + assert!(error.as_opaque().is_none()); + } + } + assert!(JsError::from_native(JsNativeErrorKind::Other, b"reserved").is_none()); + for invalid in [&b"\xff"[..], b"bad \xf0\x9f\xa6", b"\xed\xa0\x80"] { + assert!(JsError::from_native(JsNativeErrorKind::Type, invalid).is_none()); + } + } + + #[test] + fn opaque_error_inspection_retains_objects_without_invoking_getters() { + let mut context = Context::builder() + .build() + .expect("context should initialize"); + for expression in ["new TypeError('boom')", "({})"] { + let source = format!( + "globalThis.inspections = 0; + (() => {{ + const value = {expression}; + Object.defineProperties(value, {{ + name: {{ get() {{ inspections++; throw 1; }} }}, + message: {{ get() {{ inspections++; throw 2; }} }}, + cause: {{ get() {{ inspections++; throw 3; }} }}, + payload: {{ value: 7 }} + }}); + throw value; + }})()" + ); + let thrown = context + .eval(Source::from_bytes(&source)) + .expect_err("source should throw"); + let original = JsValue(thrown.as_opaque().expect("opaque throw").clone()); + let reconstructed = JsError::from_opaque(&original); + assert!(reconstructed.as_native().is_none()); + drop(reconstructed); + + let error = JsError(thrown); + assert_eq!(error.get_representation(), JsErrorRepresentation::Opaque); + assert!(error.as_native().is_none()); + let payload = error.as_opaque().expect("retained payload"); + assert!(BoaJsValue::same_value(&payload.0, &original.0)); + drop(original); + drop(error); + gc::force_collect(); + + let value = payload + .0 + .as_object() + .expect("object should be retained") + .get(js_string!("payload"), &mut context) + .expect("data property should be readable"); + assert_eq!(value.as_number(), Some(7.0)); + let inspections = context + .eval(Source::from_bytes("inspections")) + .expect("counter should be readable"); + assert_eq!(inspections.as_number(), Some(0.0)); + } + } + + #[test] + fn native_errors_retain_causes_and_aggregate_members() { + let mut context = Context::builder() + .build() + .expect("context should initialize"); + let value = context + .eval(Source::from_bytes("({ payload: 7 })")) + .expect("object should be created"); + let cause = BoaJsError::from_opaque(value); + let error = JsError( + BoaJsNativeError::aggregate(vec![cause.clone()]) + .with_message("aggregate") + .with_cause(cause) + .into(), + ); + gc::force_collect(); + + let info = error.as_native().expect("native details"); + assert_eq!(info.kind, JsNativeErrorKind::Aggregate); + assert_eq!(info.message.as_bytes(), b"aggregate"); + assert!(error.as_opaque().is_none()); + let native = error.0.as_native().expect("native error"); + let BoaJsNativeErrorKind::Aggregate(members) = native.kind() else { + panic!("aggregate members should be retained"); + }; + assert_eq!(members.len(), 1); + let cause = native.cause().expect("cause should be retained"); + assert_eq!(cause, &members[0]); + let payload = cause + .as_opaque() + .expect("opaque cause") + .as_object() + .expect("object cause") + .get(js_string!("payload"), &mut context) + .expect("data property should be readable"); + assert_eq!(payload.as_number(), Some(7.0)); + } + + #[test] + fn engine_errors_remain_distinct() { + for original in [ + EngineError::from(RuntimeLimitError::LoopIteration), + EngineError::from(RuntimeLimitError::Recursion), + EngineError::from(RuntimeLimitError::StackSize), + EngineError::from( + PanicError::new("engine panic") + .with_source(BoaJsNativeError::error().with_message("source")), + ), + ] { + let error = JsError(original.clone().into()); + assert_eq!(error.get_representation(), JsErrorRepresentation::Engine); + assert!(error.as_native().is_none()); + assert!(error.as_opaque().is_none()); + assert_eq!(error.0.as_engine(), Some(&original)); + } + } + } +} + +impl std::fmt::Debug for ffi::JsNativeErrorInfo<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message: &str = self.message.into(); + f.debug_struct("JsNativeErrorInfo") + .field("kind", &self.kind) + .field("message", &message) + .finish() + } +} diff --git a/ffi/boa_ffi/tests/c/context.c b/ffi/boa_ffi/tests/c/context.c new file mode 100644 index 00000000000..43089b1a79a --- /dev/null +++ b/ffi/boa_ffi/tests/c/context.c @@ -0,0 +1,36 @@ +#include +#include + +#include "BoaContext.h" + +int main(void) { + for (size_t i = 0; i < 32; ++i) { + BoaContext *first = boa_BoaContext_new(); + if (first == NULL) { + fputs("first context failed to initialize\n", stderr); + return EXIT_FAILURE; + } + + BoaContext *second = boa_BoaContext_new(); + if (second == NULL) { + boa_BoaContext_destroy(first); + fputs("second context failed to initialize\n", stderr); + return EXIT_FAILURE; + } + + boa_BoaContext_destroy(first); + + BoaContext *third = boa_BoaContext_new(); + if (third == NULL) { + boa_BoaContext_destroy(second); + fputs("third context failed to initialize\n", stderr); + return EXIT_FAILURE; + } + + boa_BoaContext_destroy(second); + boa_BoaContext_destroy(third); + } + + puts("C context lifecycle tests passed."); + return EXIT_SUCCESS; +} diff --git a/ffi/boa_ffi/tests/c/error.c b/ffi/boa_ffi/tests/c/error.c new file mode 100644 index 00000000000..259a85d09df --- /dev/null +++ b/ffi/boa_ffi/tests/c/error.c @@ -0,0 +1,137 @@ +#include +#include +#include +#include + +#include "JsError.h" +#include "JsValue.h" + +static bool native_errors(void) { + const JsNativeErrorKind kinds[] = { + JsNativeErrorKind_Error, JsNativeErrorKind_Eval, + JsNativeErrorKind_Range, JsNativeErrorKind_Reference, + JsNativeErrorKind_Syntax, JsNativeErrorKind_Type, + JsNativeErrorKind_Uri, JsNativeErrorKind_Aggregate, + }; + const char expected[] = "bad \xF0\x9F\xA6\x80\0tail"; + const size_t lengths[] = {0, 3, sizeof(expected) - 1}; + + for (size_t i = 0; i < sizeof(kinds) / sizeof(kinds[0]); ++i) { + for (size_t j = 0; j < sizeof(lengths) / sizeof(lengths[0]); ++j) { + char message[sizeof(expected)]; + memcpy(message, expected, sizeof(message)); + JsError *error = boa_JsError_from_native(kinds[i], + (DiplomatStringView){.data = message, .len = lengths[j]}); + if (error == NULL) { + return false; + } + memset(message, 'x', sizeof(message)); + + boa_JsError_as_native_result info = boa_JsError_as_native(error); + JsValue *payload = boa_JsError_as_opaque(error); + bool valid = boa_JsError_get_representation(error) == JsErrorRepresentation_Native && + info.is_ok && info.ok.kind == kinds[i] && + info.ok.message.len == lengths[j] && + (lengths[j] == 0 || + memcmp(info.ok.message.data, expected, lengths[j]) == 0) && + payload == NULL; + if (payload != NULL) { + boa_JsValue_destroy(payload); + } + boa_JsError_destroy(error); + if (!valid) { + return false; + } + } + } + + JsError *empty = boa_JsError_from_native(JsNativeErrorKind_Error, + (DiplomatStringView){.data = NULL, .len = 0}); + if (empty == NULL) { + return false; + } + boa_JsError_as_native_result empty_info = boa_JsError_as_native(empty); + bool empty_valid = empty_info.is_ok && + empty_info.ok.kind == JsNativeErrorKind_Error && + empty_info.ok.message.len == 0; + boa_JsError_destroy(empty); + if (!empty_valid) { + return false; + } + + const DiplomatStringView rejected_messages[] = { + {.data = "\xFF", .len = 1}, + {.data = expected, .len = 7}, + {.data = "\xED\xA0\x80", .len = 3}, + }; + for (size_t i = 0; i < sizeof(rejected_messages) / sizeof(rejected_messages[0]); ++i) { + JsError *invalid = boa_JsError_from_native(JsNativeErrorKind_Type, rejected_messages[i]); + if (invalid != NULL) { + boa_JsError_destroy(invalid); + return false; + } + } + + JsError *other = boa_JsError_from_native(JsNativeErrorKind_Other, + (DiplomatStringView){.data = "", .len = 0}); + if (other != NULL) { + boa_JsError_destroy(other); + return false; + } + return true; +} + +static bool payload_matches(JsValue *value, JsValueType kind) { + if (value == NULL || boa_JsValue_get_type(value) != kind) { + return false; + } + boa_JsValue_as_number_result number = boa_JsValue_as_number(value); + return kind == JsValueType_Number + ? number.is_ok && number.ok == 7.0 + : !number.is_ok; +} + +static bool opaque_error(JsValue *input, JsValueType kind, bool error_first) { + JsError *error = boa_JsError_from_opaque(input); + boa_JsValue_destroy(input); + + bool valid = boa_JsError_get_representation(error) == JsErrorRepresentation_Opaque && + !boa_JsError_as_native(error).is_ok; + JsValue *first = boa_JsError_as_opaque(error); + JsValue *second = boa_JsError_as_opaque(error); + if (error_first) { + boa_JsError_destroy(error); + } + + valid = valid && payload_matches(first, kind) && payload_matches(second, kind); + if (first != NULL) { + boa_JsValue_destroy(first); + } + valid = valid && payload_matches(second, kind); + if (second != NULL) { + boa_JsValue_destroy(second); + } + if (!error_first) { + valid = valid && boa_JsError_get_representation(error) == JsErrorRepresentation_Opaque; + boa_JsError_destroy(error); + } + return valid; +} + +int main(void) { + if (!native_errors()) { + fputs("native error details or message ownership failed\n", stderr); + return EXIT_FAILURE; + } + for (size_t order = 0; order < 2; ++order) { + bool error_first = order == 0; + if (!opaque_error(boa_JsValue_from_number(7.0), JsValueType_Number, error_first) || + !opaque_error(boa_JsValue_from_null(), JsValueType_Null, error_first) || + !opaque_error(boa_JsValue_from_undefined(), JsValueType_Undefined, error_first)) { + fputs("opaque error payload ownership failed\n", stderr); + return EXIT_FAILURE; + } + } + puts("C error tests passed."); + return EXIT_SUCCESS; +} diff --git a/ffi/boa_ffi/tests/c/run.sh b/ffi/boa_ffi/tests/c/run.sh new file mode 100755 index 00000000000..3c6f315bea6 --- /dev/null +++ b/ffi/boa_ffi/tests/c/run.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$script_dir/../../../.." && pwd) +cd "$repo_root" + +case $(uname -s) in + Darwin) library_name=libboa_ffi.dylib ;; + Linux) library_name=libboa_ffi.so ;; + *) echo "The C test runner supports native macOS and Linux builds." >&2; exit 1 ;; +esac + +if [ "$#" -gt 1 ]; then + echo "Usage: $0 [--ci]" >&2 + exit 2 +fi + +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-"$repo_root/target"}" +case "${1-}" in + "") + # Select the host explicitly so Cargo's configured target cannot cause us to + # accidentally link a stale native library after a cross-compilation build. + host=$(rustc -vV | sed -n 's/^host: //p') + cargo build --locked -p boa_ffi --target "$host" + library_dir=$(CDPATH= cd -- "$CARGO_TARGET_DIR/$host/debug" && pwd) + ;; + --ci) + cargo build --locked --profile ci -p boa_ffi + library_dir=$(CDPATH= cd -- "$CARGO_TARGET_DIR/ci" && pwd) + ;; + *) + echo "Usage: $0 [--ci]" >&2 + exit 2 + ;; +esac + +test_dir=$(mktemp -d "${TMPDIR:-/tmp}/boa-ffi-c.XXXXXX") +trap 'rm -rf "$test_dir"' EXIT HUP INT TERM + +for test_name in context value error; do + "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -pedantic \ + -I "$script_dir/../../bindings/c" "$script_dir/$test_name.c" \ + "$library_dir/$library_name" "-Wl,-rpath,$library_dir" -lm \ + -o "$test_dir/$test_name" + "$test_dir/$test_name" +done diff --git a/ffi/boa_ffi/tests/c/value.c b/ffi/boa_ffi/tests/c/value.c new file mode 100644 index 00000000000..c049ecdedbc --- /dev/null +++ b/ffi/boa_ffi/tests/c/value.c @@ -0,0 +1,69 @@ +#include +#include +#include +#include +#include + +#include "BoaContext.h" +#include "JsValue.h" + +static bool same_number(double actual, double expected) { + if (isnan(expected)) { + return isnan(actual); + } + if (actual != expected) { + return false; + } + return expected != 0.0 || (signbit(actual) != 0) == (signbit(expected) != 0); +} + +int main(void) { + const double numbers[] = { + 0.0, -0.0, 1.5, -2.75, + -2147483649.0, -2147483648.0, 2147483647.0, 2147483648.0, + 9007199254740991.0, NAN, INFINITY, -INFINITY, + DBL_MIN, DBL_TRUE_MIN, DBL_MAX, -DBL_MAX, + }; + + for (size_t i = 0; i < sizeof(numbers) / sizeof(numbers[0]); ++i) { + JsValue *value = boa_JsValue_from_number(numbers[i]); + for (size_t read = 0; read < 2; ++read) { + boa_JsValue_as_number_result result = boa_JsValue_as_number(value); + if (boa_JsValue_get_type(value) != JsValueType_Number || + !result.is_ok || !same_number(result.ok, numbers[i])) { + boa_JsValue_destroy(value); + fprintf(stderr, "number round trip failed at index %zu\n", i); + return EXIT_FAILURE; + } + } + boa_JsValue_destroy(value); + } + + BoaContext *context = boa_BoaContext_new(); + if (context == NULL) { + fputs("context failed to initialize\n", stderr); + return EXIT_FAILURE; + } + JsValue *null_value = boa_JsValue_from_null(); + JsValue *undefined_value = boa_JsValue_from_undefined(); + JsValue *number = boa_JsValue_from_number(42.5); + boa_BoaContext_destroy(context); + + boa_JsValue_as_number_result result = boa_JsValue_as_number(number); + bool valid = boa_JsValue_get_type(null_value) == JsValueType_Null && + boa_JsValue_get_type(undefined_value) == JsValueType_Undefined && + !boa_JsValue_as_number(null_value).is_ok && + !boa_JsValue_as_number(undefined_value).is_ok && + boa_JsValue_get_type(number) == JsValueType_Number && + result.is_ok && same_number(result.ok, 42.5); + boa_JsValue_destroy(null_value); + boa_JsValue_destroy(undefined_value); + boa_JsValue_destroy(number); + if (!valid) { + fputs("primitive values failed after context destruction\n", stderr); + return EXIT_FAILURE; + } + + puts("C value tests passed."); + return EXIT_SUCCESS; +} diff --git a/tools/diplomat-gen/Cargo.toml b/tools/diplomat-gen/Cargo.toml new file mode 100644 index 00000000000..bd9e2fafd27 --- /dev/null +++ b/tools/diplomat-gen/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "diplomat-gen" +description = "Generate Boa's C bindings with Diplomat" +publish = false +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +diplomat-tool.workspace = true + +[lints] +workspace = true diff --git a/tools/diplomat-gen/src/main.rs b/tools/diplomat-gen/src/main.rs new file mode 100644 index 00000000000..17581b47749 --- /dev/null +++ b/tools/diplomat-gen/src/main.rs @@ -0,0 +1,28 @@ +//! Regenerates the checked-in C headers for `boa_ffi`. + +use std::{fs, io, path::Path}; + +use diplomat_tool::{DocsUrlGenerator, config::Config}; + +fn main() -> io::Result<()> { + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../ffi/boa_ffi"); + let headers = crate_dir.join("bindings/c"); + + // This directory contains only generated files. Remove obsolete headers too. + match fs::remove_dir_all(&headers) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + + let mut config = Config::default(); + config.shared_config.lib_name = Some("boa_ffi".into()); + diplomat_tool::r#gen( + &crate_dir.join("src/lib.rs"), + "c", + &headers, + &DocsUrlGenerator::default(), + config, + false, + ) +}