diff --git a/.changepacks/changepack_log_native_binding.json b/.changepacks/changepack_log_native_binding.json new file mode 100644 index 000000000..2f12620dc --- /dev/null +++ b/.changepacks/changepack_log_native_binding.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Add a genuine native-addon coverage feasibility checkpoint for the forthcoming optional compiler binding. No compiler behavior or new build errors.", + "date": "2026-10-20T00:00:00.000Z" +} diff --git a/.github/workflows/W74a-binding-coverage-spike.yml b/.github/workflows/W74a-binding-coverage-spike.yml new file mode 100644 index 000000000..cecdb2320 --- /dev/null +++ b/.github/workflows/W74a-binding-coverage-spike.yml @@ -0,0 +1,90 @@ +name: W74a native coverage SPIKE + +# Replace/remove this SPIKE workflow before the binding PR leaves Draft. +on: + push: + branches: [feat/napi-binding] + paths: + - 'e2e/binding/coverage-spike/**' + - '.github/workflows/W74a-binding-coverage-spike.yml' + pull_request: + branches: [fix/portable-binding-state] + paths: + - 'e2e/binding/coverage-spike/**' + - '.github/workflows/W74a-binding-coverage-spike.yml' + +permissions: + contents: read + +jobs: + scratch-controls: + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + CARGO_BUILD_JOBS: '2' + CARGO_TERM_COLOR: never + defaults: + run: + shell: bash + working-directory: e2e/binding/coverage-spike + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Prepare private evidence directory + run: | + echo "W74A_EVIDENCE=$RUNNER_TEMP/W74a-evidence" >> "$GITHUB_ENV" + mkdir -p "$RUNNER_TEMP/W74a-evidence" + - uses: actions/setup-node@v7 + with: + node-version: latest + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions-rust-lang/setup-rust-toolchain@v2 + with: + toolchain: stable + components: rustfmt, clippy + cache: false + rustflags: '' + - name: Resolve latest official GNU x64 tarpaulin release + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p "$W74A_EVIDENCE" "$RUNNER_TEMP/W74a-tools" + gh api repos/xd009642/tarpaulin/releases/latest > "$W74A_EVIDENCE/tarpaulin-release.json" + tag=$(node -p "JSON.parse(require('node:fs').readFileSync(process.env.W74A_EVIDENCE + '/tarpaulin-release.json')).tag_name") + asset=cargo-tarpaulin-x86_64-unknown-linux-gnu.tar.gz + gh release download "$tag" --repo xd009642/tarpaulin --pattern "$asset" --dir "$W74A_EVIDENCE" + node --input-type=module <<'NODE' + import assert from 'node:assert/strict' + import { createHash } from 'node:crypto' + import { readFileSync, writeFileSync } from 'node:fs' + const root = process.env.W74A_EVIDENCE + const release = JSON.parse(readFileSync(`${root}/tarpaulin-release.json`)) + const asset = release.assets.find(({ name }) => name === 'cargo-tarpaulin-x86_64-unknown-linux-gnu.tar.gz') + assert.ok(asset) + const digest = 'sha256:' + createHash('sha256').update(readFileSync(`${root}/${asset.name}`)).digest('hex') + assert.equal(digest, asset.digest, 'Official asset digest mismatch') + writeFileSync(`${root}/tarpaulin-asset.json`, JSON.stringify({ tag: release.tag_name, name: asset.name, digest }, null, 2)) + NODE + tar -xzf "$W74A_EVIDENCE/$asset" -C "$RUNNER_TEMP/W74a-tools" + echo "$RUNNER_TEMP/W74a-tools" >> "$GITHUB_PATH" + - name: Record resolved tools and runner + run: | + printf '%s\n' "$GITHUB_SHA" "$GITHUB_REF" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" > "$W74A_EVIDENCE/source-run.txt" + { uname -a; printf '%s\n' "$ImageOS" "$ImageVersion"; rustc +stable -Vv; cargo +stable -V; cargo +stable tarpaulin --version; node --version; bun --version; } 2>&1 | tee "$W74A_EVIDENCE/versions.log" + - name: Scratch source formatting and strict clippy + run: | + cargo +stable fmt --all -- --check 2>&1 | tee "$W74A_EVIDENCE/fmt.log" + cargo +stable clippy --locked --workspace --all-targets --all-features --target-dir "$RUNNER_TEMP/W74a-clippy-target" --jobs 2 -- -D warnings 2>&1 | tee "$W74A_EVIDENCE/clippy.log" + - name: Serial Node coverage controls and direct Bun addon execution + run: node run.mjs "$W74A_EVIDENCE" 2>&1 | tee "$W74A_EVIDENCE/runner.log" + - name: Preserve raw SPIKE evidence even on failure + if: always() + uses: actions/upload-artifact@v7 + with: + name: W74a-native-coverage-SPIKE-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/W74a-evidence + retention-days: 30 + if-no-files-found: warn diff --git a/Cargo.lock b/Cargo.lock index 0f997fdb7..390f2a9f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,6 +456,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "convert_case" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -559,6 +568,12 @@ dependencies = [ "serial_test", ] +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + [[package]] name = "dashmap" version = "6.2.1" @@ -579,6 +594,31 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "devup-ui-binding" +version = "0.1.0" +dependencies = [ + "bimap", + "devup-ui-binding-core", + "extractor", + "napi", + "napi-build", + "napi-derive", + "serde", + "sheet", +] + +[[package]] +name = "devup-ui-binding-core" +version = "0.1.0" +dependencies = [ + "bimap", + "css", + "extractor", + "serde_json", + "sheet", +] + [[package]] name = "devup-ui-wasm" version = "0.1.0" @@ -587,6 +627,7 @@ dependencies = [ "boa_engine", "console_error_panic_hook", "css", + "devup-ui-binding-core", "extractor", "getrandom", "insta", @@ -792,6 +833,31 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-concurrency" version = "7.7.1" @@ -841,6 +907,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -859,8 +942,13 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1161,6 +1249,16 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1219,6 +1317,70 @@ dependencies = [ "adler2", ] +[[package]] +name = "napi" +version = "3.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e2cf85c02d9b057898c485b824c3c416b65193689e2b7f517efc94b86ed24c" +dependencies = [ + "bitflags", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b899b545d3aa6dca985939059f258c5488d34e4ecf39c274e20009748f4b846d" + +[[package]] +name = "napi-derive" +version = "3.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92cec6048ac04041ac8055bdce1ec4f353416cd443ba9222bf73af790c3d4495" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae2444f4ba7333754bb73414ff8059dec2cb23b83a95bd1a0bf08169eed1be0" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn 3.0.9", +] + +[[package]] +name = "napi-sys" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22a4f25c16a5c5411d987cd6fbd48313522ae1789ade2d6dd3efdc6d40a0fc8" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nonmax" version = "0.5.5" diff --git a/Cargo.toml b/Cargo.toml index b74fcdbe4..824e9553c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["libs/extractor", "bindings/devup-ui-wasm", "libs/sheet", "libs/css"] +members = ["libs/extractor", "bindings/devup-ui-wasm", "libs/sheet", "libs/css", "libs/binding", "bindings/devup-ui-binding"] [workspace.lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } @@ -59,3 +59,7 @@ codegen-units = 1 strip = true # Abort on panic: smaller binary, no unwinding overhead panic = "abort" + +[profile.native] +inherits = "release" +opt-level = 3 diff --git a/bindings/devup-ui-binding/Cargo.toml b/bindings/devup-ui-binding/Cargo.toml new file mode 100644 index 000000000..a01eaeff6 --- /dev/null +++ b/bindings/devup-ui-binding/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "devup-ui-binding" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Synchronous native build-host adapter for Devup UI" +license = "Apache-2.0" +repository = "https://github.com/dev-five-git/devup-ui" +keywords = ["css", "react", "devup-ui", "napi"] +categories = ["development-tools", "web-programming"] + +[lib] +crate-type = ["cdylib"] +test = false +doctest = false + +[lints] +workspace = true + +[dependencies] +devup-ui-binding-core = { path = "../../libs/binding" } +extractor = { path = "../../libs/extractor" } +sheet = { path = "../../libs/sheet" } +napi = { version = "3.14.2", features = ["napi6"] } +napi-derive = "3.6.12" +serde = { version = "1.0.229", features = ["derive"] } +bimap = { version = "0.6.3", features = ["serde"] } + +[build-dependencies] +napi-build = "2.6.0" diff --git a/bindings/devup-ui-binding/README.md b/bindings/devup-ui-binding/README.md new file mode 100644 index 000000000..bed642b04 --- /dev/null +++ b/bindings/devup-ui-binding/README.md @@ -0,0 +1,9 @@ +# Native build-host adapter (WIP) + +`adapter.cjs` privately exports `createBinding(raw)`; its returned object explicitly exposes the existing 29 WASM names. Raw addon helpers, positional requests and `interop.cjs` are not package ABI. No public loader or npm manifest is provided yet. + +The synchronous Unknown-backed decoder calls the existing engine Serde implementations, without JSON preconversion. JSON.stringify is used only after invalid-type selection for the generated-WASM debug diagnostic. Resolver handles are per environment; replacement, both resets and teardown release them. Native state still does not support concurrent independent builds. + +The decoder is private and target-bounded: unused Serde hints use trait forwarding, not an additional public compatibility API. New engine Deserialize shapes require fresh boundary and real-host coverage evidence. + +Malformed prefix policy `msg_f4283cd7dea1` is unresolved. Valid strings/nullish values are implemented; nonstrings currently encounter native argument rejection, not emulated WASM traps. This is not all-input parity or acceptance. No builds/tests/coverage have run for this implementation; main must perform same-revision Node/Bun differentials and real Node standard-tarpaulin attribution, including private error paths and finalization. diff --git a/bindings/devup-ui-binding/adapter.cjs b/bindings/devup-ui-binding/adapter.cjs new file mode 100644 index 000000000..9a7114a5c --- /dev/null +++ b/bindings/devup-ui-binding/adapter.cjs @@ -0,0 +1,151 @@ +'use strict' +const { withAccess } = require('./interop.cjs') +const encoder = new TextEncoder() +const decoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) +function string(value) { + if (typeof value === 'string') return value + const length = value.length + void (+length | 0) + const byte = new Uint8Array(1) + let offset = 0, + text = '' + for (; offset < length; offset++) { + const code = value.charCodeAt(offset) + if (code > 127) break + byte[0] = code + text += String.fromCharCode(byte[0]) + } + if (offset !== length) { + const tail = offset === 0 ? value : value.slice(offset) + const view = new Uint8Array(tail.length * 3) + const { written } = encoder.encodeInto(tail, view) + text += decoder.decode(view.subarray(0, written)) + } + return text +} +exports.createBinding = function createBinding(raw) { + raw.init() + const call = (fn, value) => withAccess((access) => fn(value, access)) + const boolean = (value) => (+value | 0) !== 0 + const index = (value) => (value == null ? undefined : value >>> 0) + function codeExtract( + filename, + code, + packageName, + cssDir, + singleCss, + importCode, + importCss, + aliases, + ) { + return call(raw.extract, [ + string(filename), + string(code), + string(packageName), + string(cssDir), + boolean(singleCss), + boolean(importCode), + boolean(importCss), + aliases, + true, + ]) + } + function codeExtractWithoutSourceMap( + filename, + code, + packageName, + cssDir, + singleCss, + importCode, + importCss, + aliases, + ) { + return call(raw.extract, [ + string(filename), + string(code), + string(packageName), + string(cssDir), + boolean(singleCss), + boolean(importCode), + boolean(importCss), + aliases, + false, + ]) + } + function setModuleResolver(resolver) { + raw.setResolver( + resolver == null + ? undefined + : (specifier, importer) => { + try { + const result = resolver.call(null, specifier, importer) + const path = Reflect.get(result, 'path') + if (typeof path !== 'string') return undefined + const code = Reflect.get(result, 'code') + return typeof code === 'string' ? { path, code } : undefined + } catch { + return undefined + } + }, + ) + } + function seedFileMap(files) { + void (files.length * 4) + const copied = [] + for (let i = 0; i < files.length; i++) copied.push(files[i]) + copied.length = +files.length >>> 0 + for (const value of copied) + if (typeof value !== 'string') + throw new Error('array contains a value of the wrong type') + raw.seedFileMap(copied) + } + if (Symbol.dispose) + raw.Output.prototype[Symbol.dispose] = raw.Output.prototype.free + return { + Output: raw.Output, + codeExtract, + codeExtractWithoutSourceMap, + exportCanonicalMap: raw.exportCanonicalMap, + exportClassMap: raw.exportClassMap, + exportFileMap: raw.exportFileMap, + exportSheet: raw.exportSheet, + getCss: (file, main) => raw.getCss(index(file), boolean(main)), + getDefaultTheme: raw.getDefaultTheme, + getPrefix: raw.getPrefix, + getThemeInterface: (pkg, color, typography, length, shadows, theme) => + raw.getThemeInterface(string(pkg), [ + string(color), + string(typography), + string(length), + string(shadows), + string(theme), + ]), + getUnreservedClassSlotCount: raw.getUnreservedClassSlotCount, + hasDevupUI: (file, code, pkg) => + raw.hasDevupUi(string(file), string(code), string(pkg)), + importCanonicalMap: (value) => call(raw.importCanonicalMap, value), + importClassMap: (value) => call(raw.importClassMap, value), + importFileMap: (value) => call(raw.importFileMap, value), + importFileRoutes: (value) => call(raw.importFileRoutes, value), + importSheet: (value) => call(raw.importSheet, value), + isDebug: raw.isDebug, + registerShorthands: (value) => call(raw.registerShorthands, value), + registerTheme: (value) => call(raw.registerTheme, value), + reserveClassSlots: (rows, pkg, single, code, css, aliases) => + call(raw.reserve, [ + rows, + string(pkg), + boolean(single), + boolean(code), + boolean(css), + aliases, + ]), + resetBuildState: () => raw.reset(false), + resetStateForTesting: () => raw.reset(true), + seedFileMap, + setAtomHoist: (value) => raw.setAtomHoist(index(value)), + setDebug: (value) => raw.setDebug(boolean(value)), + setModuleResolver, + setPrefix: (value) => raw.setPrefix(value == null ? undefined : value), + } +} diff --git a/bindings/devup-ui-binding/build.rs b/bindings/devup-ui-binding/build.rs new file mode 100644 index 000000000..0f1b01002 --- /dev/null +++ b/bindings/devup-ui-binding/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/bindings/devup-ui-binding/interop.cjs b/bindings/devup-ui-binding/interop.cjs new file mode 100644 index 000000000..00e1cfb06 --- /dev/null +++ b/bindings/devup-ui-binding/interop.cjs @@ -0,0 +1,147 @@ +'use strict' +function instance(value, type) { + try { + return value instanceof type + } catch { + return false + } +} +function debugString(value) { + if (value == null) return `${value}` + switch (typeof value) { + case 'number': + case 'boolean': + return `${value}` + case 'string': + return `"${value}"` + case 'symbol': { + const description = value.description + return description == null ? 'Symbol' : `Symbol(${description})` + } + case 'function': { + const name = value.name + return typeof name === 'string' && name.length > 0 + ? `Function(${name})` + : 'Function' + } + default: + break + } + if (Array.isArray(value)) { + const length = value.length + let text = '[' + if (length > 0) text += debugString(value[0]) + for (let i = 1; i < length; i++) text += ', ' + debugString(value[i]) + return text + ']' + } + const matches = /\[object ([^\]]+)\]/.exec(toString.call(value)) + if (!matches || matches.length < 2) return toString.call(value) + const name = matches[1] + if (name === 'Object') { + try { + return 'Object(' + JSON.stringify(value) + ')' + } catch { + return 'Object' + } + } + if (value instanceof Error) + return `${value.name}: ${value.message}\n${value.stack}` + return name +} +function bytes(value) { + const source = instance(value, Uint8Array) + ? value + : instance(value, ArrayBuffer) + ? new Uint8Array(value) + : undefined + if (source === undefined) return undefined + const copy = new Uint8Array(source.length) + Uint8Array.prototype.set.call(copy, source) + return Array.from(copy) +} +exports.withAccess = function withAccess(run) { + let failure + const access = (name, value, key) => { + let wrapped = false + try { + let result + switch (name) { + case 'array': + result = Array.isArray(value) + break + case 'safeInt': + result = Number.isSafeInteger(value) + break + case 'bytes': + result = bytes(value) + break + case 'debug': + result = `JsValue(${debugString(value)})` + break + case 'bigint': + result = String(value) + break + case 'undefined': + result = undefined + break + case 'anyMap': + result = !(Symbol.iterator in value) || instance(value, Map) + break + case 'entries': + result = Object.entries(value) + break + case 'length': + result = value.length >>> 0 + break + case 'index': + result = value[Number(key)] + break + case 'pair': + result = [value[0], value[1]] + break + case 'field': { + const field = value[key] + result = [field !== undefined || key in value, field] + break + } + case 'iter': { + wrapped = true + const method = Reflect.get(value, Symbol.iterator) + if (typeof method !== 'function') break + const iterator = method.call(value) + wrapped = false + result = + typeof iterator === 'object' && + iterator !== null && + typeof iterator.next === 'function' + ? iterator + : undefined + break + } + case 'next': { + wrapped = true + const next = value.next() + wrapped = false + result = + (+next.done | 0) !== 0 ? [true, undefined] : [false, next.value] + break + } + default: + throw new Error(`unknown private access operation: ${name}`) + } + return [true, result] + } catch (error) { + failure = () => { + if (wrapped) throw new Error(String(error)) + throw error + } + return [false, undefined] + } + } + try { + return run(access) + } catch (error) { + if (failure) return failure() + throw error + } +} diff --git a/bindings/devup-ui-binding/src/access.rs b/bindings/devup-ui-binding/src/access.rs new file mode 100644 index 000000000..de3c8fd77 --- /dev/null +++ b/bindings/devup-ui-binding/src/access.rs @@ -0,0 +1,186 @@ +use crate::value::{De, Error, item}; +use napi::ValueType; +use napi::bindgen_prelude::{Array, Unknown}; +use serde::Deserializer as _; +use serde::de::{self, DeserializeSeed, Error as _, IntoDeserializer, Visitor}; + +/// A snapshot-length array traversal or an owned scoped JS iterator traversal. +pub(crate) struct Sequence<'a, 'env> { + source: De<'a, 'env>, + range: Option>, +} +impl<'a, 'env> Sequence<'a, 'env> { + pub(crate) fn new(source: De<'a, 'env>, pairs: bool) -> Result, Error> { + if !pairs && source.op::("array", None)? { + return Self::array(source).map(Some); + } + let iterator: Unknown<'env> = source.op("iter", None)?; + if iterator.get_type()? != ValueType::Undefined { + return Ok(Some(Self { + source: source.child(iterator), + range: None, + })); + } + if pairs && source.kind()? == ValueType::Object { + return Self::array(source.child(source.op("entries", None)?)).map(Some); + } + Ok(None) + } + fn array(source: De<'a, 'env>) -> Result { + Ok(Self { + range: Some(0..source.op("length", None)?), + source, + }) + } + fn next(&mut self) -> Result>, Error> { + if let Some(range) = &mut self.range { + range + .next() + .map(|index| { + self.source + .op("index", Some(index.to_string())) + .map(|value| self.source.child(value)) + }) + .transpose() + } else { + let result: Array<'env> = self.source.op("next", None)?; + if item::(&result, 0)? { + Ok(None) + } else { + Ok(Some(self.source.child(item(&result, 1)?))) + } + } + } +} +impl<'de> de::SeqAccess<'de> for Sequence<'_, '_> { + type Error = Error; + fn next_element_seed>( + &mut self, + seed: T, + ) -> Result, Error> { + self.next()? + .map(|value| seed.deserialize(value)) + .transpose() + } + fn size_hint(&self) -> Option { + self.range.as_ref().map(std::iter::ExactSizeIterator::len) + } +} +enum Keys<'a, 'env> { + Pairs(Sequence<'a, 'env>), + Fields(De<'a, 'env>, std::slice::Iter<'static, &'static str>), +} +/// Distinct pair-stream and declared-field map access, retaining a read-once value. +pub(crate) struct Entries<'a, 'env> { + keys: Keys<'a, 'env>, + value: De<'a, 'env>, +} +impl<'a, 'env> Entries<'a, 'env> { + pub(crate) const fn pairs(sequence: Sequence<'a, 'env>) -> Self { + let value = sequence.source; + Self { + keys: Keys::Pairs(sequence), + value, + } + } + pub(crate) fn fields(source: De<'a, 'env>, fields: &'static [&'static str]) -> Self { + Self { + keys: Keys::Fields(source, fields.iter()), + value: source, + } + } +} +impl<'de> de::MapAccess<'de> for Entries<'_, '_> { + type Error = Error; + fn next_key_seed>( + &mut self, + seed: K, + ) -> Result, Error> { + match &mut self.keys { + Keys::Pairs(sequence) => match sequence.next()? { + Some(pair) => { + let pair_values: Array<'_> = pair.op("pair", None)?; + self.value = pair.child(item(&pair_values, 1)?); + seed.deserialize(pair.child(item(&pair_values, 0)?)) + .map(Some) + } + None => Ok(None), + }, + Keys::Fields(source, fields) => { + for field in fields { + let result: Array<'_> = source.op("field", Some((*field).to_string()))?; + if item::(&result, 0)? { + self.value = source.child(item(&result, 1)?); + return seed.deserialize((*field).into_deserializer()).map(Some); + } + } + Ok(None) + } + } + } + fn next_value_seed>(&mut self, seed: V) -> Result { + seed.deserialize(self.value) + } +} +/// Externally tagged enums use Object.entries, never iterable-map dispatch. +pub(crate) struct Variant<'a, 'env> { + tag: De<'a, 'env>, + payload: De<'a, 'env>, +} +impl<'a, 'env> Variant<'a, 'env> { + pub(crate) fn new(source: De<'a, 'env>, expected: &dyn de::Expected) -> Result { + match source.kind()? { + ValueType::String => Ok(Self { + tag: source, + payload: source.child(source.op("undefined", None)?), + }), + ValueType::Object => { + let entries: Unknown<'env> = source.op("entries", None)?; + let entries = source.child(entries); + let len: u32 = entries.op("length", None)?; + if len != 1 { + return Err(Error::invalid_length( + usize::try_from(len).map_err(Error::custom)?, + &"1", + )); + } + let pair = entries.child(entries.op("index", Some("0".to_string()))?); + let values: Array<'env> = pair.op("pair", None)?; + Ok(Self { + tag: source.child(item(&values, 0)?), + payload: source.child(item(&values, 1)?), + }) + } + _ => Err(source.invalid(expected)?), + } + } +} +impl<'de, 'a, 'env> de::EnumAccess<'de> for Variant<'a, 'env> { + type Error = Error; + type Variant = De<'a, 'env>; + fn variant_seed>( + self, + seed: V, + ) -> Result<(V::Value, Self::Variant), Error> { + Ok((seed.deserialize(self.tag)?, self.payload)) + } +} +impl<'de> de::VariantAccess<'de> for De<'_, '_> { + type Error = Error; + fn unit_variant(self) -> Result<(), Error> { + serde::Deserialize::deserialize(self) + } + fn newtype_variant_seed>(self, seed: T) -> Result { + seed.deserialize(self) + } + fn tuple_variant>(self, len: usize, visitor: V) -> Result { + self.deserialize_tuple(len, visitor) + } + fn struct_variant>( + self, + fields: &'static [&'static str], + visitor: V, + ) -> Result { + self.deserialize_struct("", fields, visitor) + } +} diff --git a/bindings/devup-ui-binding/src/de.rs b/bindings/devup-ui-binding/src/de.rs new file mode 100644 index 000000000..ead01cce4 --- /dev/null +++ b/bindings/devup-ui-binding/src/de.rs @@ -0,0 +1,139 @@ +use crate::access::{Entries, Sequence, Variant}; +use crate::value::{De, Error}; +use napi::ValueType; +use napi::bindgen_prelude::FromNapiValue; +use serde::de::{self, Error as _, Visitor}; + +macro_rules! integers { + ($method:ident: $($name:ident),+) => {$ ( + fn $name>(self, visitor: V) -> Result { + self.$method(visitor) + } + )+}; +} +impl<'de> de::Deserializer<'de> for De<'_, '_> { + type Error = Error; + fn deserialize_any>(self, visitor: V) -> Result { + match self.kind()? { + ValueType::Null | ValueType::Undefined => visitor.visit_unit(), + ValueType::Boolean => visitor.visit_bool(bool::from_unknown(self.value)?), + ValueType::Number => { + if self.op::("safeInt", None)? { + visitor.visit_i64(i64::from_unknown(self.value)?) + } else { + visitor.visit_f64(f64::from_unknown(self.value)?) + } + } + ValueType::BigInt => { + let text: String = self.op("bigint", None)?; + match text.parse::() { + Ok(value) => visitor.visit_i64(value), + Err(_) => visitor.visit_u64(text.parse::().map_err(|_| Error::custom( + "Couldn't deserialize i64 or u64 from a BigInt outside i64::MIN..u64::MAX bounds"))?), + } + } + ValueType::String => visitor.visit_string(String::from_unknown(self.value)?), + ValueType::Object => { + if self.op::("array", None)? { + return self.deserialize_seq(visitor); + } + if let Some(bytes) = self.op::>>("bytes", None)? { + return visitor.visit_byte_buf(bytes); + } + if self.op::("anyMap", None)? { + return self.deserialize_map(visitor); + } + Err(self.invalid(&visitor)?) + } + _ => Err(self.invalid(&visitor)?), + } + } + fn deserialize_bool>(self, visitor: V) -> Result { + if self.kind()? == ValueType::Boolean { + visitor.visit_bool(bool::from_unknown(self.value)?) + } else { + Err(self.invalid(&visitor)?) + } + } + integers!(unsigned: deserialize_u16, deserialize_u32); + fn deserialize_u64>(self, visitor: V) -> Result { + if self.kind()? == ValueType::BigInt { + visitor.visit_u64(self.bigint("u64", "u64::MIN..u64::MAX")?) + } else { + self.unsigned(visitor) + } + } + fn deserialize_string>(self, visitor: V) -> Result { + if self.kind()? == ValueType::String { + visitor.visit_string(String::from_unknown(self.value)?) + } else { + Err(self.invalid(&visitor)?) + } + } + fn deserialize_unit>(self, visitor: V) -> Result { + if self.nullish()? { + visitor.visit_unit() + } else { + Err(self.invalid(&visitor)?) + } + } + fn deserialize_option>(self, visitor: V) -> Result { + if self.nullish()? { + visitor.visit_none() + } else { + visitor.visit_some(self) + } + } + fn deserialize_newtype_struct>( + self, + _: &'static str, + visitor: V, + ) -> Result { + visitor.visit_newtype_struct(self) + } + fn deserialize_seq>(self, visitor: V) -> Result { + match Sequence::new(self, false)? { + Some(sequence) => visitor.visit_seq(sequence), + None => Err(self.invalid(&visitor)?), + } + } + fn deserialize_map>(self, visitor: V) -> Result { + match Sequence::new(self, true)? { + Some(sequence) => visitor.visit_map(Entries::pairs(sequence)), + None => Err(self.invalid(&visitor)?), + } + } + fn deserialize_struct>( + self, + _: &'static str, + fields: &'static [&'static str], + visitor: V, + ) -> Result { + if self.kind()? == ValueType::Object { + visitor.visit_map(Entries::fields(self, fields)) + } else { + Err(self.invalid(&visitor)?) + } + } + fn deserialize_enum>( + self, + _: &'static str, + _: &'static [&'static str], + visitor: V, + ) -> Result { + let variant = Variant::new(self, &visitor)?; + visitor.visit_enum(variant) + } + fn deserialize_ignored_any>(self, visitor: V) -> Result { + visitor.visit_unit() + } + serde::forward_to_deserialize_any! { + char i8 i16 i32 i64 i128 u8 u128 f32 f64 str bytes byte_buf unit_struct tuple_struct + } + fn deserialize_identifier>(self, visitor: V) -> Result { + self.deserialize_string(visitor) + } + fn deserialize_tuple>(self, _: usize, visitor: V) -> Result { + self.deserialize_seq(visitor) + } +} diff --git a/bindings/devup-ui-binding/src/host.rs b/bindings/devup-ui-binding/src/host.rs new file mode 100644 index 000000000..c8c4ace33 --- /dev/null +++ b/bindings/devup-ui-binding/src/host.rs @@ -0,0 +1,170 @@ +use crate::output::NativeOutput; +use crate::value::{Access, decode}; +use devup_ui_binding_core as core; +use extractor::{ExtractOption, ImportAlias, ModuleResolver, ResolvedModule}; +use napi::bindgen_prelude::{Array, FnArgs, FromNapiValue, FunctionRef, Unknown}; +use napi::{Env, Error, Result}; +use napi_derive::napi; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +/// Fresh private resolver return; no original callback result reaches native code. +#[napi(object)] +pub struct Resolved { + /// Resolved physical filename. + pub path: String, + /// Resolved source contents. + pub code: String, +} +type Resolver = FunctionRef, Option>; +struct State(Option>); + +/// Initialize environment-local ownership without overwriting existing state. +#[napi] +pub fn init(env: Env) -> Result<()> { + if env.get_instance_data::()?.is_none() { + env.set_instance_data(State(None), (), |context| drop(context.value))?; + } + Ok(()) +} +/// Replace the owned synchronous resolver in the current environment only. +#[napi] +pub fn set_resolver(env: Env, resolver: Option) -> Result<()> { + state(&env)?.0 = resolver.map(Rc::new); + Ok(()) +} +fn state(env: &Env) -> Result<&'static mut State> { + env.get_instance_data()? + .ok_or_else(|| Error::from_reason("native facade is not initialized")) +} +/// Keep production and full-test reset effects distinct, clearing the resolver in both. +#[napi] +pub fn reset(env: Env, full: bool) -> Result<()> { + let state = state(&env)?; + if full { + core::reset_state_internal(|| state.0 = None); + } else { + core::reset_build_state_internal(); + state.0 = None; + } + Ok(()) +} +fn with_resolver( + env: Env, + action: impl FnOnce(Option<&ModuleResolver>) -> Result, +) -> Result { + let current = state(&env)?.0.clone(); + let failure = Rc::new(RefCell::new(None)); + let resolver = current.map(|current| { + let failure = Rc::clone(&failure); + move |specifier: &str, importer: &str| match current.borrow_back(&env).and_then( + |callback| callback.call((specifier.to_string(), importer.to_string()).into()), + ) { + Ok(module) => module.map(|module| ResolvedModule { + path: module.path, + code: module.code, + }), + Err(error) => { + *failure.borrow_mut() = Some(error); + None + } + } + }); + let result = action( + resolver + .as_ref() + .map(|resolver| resolver as &ModuleResolver), + ); + match failure.take() { + Some(error) => Err(error), + None => result, + } +} +fn arg(request: &Array<'_>, index: u32) -> Result { + request + .get(index)? + .ok_or_else(|| Error::from_reason("missing private request field")) +} +fn aliases<'env>( + value: Unknown<'env>, + access: &Access<'env>, +) -> Result> { + let values: HashMap> = decode(value, access)?; + Ok(values + .into_iter() + .map(|(key, value)| { + ( + key, + match value { + Some(name) => ImportAlias::DefaultToNamed(name), + None => ImportAlias::NamedToNamed, + }, + ) + }) + .collect()) +} +/// Private positional request retains both public eight-argument extraction signatures. +#[napi] +pub fn extract<'env>(env: Env, request: Array<'env>, access: Access<'env>) -> Result { + let filename: String = arg(&request, 0)?; + let code: String = arg(&request, 1)?; + let option = ExtractOption { + package: arg(&request, 2)?, + css_dir: arg(&request, 3)?, + single_css: arg(&request, 4)?, + import_main_css: arg(&request, 5)?, + import_aliases: aliases(arg(&request, 7)?, &access)?, + }; + let import_main_css_in_css = arg(&request, 6)?; + let source_map = if arg(&request, 8)? { + core::SourceMapMode::Generate + } else { + core::SourceMapMode::Skip + }; + with_resolver(env, |resolver| { + core::code_extract( + (&filename, &code), + option, + core::ExtractionMode { + source_map, + resolver, + import_main_css_in_css, + }, + ) + .map(|output| NativeOutput(Some(output))) + .map_err(Error::from_reason) + }) +} +/// Prepare the entire reservation packet before any core allocation or scratch state. +#[napi] +pub fn reserve<'env>(env: Env, request: Array<'env>, access: Access<'env>) -> Result<()> { + let rows: Vec> = decode(arg(&request, 0)?, &access)?; + let inputs = rows + .into_iter() + .enumerate() + .map(|(index, row)| { + let [filename, code, css_dir]: [String; 3] = + row.try_into().map_err(|row: Vec| { + Error::from_reason(format!( + "reservation row {index} must contain exactly 3 strings, got {} fields", + row.len() + )) + })?; + Ok(core::ReservationInput { + filename, + code, + css_dir, + }) + }) + .collect::>>()?; + let option = ExtractOption { + package: arg(&request, 1)?, + single_css: arg(&request, 2)?, + import_main_css: arg(&request, 3)?, + import_aliases: aliases(arg(&request, 5)?, &access)?, + ..ExtractOption::default() + }; + with_resolver(env, |resolver| { + core::reserve_class_slots_internal(&inputs, option, resolver); + Ok(()) + }) +} diff --git a/bindings/devup-ui-binding/src/lib.rs b/bindings/devup-ui-binding/src/lib.rs new file mode 100644 index 000000000..9fae011c6 --- /dev/null +++ b/bindings/devup-ui-binding/src/lib.rs @@ -0,0 +1,147 @@ +//! Private native implementation; adapter.cjs alone defines the package ABI. +mod access; +mod de; +mod host; +mod output; +mod value; + +pub use host::{Resolved, extract, init, reserve, reset, set_resolver}; +pub use output::NativeOutput; + +use devup_ui_binding_core as core; +use napi::bindgen_prelude::{Either, Undefined, Unknown}; +use napi::{Error, Result}; +use napi_derive::napi; +use std::collections::{BTreeMap, HashMap, HashSet}; +use value::{Access, Wasm32Index, decode, widen_index}; + +macro_rules! import { + ($name:ident, $target:path, $kind:ty, $doc:literal) => { + #[doc = $doc] + #[napi] + pub fn $name<'env>(value: Unknown<'env>, access: Access<'env>) -> Result<()> { + $target(decode::<$kind>(value, &access)?); + Ok(()) + } + }; +} +import!( + import_sheet, + core::import_sheet_internal, + sheet::StyleSheet, + "Restore a completely decoded sheet." +); +import!( + register_theme, + core::register_theme_internal, + sheet::theme::Theme, + "Register a completely decoded theme." +); +import!(register_shorthands, core::register_shorthands_internal, BTreeMap>, "Register typed shorthand targets."); +import!(import_canonical_map, core::import_canonical_map_internal, HashMap, "Restore canonical bucket identities."); +import!(import_file_routes, core::import_file_routes_internal, HashMap>, "Restore iterable route sets."); +/// Restore wasm32-sized class slots, widening only after complete decoding. +#[napi] +pub fn import_class_map<'env>(value: Unknown<'env>, access: Access<'env>) -> Result<()> { + let map: HashMap> = decode(value, &access)?; + core::import_class_map_internal( + map.into_iter() + .map(|(file, entries)| { + ( + file, + entries + .into_iter() + .map(|(key, index)| (key, index.0)) + .collect(), + ) + }) + .collect(), + ); + Ok(()) +} +/// Restore file slots using the same bimap decoding and wasm32 width as WASM. +#[napi] +pub fn import_file_map<'env>(value: Unknown<'env>, access: Access<'env>) -> Result<()> { + let map: bimap::BiHashMap = decode(value, &access)?; + core::import_file_map_internal( + map.into_iter() + .map(|(file, index)| (file, index.0)) + .collect(), + ); + Ok(()) +} +macro_rules! export { + ($name:ident, $target:path) => { + #[doc = "Return the unchanged core serialization, without normalization."] + #[napi] + pub fn $name() -> Result { + $target().map_err(Error::from_reason) + } + }; +} +export!(export_sheet, core::export_sheet_internal); +export!(export_class_map, core::export_class_map_internal); +export!(export_file_map, core::export_file_map_internal); +export!(export_canonical_map, core::export_canonical_map_internal); +/// Return the optional prefix as a string or undefined. +#[napi] +pub fn get_prefix() -> Either { + core::get_prefix().into() +} +/// Install a valid string/nullish prefix; malformed wrapper policy remains separate. +#[napi] +pub fn set_prefix(value: Option) { + core::set_prefix(value); +} +/// Return the default theme as a string or undefined. +#[napi] +pub fn get_default_theme() -> Either { + core::get_default_theme().into() +} +/// Set debug after facade WASM-i32 conversion. +#[napi] +pub fn set_debug(value: bool) { + core::set_debug(value); +} +/// Read debug state. +#[napi] +pub fn is_debug() -> bool { + core::is_debug() +} +/// Set hoisting after facade nullish/unsigned-i32 conversion. +#[napi] +pub fn set_atom_hoist(value: Option) { + core::set_atom_hoist(value.map(widen_index)); +} +/// Materialize CSS for the facade's converted file number. +#[napi] +pub fn get_css(value: Option, import_main: bool) -> String { + core::get_css(value.map(widen_index), import_main) +} +/// Seed the array-like wrapper's copied strings, not arbitrary JS iterables. +#[napi] +pub fn seed_file_map(files: Vec) { + core::seed_file_map(&files); +} +/// Return the live allocation count as a JS number, never a `BigInt`. +#[napi] +pub fn get_unreserved_class_slot_count() -> u32 { + let [a, b, c, d, ..] = core::get_unreserved_class_slot_count().to_le_bytes(); + u32::from_le_bytes([a, b, c, d]) +} +/// Check the existing extractor's import detection. +#[napi] +pub fn has_devup_ui(filename: String, code: String, package: String) -> bool { + core::has_devup_ui(&filename, &code, &package) +} +/// Generate declarations with the existing five interface-name slots. +#[napi] +pub fn get_theme_interface(package: String, names: Vec) -> Result { + let [color, typography, length, shadows, theme]: [String; 5] = names + .try_into() + .map_err(|_| Error::from_reason("invalid private interface-name request"))?; + Ok(core::get_theme_interface( + &package, + [&color, &typography, &length, &shadows, &theme], + )) +} diff --git a/bindings/devup-ui-binding/src/output.rs b/bindings/devup-ui-binding/src/output.rs new file mode 100644 index 000000000..d8ee2a7b3 --- /dev/null +++ b/bindings/devup-ui-binding/src/output.rs @@ -0,0 +1,60 @@ +use napi::bindgen_prelude::{Either, Undefined}; +use napi::{Error, Result}; +use napi_derive::napi; + +/// Materialized native output with an explicitly consumable payload. +#[napi(js_name = "Output")] +#[derive(Default)] +pub struct NativeOutput(pub(crate) Option); +impl NativeOutput { + fn live(&self) -> Result<&devup_ui_binding_core::Output> { + self.0 + .as_ref() + .ok_or_else(|| Error::from_reason("null pointer passed to rust")) + } +} +#[napi] +impl NativeOutput { + /// Construct the same empty shell allowed by the generated WASM class. + #[napi(constructor)] + pub const fn new() -> Self { + Self(None) + } + /// Consume the payload, rejecting a shell or a previously released output. + #[napi] + pub fn free(&mut self) -> Result<()> { + self.live()?; + self.0 = None; + Ok(()) + } + /// Copy transformed source from a live output. + #[napi(getter)] + pub fn code(&self) -> Result { + Ok(self.live()?.code.clone()) + } + /// Copy the optional source map, using undefined for absence. + #[napi(getter)] + pub fn map(&self) -> Result> { + Ok(self.live()?.map.clone().into()) + } + /// Copy changed CSS, using undefined for absence. + #[napi(getter)] + pub fn css(&self) -> Result> { + Ok(self.live()?.css.clone().into()) + } + /// Copy the optional stylesheet filename. + #[napi(getter, js_name = "cssFile")] + pub fn css_file(&self) -> Result> { + Ok(self.live()?.css_file.clone().into()) + } + /// Return whether shared base CSS changed. + #[napi(getter, js_name = "updatedBaseStyle")] + pub fn updated_base_style(&self) -> Result { + Ok(self.live()?.updated_base_style) + } + /// Copy dependencies independently on every access. + #[napi(getter)] + pub fn dependencies(&self) -> Result> { + Ok(self.live()?.dependencies.clone()) + } +} diff --git a/bindings/devup-ui-binding/src/value.rs b/bindings/devup-ui-binding/src/value.rs new file mode 100644 index 000000000..ff9bf8206 --- /dev/null +++ b/bindings/devup-ui-binding/src/value.rs @@ -0,0 +1,145 @@ +use napi::ValueType; +use napi::bindgen_prelude::{Array, FnArgs, FromNapiValue, Function, Unknown}; +use serde::de::{self, DeserializeOwned, Error as _, Unexpected, Visitor}; +use std::fmt; + +/// Scoped access/exception operations supplied only by the private JS facade. +pub(crate) type Access<'env> = + Function<'env, FnArgs<(String, Unknown<'env>, Option)>, Array<'env>>; + +/// A Serde diagnostic, before the WASM-compatible outer Error is constructed. +#[derive(Debug)] +pub(crate) struct Error(String); +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} +impl std::error::Error for Error {} +impl de::Error for Error { + fn custom(message: T) -> Self { + Self(message.to_string()) + } +} +impl From for Error { + fn from(error: napi::Error) -> Self { + Self(error.reason) + } +} +/// One original JS value; children retain the same scoped decoder and access seam. +#[derive(Clone, Copy)] +pub(crate) struct De<'a, 'env> { + pub(crate) value: Unknown<'env>, + pub(crate) access: &'a Access<'env>, +} +/// Decode without preconversion, retaining existing engine Deserialize implementations. +pub(crate) fn decode<'env, T: DeserializeOwned>( + value: Unknown<'env>, + access: &Access<'env>, +) -> napi::Result { + T::deserialize(De { value, access }) + .map_err(|error| napi::Error::from_reason(format!("Error: {error}"))) +} +/// Read only a fresh private control envelope, never properties of an original input. +pub(crate) fn item(array: &Array<'_>, index: u32) -> Result { + array + .get(index)? + .ok_or_else(|| Error::custom("missing private access result")) +} +impl<'env> De<'_, 'env> { + pub(crate) const fn child(self, value: Unknown<'env>) -> Self { + Self { value, ..self } + } + pub(crate) fn kind(self) -> Result { + Ok(self.value.get_type()?) + } + pub(crate) fn nullish(self) -> Result { + Ok(matches!( + self.kind()?, + ValueType::Null | ValueType::Undefined + )) + } + pub(crate) fn op(self, name: &str, key: Option) -> Result { + let result = self + .access + .call((name.to_string(), self.value, key).into())?; + if !item::(&result, 0)? { + return Err(Error::custom("JavaScript access failed")); + } + item(&result, 1) + } + pub(crate) fn invalid(self, expected: &dyn de::Expected) -> Result { + Ok(match self.kind()? { + ValueType::Null | ValueType::Undefined => { + Error::invalid_type(Unexpected::Unit, expected) + } + ValueType::Boolean => { + Error::invalid_type(Unexpected::Bool(bool::from_unknown(self.value)?), expected) + } + ValueType::Number => { + Error::invalid_type(Unexpected::Float(f64::from_unknown(self.value)?), expected) + } + ValueType::String => Error::invalid_type( + Unexpected::Str(&String::from_unknown(self.value)?), + expected, + ), + _ => match self.op::>>("bytes", None)? { + Some(bytes) => Error::invalid_type(Unexpected::Bytes(&bytes), expected), + None => Error::invalid_type( + Unexpected::Other(&self.op::("debug", None)?), + expected, + ), + }, + }) + } + pub(crate) fn unsigned<'de, V: Visitor<'de>>(self, visitor: V) -> Result { + if self.op::("safeInt", None)? { + let number = i64::from_unknown(self.value)?; + if let Ok(number) = u64::try_from(number) { + return visitor.visit_u64(number); + } + } + Err(self.invalid(&visitor)?) + } + pub(crate) fn bigint( + self, + target: &str, + bounds: &str, + ) -> Result { + self.op::("bigint", None)?.parse().map_err(|_| { + Error::custom(format!( + "Couldn't deserialize {target} from a BigInt outside {bounds} bounds" + )) + }) + } +} +/// Widen on approved native hosts, whose usize byte width is always eight. +pub(crate) fn widen_index(value: u32) -> usize { + usize::from_le_bytes(u64::from(value).to_le_bytes()) +} +/// A serialized usize is wasm32-sized on every native host, with usize diagnostics. +#[derive(PartialEq, Eq, Hash)] +pub(crate) struct Wasm32Index(pub(crate) usize); +impl<'de> serde::Deserialize<'de> for Wasm32Index { + fn deserialize>(deserializer: D) -> Result { + struct IndexVisitor; + impl<'value> Visitor<'value> for IndexVisitor { + type Value = Wasm32Index; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("usize") + } + fn visit_newtype_struct>( + self, + deserializer: D, + ) -> Result { + deserializer.deserialize_u64(self) + } + fn visit_u64(self, value: u64) -> Result { + let value = u32::try_from(value) + .map_err(|_| E::invalid_value(Unexpected::Unsigned(value), &self))?; + Ok(Wasm32Index(widen_index(value))) + } + } + deserializer.deserialize_newtype_struct("Wasm32Index", IndexVisitor) + } +} diff --git a/bindings/devup-ui-wasm/Cargo.toml b/bindings/devup-ui-wasm/Cargo.toml index 9b58d6b74..539a48779 100644 --- a/bindings/devup-ui-wasm/Cargo.toml +++ b/bindings/devup-ui-wasm/Cargo.toml @@ -14,6 +14,7 @@ categories = ["development-tools", "wasm", "web-programming"] crate-type = ["cdylib"] [dependencies] +devup-ui-binding-core = { path = "../../libs/binding" } wasm-bindgen = "0.2.129" extractor = { path = "../../libs/extractor" } sheet = { path = "../../libs/sheet" } diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 3035834a1..5a98ecb7a 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -3,21 +3,26 @@ pub use reservation::{ ReservationInput, get_unreserved_class_slot_count, reserve_class_slots_internal, }; -use css::class_map::{set_class_map, with_class_map}; -use css::file_map::{ - canonical, is_global, set_canonical_map, set_file_map, with_canonical_map, with_file_map, +use binding_core::SourceMapMode; +#[cfg(test)] +use binding_core::{ + GLOBAL_STYLE_SHEET, import_class_map_internal as set_class_map, + import_file_map_internal as set_file_map, with_style_sheet, with_style_sheet_mut, }; +#[cfg(test)] +use css::file_map::canonical; +use devup_ui_binding_core as binding_core; +#[cfg(test)] +use extractor::extract; +#[cfg(test)] use extractor::extract_style::extract_style_value::ExtractStyleValue; -use extractor::{ - ExtractOption, ImportAlias, ModuleResolver, ResolvedModule, extract, extract_with_modules, - extract_without_source_map, has_devup_ui, -}; +use extractor::{ExtractOption, ImportAlias, ModuleResolver, ResolvedModule}; +#[cfg(test)] use rustc_hash::FxHashSet; use sheet::StyleSheet; use std::cell::RefCell; use std::collections::{BTreeMap, HashMap}; use std::fmt::Display; -use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; mod state; @@ -27,39 +32,10 @@ pub use state::{reset_state_for_testing, reset_state_internal}; #[cfg(test)] mod css_output_edge_tests; -static GLOBAL_STYLE_SHEET: LazyLock> = - LazyLock::new(|| Mutex::new(StyleSheet::default())); - -#[derive(Clone, Copy)] -enum SourceMapMode { - Generate, - Skip, -} - thread_local! { static MODULE_RESOLVER: RefCell> = const { RefCell::new(None) }; } -fn with_style_sheet(f: F) -> R -where - F: FnOnce(&StyleSheet) -> R, -{ - let guard = GLOBAL_STYLE_SHEET - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - f(&guard) -} - -fn with_style_sheet_mut(f: F) -> R -where - F: FnOnce(&mut StyleSheet) -> R, -{ - let mut guard = GLOBAL_STYLE_SHEET - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - f(&mut guard) -} - #[cfg(not(tarpaulin_include))] fn js_error(message: impl Display) -> JsValue { js_sys::Error::new(&message.to_string()).into() @@ -74,8 +50,23 @@ pub struct Output { css: Option, dependencies: Vec, } + +impl From for Output { + fn from(output: binding_core::Output) -> Self { + Self { + code: output.code, + map: output.map, + css_file: output.css_file, + updated_base_style: output.updated_base_style, + css: output.css, + dependencies: output.dependencies, + } + } +} + #[wasm_bindgen] impl Output { + #[cfg(test)] #[allow(clippy::too_many_arguments)] fn new( code: String, @@ -87,44 +78,21 @@ impl Output { import_main_css: bool, dependencies: Vec, ) -> Self { - // Use the bucket identity (single-importer collapse) so the sheet's CSS - // naming + property bucket + emitted chunk match the canonical class names - // the extractor already baked into `code`. Identity when no map is loaded. - // Global (shared-chunk) files are treated like single-css: emitted into the - // global bucket (devup-ui.css) with prefix-less naming. - let _atom_mode = css::atom_hoist::AtomModeGuard::new(single_css); - let canonical_filename = canonical(&filename); - let global = single_css || is_global(&filename); - with_style_sheet_mut(|sheet| { - // globalCss (@font-face / global selectors) is per-SOURCE-file, never - // collapsed. rm_global_css MUST use the RAW filename so a collapsed - // member (sharing the bucket-root's canonical) never wipes the root's - // globalCss. Atom property bucketing still uses canonical_filename. - let default_collected = sheet.rm_global_css(&filename, global); - let (collected, updated_base_style) = - sheet.update_styles_in_bucket(&styles, &canonical_filename, global); - Self { + binding_core::Output::new( + extractor::ExtractOutput { code, + styles, map, css_file, dependencies, - updated_base_style: updated_base_style || default_collected, - css: { - if !collected && !default_collected { - None - } else { - Some(sheet.create_css( - if global { - None - } else { - Some(&canonical_filename) - }, - import_main_css, - )) - } - }, - } - }) + }, + filename, + binding_core::CssOptions { + single_css, + import_main_css, + }, + ) + .into() } /// Get the code @@ -170,13 +138,13 @@ impl Output { #[wasm_bindgen(js_name = "setDebug")] pub fn set_debug(debug: bool) { - css::debug::set_debug(debug); + binding_core::set_debug(debug); } #[wasm_bindgen(js_name = "isDebug")] #[must_use] pub fn is_debug() -> bool { - css::debug::is_debug() + binding_core::is_debug() } /// Set the CSS class name prefix @@ -221,19 +189,18 @@ pub fn is_debug() -> bool { /// ``` #[wasm_bindgen(js_name = "setPrefix")] pub fn set_prefix(prefix: Option) { - css::set_prefix(prefix); + binding_core::set_prefix(prefix); } #[wasm_bindgen(js_name = "getPrefix")] #[must_use] pub fn get_prefix() -> Option { - css::get_prefix() + binding_core::get_prefix() } /// Internal function to import a `StyleSheet` (testable without `JsValue`) pub fn import_sheet_internal(sheet: StyleSheet) { - css::atom_hoist::restore_atom_plan(sheet.atom_plan.clone()); - with_style_sheet_mut(|global_sheet| *global_sheet = sheet); + binding_core::import_sheet_internal(sheet); } #[wasm_bindgen(js_name = "importSheet")] @@ -246,8 +213,7 @@ pub fn import_sheet(sheet_object: JsValue) -> Result<(), JsValue> { /// Internal function to export `StyleSheet` as JSON string (testable without `JsValue`) pub fn export_sheet_internal() -> Result { - with_style_sheet(|sheet| serde_json::to_string(&sheet.export_snapshot())) - .map_err(|e| e.to_string()) + binding_core::export_sheet_internal() } #[wasm_bindgen(js_name = "exportSheet")] @@ -258,20 +224,15 @@ pub fn export_sheet() -> Result { /// Internal function to export class map as JSON string (testable without `JsValue`) pub fn export_class_map_internal() -> Result { - with_class_map(|map| { - let sorted: BTreeMap<&String, BTreeMap<&String, &usize>> = map - .iter() - .map(|(file, classes)| (file, classes.iter().collect())) - .collect(); - serde_json::to_string(&sorted) - }) - .map_err(|e| e.to_string()) + binding_core::export_class_map_internal() } #[wasm_bindgen(js_name = "importClassMap")] #[cfg(not(tarpaulin_include))] pub fn import_class_map(sheet_object: JsValue) -> Result<(), JsValue> { - set_class_map(serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?); + binding_core::import_class_map_internal( + serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?, + ); Ok(()) } @@ -283,17 +244,15 @@ pub fn export_class_map() -> Result { /// Internal function to export file map as JSON string (testable without `JsValue`) pub fn export_file_map_internal() -> Result { - with_file_map(|map| { - let sorted: BTreeMap<&String, &usize> = map.iter().collect(); - serde_json::to_string(&sorted) - }) - .map_err(|e| e.to_string()) + binding_core::export_file_map_internal() } #[wasm_bindgen(js_name = "importFileMap")] #[cfg(not(tarpaulin_include))] pub fn import_file_map(sheet_object: JsValue) -> Result<(), JsValue> { - set_file_map(serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?); + binding_core::import_file_map_internal( + serde_wasm_bindgen::from_value(sheet_object).map_err(js_error)?, + ); Ok(()) } @@ -305,22 +264,18 @@ pub fn export_file_map() -> Result { /// Internal function to import the canonical (bucket) map (testable without `JsValue`) pub fn import_canonical_map_internal(map: HashMap) { - set_canonical_map(map); + binding_core::import_canonical_map_internal(map); } /// Internal function to export the canonical map as JSON string (testable without `JsValue`) pub fn export_canonical_map_internal() -> Result { - with_canonical_map(|map| { - let sorted: BTreeMap<&String, &String> = map.iter().collect(); - serde_json::to_string(&sorted) - }) - .map_err(|e| e.to_string()) + binding_core::export_canonical_map_internal() } #[wasm_bindgen(js_name = "importCanonicalMap")] #[cfg(not(tarpaulin_include))] pub fn import_canonical_map(map_object: JsValue) -> Result<(), JsValue> { - set_canonical_map(serde_wasm_bindgen::from_value(map_object).map_err(js_error)?); + import_canonical_map_internal(serde_wasm_bindgen::from_value(map_object).map_err(js_error)?); Ok(()) } @@ -335,7 +290,7 @@ pub fn export_canonical_map() -> Result { /// workers reach files in. #[wasm_bindgen(js_name = "seedFileMap")] pub fn seed_file_map(files: Vec) { - css::file_map::seed_file_numbers(&files); + binding_core::seed_file_map(&files); } /// Forget everything one build left in the engine. @@ -344,15 +299,7 @@ pub fn seed_file_map(files: Vec) { /// and the module resolver, so the next build starts from its own options /// alone. Theme, shorthands and debug mode are set by every build, and stay. pub fn reset_build_state_internal() { - css::slot_reservation::reset(); - css::class_map::reset_class_map(); - css::file_map::reset_file_map(); - css::file_map::reset_canonical_map(); - css::file_routes::set_file_routes(HashMap::new()); - css::atom_hoist::set_atom_hoist(None); - css::atom_hoist::restore_atom_plan(None); - css::set_prefix(None); - with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + binding_core::reset_build_state_internal(); MODULE_RESOLVER.with_borrow_mut(|current| *current = None); } @@ -371,12 +318,12 @@ pub fn reset_build_state() { /// Pair with `importFileRoutes` to provide the file -> routes mapping. #[wasm_bindgen(js_name = "setAtomHoist")] pub fn set_atom_hoist(threshold: Option) { - css::atom_hoist::set_atom_hoist(threshold); + binding_core::set_atom_hoist(threshold); } /// Internal function to import the file -> routes map (testable without `JsValue`) pub fn import_file_routes_internal(map: HashMap>) { - css::file_routes::set_file_routes(map); + binding_core::import_file_routes_internal(map); } /// Import the file -> set-of-route-ids mapping used to decide atom hoisting. @@ -387,9 +334,7 @@ pub fn import_file_routes_internal(map: HashMap Result<(), JsValue> { - css::file_routes::set_file_routes( - serde_wasm_bindgen::from_value(map_object).map_err(js_error)?, - ); + import_file_routes_internal(serde_wasm_bindgen::from_value(map_object).map_err(js_error)?); Ok(()) } @@ -492,31 +437,16 @@ fn code_extract_internal_impl( import_main_css: import_main_css_in_code, import_aliases, }; - let extracted = match (resolver, source_map) { - (Some(resolver), mode) => extract_with_modules( - filename, - code, - option, - matches!(mode, SourceMapMode::Generate), + binding_core::code_extract( + (filename, code), + option, + binding_core::ExtractionMode { + source_map, resolver, - ), - (None, SourceMapMode::Generate) => extract(filename, code, option), - (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), - }; - - match extracted { - Ok(output) => Ok(Output::new( - output.code, - output.styles, - output.map, - single_css, - filename.to_string(), - output.css_file, import_main_css_in_css, - output.dependencies, - )), - Err(error) => Err(error.to_string()), - } + }, + ) + .map(Output::from) } /// Set how the imports of the files being extracted are resolved. @@ -656,7 +586,7 @@ pub fn code_extract_without_source_map( /// Internal function to register theme (testable without `JsValue`) pub fn register_theme_internal(theme: sheet::theme::Theme) { - with_style_sheet_mut(|sheet| sheet.set_theme(theme)); + binding_core::register_theme_internal(theme); } #[wasm_bindgen(js_name = "registerTheme")] @@ -670,7 +600,7 @@ pub fn register_theme(theme_object: JsValue) -> Result<(), JsValue> { /// Internal function to register custom style-property shorthands. pub fn register_shorthands_internal(shorthands: BTreeMap>) { - css::set_custom_shorthands(shorthands); + binding_core::register_shorthands_internal(shorthands); } #[wasm_bindgen(js_name = "registerShorthands")] @@ -684,24 +614,13 @@ pub fn register_shorthands(shorthands: JsValue) -> Result<(), JsValue> { #[wasm_bindgen(js_name = "getDefaultTheme")] #[cfg(not(tarpaulin_include))] pub fn get_default_theme() -> Result, JsValue> { - Ok(with_style_sheet(|sheet| sheet.theme.get_default_theme())) + Ok(binding_core::get_default_theme()) } #[wasm_bindgen(js_name = "getCss")] #[cfg(not(tarpaulin_include))] pub fn get_css(file_num: Option, import_main_css: bool) -> Result { - Ok(with_style_sheet(|sheet| { - if let Some(file_num) = file_num { - with_file_map(|map| { - sheet.create_css( - map.get_by_right(&file_num).map(String::as_str), - import_main_css, - ) - }) - } else { - sheet.create_css(None, import_main_css) - } - })) + Ok(binding_core::get_css(file_num, import_main_css)) } #[wasm_bindgen(js_name = "getThemeInterface")] @@ -714,23 +633,23 @@ pub fn get_theme_interface( shadows_interface_name: &str, theme_interface_name: &str, ) -> String { - with_style_sheet(|sheet| { - sheet.create_interface( - package_name, + binding_core::get_theme_interface( + package_name, + [ color_interface_name, typography_interface_name, length_interface_name, shadows_interface_name, theme_interface_name, - ) - }) + ], + ) } #[wasm_bindgen(js_name = "hasDevupUI")] #[cfg(not(tarpaulin_include))] #[must_use] pub fn has_devup_ui_wasm(filename: &str, code: &str, package: &str) -> bool { - has_devup_ui(filename, code, package) + binding_core::has_devup_ui(filename, code, package) } #[cfg(test)] diff --git a/bindings/devup-ui-wasm/src/reservation.rs b/bindings/devup-ui-wasm/src/reservation.rs index b485b671e..26ebfa7d1 100644 --- a/bindings/devup-ui-wasm/src/reservation.rs +++ b/bindings/devup-ui-wasm/src/reservation.rs @@ -1,89 +1,17 @@ //! Rule-free reservation through the real extractor and sheet allocation path. -use bimap::BiHashMap; -use css::{file_map, slot_reservation::ScratchSlots}; -use extractor::reservation_state::ScratchExtractionState; -use extractor::{ExtractOption, ModuleResolver, extract_with_modules, extract_without_source_map}; -use sheet::StyleSheet; +#[cfg(test)] +use css::file_map; +use extractor::{ExtractOption, ModuleResolver}; use wasm_bindgen::prelude::*; +#[cfg(test)] use crate::with_style_sheet_mut; +pub use devup_ui_binding_core::{ReservationInput, reserve_class_slots_internal}; mod rows; #[cfg(not(tarpaulin_include))] use crate::{MODULE_RESOLVER, call_module_resolver, import_aliases_from_js, js_error}; -/// Prepared physical input, keyed exactly as its later real transform. -pub struct ReservationInput { - pub filename: String, - pub code: String, - pub css_dir: String, -} - -struct ScratchSheet { - live: StyleSheet, - files: BiHashMap, -} - -impl Default for ScratchSheet { - fn default() -> Self { - let files = file_map::get_file_map(); - let live = with_style_sheet_mut(|sheet| { - let mut live = std::mem::take(sheet); - sheet.theme = std::mem::take(&mut live.theme); - live - }); - Self { live, files } - } -} - -impl Drop for ScratchSheet { - fn drop(&mut self) { - with_style_sheet_mut(|sheet| { - std::mem::swap(&mut self.live.theme, &mut sheet.theme); - *sheet = std::mem::take(&mut self.live); - }); - file_map::set_file_map(std::mem::take(&mut self.files)); - } -} - -/// Reserve in supplied physical-path order; suppress per-input errors, emitting nothing. -pub fn reserve_class_slots_internal( - inputs: &[ReservationInput], - option: ExtractOption, - resolver: Option<&ModuleResolver>, -) { - let _atom_mode = css::atom_hoist::AtomModeGuard::new(option.single_css); - let slots = ScratchSlots::default(); - let files: Vec = inputs.iter().map(|input| input.filename.clone()).collect(); - file_map::seed_file_numbers(&files); - for input in inputs { - let _ = file_map::get_file_num_by_filename(&input.filename); - } - let _sheet = ScratchSheet::default(); - let _extraction = ScratchExtractionState::default(); - for input in inputs { - let file_option = ExtractOption { - css_dir: input.css_dir.clone(), - ..option.clone() - }; - let extracted = match resolver { - Some(resolver) => { - extract_with_modules(&input.filename, &input.code, file_option, false, resolver) - } - None => extract_without_source_map(&input.filename, &input.code, file_option), - }; - if let Ok(output) = extracted { - let bucket = file_map::canonical(&input.filename); - let global = option.single_css || file_map::is_global(&input.filename); - with_style_sheet_mut(|sheet| { - sheet.rm_global_css(&input.filename, global); - sheet.update_styles_in_bucket(&output.styles, &bucket, global); - }); - } - } - slots.commit(); -} - /// Reserve `[filename, code, cssDir]` rows using the current resolver and configuration. #[wasm_bindgen(js_name = "reserveClassSlots")] #[cfg(not(tarpaulin_include))] @@ -121,7 +49,7 @@ pub fn reserve_class_slots( #[must_use] #[wasm_bindgen(js_name = "getUnreservedClassSlotCount")] pub fn get_unreserved_class_slot_count() -> usize { - css::slot_reservation::unreserved_slot_count() + devup_ui_binding_core::get_unreserved_class_slot_count() } #[cfg(test)] diff --git a/bindings/devup-ui-wasm/src/state.rs b/bindings/devup-ui-wasm/src/state.rs index 46a803c64..d503fad73 100644 --- a/bindings/devup-ui-wasm/src/state.rs +++ b/bindings/devup-ui-wasm/src/state.rs @@ -3,10 +3,9 @@ use wasm_bindgen::prelude::*; /// Internal test boundary: clear the engine, including calling-thread caches. /// Never call during extraction or a resolver callback, or between build environments. pub fn reset_state_internal() { - super::with_style_sheet_mut(|sheet| *sheet = sheet::StyleSheet::default()); - super::MODULE_RESOLVER.with_borrow_mut(|resolver| *resolver = None); - css::test_state::reset_state_for_testing(); - extractor::test_state::reset_caches_for_testing(); + devup_ui_binding_core::reset_state_internal(|| { + super::MODULE_RESOLVER.with_borrow_mut(|resolver| *resolver = None); + }); } /// Internal/testing-only reset, deliberately present in normal WASM artifacts. diff --git a/e2e/binding/comparison.mjs b/e2e/binding/comparison.mjs new file mode 100644 index 000000000..450dcba41 --- /dev/null +++ b/e2e/binding/comparison.mjs @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +function firstByte(left, right) { + let offset = 0 + while ( + offset < Math.min(left.length, right.length) && + left[offset] === right[offset] + ) + offset++ + return { + offset, + leftLength: left.length, + rightLength: right.length, + leftHex: left + .subarray(Math.max(0, offset - 32), offset + 64) + .toString('hex'), + rightHex: right + .subarray(Math.max(0, offset - 32), offset + 64) + .toString('hex'), + } +} +function leaf(left, right, path = '$') { + if ( + typeof left !== typeof right || + left === null || + right === null || + typeof left !== 'object' + ) + return Object.is(left, right) + ? null + : { + field: path, + leftType: typeof left, + rightType: typeof right, + ...firstByte( + Buffer.from( + typeof left === 'string' + ? left + : (JSON.stringify(left) ?? 'undefined'), + ), + Buffer.from( + typeof right === 'string' + ? right + : (JSON.stringify(right) ?? 'undefined'), + ), + ), + } + if ( + left.tag === 'object' && + right.tag === 'object' && + left.value.length === right.value.length + ) { + for (let i = 0; i < left.value.length; i++) { + const [key, value] = left.value[i], + [otherKey, otherValue] = right.value[i] + const diff = + leaf(key, otherKey, `${path}.key[${i}]`) ?? + leaf(value, otherValue, `${path}.${key.value ?? i}`) + if (diff) return diff + } + return null + } + for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) { + if (!Object.hasOwn(left, key) || !Object.hasOwn(right, key)) + return { + field: `${path}.${key}`, + leftPresent: Object.hasOwn(left, key), + rightPresent: Object.hasOwn(right, key), + } + const diff = leaf(left[key], right[key], `${path}.${key}`) + if (diff) return diff + } + return null +} +export function compare(output, problems) { + const comparisons = [] + for (const [a, b] of [ + ['wasm-1', 'wasm-2'], + ['wasm-1', 'wasm-3'], + ['native-1', 'native-2'], + ['native-1', 'native-3'], + ...Array.from({ length: 3 }, (_, i) => [ + `wasm-${i + 1}`, + `native-${i + 1}`, + ]), + ]) { + const comparison = { left: a, right: b, equal: false } + comparisons.push(comparison) + try { + const left = readFileSync(join(output, a, 'records.jsonl')), + right = readFileSync(join(output, b, 'records.jsonl')) + comparison.equal = left.equals(right) + if (!comparison.equal) { + comparison.firstDiff = firstByte(left, right) + const line = + left + .subarray(0, comparison.firstDiff.offset) + .toString('utf8') + .split('\n').length - 1 + const l = left.toString('utf8').split('\n')[line], + r = right.toString('utf8').split('\n')[line] + comparison.record = line + comparison.fieldDiff = leaf(JSON.parse(l), JSON.parse(r)) + problems.push({ + comparison: `${a}:${b}`, + reason: 'literal record bytes differ', + }) + } + } catch (error) { + problems.push({ comparison: `${a}:${b}`, reason: error.message }) + } + } + return comparisons +} diff --git a/e2e/binding/corpus.mjs b/e2e/binding/corpus.mjs new file mode 100644 index 000000000..c8083dcc4 --- /dev/null +++ b/e2e/binding/corpus.mjs @@ -0,0 +1,14 @@ +import { boundary } from './corpus/boundary.mjs' +import { extraction } from './corpus/extraction.mjs' +import { state } from './corpus/state.mjs' + +export { + box, + dependent, + file, + pkg, + source, + theme, +} from './corpus/extraction.mjs' + +export const corpus = [...extraction, ...state, ...boundary] diff --git a/e2e/binding/corpus/boundary.mjs b/e2e/binding/corpus/boundary.mjs new file mode 100644 index 000000000..c4d7c169e --- /dev/null +++ b/e2e/binding/corpus/boundary.mjs @@ -0,0 +1,193 @@ +import { box, dependent, file, resolved, source } from './extraction.mjs' + +const cases = [] +const add = (name, run) => cases.push({ name, run }) +add('numeric-wrapper-and-boolean-abi', (h) => { + for (const value of [ + undefined, + null, + NaN, + Infinity, + -Infinity, + -1, + 0, + 0.9, + 4294967297, + '2', + false, + true, + 1n, + Symbol('index'), + ]) { + h.call('setAtomHoist', value) + h.call('getCss', value, false) + h.call('setDebug', value) + h.extract(source, { flags: [value, value, value] }) + } + h.call('setAtomHoist') + h.call('getCss') + h.call('setDebug') +}) +add('decoder-width-overflow-and-enums', (h) => { + for (const value of [ + -1, + 1.5, + NaN, + Infinity, + 4294967295, + 4294967296, + Number.MAX_SAFE_INTEGER, + 1n, + ]) { + h.call('importFileMap', { [file]: value }) + h.call('importClassMap', { [file]: { color: value } }) + h.call('importFileRoutes', { [file]: [value] }) + } + h.extract(box('_hover={{color:"red"}} _before={{content:\'"x"\'}}'), { + single: false, + }) + const sheet = JSON.parse(h.w.exportSheet()) + h.call('importSheet', sheet) + for (const value of [ + { css: 4 }, + { css: { broken: [{ selector: { Unknown: [] } }] } }, + ]) + h.call('importSheet', value) +}) +add('map-struct-value-getters-undefined-collections', (h) => { + for (const [name, good, bad] of [ + ['FileMap', { [file]: 4 }, { [file]: NaN }], + [ + 'ClassMap', + { [file]: new Map([['color', 4]]) }, + { [file]: { color: -1 } }, + ], + ['CanonicalMap', { [file]: 'src/root.tsx' }, { [file]: 8 }], + ['Sheet', {}, { css: 4 }], + ]) { + h.call(`import${name}`, good) + h.call(`import${name}`, new Map(Object.entries(good))) + h.call(`import${name}`, bad) + } + const unknown = { + get unknown() { + throw new RangeError('unknown getter Ω') + }, + } + for (const value of [ + unknown, + { colors: undefined }, + { colors: null }, + new Map([ + ['colors', new Map([['default', new Map([['primary', 'red']])]])], + ]), + { typography: { heading: { fontSize: new Set([12]) } } }, + { typography: { heading: { fontSize: new Map([['nested', 12]]) } } }, + ]) + h.call('registerTheme', value) + for (const value of [ + unknown, + { [file]: undefined }, + new Map([[file, 7]]), + { [file]: 7 }, + Object.create({ inherited: 4 }), + ]) + h.call('importFileMap', value) + h.call('importFileRoutes', new Map([[file, new Set([0, 2])]])) + h.call( + 'registerShorthands', + new Map([['insetX', new Set(['left', 'right'])]]), + ) +}) +add('diagnostic-tojson-cycle-error-getter', (h) => { + const calls = [], + cycle = {} + cycle.self = cycle + const custom = { + toJSON() { + calls.push('toJSON') + return 'diagnostic Ω' + }, + } + for (const value of [ + custom, + cycle, + { + get value() { + throw new SyntaxError('diagnostic getter Ω') + }, + }, + () => 1, + Symbol('value'), + ]) + h.call('importFileMap', { [file]: value }) + h.note('diagnostic-calls', calls) +}) +add('resolver-boundary-order-this-replacement', (h) => { + const events = [] + const variants = [ + undefined, + null, + 7, + () => undefined, + () => null, + () => 7, + () => ({ path: resolved.path, code: 7 }), + () => { + throw new TypeError('resolver Ω') + }, + () => ({ + get path() { + events.push('path') + return 7 + }, + get code() { + events.push('unexpected-code') + throw new Error('code') + }, + }), + () => ({ + get path() { + events.push('path') + throw new RangeError('path Ω') + }, + code: 'x', + }), + () => ({ + path: resolved.path, + get code() { + events.push('code') + throw new SyntaxError('code Ω') + }, + }), + () => Object.assign(function result() {}, resolved), + () => resolved, + () => { + h.w.setModuleResolver(() => resolved) + return resolved + }, + ] + for (const [i, variant] of variants.entries()) { + h.call( + 'setModuleResolver', + typeof variant === 'function' + ? function (...args) { + events.push([i, this === null, args]) + return variant() + } + : variant, + ) + h.extract(dependent, { filename: `src/resolver-${i}.tsx` }) + h.extract(dependent, { filename: `src/replaced-${i}.tsx` }) + } + h.note('resolver-events', events) + h.call('setModuleResolver', null) +}) +add('prefix-valid-and-malformed-boolean-strict', (h) => { + for (const value of ['du-', null, undefined, '', false, true]) { + h.call('setPrefix', value) + h.extract(source) + } + h.call('setPrefix') +}) +export const boundary = cases diff --git a/e2e/binding/corpus/extraction.mjs b/e2e/binding/corpus/extraction.mjs new file mode 100644 index 000000000..39a13b57f --- /dev/null +++ b/e2e/binding/corpus/extraction.mjs @@ -0,0 +1,157 @@ +export const pkg = '@devup-ui/react', + file = 'src/identity.tsx' +export const box = (props) => + `import { Box } from '${pkg}'; export const view = ` +export const source = box('p={3} bg="red" gap={gap}') +export const dependent = `import { Box } from '${pkg}'; import { color } from './tokens'; export const view = ` +export const theme = { + colors: { default: { primary: '#123456' }, dark: { primary: '#ffffff' } }, + typography: { heading: [{ fontSize: '12px' }, { fontSize: '20px' }] }, + length: { default: { gutter: ['4px', null, '8px'] } }, + shadow: { default: { card: ['0 1px 2px red', null, '0 2px 4px blue'] } }, +} +export const resolved = { + path: 'src/tokens.ts', + code: 'export const color = "purple";', +} +const cases = [] +const add = (name, run) => cases.push({ name, run }) +add('noop-and-plain-js', (h) => { + for (const code of ['', 'export const value = 1', source]) { + h.call('hasDevupUI', file, code, pkg) + h.extract(code) + } +}) +add('scaled-dynamic-repeat-map-modes', (h) => { + h.extract(source) + h.extract(source) + h.extract(box('m={2} w={width} opacity={active ? 1 : 0}'), { + method: 'codeExtractWithoutSourceMap', + single: false, + }) + for (const flags of [ + [false, true, true], + [true, true, false], + ]) + h.extract(source, { flags }) +}) +add('responsive-selector-cascade', (h) => + h.extract( + box( + 'p={[1,null,3]} _hover={{bg:["red","blue"],_focus:{color:"white"}}} selectors={{"& > span":{m:2}}}', + ), + ), +) +add('theme-tokens-and-interfaces', (h) => { + for (const value of [theme, new Map(Object.entries(theme))]) { + h.call('registerTheme', value) + h.extract( + box( + 'color="$primary" typography="heading" w="$gutter" boxShadow="$card"', + ), + ) + } +}) +add('custom-shorthands-object-map', (h) => { + for (const value of [ + { insetX: ['left', 'right'] }, + new Map([['insetX', ['left', 'right']]]), + { insetX: 4 }, + ]) { + h.call('registerShorthands', value) + h.extract(box('insetX={[1,null,3]} _hover={{insetX:2}}')) + } +}) +add('styled-emotion-stylex-ve-static-resolver', (h) => { + const modules = { + './tokens': resolved, + './styles.css': { + path: 'src/styles.css.ts', + code: `import { style } from '@vanilla-extract/css'; export const card = style({color:'red',padding:8});`, + }, + } + const calls = [] + h.call('setModuleResolver', function (specifier, importer) { + calls.push([specifier, importer]) + return modules[specifier] + }) + for (const code of [ + `import { styled } from '${pkg}'; export const Card = styled('div',{bg:'red',p:2});`, + `import { css } from '@emotion/react'; export const view =
;`, + `import * as stylex from '@stylexjs/stylex'; const s=stylex.create({root:{color:'red'}}); export const view=
;`, + `import {card} from './styles.css'; export const view=
;`, + dependent, + ]) + h.extract(code) + h.note('resolver-calls', calls) +}) +add('aliases-object-map-null-invalid', (h) => { + for (const aliases of [ + { '@alias': 'Box' }, + new Map([['@alias', 'Box']]), + { '@alias': null }, + { '@alias': 7 }, + null, + undefined, + ]) + h.extract("import Box from '@alias'; export const view=;", { + aliases, + }) +}) +add('global-css-and-font-faces', (h) => + h.extract( + `import { globalCss } from '${pkg}'; globalCss({'html, body':{m:0},'@font-face':{fontFamily:'Identity',src:'url(identity.woff2)'},body:{color:'red'}});`, + ), +) +add('keyframes-ordinary-atom-content-boundaries', (h) => { + h.call('setAtomHoist', 1) + h.call('importFileRoutes', { [file]: [0] }) + for (const steps of [ + 'from:{opacity:0},to:{opacity:1}', + "'0%, 50%':{transform:'translate(1px, 2px)'},'100%':{transform:'none'}", + 'from:{},to:{opacity:1}', + ]) { + h.extract( + `import {keyframes,Box} from '${pkg}'; const spin=keyframes({${steps}}); export const view=;`, + ) + h.extract( + `import {keyframes,css} from '${pkg}'; const spin=keyframes({${steps}}); export const cls=css({animationName:spin});`, + { single: false }, + ) + } +}) +add('large-same-bucket-128-boxes-keyframes', (h) => + h.extract( + `import {Box,keyframes} from '${pkg}'; const spin=keyframes({from:{opacity:0,transform:'scale(0)'},to:{opacity:1,transform:'scale(1)'}}); export const view=<>${Array.from({ length: 128 }, (_, i) => ``).join('')};`, + ), +) +add('atom-hoist-canonical-routes', (h) => { + h.call('setAtomHoist', 2) + h.call('importCanonicalMap', { [file]: 'src/root.tsx' }) + for (const routes of [ + { 'src/root.tsx': [0, 3, 3] }, + new Map([['src/root.tsx', new Set([0, 3])]]), + ]) { + h.call('importFileRoutes', routes) + h.extract(source, { single: false }) + h.extract(source, { filename: 'src/root.tsx', single: false }) + } +}) +add('seed-unsorted-duplicates', (h) => { + h.call('seedFileMap', ['src/z.tsx', file, 'src/a.tsx', file]) + h.extract(source, { single: false }) + h.call('seedFileMap', new Set([file])) +}) +add('unicode-source-path-content', (h) => + h.extract( + box('title="한글 Ω 😀" content={\'"雪\\\\a"\'} color="rébeccapurple"'), + { filename: 'src/한글-Ω-😀.tsx' }, + ), +) +add('located-extractor-error', (h) => { + h.extract(source) + h.extract( + `import {css} from '${pkg}';\nexport const cls=css({color:window.name});`, + ) +}) +export const extraction = cases diff --git a/e2e/binding/corpus/state.mjs b/e2e/binding/corpus/state.mjs new file mode 100644 index 000000000..dd13de472 --- /dev/null +++ b/e2e/binding/corpus/state.mjs @@ -0,0 +1,96 @@ +import { + box, + dependent, + file, + pkg, + resolved, + source, + theme, +} from './extraction.mjs' + +const rows = [ + [file, source, 'df'], + ['src/second.tsx', box('color="blue"'), 'df'], +] +const cases = [] +const add = (name, run) => cases.push({ name, run }) +add('reservation-forward-reverse-new-repeat', (h) => { + for (const replay of [rows, [...rows].reverse()]) { + h.call('resetBuildState') + h.call('seedFileMap', ['src/second.tsx', file]) + h.call('reserveClassSlots', rows, pkg, false, false, false, {}) + for (const [filename, code] of replay) + h.extract(code, { filename, single: false }) + for (let i = 0; i < 2; i++) + h.extract(box('color="green" gap={gap}'), { + filename: 'src/new.tsx', + single: false, + }) + } +}) +add('reservation-malformed-second-row-arity-nonmutation', (h) => { + h.extract(source) + for (const value of [ + [rows[0], ['src/bad.tsx', 7, 'df']], + [[file, source]], + [[file, source, 'df', 'extra']], + null, + ]) + h.call('reserveClassSlots', value, pkg, false, false, false, {}) + h.call('reserveClassSlots', rows, pkg, false, false, false, { bad: 7 }) +}) +add('four-raw-states-explicit-import-roundtrip', (h) => { + h.call('registerTheme', theme) + h.call('importCanonicalMap', { [file]: 'src/root.tsx' }) + h.extract(source, { single: false }) + const packet = h.exports() + h.call('resetBuildState') + for (const name of ['Sheet', 'ClassMap', 'FileMap', 'CanonicalMap']) + h.call(`import${name}`, JSON.parse(packet[`export${name}`])) + h.call('registerTheme', theme) + h.extract(source, { single: false }) +}) +add('distinct-resets-and-resolver-teardown', (h) => { + for (const reset of ['resetBuildState', 'resetStateForTesting']) { + const calls = [] + h.call('registerTheme', theme) + h.call('registerShorthands', { insetX: ['left', 'right'] }) + h.call('setDebug', true) + h.call('setPrefix', 'dirty-') + h.call('setAtomHoist', 2) + h.call('setModuleResolver', (...args) => { + calls.push(args) + return resolved + }) + h.extract(dependent) + h.call(reset) + h.extract(dependent) + h.extract(box('insetX={2} color="$primary"')) + h.note(`${reset}-calls`, calls) + } +}) +add('output-copies-release-getters-dispose', (h) => { + h.step('construct-Output', () => new h.w.Output()) + for (const release of ['free', Symbol.dispose]) { + let out + h.step('allocate-Output', () => { + out = h.open(source) + return out instanceof h.w.Output + }) + if (!out) + throw new Error('Output allocation failed outside a boundary-error case') + h.step('dependency-copy', () => { + const a = out.dependencies, + b = out.dependencies + a.push('local-only') + return { same: a === b, after: out.dependencies } + }) + for (const key of h.fields) h.step(`live-${key}`, () => out[key]) + h.step(`release-${String(release)}`, () => out[release]()) + for (const key of [...h.fields, 'free', Symbol.dispose]) + h.step(`released-${String(key)}`, () => + typeof key === 'symbol' || key === 'free' ? out[key]() : out[key], + ) + } +}) +export const state = cases diff --git a/e2e/binding/coverage-spike/Cargo.lock b/e2e/binding/coverage-spike/Cargo.lock new file mode 100644 index 000000000..9830ca952 --- /dev/null +++ b/e2e/binding/coverage-spike/Cargo.lock @@ -0,0 +1,289 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "convert_case" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "napi" +version = "3.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e2cf85c02d9b057898c485b824c3c416b65193689e2b7f517efc94b86ed24c" +dependencies = [ + "bitflags", + "ctor", + "futures", + "libc", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b899b545d3aa6dca985939059f258c5488d34e4ecf39c274e20009748f4b846d" + +[[package]] +name = "napi-derive" +version = "3.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92cec6048ac04041ac8055bdce1ec4f353416cd443ba9222bf73af790c3d4495" +dependencies = [ + "convert_case", + "ctor", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "napi-derive-backend" +version = "6.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae2444f4ba7333754bb73414ff8059dec2cb23b83a95bd1a0bf08169eed1be0" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "semver", + "syn", +] + +[[package]] +name = "napi-sys" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22a4f25c16a5c5411d987cd6fbd48313522ae1789ade2d6dd3efdc6d40a0fc8" +dependencies = [ + "libloading", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "w74a-addon" +version = "0.0.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "w74a-core", +] + +[[package]] +name = "w74a-core" +version = "0.0.0" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" diff --git a/e2e/binding/coverage-spike/Cargo.toml b/e2e/binding/coverage-spike/Cargo.toml new file mode 100644 index 000000000..74077c2fe --- /dev/null +++ b/e2e/binding/coverage-spike/Cargo.toml @@ -0,0 +1,33 @@ +[workspace] +members = ["addon"] +resolver = "3" + +[workspace.package] +edition = "2024" +rust-version = "1.88" +publish = false + +[workspace.lints.rust] +unsafe_code = "warn" +unused_must_use = "deny" + +[workspace.lints.clippy] +all = "warn" +pedantic = "warn" + +[package] +name = "w74a-core" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[lib] +path = "core.rs" + +[[test]] +name = "W74a_native_host" +path = "native_host.rs" + +[lints] +workspace = true diff --git a/e2e/binding/coverage-spike/README.md b/e2e/binding/coverage-spike/README.md new file mode 100644 index 000000000..4d830ced4 --- /dev/null +++ b/e2e/binding/coverage-spike/README.md @@ -0,0 +1,30 @@ +# W74a native coverage SPIKE + +Standalone scratch workspace, not production/root-package coverage. Rust sources and +lock copy the final lint-clean private probe; the CJS scenario is unchanged semantically. +With current Node, Bun, stable Rust and cargo-tarpaulin already available, run: + +```sh +node e2e/binding/coverage-spike/run.mjs /absolute/private/evidence +``` + +A unique evidence child holds the exact instrumented `.node`, raw JSON/XML/logs, +commands, profiles and hashes. Node-negative must pass the host and fail the unchanged +100% threshold; Node-positive must cover all 17 addon + 2 core lines. Only sentinel +55–57 miss in negative. Mapping discrepancies fail with evidence, never a weaker gate. +`build.rs`/`native_host.rs` 0/0 are visibly unmeasured scaffolding, not measured coverage. +Hit magnitudes are only zero/nonzero. Linux acceptance and actual-package coverage +remain main-agent gates; replace/remove the SPIKE workflow before the PR leaves Draft. + +Bun runs the same positive `host.cjs` scenario directly with the same built `.node`, +independently checked for a natural zero exit, the exact requested/real addon path, +positive selector, and unchanged source/image hashes. `evidence.bunExecution` labels +this as real-addon execution, not Rust coverage; it records the observed Bun runtime +identity, with Bun's version also retained from the existing version command. +No Bun `.profraw` is required or copied. Node's profile assertions, source/line +denominators and standard tarpaulin 100% threshold remain unchanged. + +Historically, Bun 1.4.3 passed the functional assertions on Linux but emitted no addon +profile. This is a possible upstream issue, not investigated here. The approved +spike gate distinguishes Node Rust coverage from Bun addon execution; full production +Bun coverage is separate and remains unproved. diff --git a/e2e/binding/coverage-spike/addon/Cargo.toml b/e2e/binding/coverage-spike/addon/Cargo.toml new file mode 100644 index 000000000..d2a93206c --- /dev/null +++ b/e2e/binding/coverage-spike/addon/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "w74a-addon" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[lib] +path = "lib.rs" +crate-type = ["cdylib"] + +[dependencies] +w74a-core = { path = ".." } +napi = "^3.14.2" +napi-derive = "^3.6.12" + +[build-dependencies] +napi-build = "^2.6.0" + +[lints] +workspace = true diff --git a/e2e/binding/coverage-spike/addon/build.rs b/e2e/binding/coverage-spike/addon/build.rs new file mode 100644 index 000000000..0f1b01002 --- /dev/null +++ b/e2e/binding/coverage-spike/addon/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/e2e/binding/coverage-spike/addon/lib.rs b/e2e/binding/coverage-spike/addon/lib.rs new file mode 100644 index 000000000..ea0807d0a --- /dev/null +++ b/e2e/binding/coverage-spike/addon/lib.rs @@ -0,0 +1,58 @@ +//! Live JS-only boundaries for the W74a coverage experiment. +use napi::{Env, Error, JsNumber, Result, Status, bindgen_prelude::Function}; +use napi_derive::napi; + +/// Converts through the pure core, then creates a real JS value using Env. +/// # Errors +/// Rejects negative input or a failed Node-API value creation. +#[napi] +pub fn converted(env: &Env, value: i32) -> Result> { + let value = w74a_core::nonnegative(value) + .map_err(|error| Error::new(Status::InvalidArg, error.to_string()))?; + env.create_uint32(value) +} + +/// Resolves synchronously; callback throws/conversion failures are cache misses. +#[must_use = "Use the resolved value or handle the cache miss"] +#[napi] +pub fn callback_probe(callback: Function<'_, (), i32>) -> Option { + callback.call(()).ok() +} + +/// Owns a native allocation until explicit free (or normal finalization). +#[napi] +pub struct Probe { + value: Option, +} + +#[napi] +impl Probe { + #[napi(constructor)] + #[must_use = "Use the native probe instance"] + pub fn new(value: String) -> Self { + Self { value: Some(value) } + } + + /// # Errors + /// Rejects access after explicit free. + #[napi(getter)] + pub fn value(&self) -> Result { + match &self.value { + Some(value) => Ok(value.clone()), + None => Err(Error::new(Status::InvalidArg, "W74a probe is freed")), + } + } + + #[napi] + pub fn free(&mut self) -> bool { + self.value.take().is_some() + } +} + +/// Negative control must leave these executable body lines at zero hits. +#[must_use = "Use the control result"] +#[napi] +pub fn control_only(value: u32) -> u32 { + let rotated = value.rotate_left(3); // W74a control sentinel A + rotated ^ 0x74a // W74a control sentinel B +} diff --git a/e2e/binding/coverage-spike/audit.mjs b/e2e/binding/coverage-spike/audit.mjs new file mode 100644 index 000000000..d96df6e81 --- /dev/null +++ b/e2e/binding/coverage-spike/audit.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +export function audit(entry, result, denominator) { + const scratch = dirname(fileURLToPath(import.meta.url)) + const negative = entry.name === 'negative' + const json = join(entry.reports, 'tarpaulin-report.json') + const report = JSON.parse(readFileSync(json, 'utf8')) + entry.files = report.files + .map((file) => { + const path = join(...file.path) + assert.equal( + readFileSync(path, 'utf8'), + file.content, + 'Reported source differs', + ) + const local = relative(scratch, path).replaceAll('\\', '/') + assert.ok( + Object.hasOwn(entry.sourceHashes, local), + 'Unexpected report source', + ) + return { + path: local, + sha256: entry.sourceHashes[local], + covered: file.covered, + coverable: file.coverable, + lines: file.traces + .map((trace) => ({ line: trace.line, hit: trace.stats.Line > 0 })) + .sort((a, b) => a.line - b.line), + } + }) + .sort((a, b) => a.path.localeCompare(b.path)) + assert.equal(result.status, negative ? 1 : 0, 'Unexpected tarpaulin exit') + assert.match(result.stdout, /test result: ok\. 1 passed; 0 failed/) + if (negative) { + assert.match( + result.stdout + result.stderr, + /Coverage is below the failure threshold/, + ) + } + entry.host = JSON.parse( + result.stdout.match(/\{"event":"W74a.host"[^\n]*\}/)?.[0] ?? 'null', + ) + assert.equal(entry.host.loadedAddon, entry.image) + assert.equal(entry.host.requestedAddon, entry.image) + assert.equal(entry.host.control, negative ? 'negative' : 'positive') + assert.match(entry.host.LLVM_PROFILE_FILE, /%m.*%p|%p.*%m/) + assert.ok(entry.host.LLVM_PROFILE_FILE.startsWith(entry.target)) + assert.ok(entry.profiles.filter((profile) => profile.bytes > 0).length >= 2) + const current = entry.files.map(({ path, sha256, coverable, lines }) => ({ + path, + sha256, + coverable, + lines: lines.map(({ line }) => line), + })) + if (denominator) { + assert.deepEqual(current, denominator, 'Source/line denominator changed') + } + for (const [path, expected] of [ + ['addon/lib.rs', 17], + ['core.rs', 2], + ]) { + const file = entry.files.find((item) => item.path === path) + assert.ok(file, `${path}: missing from coverage`) + assert.equal( + file.coverable, + expected, + `${path}: mapping discrepancy; inspect raw evidence, do not weaken gate`, + ) + assert.equal(file.lines.length, expected) + const omitted = negative && path === 'addon/lib.rs' ? [55, 56, 57] : [] + assert.equal(file.covered, expected - omitted.length) + for (const { line, hit } of file.lines) { + assert.equal(hit, !omitted.includes(line)) + } + assert.deepEqual( + file.lines.map(({ line }) => line), + path === 'addon/lib.rs' + ? [9, 10, 11, 12, 18, 19, 32, 33, 39, 40, 41, 42, 47, 48, 55, 56, 57] + : [7, 8], + ) + } + for (const path of ['addon/build.rs', 'native_host.rs']) { + assert.ok( + entry.files.some((file) => file.path === path), + `${path}: scaffolding disappeared`, + ) + } + return current +} diff --git a/e2e/binding/coverage-spike/core.rs b/e2e/binding/coverage-spike/core.rs new file mode 100644 index 000000000..8843cdeee --- /dev/null +++ b/e2e/binding/coverage-spike/core.rs @@ -0,0 +1,9 @@ +//! Host-independent nonnegative integer conversion. + +/// Converts a signed input without silently wrapping negative values. +/// +/// # Errors +/// Returns the standard conversion error for a negative input. +pub fn nonnegative(value: i32) -> Result { + u32::try_from(value) +} diff --git a/e2e/binding/coverage-spike/host.cjs b/e2e/binding/coverage-spike/host.cjs new file mode 100644 index 000000000..9abf7998e --- /dev/null +++ b/e2e/binding/coverage-spike/host.cjs @@ -0,0 +1,62 @@ +'use strict' +const assert = require('node:assert/strict') +const { realpathSync } = require('node:fs') +const { extname, isAbsolute } = require('node:path') + +// Given: exactly one explicit scenario selector and an absolute native image. +const control = process.env.W74A_CONTROL +assert.ok( + control === 'negative' || control === 'positive', + 'W74A_CONTROL required', +) +const requested = process.env.W74A_ADDON +assert.equal(typeof requested, 'string', 'W74A_ADDON required') +assert.ok(isAbsolute(requested), 'W74A_ADDON must be absolute') +assert.equal(extname(requested), '.node') +const loaded = realpathSync(requested) +console.info( + JSON.stringify({ + event: 'W74a.host', + control, + LLVM_PROFILE_FILE: process.env.LLVM_PROFILE_FILE ?? null, + requestedAddon: requested, + loadedAddon: loaded, + }), +) +const addon = require(loaded) + +// When/Then: exercise success/error and a real Env conversion through JS only. +assert.equal(addon.converted(42), 42) +assert.throws(() => addon.converted(-1), { code: 'InvalidArg' }) +assert.equal( + addon.callbackProbe(() => 73), + 73, +) +assert.equal( + addon.callbackProbe(() => { + throw new Error('W74a callback miss') + }), + null, +) +assert.equal( + addon.callbackProbe(() => 'invalid return'), + null, +) +assert.equal(addon.converted(0), 0) // A throw must not leave an exception pending. +const probe = new addon.Probe('W74a native allocation') +assert.equal(probe.value, 'W74a native allocation') +assert.equal(probe.free(), true) +assert.throws(() => probe.value, { code: 'InvalidArg' }) +assert.equal(probe.free(), false) + +// Same native object/source in both controls; only this invocation differs. +switch (control) { + case 'negative': + break + case 'positive': + assert.equal(addon.controlOnly(7), 0x772) + break + default: + assert.fail('unreachable W74A_CONTROL') +} +// No process.exit(), GC polling, timers, detached work or profile mutation. diff --git a/e2e/binding/coverage-spike/native_host.rs b/e2e/binding/coverage-spike/native_host.rs new file mode 100644 index 000000000..40d1ebbd3 --- /dev/null +++ b/e2e/binding/coverage-spike/native_host.rs @@ -0,0 +1,13 @@ +use std::{env, error::Error, path::Path, process::Command}; + +#[test] +fn scenarios_succeed_when_loaded_by_current_host() -> Result<(), Box> { + // Given: caller-selected Node/Bun, addon and control; no addon linkage here. + let host = env::var_os("W74A_HOST").ok_or("W74A_HOST must select current Node or Bun")?; + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("host.cjs"); + // When: inherit the complete environment, including LLVM_PROFILE_FILE. + let status = Command::new(host).arg(script).status()?; + // Then: status() waits for natural exit and JS assertions must succeed. + assert!(status.success(), "W74a JS host failed: {status}"); + Ok(()) +} diff --git a/e2e/binding/coverage-spike/run.mjs b/e2e/binding/coverage-spike/run.mjs new file mode 100644 index 000000000..235aafa04 --- /dev/null +++ b/e2e/binding/coverage-spike/run.mjs @@ -0,0 +1,257 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + closeSync, + copyFileSync, + mkdirSync, + mkdtempSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + statSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { audit } from './audit.mjs' + +assert.equal(process.argv.length, 3, 'Usage: node run.mjs ') +const scratch = dirname(fileURLToPath(import.meta.url)) +const destination = resolve(process.argv[2]) +const location = relative(scratch, destination) +assert.ok( + location === '..' || location.startsWith(`..${sep}`) || isAbsolute(location), + 'Keep evidence outside scratch', +) +mkdirSync(destination, { recursive: true }) +const output = realpathSync(mkdtempSync(join(destination, 'W74a-'))) +const hash = (path) => + createHash('sha256').update(readFileSync(path)).digest('hex') +const save = (name, value) => + writeFileSync(join(output, name), `${JSON.stringify(value, null, 2)}\n`) +const inputs = ( + 'Cargo.toml Cargo.lock core.rs native_host.rs host.cjs ' + + 'addon/Cargo.toml addon/build.rs addon/lib.rs run.mjs audit.mjs' +).split(' ') +const hashes = () => + Object.fromEntries(inputs.map((path) => [path, hash(join(scratch, path))])) +const original = hashes() +assert.ok(!process.env.RUST_MIN_STACK, 'Default Rust stack required') +const env = { + ...process.env, + CARGO_BUILD_JOBS: '2', + RUSTUP_TOOLCHAIN: 'stable', +} +delete env.RUSTFLAGS +delete env.CARGO_ENCODED_RUSTFLAGS +delete env.LLVM_PROFILE_FILE +const evidence = { + verdict: 'INCOMPLETE', + platform: process.platform, + arch: process.arch, + node: process.version, + sources: original, + controls: [], + limits: [ + 'SPIKE only, not production coverage.', + 'build.rs/native_host.rs 0/0 entries are unmeasured scaffolding, not measured coverage.', + 'Hit counts are used only as zero/nonzero; raw JSON/XML are retained.', + ], +} +function run(name, command, childEnv = env) { + const [program, ...args] = command + const stdout = join(output, `${name}.stdout.log`) + const stderr = join(output, `${name}.stderr.log`) + const handles = [openSync(stdout, 'wx'), openSync(stderr, 'wx')] + let result + try { + result = spawnSync(program, args, { + cwd: scratch, + env: childEnv, + stdio: ['ignore', ...handles], + }) + } finally { + handles.forEach((handle) => closeSync(handle)) + } + save(`${name}.command.json`, { + command, + cwd: scratch, + status: result.status, + signal: result.signal, + rustflags: childEnv.RUSTFLAGS ?? null, + llvmProfileFile: childEnv.LLVM_PROFILE_FILE ?? null, + error: result.error?.message, + }) + if (result.error) throw result.error + assert.equal(result.signal, null, `${name}: natural child exit required`) + return { + status: result.status, + stdout: readFileSync(stdout, 'utf8'), + stderr: readFileSync(stderr, 'utf8'), + } +} +try { + for (const [name, command] of [ + ['rust', ['rustc', '+stable', '-Vv']], + ['cargo', ['cargo', '+stable', '-V']], + ['tarpaulin', ['cargo', '+stable', 'tarpaulin', '--version']], + ['node', [process.execPath, '--version']], + ['bun', ['bun', '--version']], + ]) { + const version = run(name, command) + assert.equal(version.status, 0, `${name}: version check failed`) + if (name === 'bun') evidence.bun = version.stdout.trim() + } + const bun = run('bun-path', [ + 'bun', + '-p', + 'JSON.stringify({execPath:process.execPath,version:process.versions.bun})', + ]) + assert.equal(bun.status, 0) + const bunRuntime = JSON.parse(bun.stdout) + assert.equal( + bunRuntime.version, + evidence.bun, + 'Bun runtime identity mismatch', + ) + assert.ok(isAbsolute(bunRuntime.execPath), 'Absolute Bun executable required') + const target = join(output, 'build-target') + const build = run( + 'build', + [ + 'cargo', + '+stable', + ...'build --locked -p w74a-addon --jobs 2'.split(' '), + '--target-dir', + target, + ], + { + ...env, + RUSTFLAGS: '-C instrument-coverage -C debuginfo=2 -C strip=none', + LLVM_PROFILE_FILE: join(output, 'build_%m_%p.profraw'), + }, + ) + assert.equal(build.status, 0, 'Instrumented debug build failed') + const library = { + win32: 'w74a_addon.dll', + linux: 'libw74a_addon.so', + darwin: 'libw74a_addon.dylib', + }[process.platform] + assert.ok(library, 'Unsupported scratch host') + const built = join(target, 'debug', library) + const image = join(output, 'W74a-addon.node') + copyFileSync(built, image) + evidence.image = { + path: image, + sha256: hash(image), + built, + builtSha256: hash(built), + } + assert.equal(evidence.image.sha256, evidence.image.builtSha256) + let denominator + for (const name of ['negative', 'positive']) { + const negative = name === 'negative' + const controlTarget = join(output, `${name}-target`) + const reports = join(output, name) + mkdirSync(reports) + assert.deepEqual(hashes(), original, 'Inputs changed between controls') + const result = run( + name, + [ + 'cargo', + '+stable', + ...( + 'tarpaulin --locked --workspace --test W74a_native_host --engine llvm ' + + '--skip-clean --jobs 2 --out Json --out Xml --out Stdout --fail-under 100' + ).split(' '), + '--target-dir', + controlTarget, + '--objects', + image, + '--output-dir', + reports, + ], + { + ...env, + W74A_ADDON: image, + W74A_CONTROL: negative ? 'negative' : 'positive', + W74A_HOST: process.execPath, + }, + ) + const profileRoot = join(controlTarget, 'tarpaulin', 'profraws') + const profiles = readdirSync(profileRoot) + .filter((path) => path.endsWith('.profraw')) + .map((path) => { + const fullPath = join(profileRoot, path) + return { + path: fullPath, + bytes: statSync(fullPath).size, + sha256: hash(fullPath), + } + }) + const entry = { + name, + status: result.status, + target: controlTarget, + reports, + profiles, + image, + sourceHashes: hashes(), + imageSha256: hash(image), + } + evidence.controls.push(entry) + save(`${name}.profiles.json`, profiles) + entry.reportHashes = { + json: hash(join(reports, 'tarpaulin-report.json')), + xml: hash(join(reports, 'cobertura.xml')), + } + denominator = audit(entry, result, denominator) + assert.deepEqual(hashes(), original) + assert.deepEqual(entry.sourceHashes, original) + assert.equal(entry.imageSha256, evidence.image.sha256) + assert.equal(hash(image), evidence.image.sha256) + save(`${name}.inventory.json`, entry) + } + assert.deepEqual(hashes(), original, 'Inputs changed before Bun execution') + assert.equal(hash(image), evidence.image.sha256) + const bunResult = run( + 'bun-positive', + [bunRuntime.execPath, join(scratch, 'host.cjs')], + { + ...env, + W74A_CONTROL: 'positive', + W74A_ADDON: image, + LLVM_PROFILE_FILE: join(output, 'bun-execution_%m_%p.profraw'), + }, + ) + assert.equal( + bunResult.status, + 0, + 'Bun: positive host must exit naturally with 0', + ) + const bunHost = JSON.parse( + bunResult.stdout.match(/\{"event":"W74a.host"[^\n]*\}/)?.[0] ?? 'null', + ) + assert.equal(bunHost.requestedAddon, image) + assert.equal(bunHost.loadedAddon, image) + assert.equal(bunHost.control, 'positive') + evidence.bunExecution = { + kind: 'real-addon execution only; not Rust coverage', + runtime: bunRuntime, + status: bunResult.status, + host: bunHost, + sourceHashes: hashes(), + imageSha256: hash(image), + builtSha256: hash(built), + } + assert.deepEqual(evidence.bunExecution.sourceHashes, original) + assert.equal(evidence.bunExecution.imageSha256, evidence.image.sha256) + assert.equal(evidence.bunExecution.builtSha256, evidence.image.builtSha256) + evidence.verdict = 'SCRATCH CONTROLS PASS; production coverage unproved' +} finally { + save('evidence.json', evidence) + console.info(`W74a evidence: ${output}`) +} diff --git a/e2e/binding/host.mjs b/e2e/binding/host.mjs new file mode 100644 index 000000000..1bc830d55 --- /dev/null +++ b/e2e/binding/host.mjs @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict' +import { + appendFileSync, + readdirSync, + realpathSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join } from 'node:path' + +import { corpus, file, pkg, source } from './corpus.mjs' +import { capture, encode, hash } from './observation.mjs' + +export { encode } from './observation.mjs' +const [entry, destination] = process.argv.slice(2) +assert.equal(process.argv.length, 4) +assert.ok(isAbsolute(entry) && isAbsolute(destination)) +assert.equal(process.env.NODE_OPTIONS, '') +assert.equal(process.env.BUN_OPTIONS, '') +assert.deepEqual(process.execArgv, []) +const require = createRequire(import.meta.url), + loaded = realpathSync(entry), + w = require(loaded) +const save = (name, value) => + writeFileSync( + join(destination, name), + JSON.stringify(value, null, 2) + '\n', + { flag: 'wx' }, + ) +const modules = Object.keys(require.cache) +const images = [ + ...new Set([ + ...modules, + ...[...new Set(modules.map(dirname))].flatMap((directory) => + readdirSync(directory) + .filter((name) => name.endsWith('.wasm')) + .map((name) => join(directory, name)), + ), + ]), +] +save('provenance.json', { + requested: entry, + loaded, + runtime: { + execPath: process.execPath, + version: process.version, + versions: process.versions, + platform: process.platform, + arch: process.arch, + execArgv: process.execArgv, + sha256: hash(process.execPath), + }, + modules: images.map((path) => ({ path, sha256: hash(path) })), + scenarios: corpus.map((c) => c.name), +}) +const names = + 'Output codeExtract codeExtractWithoutSourceMap exportCanonicalMap exportClassMap exportFileMap exportSheet getCss getDefaultTheme getPrefix getThemeInterface getUnreservedClassSlotCount hasDevupUI importCanonicalMap importClassMap importFileMap importFileRoutes importSheet isDebug registerShorthands registerTheme reserveClassSlots resetBuildState resetStateForTesting seedFileMap setAtomHoist setDebug setModuleResolver setPrefix'.split( + ' ', + ) +const abi = { + names: Object.keys(w).sort(), + types: names.map((name) => [name, typeof w[name]]), + arities: ['codeExtract', 'codeExtractWithoutSourceMap'].map((name) => [ + name, + w[name]?.length, + ]), +} +abi.result = capture(() => { + assert.deepEqual(abi.names, [...names].sort()) + for (const name of names) assert.equal(typeof w[name], 'function', name) + for (const [, arity] of abi.arities) assert.equal(arity, 8) + assert.equal(typeof w.Output.prototype.free, 'function') +}) +save('abi.json', abi) +const fields = [ + 'code', + 'map', + 'css', + 'cssFile', + 'updatedBaseStyle', + 'dependencies', +] +const exports4 = [ + 'exportSheet', + 'exportClassMap', + 'exportFileMap', + 'exportCanonicalMap', +] +let scenario = '', + sequence = 0 +const emit = (record) => + appendFileSync( + join(destination, 'records.jsonl'), + JSON.stringify({ scenario, sequence: sequence++, ...record }) + '\n', + ) +const exportsRaw = () => + Object.fromEntries(exports4.map((name) => [name, w[name]()])) +const snapshot = () => ({ + exports: Object.fromEntries( + exports4.map((name) => [name, capture(() => w[name]())]), + ), + config: Object.fromEntries( + [ + 'getPrefix', + 'isDebug', + 'getDefaultTheme', + 'getUnreservedClassSlotCount', + ].map((name) => [name, capture(() => w[name]())]), + ), + interface: capture(() => + w.getThemeInterface( + pkg, + 'Colors', + 'Typography', + 'Length', + 'Shadow', + 'Theme', + ), + ), + css: [null, 0, 1, 2, 3, 4, 7, 128].flatMap((index) => + [false, true].map((main) => ({ + index, + main, + result: capture(() => w.getCss(index, main)), + })), + ), +}) +const h = { + w, + fields, + exports: exportsRaw, + note: (label, value) => emit({ kind: 'note', label, value: encode(value) }), + step(label, action, input) { + const before = snapshot() + const result = capture(action) + emit({ + kind: 'step', + label, + input: encode(input), + before, + result, + after: snapshot(), + }) + return result + }, + call(name, ...args) { + return h.step(name, () => w[name](...args), args) + }, + open(code = source, options = {}) { + const { + filename = file, + method = 'codeExtract', + single = true, + flags = [single, false, false], + } = options + return w[method]( + filename, + code, + pkg, + 'df', + ...flags, + Object.hasOwn(options, 'aliases') ? options.aliases : {}, + ) + }, + extract(code = source, options = {}) { + h.step( + options.method ?? 'codeExtract', + () => { + const out = h.open(code, options) + try { + return { + instanceOfOutput: out instanceof w.Output, + ...Object.fromEntries( + fields.map((key) => [ + key, + key in out ? out[key] : { tag: 'absent' }, + ]), + ), + } + } finally { + out.free() + } + }, + [code, options], + ) + }, +} +assert.equal(corpus.length, 25) +assert.equal(new Set(corpus.map((c) => c.name)).size, 25) +for (const test of corpus) { + scenario = test.name + assert.equal(h.call('resetStateForTesting').tag, 'return') + emit({ kind: 'start', state: snapshot() }) + test.run(h) + emit({ kind: 'complete', state: snapshot() }) +} +if (abi.result.tag !== 'return') process.exitCode = 1 diff --git a/e2e/binding/identity.mjs b/e2e/binding/identity.mjs new file mode 100644 index 000000000..70eb1e265 --- /dev/null +++ b/e2e/binding/identity.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + closeSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + statSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { compare } from './comparison.mjs' +import { corpus } from './corpus.mjs' + +assert.ok( + process.argv.length === 5 || process.argv.length === 6, + 'Usage: node e2e/binding/identity.mjs [runtime-executable]', +) +const [wasm, native, evidence, runtime = process.execPath] = + process.argv.slice(2) +for (const path of [wasm, native, evidence]) + assert.ok(isAbsolute(path), 'Absolute paths required') +mkdirSync(evidence) +const output = realpathSync(evidence), + here = dirname(fileURLToPath(import.meta.url)), + root = resolve(here, '../..') +const sha = (bytes) => createHash('sha256').update(bytes).digest('hex') +const save = (path, value) => + writeFileSync(join(output, path), JSON.stringify(value, null, 2) + '\n', { + flag: 'wx', + }) +function sources(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((item) => { + const path = join(directory, item.name) + return item.isDirectory() && + !['target', 'node_modules', '.git', 'pkg'].includes(item.name) + ? sources(path) + : item.isFile() && /\.(rs|toml|lock|mjs|cjs)$/.test(item.name) + ? [ + { + path, + bytes: statSync(path).size, + sha256: sha(readFileSync(path)), + }, + ] + : [] + }) +} +const provenance = { + runner: { + execPath: process.execPath, + version: process.version, + versions: process.versions, + platform: process.platform, + arch: process.arch, + }, + runtime, + entries: [wasm, native].map((path) => ({ + path, + realpath: realpathSync(path), + sha256: sha(readFileSync(path)), + })), + sources: [ + ...sources(join(root, 'libs')), + ...sources(join(root, 'bindings')), + ...sources(here), + ...['Cargo.toml', 'Cargo.lock'].map((name) => { + const path = join(root, name) + return { path, sha256: sha(readFileSync(path)) } + }), + ], +} +save('provenance.json', provenance) +const runs = [], + problems = [] +for (const [engine, entry] of [ + ['wasm', wasm], + ['native', native], +]) + for (let repeat = 1; repeat <= 3; repeat++) { + const name = `${engine}-${repeat}`, + directory = join(output, name) + mkdirSync(directory) + const stdout = openSync(join(directory, 'stdout.log'), 'wx'), + stderr = openSync(join(directory, 'stderr.log'), 'wx') + let result + try { + result = spawnSync(runtime, [join(here, 'host.mjs'), entry, directory], { + cwd: directory, + env: { ...process.env, NODE_OPTIONS: '', BUN_OPTIONS: '' }, + stdio: ['ignore', stdout, stderr], + timeout: 120000, + killSignal: 'SIGKILL', + }) + } finally { + closeSync(stdout) + closeSync(stderr) + } + const run = { + name, + engine, + repeat, + command: [runtime, join(here, 'host.mjs'), entry, directory], + status: result.status, + signal: result.signal, + error: result.error + ? { + name: result.error.name, + message: result.error.message, + code: result.error.code, + } + : null, + } + runs.push(run) + if (result.error || result.signal !== null || result.status !== 0) + problems.push({ run: name, reason: 'natural zero exit required' }) + try { + const records = readFileSync(join(directory, 'records.jsonl')) + run.recordBytes = records.length + run.recordSha256 = sha(records) + const parsed = records + .toString('utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)) + assert.deepEqual( + parsed + .filter((record) => record.kind === 'complete') + .map((record) => record.scenario), + corpus.map((test) => test.name), + ) + assert.ok(parsed.every((record, i) => record.sequence === i)) + const host = JSON.parse( + readFileSync(join(directory, 'provenance.json'), 'utf8'), + ) + assert.equal(host.loaded, realpathSync(entry)) + const abi = JSON.parse(readFileSync(join(directory, 'abi.json'), 'utf8')) + assert.equal(abi.result.tag, 'return') + } catch (error) { + problems.push({ run: name, reason: error.message }) + } + run.artifacts = readdirSync(directory).map((path) => ({ + path, + bytes: statSync(join(directory, path)).size, + sha256: sha(readFileSync(join(directory, path))), + })) + save(`${name}.json`, run) + } +const comparisons = compare(output, problems) +save('comparison.json', { + verdict: problems.length ? 'FAIL' : 'PASS', + scenarios: corpus.map((test) => test.name), + runs, + comparisons, + problems, +}) +console.info(`${problems.length ? 'FAIL' : 'PASS'}: ${output}`) +if (problems.length) process.exitCode = 1 diff --git a/e2e/binding/observation.mjs b/e2e/binding/observation.mjs new file mode 100644 index 000000000..f7598c505 --- /dev/null +++ b/e2e/binding/observation.mjs @@ -0,0 +1,80 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' + +export function encode(value, seen = new Set()) { + if (value === undefined) return { tag: 'undefined' } + if (value === null) return { tag: 'null' } + const tag = typeof value + switch (tag) { + case 'string': + return { tag, value, bytes: Buffer.byteLength(value, 'utf8') } + case 'boolean': + return { tag, value } + case 'number': + return { + tag, + value: Object.is(value, -0) + ? '-0' + : Number.isFinite(value) + ? value + : String(value), + } + case 'bigint': + case 'symbol': + return { tag, value: String(value) } + case 'function': + return { tag, name: value.name } + case 'object': { + if (seen.has(value)) return { tag: 'cycle' } + const next = new Set(seen).add(value) + if (Array.isArray(value)) + return { + tag: 'array', + value: Array.from({ length: value.length }, (_, i) => + Object.hasOwn(value, i) + ? encode(value[i], next) + : { tag: 'absent' }, + ), + } + if (value instanceof Map) + return { + tag: 'Map', + value: [...value].map(([k, v]) => [encode(k, next), encode(v, next)]), + } + if (value instanceof Set) + return { tag: 'Set', value: [...value].map((v) => encode(v, next)) } + return { + tag, + value: Reflect.ownKeys(value).map((key) => { + const d = Object.getOwnPropertyDescriptor(value, key) + return [ + encode(key, next), + 'value' in d + ? encode(d.value, next) + : { tag: 'accessor', get: encode(d.get), set: encode(d.set) }, + ] + }), + } + } + default: + throw new Error(`Unsupported encoder type ${tag}`) + } +} +const failure = (error) => + error instanceof Error + ? { + tag: 'error', + class: error.constructor.name, + name: encode(error.name), + message: encode(error.message), + } + : { tag: 'thrown', value: encode(error) } +export const capture = (action) => { + try { + return { tag: 'return', value: encode(action()) } + } catch (error) { + return { tag: 'throw', error: failure(error) } + } +} +export const hash = (path) => + createHash('sha256').update(readFileSync(path)).digest('hex') diff --git a/libs/binding/Cargo.toml b/libs/binding/Cargo.toml new file mode 100644 index 000000000..aa50e11f4 --- /dev/null +++ b/libs/binding/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "devup-ui-binding-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Host-neutral extraction and state orchestration for Devup UI bindings" +license = "Apache-2.0" +repository = "https://github.com/dev-five-git/devup-ui" +keywords = ["css", "react", "devup-ui", "bindings"] +categories = ["development-tools", "web-programming"] + +[lints] +workspace = true + +[dependencies] +extractor = { path = "../extractor" } +sheet = { path = "../sheet" } +css = { path = "../css" } +serde_json = "1.0.154" +bimap = { version = "0.6.3", features = ["serde"] } diff --git a/libs/binding/README.md b/libs/binding/README.md new file mode 100644 index 000000000..2c0980b4b --- /dev/null +++ b/libs/binding/README.md @@ -0,0 +1,5 @@ +# Devup UI Binding Core + +Host-neutral extraction, stylesheet, state and reservation orchestration shared by +the build-host bindings. JavaScript values and resolver handles stay in each host +adapter; this crate does not add a styling runtime or concurrent build isolation. diff --git a/libs/binding/src/lib.rs b/libs/binding/src/lib.rs new file mode 100644 index 000000000..87bd2a389 --- /dev/null +++ b/libs/binding/src/lib.rs @@ -0,0 +1,163 @@ +//! Host-neutral binding orchestration; JavaScript values belong to the host adapter. + +mod output; +mod reservation; + +pub use css::atom_hoist::set_atom_hoist; +pub use css::debug::{is_debug, set_debug}; +pub use css::file_routes::set_file_routes as import_file_routes_internal; +pub use css::slot_reservation::unreserved_slot_count as get_unreserved_class_slot_count; +pub use css::{get_prefix, set_custom_shorthands as register_shorthands_internal, set_prefix}; +pub use extractor::has_devup_ui; +pub use output::{CssOptions, ExtractionMode, Output, SourceMapMode, code_extract}; +pub use reservation::{ReservationInput, reserve_class_slots_internal}; + +use css::class_map::{set_class_map, with_class_map}; +use css::file_map::{set_canonical_map, set_file_map, with_canonical_map, with_file_map}; +use sheet::StyleSheet; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{LazyLock, Mutex}; + +/// The single stylesheet shared by extraction, reservation and host test seams. +pub static GLOBAL_STYLE_SHEET: LazyLock> = + LazyLock::new(|| Mutex::new(StyleSheet::default())); + +/// Borrow the live stylesheet, recovering its contents after an unwind. +pub fn with_style_sheet(f: F) -> R +where + F: FnOnce(&StyleSheet) -> R, +{ + let guard = GLOBAL_STYLE_SHEET + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&guard) +} + +/// Mutably borrow the live stylesheet, recovering its contents after an unwind. +pub fn with_style_sheet_mut(f: F) -> R +where + F: FnOnce(&mut StyleSheet) -> R, +{ + let mut guard = GLOBAL_STYLE_SHEET + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(&mut guard) +} + +/// Restore the sheet and its frozen atom plan together. +pub fn import_sheet_internal(sheet: StyleSheet) { + css::atom_hoist::restore_atom_plan(sheet.atom_plan.clone()); + with_style_sheet_mut(|global_sheet| *global_sheet = sheet); +} + +/// Export the deterministic sheet snapshot without changing live storage. +pub fn export_sheet_internal() -> Result { + with_style_sheet(|sheet| serde_json::to_string(&sheet.export_snapshot())) + .map_err(|e| e.to_string()) +} + +/// Restore class allocations decoded by a host adapter. +pub fn import_class_map_internal(map: HashMap>) { + set_class_map(map); +} + +/// Export class allocations in file and class-key order. +pub fn export_class_map_internal() -> Result { + with_class_map(|map| { + let sorted: BTreeMap<&String, BTreeMap<&String, &usize>> = map + .iter() + .map(|(file, classes)| (file, classes.iter().collect())) + .collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) +} + +/// Restore file numbers decoded by a host adapter. +pub fn import_file_map_internal(map: bimap::BiHashMap) { + set_file_map(map); +} + +/// Export file numbers in path order. +pub fn export_file_map_internal() -> Result { + with_file_map(|map| { + let sorted: BTreeMap<&String, &usize> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) +} + +/// Restore canonical bucket identities decoded by a host adapter. +pub fn import_canonical_map_internal(map: HashMap) { + set_canonical_map(map); +} + +/// Export canonical bucket identities in path order. +pub fn export_canonical_map_internal() -> Result { + with_canonical_map(|map| { + let sorted: BTreeMap<&String, &String> = map.iter().collect(); + serde_json::to_string(&sorted) + }) + .map_err(|e| e.to_string()) +} + +/// Seed deterministic file numbers without replacing existing assignments. +pub fn seed_file_map(files: &[String]) { + css::file_map::seed_file_numbers(files); +} + +/// Clear production build state, retaining theme registries, shorthands and debug. +/// The host adapter separately clears its resolver handle. +pub fn reset_build_state_internal() { + css::slot_reservation::reset(); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + css::file_map::reset_canonical_map(); + css::file_routes::set_file_routes(HashMap::new()); + css::atom_hoist::set_atom_hoist(None); + css::atom_hoist::restore_atom_plan(None); + css::set_prefix(None); + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); +} + +/// Clear test state at a quiescent boundary, clearing the host resolver before registries. +pub fn reset_state_internal(clear_resolver: impl FnOnce()) { + with_style_sheet_mut(|sheet| *sheet = StyleSheet::default()); + clear_resolver(); + css::test_state::reset_state_for_testing(); + extractor::test_state::reset_caches_for_testing(); +} + +/// Register a decoded theme in the live stylesheet. +pub fn register_theme_internal(theme: sheet::theme::Theme) { + with_style_sheet_mut(|sheet| sheet.set_theme(theme)); +} + +/// Return the theme used without an explicit variant selector. +pub fn get_default_theme() -> Option { + with_style_sheet(|sheet| sheet.theme.get_default_theme()) +} + +/// Materialize CSS for a numbered file or the global sheet. +pub fn get_css(file_num: Option, import_main_css: bool) -> String { + with_style_sheet(|sheet| { + if let Some(file_num) = file_num { + with_file_map(|map| { + sheet.create_css( + map.get_by_right(&file_num).map(String::as_str), + import_main_css, + ) + }) + } else { + sheet.create_css(None, import_main_css) + } + }) +} + +/// Generate declarations with color, typography, length, shadow and theme interface names. +pub fn get_theme_interface(package_name: &str, names: [&str; 5]) -> String { + let [color, typography, length, shadows, theme] = names; + with_style_sheet(|sheet| { + sheet.create_interface(package_name, color, typography, length, shadows, theme) + }) +} diff --git a/libs/binding/src/output.rs b/libs/binding/src/output.rs new file mode 100644 index 000000000..333743eee --- /dev/null +++ b/libs/binding/src/output.rs @@ -0,0 +1,120 @@ +use css::file_map::{canonical, is_global}; +use extractor::{ + ExtractOption, ExtractOutput, ModuleResolver, extract, extract_with_modules, + extract_without_source_map, +}; + +use crate::with_style_sheet_mut; + +/// Whether extraction generates a source map. +#[derive(Clone, Copy)] +pub enum SourceMapMode { + /// Generate source maps using the existing extractor path. + Generate, + /// Skip source-map generation. + Skip, +} + +/// CSS emission settings shared by real transforms and output test seams. +#[derive(Clone, Copy)] +pub struct CssOptions { + /// Emit all atoms to the global sheet. + pub single_css: bool, + /// Import the main stylesheet from emitted file CSS. + pub import_main_css: bool, +} + +/// Host-neutral extraction routing and CSS import policy. +pub struct ExtractionMode<'a> { + /// Source-map generation policy. + pub source_map: SourceMapMode, + /// Borrow the host resolver only for this synchronous extraction. + pub resolver: Option<&'a ModuleResolver>, + /// Import the main stylesheet from emitted file CSS. + pub import_main_css_in_css: bool, +} + +/// Materialized transform result, independent of host handles and getters. +pub struct Output { + /// Transformed source. + pub code: String, + /// Generated source map, when requested and available. + pub map: Option, + /// Emitted stylesheet filename. + pub css_file: Option, + /// Whether shared base CSS changed. + pub updated_base_style: bool, + /// Changed CSS, absent when the stylesheet is unchanged. + pub css: Option, + /// Files read through the module resolver. + pub dependencies: Vec, +} + +impl Output { + /// Commit extracted styles to their existing bucket and materialize changed CSS. + pub fn new(output: ExtractOutput, filename: String, options: CssOptions) -> Self { + // Use the bucket identity (single-importer collapse) so the sheet's CSS + // naming + property bucket + emitted chunk match the canonical class names + // the extractor already baked into `code`. Identity when no map is loaded. + // Global (shared-chunk) files are treated like single-css: emitted into the + // global bucket (devup-ui.css) with prefix-less naming. + let _atom_mode = css::atom_hoist::AtomModeGuard::new(options.single_css); + let canonical_filename = canonical(&filename); + let global = options.single_css || is_global(&filename); + with_style_sheet_mut(|sheet| { + // Global CSS belongs to the raw SOURCE file, never its collapsed bucket. + let default_collected = sheet.rm_global_css(&filename, global); + let (collected, updated_base_style) = + sheet.update_styles_in_bucket(&output.styles, &canonical_filename, global); + Self { + code: output.code, + map: output.map, + css_file: output.css_file, + dependencies: output.dependencies, + updated_base_style: updated_base_style || default_collected, + css: { + if !collected && !default_collected { + None + } else { + Some(sheet.create_css( + if global { + None + } else { + Some(&canonical_filename) + }, + options.import_main_css, + )) + } + }, + } + }) + } +} + +/// Extract a `(filename, code)` source with an optional borrowed host resolver. +pub fn code_extract( + source: (&str, &str), + option: ExtractOption, + mode: ExtractionMode<'_>, +) -> Result { + let (filename, code) = source; + let css = CssOptions { + single_css: option.single_css, + import_main_css: mode.import_main_css_in_css, + }; + let extracted = match (mode.resolver, mode.source_map) { + (Some(resolver), source_map) => extract_with_modules( + filename, + code, + option, + matches!(source_map, SourceMapMode::Generate), + resolver, + ), + (None, SourceMapMode::Generate) => extract(filename, code, option), + (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), + }; + match extracted { + Ok(output) => Ok(Output::new(output, filename.to_string(), css)), + Err(error) => Err(error.to_string()), + } +} diff --git a/libs/binding/src/reservation.rs b/libs/binding/src/reservation.rs new file mode 100644 index 000000000..75ee97e2f --- /dev/null +++ b/libs/binding/src/reservation.rs @@ -0,0 +1,84 @@ +//! Rule-free reservation through the real extractor and sheet allocation path. + +use bimap::BiHashMap; +use css::{file_map, slot_reservation::ScratchSlots}; +use extractor::reservation_state::ScratchExtractionState; +use extractor::{ExtractOption, ModuleResolver, extract_with_modules, extract_without_source_map}; +use sheet::StyleSheet; + +use crate::with_style_sheet_mut; + +/// Prepared physical input, keyed exactly as its later real transform. +pub struct ReservationInput { + /// Physical source path. + pub filename: String, + /// Source contents. + pub code: String, + /// CSS output directory for this source. + pub css_dir: String, +} + +struct ScratchSheet { + live: StyleSheet, + files: BiHashMap, +} + +impl Default for ScratchSheet { + fn default() -> Self { + let files = file_map::get_file_map(); + let live = with_style_sheet_mut(|sheet| { + let mut live = std::mem::take(sheet); + sheet.theme = std::mem::take(&mut live.theme); + live + }); + Self { live, files } + } +} + +impl Drop for ScratchSheet { + fn drop(&mut self) { + with_style_sheet_mut(|sheet| { + std::mem::swap(&mut self.live.theme, &mut sheet.theme); + *sheet = std::mem::take(&mut self.live); + }); + file_map::set_file_map(std::mem::take(&mut self.files)); + } +} + +/// Reserve in supplied physical-path order; suppress per-input errors, emitting nothing. +pub fn reserve_class_slots_internal( + inputs: &[ReservationInput], + option: ExtractOption, + resolver: Option<&ModuleResolver>, +) { + let _atom_mode = css::atom_hoist::AtomModeGuard::new(option.single_css); + let slots = ScratchSlots::default(); + let files: Vec = inputs.iter().map(|input| input.filename.clone()).collect(); + file_map::seed_file_numbers(&files); + for input in inputs { + let _ = file_map::get_file_num_by_filename(&input.filename); + } + let _sheet = ScratchSheet::default(); + let _extraction = ScratchExtractionState::default(); + for input in inputs { + let file_option = ExtractOption { + css_dir: input.css_dir.clone(), + ..option.clone() + }; + let extracted = match resolver { + Some(resolver) => { + extract_with_modules(&input.filename, &input.code, file_option, false, resolver) + } + None => extract_without_source_map(&input.filename, &input.code, file_option), + }; + if let Ok(output) = extracted { + let bucket = file_map::canonical(&input.filename); + let global = option.single_css || file_map::is_global(&input.filename); + with_style_sheet_mut(|sheet| { + sheet.rm_global_css(&input.filename, global); + sheet.update_styles_in_bucket(&output.styles, &bucket, global); + }); + } + } + slots.commit(); +} diff --git a/libs/binding/tests/css.rs b/libs/binding/tests/css.rs new file mode 100644 index 000000000..d5ade1b4c --- /dev/null +++ b/libs/binding/tests/css.rs @@ -0,0 +1,46 @@ +use devup_ui_binding_core::{ExtractionMode, SourceMapMode}; +use extractor::ExtractOption; + +#[test] +fn numbered_css_selects_its_file_when_other_files_exist() -> Result<(), String> { + // Given: two independently emitted files with explicit, nonconsecutive numbers. + devup_ui_binding_core::reset_state_internal(|| {}); + devup_ui_binding_core::import_file_map_internal( + [("red.tsx".to_string(), 7), ("blue.tsx".to_string(), 3)] + .into_iter() + .collect(), + ); + let option = || ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "df".to_string(), + ..ExtractOption::default() + }; + let mode = || ExtractionMode { + source_map: SourceMapMode::Skip, + resolver: None, + import_main_css_in_css: false, + }; + let red = devup_ui_binding_core::code_extract( + ( + "red.tsx", + "import { Box } from '@devup-ui/react'; export const view = ;", + ), + option(), + mode(), + )?; + devup_ui_binding_core::code_extract( + ( + "blue.tsx", + "import { Box } from '@devup-ui/react'; export const view = ;", + ), + option(), + mode(), + )?; + + // When: the host requests the red file by its imported number. + let selected = devup_ui_binding_core::get_css(Some(7), false); + + // Then: only the same file CSS returned by that transform is selected. + assert_eq!(Some(selected), red.css); + Ok(()) +}