diff --git a/.cargo/config.toml b/.cargo/config.toml index e15f5077..17f9915b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,8 @@ # `cargo wasm` build edge-python release WebAssembly artifact. [alias] wasm = "build --release --target wasm32-unknown-unknown -p edge-python" + +# `--profile native` speed-first build of the native port binaries. +[profile.native] +inherits = "release" +opt-level = 3 diff --git a/.github/actions/cdn-deploy/action.yml b/.github/actions/cdn-deploy/action.yml index e5bb142b..34aa3883 100644 --- a/.github/actions/cdn-deploy/action.yml +++ b/.github/actions/cdn-deploy/action.yml @@ -32,6 +32,13 @@ runs: path: /tmp/cli/ merge-multiple: true + - name: Download native artifacts + uses: actions/download-artifact@v8 + with: + pattern: native-* + path: /tmp/native/ + merge-multiple: true + - name: Install brotli shell: bash run: command -v brotli >/dev/null || (sudo apt-get update && sudo apt-get install -y brotli) @@ -45,10 +52,10 @@ runs: brotli -q 11 -c /tmp/compiler/compiler.wasm > _site/compiler.wasm printf '/compiler.wasm\n Content-Type: application/wasm\n Content-Encoding: br\n Cache-Control: public, max-age=0, must-revalidate\n' > _site/_headers - # js package: served under /runtime/ (public embed URLs); tests/ not shipped. - cp -r js/src _site/runtime/src + # runtime package: served under /runtime/ (public embed URLs); tests/ not shipped. + cp -r runtime/src _site/runtime/src # Compat alias: cached index.js builds resolve ../worker/worker.js. - cp -r js/src/worker _site/runtime/worker + cp -r runtime/src/worker _site/runtime/worker # std: .wasm / .py. cp -r /tmp/std/. _site/std/ @@ -68,6 +75,12 @@ runs: cp -r /tmp/cli/. _site/cli/ cp cli/setup/install.sh cli/setup/uninstall.sh _site/cli/ + # native assets, the std plugin .so files, under /native// matching the resolver's URL. + contract="$(sed -n 's/^pub const RUNTIME_CONTRACT: &str = "\(.*\)";$/\1/p' src/devkit.rs)" + test -n "$contract" + mkdir -p "_site/native/$contract" + cp -r /tmp/native/. "_site/native/$contract/" + - name: Report served size shell: bash run: | diff --git a/.github/actions/cli/action.yml b/.github/actions/cli/action.yml index 79e15791..15a4cac9 100644 --- a/.github/actions/cli/action.yml +++ b/.github/actions/cli/action.yml @@ -44,6 +44,12 @@ runs: shell: bash run: sudo apt-get update && sudo apt-get install -y musl-tools + # Gnu releases link through zig against a glibc 2.28 floor so dlopen'd std plugins work everywhere. + - name: Install cargo-zigbuild + if: inputs.mode == 'release' && contains(inputs.target, 'linux-gnu') + shell: bash + run: command -v cargo-zigbuild >/dev/null || pip3 install --user cargo-zigbuild + - name: Cache Rust (release) if: inputs.mode == 'release' uses: Swatinem/rust-cache@v2 @@ -55,7 +61,12 @@ runs: if: inputs.mode == 'release' shell: bash working-directory: cli - run: cargo build --release --target "${{ inputs.target }}" + run: | + export PATH="$HOME/.local/bin:$PATH" + case "${{ inputs.target }}" in + *linux-gnu) cargo zigbuild --release --target "${{ inputs.target }}.2.28" ;; + *) cargo build --release --target "${{ inputs.target }}" ;; + esac - name: Tar if: inputs.mode == 'release' @@ -89,6 +100,22 @@ runs: name: compiler_wasm path: target/wasm32-unknown-unknown/release/ + # Same idea for the native engine: this run's std plugins and test.py, never the CDN. + - name: Download native std plugins + if: inputs.mode == 'test' + uses: actions/download-artifact@v8 + with: + pattern: native-*-x86_64-unknown-linux-gnu + path: std_local/native/ + merge-multiple: true + + - name: Download std test.py + if: inputs.mode == 'test' + uses: actions/download-artifact@v8 + with: + name: dist-test + path: std_local/std/ + # Drives a real Chromium; ubuntu-latest ships google-chrome-stable. - name: Test if: inputs.mode == 'test' @@ -97,4 +124,5 @@ runs: env: EDGE_RUNTIME_DIR: ${{ github.workspace }}/js EDGE_COMPILER_WASM: ${{ github.workspace }}/target/wasm32-unknown-unknown/release/compiler.wasm + EDGE_STD_DIR: ${{ github.workspace }}/std_local run: cargo test diff --git a/.github/actions/compiler/action.yml b/.github/actions/compiler/action.yml index f9c81ce5..c10cd9aa 100644 --- a/.github/actions/compiler/action.yml +++ b/.github/actions/compiler/action.yml @@ -39,6 +39,12 @@ runs: shell: bash run: cargo clippy --all-targets --no-default-features -- -D warnings + # The native engine module only compiles under this feature, e2e coverage lives in cli/tests. + - name: Clippy (native engine) + if: inputs.mode == 'check' + shell: bash + run: cargo clippy --lib --no-default-features --features native -- -D warnings + - name: Install wasm target (check) if: inputs.mode == 'check' shell: bash diff --git a/.github/actions/js/action.yml b/.github/actions/js/action.yml index 69592748..22e343f1 100644 --- a/.github/actions/js/action.yml +++ b/.github/actions/js/action.yml @@ -23,7 +23,7 @@ runs: - name: Lint if: inputs.mode == 'lint' shell: bash - run: deno lint js/ + run: deno lint runtime/ # Test against this run's compiler, not the deployed CDN copy. - name: Download compiler wasm @@ -50,4 +50,4 @@ runs: - name: Test if: inputs.mode == 'test' shell: bash - run: deno test --allow-all js/tests/js.test.js + run: deno test --allow-all runtime/tests/runtime.test.js diff --git a/.github/actions/native/action.yml b/.github/actions/native/action.yml new file mode 100644 index 00000000..bd2cf645 --- /dev/null +++ b/.github/actions/native/action.yml @@ -0,0 +1,98 @@ +name: Native std packages +description: std packages as native plugin cdylibs, linux with a glibc 2.28 floor plus darwin dylibs. CDN staging + tag Release upload. + +inputs: + package: + description: "json | re | math | struct" + required: true + target: + description: Rust linux-gnu or apple-darwin target triple. + required: true + github-token: + description: Token for the tag Release upload (tags only). + required: false + default: "" + +runs: + using: composite + steps: + # Nightly rotates daily, its manifest hash keys an exact-version toolchain cache. + - name: Toolchain cache key + id: toolchain + shell: bash + run: echo "nightly=$(curl -fsSL https://static.rust-lang.org/dist/channel-rust-nightly.toml.sha256 | cut -c1-16)" >> "$GITHUB_OUTPUT" + + # Restoring update-hashes lets rustup see the toolchain as current and skip reinstalling. + - name: Cache toolchain + uses: actions/cache@v5 + with: + path: | + ~/.rustup/toolchains + ~/.rustup/update-hashes + key: rustup-std-${{ inputs.target }}-${{ steps.toolchain.outputs.nightly }} + + # build-std needs nightly plus rust-src. + - name: Toolchain (nightly) + uses: dtolnay/rust-toolchain@nightly + with: + targets: ${{ inputs.target }} + components: rust-src + + # The pip wheel bundles zig, which links against a chosen glibc floor. + - name: Install cargo-zigbuild + if: contains(inputs.target, 'linux') + shell: bash + run: command -v cargo-zigbuild >/dev/null || pip3 install --user cargo-zigbuild + + - name: Cache Rust + uses: Swatinem/rust-cache@v2 + with: + workspaces: std/${{ inputs.package }} -> std/${{ inputs.package }}/target + key: native-${{ inputs.target }} + + # Glibc 2.28 covers every mainstream distro of the last decade; Mach-O leaves edge_* to dlopen via dynamic_lookup. + - name: Build + shell: bash + working-directory: std/${{ inputs.package }} + env: + TARGET: ${{ inputs.target }} + SIZE_FLAGS: -Z location-detail=none -Z fmt-debug=none -Z unstable-options -C panic=immediate-abort + run: | + export PATH="$HOME/.local/bin:$PATH" + case "$TARGET" in + *linux-gnu) + RUSTFLAGS="$SIZE_FLAGS" cargo +nightly zigbuild --profile native --target "${TARGET}.2.28" -Z build-std=std,panic_abort ;; + *apple-darwin) + RUSTFLAGS="$SIZE_FLAGS -C link-arg=-undefined -C link-arg=dynamic_lookup" \ + cargo +nightly build --profile native --target "$TARGET" -Z build-std=std,panic_abort ;; + esac + + # Assets carry the short arch, the lib glob absorbs keyword-crate names like libedge_struct. + - name: Package + shell: bash + env: + TARGET: ${{ inputs.target }} + run: | + ARCH="${TARGET%%-*}" + case "$TARGET" in + *apple-darwin) EXT="dylib" ;; + *) EXT="so" ;; + esac + mkdir -p _native + cp "std/${{ inputs.package }}/target/${TARGET}/native/"lib*."$EXT" "_native/${{ inputs.package }}-${ARCH}.${EXT}" + + # The `native-` prefix is the glob cdn-deploy stages under /native/. + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: native-${{ inputs.package }}-${{ inputs.target }} + path: _native/ + retention-days: 1 + + - name: Upload Release assets + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v3 + with: + token: ${{ inputs.github-token }} + files: _native/* + fail_on_unmatched_files: true diff --git a/.github/actions/std/action.yml b/.github/actions/std/action.yml index 37ad9e35..67f14270 100644 --- a/.github/actions/std/action.yml +++ b/.github/actions/std/action.yml @@ -136,7 +136,7 @@ runs: working-directory: std env: STDPKG: ${{ inputs.package }} - run: deno test --allow-all tests/ + run: deno test --allow-all harness/ # Stage native -> .wasm, pure-Python -> .py. - name: Stage artifact diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c1564de6..d2fae5c9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -86,6 +86,35 @@ jobs: with: package: ${{ matrix.package }} + # std packages as native plugin cdylibs for the CLI's in-process engine. Assets ship to the CDN and tag Releases. + native: + name: Native / ${{ matrix.package }} (${{ matrix.target }}) + needs: [compiler-check] + permissions: + contents: write + strategy: + fail-fast: false + matrix: + package: [json, re, math, struct] + target: [aarch64-unknown-linux-gnu, x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin] + include: + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04-arm + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + - target: aarch64-apple-darwin + runner: macos-15 + - target: x86_64-apple-darwin + runner: macos-15 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/native + with: + package: ${{ matrix.package }} + target: ${{ matrix.target }} + github-token: ${{ github.token }} + # Clippy + check once (no matrix); gates the release build. cli-lint: name: CLI / Lint and Check @@ -104,6 +133,10 @@ jobs: fail-fast: false matrix: include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04-arm - target: x86_64-unknown-linux-musl runner: ubuntu-latest - target: aarch64-unknown-linux-musl @@ -122,7 +155,7 @@ jobs: cli-test: name: CLI / Test - needs: [host, std, cli-release] + needs: [host, std, native, cli-release] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -132,7 +165,7 @@ jobs: cdn: name: Cloudflare Upload (CDN) - needs: [cli-test] + needs: [cli-test, native] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 0333c586..646401c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,49 +1,49 @@ -# Rust -**/target/ -Cargo.lock - -# macOS -.DS_Store -**/.DS_Store -._* - -# Windows -Thumbs.db -Desktop.ini - -# Editors -.vscode/ -.idea/ -*.swp - -# Node (for the docs) -node_modules/ -**/node_modules/ -dist/ -*.log - -# Next.js / Nextra (docs) -.next/ -**/.next/ -out/ -**/out/ - -# WASM -pkg/ -**/pkg/ - -# Cloudflare -.wrangler/ - -# Secrets -.env -.env.* - -# Fuzz -**/in/ -**/out/ -**/target/ -**/edge.dict +# Rust +**/target/ +Cargo.lock + +# macOS +.DS_Store +**/.DS_Store +._* + +# Windows +Thumbs.db +[Dd]esktop.ini + +# Editors +.vscode/ +.idea/ +*.swp +*~ + +# Node (for the docs) +node_modules/ +**/node_modules/ +dist/ +*.log + +# Next.js / Nextra (docs) +.next/ +**/.next/ +docs/out/ + +# WASM +pkg/ +**/pkg/ + +# Cloudflare +.wrangler/ + +# Secrets +.env +.env.* + +# Fuzz +fuzz/in/ +fuzz/out/ +fuzz/logs/ +fuzz/edge.dict # MicroPython differential corpus (scratch, not part of this repo) _micropython/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8f9dabe..da618b3e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,21 +13,22 @@ For large changes, please try reaching out to the Edge maintainers via GitHub Is Run the following commands before sending a pull request to ensure code quality: - `cargo wasm` Build the `compiler.wasm`. -- `cargo test --release --no-default-features` Run the compiler test suite. -- `cargo clippy --all-targets --no-default-features -- -D warnings` Lint the Rust code. +- `cargo test --release` Run the compiler test suite. +- `cargo clippy --all-targets -- -D warnings` Lint the Rust code. - `cargo clippy --lib --target wasm32-unknown-unknown -p edge-python -p slugify-mod -- -D warnings` Lint the wasm build. +- `cargo clippy --lib --features native -- -D warnings` Lint the native engine module. - `cargo shear` Detect unused dependencies. - For significant changes, execute the [fuzzer](https://edgepython.com/implementation/fuzzing/) to check for new crashes or performance regressions. The test suite (`tests/`, fixtures in `tests/cases/vm.json`) runs every case under `Limits::sandbox()`, not the default `none()`. The budget, heap, and call-depth guards short-circuit under `none` (`sandbox_off`), so only the bounded profile exercises them — that way a regression that lets a loop run unbounded, recurse without limit, or materialise an oversized collection becomes a failing `MemoryError` / `RecursionError` assertion instead of a hang. Every fixture must stay within the sandbox budget. -*Other packages have their own build and test setup — see the `README.md` in the relevant path. Code comments must be a single line of fewer than 30 words; if a change is too large, add a short section to the corresponding page under `./docs/content` instead.* +*Other packages have their own build and test setup — see the `README.md` in the relevant path.* `cli/` tests hit the CDN runtime by default; `EDGE_RUNTIME_DIR` and `EDGE_COMPILER_WASM` swap in local copies for end-to-end validation before a deploy: ```bash cargo wasm && cd cli -EDGE_RUNTIME_DIR=../js EDGE_COMPILER_WASM=../target/wasm32-unknown-unknown/release/compiler.wasm cargo test +EDGE_RUNTIME_DIR=../runtime EDGE_COMPILER_WASM=../target/wasm32-unknown-unknown/release/compiler.wasm cargo test ``` A CI job will be run by the maintainer after the PR has been created. diff --git a/Cargo.toml b/Cargo.toml index bc00e702..fec734b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,11 +4,10 @@ version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true -description = "Single-pass SSA compiler and threaded-code VM for a sandboxed Python subset. NaN-boxed values, dual inline caching, super-instruction fusion, pure-function memoization, mark-sweep GC; classes with inheritance and dunder protocol, async/await, pattern matching, and packages.json imports. around 200 KB WebAssembly module release." +description = "Single-pass SSA bytecode compiler and threaded-code stack VM for a sandboxed Python subset. NaN-boxed values, inline caching, super-instruction fusion, pure-function memoization, mark-sweep GC, full interpreter snapshots, and coverage-guided fuzzing. Runs in the browser as a WebAssembly module and natively inside the CLI." readme = "README.md" autotests = false autobins = false -links = "compiler" [lib] name = "compiler" @@ -20,10 +19,9 @@ doctest = false name = "tests" path = "tests/main.rs" -# `prebuilt` (default on) gates the `build.rs` download of `compiler.wasm`. Consumers get the wasm transparently; this repo's own producer-side commands pass `--no-default-features` to skip the fetch (nothing here reads `DEP_COMPILER_WASM`). [features] -default = ["prebuilt"] -prebuilt = [] +# Gates the native engine module (resolver, dlopen loader, host modules) out of wasm and plain lib builds. +native = ["dep:libloading", "dep:ureq"] [dependencies] wasm-abi = { workspace = true } @@ -31,6 +29,10 @@ hashbrown = { workspace = true } itoa = { workspace = true } libm = { workspace = true } +# Native-engine only, dlopen for std plugins and blocking https for URL imports. +libloading = { version = "0.9", optional = true } +ureq = { version = "3.3.0", optional = true } + # WASM-only global allocator. Pulled into wasm32 builds automatically; absent on host builds (where std's allocator is fine and `compiler.wasm` is just a library artifact, not the runtime target). [target.'cfg(target_arch = "wasm32")'.dependencies] dlmalloc = { version = "0.2", default-features = false, features = ["global"] } diff --git a/README.md b/README.md index f898cc89..93c2499e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@
-Single-pass SSA bytecode compiler and threaded-code stack VM for a sandboxed Python subset: NaN-boxed values, inline caching, super-instruction fusion, pure-function memoization, mark-sweep GC. Coverage-guided fuzzing; runs in the browser as a WebAssembly module. +Single-pass SSA bytecode compiler and threaded-code stack VM for a sandboxed Python subset. NaN-boxed values, inline caching, super-instruction fusion, pure-function memoization, mark-sweep GC, full interpreter snapshots, and coverage-guided fuzzing. Runs in the browser as a WebAssembly module and natively inside the CLI. - Secure by default. No file, network, or environment access, unless explicitly enabled by the [host](https://edgepython.com/reference/packages#host-libraries). - Around 200 KB footprint. The full compiler and runtime ship as a single WASM binary. @@ -22,26 +22,31 @@ Single-pass SSA bytecode compiler and threaded-code stack VM for a sandboxed Pyt ## Repository layout -Cargo workspace rooted at the engine crate (`edge-python`); commands work from any directory. +A Cargo workspace at the repo root holds the engine, `abi` and `pdk`. `cli/`, `fuzz/` and each `std/*` package are standalone workspaces with their own build and test commands; the commands below run from the repo root. ```text ├── abi ├── cli +│ └── src +│ ├── cmd +│ └── engine ├── docs ├── fuzz ├── host -├── js ├── pdk +├── runtime ├── src │ ├── lexer +│ ├── native +│ │ └── builtins │ ├── packages │ ├── parser │ ├── util +│ ├── value │ ├── vm -│ │ ├── builtins -│ │ ├── handlers -│ │ │ └── builtin_methods -│ │ └── types +│ │ ├── globals +│ │ ├── methods +│ │ └── opcodes │ └── wasm ├── std └── tests @@ -49,26 +54,26 @@ Cargo workspace rooted at the engine crate (`edge-python`); commands work from a ``` ```bash -cargo wasm # release .wasm (the distributed artifact) +cargo wasm # local release .wasm (CI ships a further size-optimised build) cargo build --release # host .rlib + cdylib for Rust embedders -cargo test --release --no-default-features # run the compiler test suite +cargo clippy --lib --features native # lint the native engine module +cargo test --release # run the compiler test suite ``` -*`--no-default-features` disables the `prebuilt` feature, which otherwise triggers a `build.rs` download of `compiler.wasm` from the GitHub release: that download is only needed by external Rust crates that consume this library, not when developing locally.* - ## Architecture Single-pass pipeline: source -> SSA bytecode chunk; stack interpreter with adaptive inline caching and pure-function memoization. * **Lexer** (`src/lexer/`) LUT-driven, offset-based tokens. * **Parser** (`src/parser/`) Pratt precedence, SSA-versioned bytecode with `Phi` at joins, no AST. -* **Optimizer** (`src/vm/optimizer.rs`) constant folding, Phi-noop elimination, dead-code compaction. -* **VM** (`src/vm/`) flat-match dispatch, scalar + instance-dunder inline caches, pure-function template memoization, NaN-boxed 64-bit `Val` with a mark-and-sweep arena. +* **Optimizer** (`src/optimizer.rs`) constant folding, Phi-noop elimination, dead-code compaction. +* **Values** (`src/value/`) NaN-boxed 64-bit `Val`, heap objects, and the mark-and-sweep arena the whole pipeline shares. +* **VM** (`src/vm/`) flat-match dispatch, scalar + instance-dunder inline caches, pure-function template memoization; `opcodes/` implements opcodes, `globals/` the global functions, `methods/` the builtin-type methods. * **Resolver** (`src/packages/`) host-injected; native imports register for `CallExtern` dispatch. Full rationale, NaN-box patterns, IC thresholds, GC roots, and intentional omissions: [Design](https://edgepython.com/implementation/design). Lexer and parser internals: [Lexical](https://edgepython.com/implementation/lexical), [Syntax](https://edgepython.com/implementation/syntax). -Native modules ship via three delivery paths (CDN `.wasm`, host capability, JS host module), see [Writing modules](https://edgepython.com/reference/writing-modules). +Native modules ship via four delivery paths (CDN `.wasm`, native `.so`/`.dylib` plugin, host capability, JS host module), see [Writing modules](https://edgepython.com/reference/writing-modules). ## Quick start @@ -85,7 +90,7 @@ cargo install --path cli edge -h # List all commands ``` -`edge` hosts the runtime in a headless Chromium for `run`, `repl` and `test`; `install.sh` downloads a pinned `chrome-headless-shell` into `~/.cache/edge` unless a system Chrome/Chromium is already installed. +`run`, `repl` and `test` execute in the built-in native engine; `--web` hosts the runtime in a headless Chromium instead, and `install.sh` downloads a pinned `chrome-headless-shell` into `~/.cache/edge` unless a system Chrome/Chromium is already installed (`EDGE_NO_BROWSER=1` skips it). ### Browser @@ -106,11 +111,46 @@ The runtime spawns a Web Worker that pre-fetches imports, dispatches native call ### Rust host -Edge Python is a `cdylib`: a Rust host can instantiate `compiler.wasm` and call its exports directly, the same `.wasm` that ships to browsers; the host owns I/O. Declaring `edge-python` as a Cargo dependency fetches the matching release `.wasm` automatically (exposed as `DEP_COMPILER_WASM`), see [Consuming the release](https://edgepython.com/reference/wasm-abi#consuming-the-release-from-a-rust-crate). To add native modules from your own crate, implement the `Resolver` trait, see [Writing modules](https://edgepython.com/reference/writing-modules). +Edge Python is a `cdylib`: a Rust host can instantiate `compiler.wasm` and call its exports directly, the same `.wasm` that ships to browsers; the host owns I/O. The crate fetches nothing at build time, so vendor the `.wasm` from a tagged release and pin it by checksum, see [Consuming the release](https://edgepython.com/reference/wasm-abi#consuming-the-release-from-a-rust-crate). To add native modules from your own crate, implement the `Resolver` trait, see [Writing modules](https://edgepython.com/reference/writing-modules). + +### Native + +`edge run`, `edge repl`, and `edge test` execute in the CLI's in-process native engine by default (`--web` restores Chromium); tagged releases and the CDN's `/native/` route carry each std package as a native plugin library ([native engine](https://edgepython.com/reference/native)). + +```python +# hello.py +async def greet(name): + await sleep(0.1) + print(f"hello {name}") + +await greet("edge") +``` + +```text +$ edge run hello.py +hello edge +``` + +Imports, std packages, and the built-in `time` / `network` modules resolve without a browser: + +```python +# app.py +import json +from "./lib/helper.py" import double + +data = json.loads('{"n": 21}') +await sleep(0.1) +print(json.dumps({"result": double(data["n"])})) +``` + +```text +$ edge run app.py +{"result":42} +``` ## What it is -Edge Python targets sandboxed in-browser execution: a dynamic, multi-paradigm Python subset with classes, async/await, structural pattern matching, and compile-time module resolution. There is no bundled stdlib, modules are external artifacts. +Edge Python targets sandboxed execution, in the browser and in the CLI's native engine: a dynamic, multi-paradigm Python subset with classes, async/await, structural pattern matching, and compile-time module resolution. There is no bundled stdlib, modules are external artifacts. Full language reference, scope, and what intentionally isn't supported: [What Edge Python is](https://edgepython.com/getting-started/what-it-is). diff --git a/build.rs b/build.rs deleted file mode 100644 index 534eceb3..00000000 --- a/build.rs +++ /dev/null @@ -1,52 +0,0 @@ -/* -Downloads `.wasm` from GitHub release into OUT_DIR; path exposed via `DEP__WASM`. -URL: `/releases/download/v/.wasm`. No-op on wasm32. Requires `curl`. -Gated on default-on `prebuilt` feature; producers opt out with `--no-default-features`. -*/ - -use std::{env, path::PathBuf, process::Command}; - -fn main() { - println!("cargo::rerun-if-changed=build.rs"); - - if env::var("CARGO_CFG_TARGET_ARCH").as_deref() == Ok("wasm32") { - return; - } - - if env::var_os("CARGO_FEATURE_PREBUILT").is_none() { - return; - } - - let links = env::var("CARGO_MANIFEST_LINKS").expect("set `links` in [package] of Cargo.toml"); - let asset = format!("{links}.wasm"); - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set")); - let dst = out_dir.join(&asset); - - if !dst.exists() { - let repo = env::var("CARGO_PKG_REPOSITORY") - .expect("set `repository` in [package] of Cargo.toml") - .trim_end_matches('/') - .to_string(); - let version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION not set"); - let url = format!("{repo}/releases/download/v{version}/{asset}"); // Include `/v{version}` to follow the GitHub tags format. - - let status = Command::new("curl") - .args(["-fsSL", "-o"]) - .arg(&dst) - .arg(&url) - .status() - .expect("`curl` must be on PATH to fetch the pre-built wasm"); - - if !status.success() { - let _ = std::fs::remove_file(&dst); - panic!( - "failed to download {asset} from {url}\n\ - hint: ensure the release tag v{version} exists with `{asset}` \ - as an asset, or pre-place the binary at {}", - dst.display() - ); - } - } - - println!("cargo::metadata=wasm={}", dst.display()); -} diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 5d050fa9..4f10aa6d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -5,12 +5,15 @@ name = "edge" version = "0.1.0" edition = "2024" +description = "The Edge Python developer CLI. Runs, tests, and scaffolds scripts on the in-process native engine by default, drives the browser runtime behind --web, and serves and bundles web apps." [[bin]] name = "edge" path = "src/main.rs" [dependencies] +# The engine crate, devkit helpers plus the in-process native engine. +edge-python = { path = "..", features = ["native"] } clap = { version = "4", features = ["derive"] } tiny_http = "0.12" serde = { version = "1", features = ["derive"] } @@ -25,6 +28,10 @@ rustyline = "17.0.0" # HTTPS fetcher used by `edge build` to vendor the runtime, compiler.wasm, and packages. ureq = "3" +# Static musl builds keep musl's slow malloc otherwise. +[target.'cfg(target_env = "musl")'.dependencies] +mimalloc = "0.1" + [dev-dependencies] # Per-test tempdir; auto-cleans on drop so cases don't leak state into the project. tempfile = "3" diff --git a/cli/build.rs b/cli/build.rs new file mode 100644 index 00000000..97602b98 --- /dev/null +++ b/cli/build.rs @@ -0,0 +1,6 @@ +fn main() { + // dlopen'd std plugins resolve their edge_* imports against the binary's dynamic symbols. + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") { + println!("cargo::rustc-link-arg-bin=edge=-rdynamic"); + } +} diff --git a/cli/setup/install.sh b/cli/setup/install.sh index 32e26770..4bb7c80e 100644 --- a/cli/setup/install.sh +++ b/cli/setup/install.sh @@ -10,8 +10,21 @@ INSTALL_DIR="${EDGE_INSTALL_DIR:-$HOME/.local/bin}" CHROME_DIR="${EDGE_CHROME_DIR:-$HOME/.cache/edge}" CHROME_BUILD="${EDGE_CHROME_BUILD:-131.0.6778.85}" +# Glibc 2.28 or newer gets the gnu build (std .so plugins load); older or musl systems get the static fallback. +linux_libc() { + glibc="$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $2}')" + if [ -n "$glibc" ]; then + major="${glibc%%.*}" + minor="${glibc#*.}"; minor="${minor%%.*}" + if [ "$major" -gt 2 ] 2>/dev/null || { [ "$major" -eq 2 ] && [ "$minor" -ge 28 ]; } 2>/dev/null; then + echo "gnu"; return + fi + fi + echo "musl" +} + case "$(uname -s)" in - Linux) os="unknown-linux-musl" ;; + Linux) os="unknown-linux-$(linux_libc)" ;; Darwin) os="apple-darwin" ;; *) echo "unsupported OS: $(uname -s)" >&2; exit 1 ;; esac @@ -25,14 +38,14 @@ esac target="${arch}-${os}" case "$target" in - x86_64-unknown-linux-musl|aarch64-unknown-linux-musl|x86_64-apple-darwin|aarch64-apple-darwin) ;; + x86_64-unknown-linux-gnu|aarch64-unknown-linux-gnu|x86_64-unknown-linux-musl|aarch64-unknown-linux-musl|x86_64-apple-darwin|aarch64-apple-darwin) ;; *) echo "no prebuilt for $target yet; build from source with 'cargo install --path cli'" >&2; exit 1 ;; esac # Map our target to the chrome-for-testing platform folder name. case "$target" in - x86_64-unknown-linux-musl) chrome_platform="linux64" ;; - aarch64-unknown-linux-musl) chrome_platform="" ;; # no headless-shell build for linux-arm64 + x86_64-unknown-linux-*) chrome_platform="linux64" ;; + aarch64-unknown-linux-*) chrome_platform="" ;; # no headless-shell build for linux-arm64 x86_64-apple-darwin) chrome_platform="mac-x64" ;; aarch64-apple-darwin) chrome_platform="mac-arm64" ;; esac @@ -79,7 +92,10 @@ curl -fsSL "${BASE}/edge-${target}.tar.gz" | tar -xz -C "$INSTALL_DIR" edge chmod +x "$INSTALL_DIR/edge" echo "installed $INSTALL_DIR/edge" -if have_browser; then +# The native engine needs no browser; EDGE_NO_BROWSER skips the download for server installs. +if [ -n "${EDGE_NO_BROWSER:-}" ]; then + echo "browser: skipped (EDGE_NO_BROWSER); web commands need Chrome/Chromium or EDGE_CHROME_PATH" +elif have_browser; then echo "browser: found an existing Chrome/Chromium, skipping chrome-headless-shell download" else install_browser diff --git a/cli/src/build.rs b/cli/src/cmd/build.rs similarity index 97% rename from cli/src/build.rs rename to cli/src/cmd/build.rs index faa453e0..57f74baa 100644 --- a/cli/src/build.rs +++ b/cli/src/cmd/build.rs @@ -8,7 +8,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::Instant; -use crate::pkg::{self, Kind, Manifest}; +use crate::manifest::{Kind, Manifest}; // Production layout we mirror into dist/runtime/ and dist/. const RUNTIME_BASE: &str = "https://cdn.edgepython.com/runtime/"; @@ -29,7 +29,7 @@ const RUNTIME_FILES: &[&str] = &[ "src/worker/engine.js", ]; -const INDEX_HTML: &str = include_str!("templates/dist.html"); +const INDEX_HTML: &str = include_str!("../templates/dist.html"); pub fn run(manifest_path: &Path, out_dir: PathBuf) -> Result<()> { let t0 = Instant::now(); @@ -173,7 +173,7 @@ fn vendor_packages( for name in imports { // Unknown names are project-local .py modules; let the runtime resolve them at run time. - let Some((kind, url)) = pkg::resolve(name, manifest) else { continue }; + let Some((kind, url)) = crate::manifest::resolve(name, manifest) else { continue }; let bytes = fetch(&url).with_context(|| format!("fetching {url}"))?; let local = match kind { // std packages are .wasm, except pure-Python ones (test) served as .py; preserve the real extension. diff --git a/cli/src/init.rs b/cli/src/cmd/init.rs similarity index 86% rename from cli/src/init.rs rename to cli/src/cmd/init.rs index e32af98b..bb8937cb 100644 --- a/cli/src/init.rs +++ b/cli/src/cmd/init.rs @@ -3,12 +3,12 @@ */ use anyhow::{bail, Context, Result}; +use compiler::devkit::SCAFFOLD_MAIN_PY; use std::fs; use std::path::Path; -const MAIN_PY: &str = "print(\"hello from edge python\")\n"; const PACKAGES_JSON: &str = "{}\n"; -const INDEX_HTML: &str = include_str!("templates/scaffold.html"); +const INDEX_HTML: &str = include_str!("../templates/scaffold.html"); fn index_html(title: &str) -> String { INDEX_HTML.replace("__EDGE_TITLE__", title) @@ -25,7 +25,7 @@ pub fn run(name: Option<&str>, bare: bool) -> Result<()> { fs::create_dir_all(root).with_context(|| format!("creating {dir}"))?; } - fs::write(root.join("main.py"), MAIN_PY)?; + fs::write(root.join("main.py"), SCAFFOLD_MAIN_PY)?; fs::write(root.join("packages.json"), PACKAGES_JSON)?; let mut items = vec![]; diff --git a/cli/src/cmd/mod.rs b/cli/src/cmd/mod.rs new file mode 100644 index 00000000..fb43ec10 --- /dev/null +++ b/cli/src/cmd/mod.rs @@ -0,0 +1,11 @@ +/* +One file per `edge` subcommand. Each exposes the entry point `main.rs` dispatches to. +*/ + +pub mod build; +pub mod init; +pub mod pkg; +pub mod repl; +pub mod serve; +pub mod test; +pub mod uninstall; diff --git a/cli/src/cmd/pkg.rs b/cli/src/cmd/pkg.rs new file mode 100644 index 00000000..56d0f458 --- /dev/null +++ b/cli/src/cmd/pkg.rs @@ -0,0 +1,84 @@ +/* +`edge add` and `edge remove`: resolve package specs, then rewrite packages.json. +*/ + +use anyhow::{anyhow, bail, Result}; +use std::path::Path; + +use crate::manifest::{registry, Kind, Manifest}; +use crate::ui; + +pub fn add(path: &Path, pkgs: &[String]) -> Result<()> { + if pkgs.is_empty() { + bail!("nothing to add: pass one or more package names"); + } + // Validate every spec first so a single unknown name aborts before any write or print. + let resolved: Vec<(&str, Kind, String)> = pkgs + .iter() + .map(|spec| { + let (name, url_override) = parse_spec(spec); + let (kind, url) = match url_override { + Some(u) => (kind_from_url(&u), u), + None => registry(name) + .ok_or_else(|| anyhow!("unknown package '{name}'; give a url with {name}="))?, + }; + Ok::<_, anyhow::Error>((name, kind, url)) + }) + .collect::>()?; + + let mut m = Manifest::load(path)?; + for (name, kind, url) in resolved { + match kind { + Kind::Std => { + m.imports.insert(name.to_string(), url); + ui::added(name, "std"); + } + Kind::Host => { + m.host.insert(name.to_string(), url); + ui::added(name, "host"); + } + } + } + m.save(path)?; + ui::note("updated packages.json"); + Ok(()) +} + +pub fn remove(path: &Path, pkgs: &[String]) -> Result<()> { + if pkgs.is_empty() { + bail!("nothing to remove: pass one or more package names"); + } + let mut m = Manifest::load(path)?; + let names: Vec<&str> = pkgs.iter().map(|s| parse_spec(s).0).collect(); + // Validate every name exists first so a single bad one aborts before any write or print. + for name in &names { + if !m.imports.contains_key(*name) && !m.host.contains_key(*name) { + bail!("'{name}' is not in {}", path.display()); + } + } + for name in names { + let _ = m.imports.remove(name).is_some() | m.host.remove(name).is_some(); + ui::removed(name); + } + m.save(path)?; + ui::note("updated packages.json"); + Ok(()) +} + +/// Parse `name` or `name=url`. +fn parse_spec(spec: &str) -> (&str, Option) { + if let Some((name, url)) = spec.split_once('=') { + return (name, Some(url.to_string())); + } + (spec, None) +} + +/// A `.wasm` url is a worker-side std package; anything else is a host library. +fn kind_from_url(url: &str) -> Kind { + if url.ends_with(".wasm") { + Kind::Std + } else { + Kind::Host + } +} + diff --git a/cli/src/repl.rs b/cli/src/cmd/repl.rs similarity index 72% rename from cli/src/repl.rs rename to cli/src/cmd/repl.rs index dd2d85ad..046b990f 100644 --- a/cli/src/repl.rs +++ b/cli/src/cmd/repl.rs @@ -8,16 +8,20 @@ use rustyline::history::DefaultHistory; use rustyline::Editor; use std::path::Path; -use crate::engine::Session; -use crate::pkg::Manifest; +use crate::engine::{Backend, Session}; +use crate::engine::native::NativeSession; +use crate::manifest::Manifest; const PROMPT: &str = ">>> "; type Repl = Editor<(), DefaultHistory>; -pub fn run(manifest_path: &Path) -> Result<()> { - let manifest = Manifest::load(manifest_path)?; - let mut session = Session::open(&manifest)?; +pub fn run(manifest_path: &Path, packages: Option<&Path>, web: bool) -> Result<()> { + let mut session: Box = if web { + Box::new(Session::open(&Manifest::load(manifest_path)?)?) + } else { + Box::new(NativeSession::open(packages)) + }; println!("Edge Python {} · .reset to start fresh · .exit, Ctrl+C or Ctrl+D to quit", env!("CARGO_PKG_VERSION")); let mut rl: Repl = Editor::new()?; @@ -35,7 +39,7 @@ pub fn run(manifest_path: &Path) -> Result<()> { match trimmed { ".exit" => break, ".reset" => { - // Wipe runtime modules in place; the browser keeps running. + // Wipe the interpreter in place; the engine keeps running. session.reset()?; rl.clear_screen()?; continue; @@ -43,7 +47,7 @@ pub fn run(manifest_path: &Path) -> Result<()> { _ => {} } - let outcome = session.eval(&line, None, crate::engine::emit_chunk)?; + let outcome = session.eval(&line, None, &mut |l| crate::engine::emit_chunk(l))?; // `raise SystemExit` quits the session with its code, matching the one-shot runner. if let Some(code) = outcome.exit_code { std::process::exit(code); diff --git a/cli/src/serve.rs b/cli/src/cmd/serve.rs similarity index 85% rename from cli/src/serve.rs rename to cli/src/cmd/serve.rs index 112a8862..9afa459f 100644 --- a/cli/src/serve.rs +++ b/cli/src/cmd/serve.rs @@ -38,8 +38,18 @@ pub fn run(dir: PathBuf, host: &str, port: u16, open: bool) -> Result<()> { Ok(()) } +// True when a request path walks above the project root, shared with the harness server. +pub(crate) fn escapes_root(rel: &str) -> bool { + rel.split('/').any(|seg| seg == "..") +} + fn serve_file(req: tiny_http::Request, dir: &Path, url: &str) { let rel = url.trim_start_matches('/'); + // A LAN bind is advertised, so a traversal must not reach outside the served directory. + if escapes_root(rel) { + let _ = req.respond(Response::from_string("404 not found").with_status_code(404)); + return; + } let mut path = dir.join(rel); if rel.is_empty() || path.is_dir() { path = path.join("index.html"); @@ -156,3 +166,18 @@ fn open_url(url: &str) -> std::io::Result<()> { fn open_url(url: &str) -> std::io::Result<()> { std::process::Command::new("xdg-open").arg(url).spawn().map(|_| ()) } + +#[cfg(test)] +mod tests { + use super::escapes_root; + + #[test] + fn traversal_is_rejected_and_normal_paths_pass() { + for rel in ["..", "../outside.txt", "../../etc/passwd", "app/../../x", "a/../../b"] { + assert!(escapes_root(rel), "should reject {rel:?}"); + } + for rel in ["", "index.html", "app/main.py", "a..b/c", "..a/b", "dist/x..y.js"] { + assert!(!escapes_root(rel), "should allow {rel:?}"); + } + } +} diff --git a/cli/src/test.rs b/cli/src/cmd/test.rs similarity index 65% rename from cli/src/test.rs rename to cli/src/cmd/test.rs index 88879a7f..5d40bf49 100644 --- a/cli/src/test.rs +++ b/cli/src/cmd/test.rs @@ -3,49 +3,52 @@ */ use anyhow::{bail, Context, Result}; -use std::path::{Path, PathBuf}; +use compiler::devkit::{discover_tests, TEST_DRIVER}; +use std::path::Path; -use crate::engine::{self, Session}; -use crate::pkg::Manifest; +use crate::engine::{self, Backend, Session}; +use crate::engine::native::NativeSession; +use crate::manifest::Manifest; use crate::ui; -// Runs registered tests; exit 3 flags an empty file. -const DRIVER: &str = "import test\nif not test._tests:\n raise SystemExit(3)\ntest.run()"; - -pub fn run(manifest_path: &Path, path: Option<&Path>) -> Result<()> { +pub fn run(manifest_path: &Path, packages: Option<&Path>, web: bool, path: Option<&Path>) -> Result<()> { let target = path.unwrap_or(Path::new(".")); let files = if target.is_file() { vec![target.to_path_buf()] } else { - let mut found = Vec::new(); - discover(target, &mut found); - found.sort(); - found + discover_tests(target) }; if files.is_empty() { bail!("no *_test.py files found under {}", target.display()); } let manifest = Manifest::load(manifest_path)?; - let mut session = open_or_die(&manifest); + let open = || -> Result> { + Ok(if web { + Box::new(Session::open(&manifest)?) + } else { + Box::new(NativeSession::open(packages)) + }) + }; + let mut session = open_or_die(&open); let started = std::time::Instant::now(); let mut failed = 0usize; for (i, file) in files.iter().enumerate() { if i > 0 && session.reset().is_err() { drop(session); - session = open_or_die(&manifest); + session = open_or_die(&open); } let result = std::fs::read_to_string(file) .with_context(|| format!("reading {}", file.display())) - .and_then(|src| run_file(&mut session, &src, file)); + .and_then(|src| run_file(session.as_mut(), &src, file)); let (ok, reason) = match result { Ok(v) => v, // A wedged session poisons later files; reopen. Err(e) => { ui::error(&e); drop(session); - session = open_or_die(&manifest); + session = open_or_die(&open); (false, Some("error")) } }; @@ -61,8 +64,8 @@ pub fn run(manifest_path: &Path, path: Option<&Path>) -> Result<()> { } /// Exit 2 keeps infra failures distinct from red tests. -fn open_or_die(manifest: &Manifest) -> Session { - match Session::open(manifest) { +fn open_or_die(open: &dyn Fn() -> Result>) -> Box { + match open() { Ok(s) => s, Err(e) => { ui::error(&e); @@ -72,9 +75,9 @@ fn open_or_die(manifest: &Manifest) -> Session { } /// Eval the file, then the driver when it didn't exit itself. -fn run_file(session: &mut Session, src: &str, file: &Path) -> Result<(bool, Option<&'static str>)> { +fn run_file(session: &mut dyn Backend, src: &str, file: &Path) -> Result<(bool, Option<&'static str>)> { let base = engine::base_dir(file); - let outcome = session.eval(src, base.as_deref(), engine::emit_chunk)?; + let outcome = session.eval(src, base.as_deref(), &mut |l| engine::emit_chunk(l))?; let outcome = match (outcome.err, outcome.exit_code) { (Some(err), _) => { ui::traceback(&err); @@ -82,7 +85,7 @@ fn run_file(session: &mut Session, src: &str, file: &Path) -> Result<(bool, Opti } // The file drove run() itself. (None, Some(code)) => return Ok((code == 0, None)), - (None, None) => session.eval(DRIVER, None, engine::emit_chunk)?, + (None, None) => session.eval(TEST_DRIVER, None, &mut |l| engine::emit_chunk(l))?, }; if let Some(err) = outcome.err { ui::traceback(&err); @@ -95,22 +98,6 @@ fn run_file(session: &mut Session, src: &str, file: &Path) -> Result<(bool, Opti }) } -/// Collect *_test.py under `dir`, skipping dist and hidden entries. -fn discover(dir: &Path, out: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { return }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name) = name.to_str() else { continue }; - if name.starts_with('.') || name == "dist" { continue; } - let path = entry.path(); - if path.is_dir() { - discover(&path, out); - } else if name.ends_with("_test.py") { - out.push(path); - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -123,12 +110,9 @@ mod tests { std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(&p, "").unwrap(); } - let mut found = Vec::new(); - discover(dir.path(), &mut found); - found.sort(); - let names: Vec<_> = found + let names: Vec<_> = discover_tests(dir.path()) .iter() - .map(|p| p.strip_prefix(dir.path()).unwrap().to_str().unwrap()) + .map(|p| p.strip_prefix(dir.path()).unwrap().to_str().unwrap().to_string()) .collect(); assert_eq!(names, ["a_test.py", "sub/b_test.py"]); } diff --git a/cli/src/uninstall.rs b/cli/src/cmd/uninstall.rs similarity index 96% rename from cli/src/uninstall.rs rename to cli/src/cmd/uninstall.rs index 33d02aa0..2d6cf415 100644 --- a/cli/src/uninstall.rs +++ b/cli/src/cmd/uninstall.rs @@ -7,7 +7,7 @@ use std::io::Write; use std::process::Command; // Bundled at compile time so the binary is self-contained; no network needed to clean up. -const UNINSTALL_SH: &str = include_str!("../setup/uninstall.sh"); +const UNINSTALL_SH: &str = include_str!("../../setup/uninstall.sh"); pub fn run() -> Result<()> { println!("This removes the edge binary and the PATH entry from your shell rc files."); diff --git a/cli/src/engine/mod.rs b/cli/src/engine/mod.rs new file mode 100644 index 00000000..17db37bf --- /dev/null +++ b/cli/src/engine/mod.rs @@ -0,0 +1,28 @@ +/* +The engine seam. `web` drives a headless browser, `native` drives the in-process VM. +*/ + +use anyhow::Result; + +pub mod native; +pub mod web; + +pub use web::{base_dir, run, Session}; + +pub struct Outcome { + pub err: Option, + pub exit_code: Option, +} + +/// The engine seam: repl and test drive the browser session or the native VM through this. +pub trait Backend { + fn eval(&mut self, src: &str, base: Option<&str>, on_line: &mut dyn FnMut(&str)) -> Result; + fn reset(&mut self) -> Result<()>; +} + +pub fn emit_chunk(chunk: &str) { + use std::io::Write; + let mut out = std::io::stdout(); + let _ = out.write_all(chunk.as_bytes()); + let _ = out.flush(); +} diff --git a/cli/src/engine/native.rs b/cli/src/engine/native.rs new file mode 100644 index 00000000..68bc7b99 --- /dev/null +++ b/cli/src/engine/native.rs @@ -0,0 +1,101 @@ +use anyhow::Result; +use compiler::native::{boot_vm, drive, drive_session, parse_source, restore_and_run, RunOpts, Step}; +use compiler::parser::SSAChunk; +use compiler::vm::{Limits, VM}; +use std::io::{IsTerminal, Read}; +use std::path::Path; + +use super::{Backend, Outcome}; + +/// The in-process engine behind the same seam as the browser session; no server, no CDP. +pub struct NativeSession { + vm: Option>, + packages: Option, +} + +impl NativeSession { + pub fn open(packages: Option<&Path>) -> Self { + Self { vm: None, packages: packages.map(path_spec) } + } +} + +impl Backend for NativeSession { + /// Prints stream straight to stdout through the VM hook, so `on_line` never fires here. + fn eval(&mut self, src: &str, base: Option<&str>, _on_line: &mut dyn FnMut(&str)) -> Result { + let chunk = match parse_source(src, base.unwrap_or(""), self.packages.as_deref()) { + Ok(c) => c, + Err(rendered) => return Ok(Outcome { err: Some(rendered), exit_code: None }), + }; + let vm = match self.vm.as_mut() { + // One interpreter across evals, each input adopts a fresh chunk so history never re-executes. + Some(v) => { + let chunk_static: &'static SSAChunk = Box::leak(Box::new(chunk)); + v.adopt_entry_chunk(chunk_static); + v.reset_budget(Limits::sandbox().ops); + v + } + None => self.vm.insert(boot_vm(chunk, Limits::sandbox(), 0)), + }; + // Named native imports live only in the chunk extern table, mirror them for later evals. + if let Err(e) = vm.bind_chunk_externs() { + let msg = e.render(); + vm.clear_error_state(); + return Ok(Outcome { err: Some(msg), exit_code: None }); + } + let step = drive_session(vm, src, None); + vm.clear_error_state(); + Ok(match step { + Step::Done => Outcome { err: None, exit_code: None }, + Step::Exit(code) => Outcome { err: None, exit_code: Some(code as i32) }, + Step::Error(t) | Step::Suspended(t) => Outcome { err: Some(t), exit_code: None }, + }) + } + + /// Next eval boots a fresh interpreter; dropping the VM is the whole reset. + fn reset(&mut self) -> Result<()> { + self.vm = None; + Ok(()) + } +} + +/// One-shot native run mirroring `engine::run`; returns the process exit code. +pub fn run(file: Option<&Path>, opts: &RunOpts) -> Result { + if let Some(state) = &opts.restore_state { + return Ok(restore_and_run(state, opts)); + } + let mut stdin = std::io::stdin(); + let (src, name) = match file { + Some(p) => (std::fs::read_to_string(p).map_err(|e| anyhow::anyhow!("reading {}: {e}", p.display()))?, path_spec(p)), + None => { + // A bare `edge run` from a terminal would block on stdin forever; force a pipe or path. + if stdin.is_terminal() { + anyhow::bail!("no script given; pass a file path or pipe Python to stdin"); + } + let mut s = String::new(); + stdin.read_to_string(&mut s).map_err(|e| anyhow::anyhow!("reading stdin: {e}"))?; + (s, String::from("")) + } + }; + let dir = compiler::packages::dir_of(&name).to_string(); + let chunk = match parse_source(&src, &dir, opts.packages.as_deref()) { + Ok(c) => c, + Err(rendered) => { + crate::ui::traceback(&rendered); + return Ok(1); + } + }; + let mut vm = boot_vm(chunk, Limits::sandbox(), opts.preempt); + // With a file argument piped stdin feeds `input()`, one line per call. + if file.is_some() && !stdin.is_terminal() { + let mut buf = String::new(); + if stdin.read_to_string(&mut buf).is_ok() && !buf.is_empty() { + vm.input_buffer = buf.split('\n').map(String::from).collect(); + } + } + Ok(drive(&mut vm, &src, Some(&name), opts)) +} + +/// Forward-slash spec of a path, the shape the resolver walks. +fn path_spec(p: &Path) -> String { + p.to_string_lossy().replace('\\', "/") +} diff --git a/cli/src/engine.rs b/cli/src/engine/web.rs similarity index 94% rename from cli/src/engine.rs rename to cli/src/engine/web.rs index 185bff73..4f2b18a9 100644 --- a/cli/src/engine.rs +++ b/cli/src/engine/web.rs @@ -11,10 +11,11 @@ use std::thread; use std::time::{Duration, Instant}; use tiny_http::{Header, Response, Server}; -use crate::pkg::Manifest; +use super::Outcome; +use crate::manifest::Manifest; // Harness exposes `window.__edgeRun(src)` and `window.__edgeReady` once the worker boots. -const HARNESS: &str = include_str!("templates/harness.html"); +const HARNESS: &str = include_str!("../templates/harness.html"); // The runtime is async, so we poll state instead of blocking on one CDP call. const POLL_JS: &str = "window.__edge ? JSON.stringify(window.__edge) : ''"; @@ -38,9 +39,14 @@ struct State { } /// Result of one eval: streamed lines went out via the `on_line` callback; only the error and exit code survive. -pub struct Outcome { - pub err: Option, - pub exit_code: Option, +impl super::Backend for Session { + fn eval(&mut self, src: &str, base: Option<&str>, on_line: &mut dyn FnMut(&str)) -> Result { + Session::eval(self, src, base, |line| on_line(line)) + } + + fn reset(&mut self) -> Result<()> { + Session::reset(self) + } } /// A live session: the worker keeps the interpreter alive, so each eval runs its input exactly once. @@ -84,17 +90,10 @@ impl Session { } /// Write a raw stdout chunk (byte-stream: it already carries its own `end`) and flush so streaming output appears at once. -pub fn emit_chunk(chunk: &str) { - use std::io::Write; - let mut out = std::io::stdout(); - let _ = out.write_all(chunk.as_bytes()); - let _ = out.flush(); -} - /// One-shot: open a session, eval `src`, stream stdout, tear down. Returns the process exit code (0 clean, 1 on error, or the script's `SystemExit` code). pub fn run(src: &str, manifest: &Manifest, base: Option<&str>) -> Result { let mut session = Session::open(manifest)?; - let outcome = session.eval(src, base, emit_chunk)?; + let outcome = session.eval(src, base, super::emit_chunk)?; if let Some(err) = outcome.err { crate::ui::traceback(&err); return Ok(1); @@ -248,9 +247,9 @@ fn serve(packages: String) -> Result { /// Read a project file so the worker can import it. fn project_file(root: &Path, path: &str) -> Option<(Vec, &'static str)> { let rel = path.trim_start_matches('/'); - if rel.is_empty() || rel.split('/').any(|seg| seg == "..") { return None; } + if rel.is_empty() || crate::cmd::serve::escapes_root(rel) { return None; } let full = root.join(rel); - Some((std::fs::read(&full).ok()?, crate::serve::content_type(&full))) + Some((std::fs::read(&full).ok()?, crate::cmd::serve::content_type(&full))) } /// Directory of `file` as an eval base, when inside the project. @@ -264,7 +263,7 @@ pub fn base_dir(file: &Path) -> Option { /* Test hooks: EDGE_FAKE_CONTRACT lands after element.js, so the fake wins; EDGE_RUNTIME_DIR swaps the CDN runtime and compiler for locally served copies. */ fn harness_page() -> String { - let mut page = HARNESS.to_string(); + let mut page = HARNESS.replacen("%CONTRACT%", compiler::devkit::RUNTIME_CONTRACT, 1); if let Ok(v) = std::env::var("EDGE_FAKE_CONTRACT") { // Non-numeric sentinels inject as strings, still unequal to any contract. let lit = if v.parse::().is_ok() { v } else { format!("{v:?}") }; diff --git a/cli/src/main.rs b/cli/src/main.rs index 484e4885..dfd6f4f2 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -2,22 +2,22 @@ edge: the Edge Python developer CLI. Run, serve, test, and scaffold Edge Python apps. */ -mod ui; -mod init; -mod pkg; -mod serve; +mod cmd; mod engine; -mod repl; -mod build; -mod test; -mod uninstall; +mod manifest; +mod ui; use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use std::io::{IsTerminal, Read}; use std::path::{Path, PathBuf}; -use pkg::Manifest; +use manifest::Manifest; + +// Static musl builds keep musl's slow malloc otherwise. +#[cfg(target_env = "musl")] +#[global_allocator] +static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc; #[derive(Parser)] #[command(name = "edge", version, about = "The Edge Python developer command line interface.", after_help = "Press Ctrl+C at any time to exit cleanly.", after_long_help = "Docs: https://edgepython.com/", color = clap::ColorChoice::Never)] @@ -28,12 +28,30 @@ struct Cli { /// Use a specific manifest instead of ./packages.json. #[arg(long, global = true)] packages: Option, + + /// Drive the browser runtime instead of the in-process native engine. + #[arg(long, global = true)] + web: bool, } #[derive(Subcommand)] enum Cmd { /// Run a script. - Run { file: Option }, + Run { + file: Option, + /// Feed each line of this file (or FIFO) into one receive() call. Native only. + #[arg(long)] + events: Option, + /// Snapshot to this file when the script suspends on an unservable wait. Native only. + #[arg(long)] + save_state: Option, + /// Boot from a snapshot instead of a script and keep running. Native only. + #[arg(long)] + restore_state: Option, + /// Yield every n loop back-edges and resume. Native only. + #[arg(long)] + preempt: Option, + }, /// Interactive shell. Ctrl+C, Ctrl+D, or .exit to quit. Repl, /// Dev server with live reload. @@ -71,18 +89,37 @@ fn main() -> Result<()> { let cli = Cli::parse(); ctrlc::set_handler(|| std::process::exit(130)).ok(); - let manifest_path = cli.packages.unwrap_or_else(|| PathBuf::from("packages.json")); + let manifest_path = cli.packages.clone().unwrap_or_else(|| PathBuf::from("packages.json")); let result = match cli.cmd { - Cmd::Init { name, bare } => init::run(name.as_deref(), bare), - Cmd::Add { pkgs } => pkg::add(&manifest_path, &pkgs), - Cmd::Remove { pkgs } => pkg::remove(&manifest_path, &pkgs), - Cmd::Serve { host, port, open } => serve::run(PathBuf::from("."), &host, port, open), - Cmd::Run { file } => run_script(&manifest_path, file.as_deref()), - Cmd::Repl => repl::run(&manifest_path), - Cmd::Build { out } => build::run(&manifest_path, out.unwrap_or_else(|| PathBuf::from("dist"))), - Cmd::Uninstall => uninstall::run(), - Cmd::Test { path } => test::run(&manifest_path, path.as_deref()), + Cmd::Init { name, bare } => cmd::init::run(name.as_deref(), bare), + Cmd::Add { pkgs } => cmd::pkg::add(&manifest_path, &pkgs), + Cmd::Remove { pkgs } => cmd::pkg::remove(&manifest_path, &pkgs), + Cmd::Serve { host, port, open } => cmd::serve::run(PathBuf::from("."), &host, port, open), + Cmd::Run { file, events, save_state, restore_state, preempt } => { + if cli.web { + if events.is_some() || save_state.is_some() || restore_state.is_some() || preempt.is_some() { + Err(anyhow::anyhow!("--events, --save-state, --restore-state and --preempt are native-only; drop --web")) + } else { + run_script(&manifest_path, file.as_deref()) + } + } else { + let opts = compiler::native::RunOpts { + packages: cli.packages.as_deref().map(|p| p.to_string_lossy().replace('\\', "/")), + preempt: preempt.unwrap_or(0), + events: events.map(|p| p.to_string_lossy().into_owned()), + save_state: save_state.map(|p| p.to_string_lossy().into_owned()), + restore_state: restore_state.map(|p| p.to_string_lossy().into_owned()), + }; + engine::native::run(file.as_deref(), &opts).map(|code| { + if code != 0 { std::process::exit(code) } + }) + } + } + Cmd::Repl => cmd::repl::run(&manifest_path, cli.packages.as_deref(), cli.web), + Cmd::Build { out } => cmd::build::run(&manifest_path, out.unwrap_or_else(|| PathBuf::from("dist"))), + Cmd::Uninstall => cmd::uninstall::run(), + Cmd::Test { path } => cmd::test::run(&manifest_path, cli.packages.as_deref(), cli.web, path.as_deref()), }; if let Err(e) = result { diff --git a/cli/src/manifest.rs b/cli/src/manifest.rs new file mode 100644 index 00000000..a33f87e6 --- /dev/null +++ b/cli/src/manifest.rs @@ -0,0 +1,70 @@ +/* +The packages.json model and the official package registry. +*/ + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::Path; + +/* The manifest: `imports` for worker-side .wasm/.py modules, `host` for main-thread JS libraries. */ +#[derive(Default, Serialize, Deserialize)] +pub struct Manifest { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub imports: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub host: BTreeMap, +} + +impl Manifest { + /// Load the manifest, or an empty one when the file is absent. + pub fn load(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display())) + } + + /// Write the manifest back as pretty JSON with a trailing newline. + pub(crate) fn save(&self, path: &Path) -> Result<()> { + let text = serde_json::to_string_pretty(self)?; + std::fs::write(path, format!("{text}\n")).with_context(|| format!("writing {}", path.display())) + } +} + +/* Official package registry. Mirrors the runtime's built-in default manifest. */ +pub enum Kind { + Std, + Host, +} + +use compiler::devkit::{HOST_PACKAGES as HOST, STD_PACKAGES as STD}; + +/// Resolve a bare name against the official registry; user manifest overrides go through `resolve`. +pub fn registry(name: &str) -> Option<(Kind, String)> { + if STD.contains(&name) { + Some((Kind::Std, std_url(name))) + } else if HOST.contains(&name) { + Some((Kind::Host, format!("https://cdn.edgepython.com/host/{name}/index.js"))) + } else { + None + } +} + +/// CDN url for a std package. Most ship as `.wasm`; `test` is pure Edge Python, served as `.py`. Mirrors runtime/src/defaults.js. +fn std_url(name: &str) -> String { + let ext = if name == "test" { "py" } else { "wasm" }; + format!("https://cdn.edgepython.com/std/{name}.{ext}") +} + +/// Resolve `name` for the runtime: user manifest entry first, registry fallback. +pub fn resolve(name: &str, manifest: &Manifest) -> Option<(Kind, String)> { + if let Some(url) = manifest.imports.get(name) { + return Some((Kind::Std, url.clone())); + } + if let Some(url) = manifest.host.get(name) { + return Some((Kind::Host, url.clone())); + } + registry(name) +} diff --git a/cli/src/pkg.rs b/cli/src/pkg.rs deleted file mode 100644 index 9c458329..00000000 --- a/cli/src/pkg.rs +++ /dev/null @@ -1,147 +0,0 @@ -/* -Packages: the packages.json model, the official package registry, and the add/remove commands. -*/ - -use anyhow::{anyhow, bail, Context, Result}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::path::Path; - -use crate::ui; - -/* The manifest: `imports` for worker-side .wasm/.py modules, `host` for main-thread JS libraries. */ -#[derive(Default, Serialize, Deserialize)] -pub struct Manifest { - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub imports: BTreeMap, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub host: BTreeMap, -} - -impl Manifest { - /// Load the manifest, or an empty one when the file is absent. - pub fn load(path: &Path) -> Result { - if !path.exists() { - return Ok(Self::default()); - } - let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; - serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display())) - } - - /// Write the manifest back as pretty JSON with a trailing newline. - fn save(&self, path: &Path) -> Result<()> { - let text = serde_json::to_string_pretty(self)?; - std::fs::write(path, format!("{text}\n")).with_context(|| format!("writing {}", path.display())) - } -} - -/* Official package registry. Mirrors the runtime's built-in default manifest. */ -pub enum Kind { - Std, - Host, -} - -const STD: [&str; 5] = ["json", "re", "math", "struct", "test"]; -const HOST: [&str; 4] = ["dom", "network", "storage", "time"]; - -/// Resolve a bare name against the official registry; user manifest overrides go through `resolve`. -pub fn registry(name: &str) -> Option<(Kind, String)> { - if STD.contains(&name) { - Some((Kind::Std, std_url(name))) - } else if HOST.contains(&name) { - Some((Kind::Host, format!("https://cdn.edgepython.com/host/{name}/index.js"))) - } else { - None - } -} - -/// CDN url for a std package. Most ship as `.wasm`; `test` is pure Edge Python, served as `.py`. Mirrors runtime/src/defaults.js. -fn std_url(name: &str) -> String { - let ext = if name == "test" { "py" } else { "wasm" }; - format!("https://cdn.edgepython.com/std/{name}.{ext}") -} - -/// Resolve `name` for the runtime: user manifest entry first, registry fallback. -pub fn resolve(name: &str, manifest: &Manifest) -> Option<(Kind, String)> { - if let Some(url) = manifest.imports.get(name) { - return Some((Kind::Std, url.clone())); - } - if let Some(url) = manifest.host.get(name) { - return Some((Kind::Host, url.clone())); - } - registry(name) -} - -pub fn add(path: &Path, pkgs: &[String]) -> Result<()> { - if pkgs.is_empty() { - bail!("nothing to add: pass one or more package names"); - } - // Validate every spec first so a single unknown name aborts before any write or print. - let resolved: Vec<(&str, Kind, String)> = pkgs - .iter() - .map(|spec| { - let (name, url_override) = parse_spec(spec); - let (kind, url) = match url_override { - Some(u) => (kind_from_url(&u), u), - None => registry(name) - .ok_or_else(|| anyhow!("unknown package '{name}'; give a url with {name}="))?, - }; - Ok::<_, anyhow::Error>((name, kind, url)) - }) - .collect::>()?; - - let mut m = Manifest::load(path)?; - for (name, kind, url) in resolved { - match kind { - Kind::Std => { - m.imports.insert(name.to_string(), url); - ui::added(name, "std"); - } - Kind::Host => { - m.host.insert(name.to_string(), url); - ui::added(name, "host"); - } - } - } - m.save(path)?; - ui::note("updated packages.json"); - Ok(()) -} - -pub fn remove(path: &Path, pkgs: &[String]) -> Result<()> { - if pkgs.is_empty() { - bail!("nothing to remove: pass one or more package names"); - } - let mut m = Manifest::load(path)?; - let names: Vec<&str> = pkgs.iter().map(|s| parse_spec(s).0).collect(); - // Validate every name exists first so a single bad one aborts before any write or print. - for name in &names { - if !m.imports.contains_key(*name) && !m.host.contains_key(*name) { - bail!("'{name}' is not in {}", path.display()); - } - } - for name in names { - let _ = m.imports.remove(name).is_some() | m.host.remove(name).is_some(); - ui::removed(name); - } - m.save(path)?; - ui::note("updated packages.json"); - Ok(()) -} - -/// Parse `name` or `name=url`. -fn parse_spec(spec: &str) -> (&str, Option) { - if let Some((name, url)) = spec.split_once('=') { - return (name, Some(url.to_string())); - } - (spec, None) -} - -/// A `.wasm` url is a worker-side std package; anything else is a host library. -fn kind_from_url(url: &str) -> Kind { - if url.ends_with(".wasm") { - Kind::Std - } else { - Kind::Host - } -} diff --git a/cli/src/templates/harness.html b/cli/src/templates/harness.html index 85a0cb34..40e2e062 100644 --- a/cli/src/templates/harness.html +++ b/cli/src/templates/harness.html @@ -11,8 +11,8 @@ const st = { lines: [], done: false, ok: false, err: "", code: null }; window.__edge = st; const el = document.getElementById("ep"); - // Bump together with runtime element.js. - const CONTRACT = "0.1.0"; + // The CLI injects devkit::RUNTIME_CONTRACT here at page build. + const CONTRACT = "%CONTRACT%"; (async () => { // Missing marker means older runtime, plain wait. if (window.__edgeContract !== undefined && window.__edgeContract !== CONTRACT) { diff --git a/cli/tests/cli.json b/cli/tests/cli.json index 8ad00fbb..7083dda5 100644 --- a/cli/tests/cli.json +++ b/cli/tests/cli.json @@ -115,6 +115,7 @@ }, { "given": { "u.py": "val = 5\n", "t.py": "from \"./u.py\" import val\nprint(\"got\", val)\n" }, + "engine": "both", "run": ["run", "t.py"], "stdout": ["got 5"] }, @@ -124,11 +125,13 @@ }, { "given": { "t.py": "print('hi from edge')\n" }, + "engine": "both", "run": ["run", "t.py"], "stdout": ["hi from edge"] }, { "given": { "boom.py": "x = 1 / 0\n" }, + "engine": "both", "run": ["run", "boom.py"], "fails": ["ZeroDivisionError"] }, @@ -138,11 +141,13 @@ "creates": ["dist/index.html", "dist/main.py", "dist/compiler.wasm"] }, { + "engine": "both", "run": ["repl"], - "stdin": "from math import sqrt\ndef double(n): return sqrt(n) * 2\nprint(double(4))\n.exit\n", + "stdin": "def double(n): return n * 2\nprint(double(2.0))\n.exit\n", "stdout": ["4.0"] }, { + "engine": "both", "run": ["repl"], "stdin": "x = 42\n.reset\nprint(x)\n.exit\n", "stderr": ["NameError"] @@ -153,6 +158,7 @@ "stdout": ["done"] }, { + "engine": "both", "run": ["repl"], "stdin": "x = 1\nx = 2; 1 / 0\nprint(x)\n.exit\n", "stdout": ["2"], @@ -161,6 +167,7 @@ { "given": {"main.py": "print(1)"}, "env": {"EDGE_FAKE_CONTRACT": "inf"}, + "engine": "web", "run": ["run", "main.py"], "fails": ["cli built for runtime contract 0.1.0, the CDN serves contract inf", "install.sh"] } diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index bf113973..e3611570 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -9,9 +9,12 @@ use std::io::Write; use std::process::{Command, Stdio}; #[derive(Deserialize)] +#[serde(deny_unknown_fields)] struct Case { #[serde(default)] given: BTreeMap, #[serde(default)] env: BTreeMap, + // "both" runs the case through native and web, "web" prepends --web, absent runs as written. + #[serde(default)] engine: Option, run: Vec, #[serde(default)] stdin: String, #[serde(default)] stdout: Vec, @@ -27,19 +30,32 @@ fn cli_suite() { let bin = env!("CARGO_BIN_EXE_edge"); let mut failed = vec![]; for c in &cases { - if let Err(e) = check(bin, c) { failed.push(format!("[edge {}] {e}", c.run.join(" "))); } + // Shared-semantics cases run through both engines, a divergence is a bug in one of them. + let passes: &[bool] = match c.engine.as_deref() { + None => &[false], + Some("web") => &[true], + Some("both") => &[false, true], + Some(other) => panic!("cli.json: unknown engine {other:?}"), + }; + for &web in passes { + if let Err(e) = check(bin, c, web) { + failed.push(format!("[edge {}{}] {e}", if web { "--web " } else { "" }, c.run.join(" "))); + } + } } assert!(failed.is_empty(), "\n{}", failed.join("\n")); } -fn check(bin: &str, c: &Case) -> Result<(), String> { +fn check(bin: &str, c: &Case, web: bool) -> Result<(), String> { let dir = tempfile::tempdir().map_err(|e| e.to_string())?; for (p, v) in &c.given { let path = dir.path().join(p); if let Some(d) = path.parent() { let _ = std::fs::create_dir_all(d); } std::fs::write(path, v).map_err(|e| e.to_string())?; } - let mut child = Command::new(bin).args(&c.run).current_dir(&dir).envs(&c.env) + let mut cmd = Command::new(bin); + if web { cmd.arg("--web"); } + let mut child = cmd.args(&c.run).current_dir(&dir).envs(&c.env) .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()) .spawn().map_err(|e| e.to_string())?; if !c.stdin.is_empty() { diff --git a/cli/tests/native.rs b/cli/tests/native.rs new file mode 100644 index 00000000..0a631fdf --- /dev/null +++ b/cli/tests/native.rs @@ -0,0 +1,204 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU32, Ordering}; + +const BIN: &str = env!("CARGO_BIN_EXE_edge"); + +static DIR_SEQ: AtomicU32 = AtomicU32::new(0); + +fn scratch(name: &str) -> PathBuf { + let seq = DIR_SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("edge-native-test-{}-{name}-{seq}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn run_in(dir: &Path, args: &[&str], stdin: Option<&str>) -> (String, String, i32) { + let mut cmd = Command::new(BIN); + cmd.current_dir(dir).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() }); + let mut child = cmd.spawn().unwrap(); + if let Some(input) = stdin { + use std::io::Write; + child.stdin.take().unwrap().write_all(input.as_bytes()).unwrap(); + } + let out = child.wait_with_output().unwrap(); + ( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + out.status.code().unwrap_or(-1), + ) +} + +#[test] +fn runs_a_script_and_streams_stdout() { + let dir = scratch("run"); + std::fs::write(dir.join("main.py"), "print(\"native ok\")\n").unwrap(); + let (out, _, code) = run_in(&dir, &["run", "main.py"], None); + assert_eq!(out, "native ok\n"); + assert_eq!(code, 0); +} + +#[test] +fn resolves_quoted_imports_from_the_script_dir() { + let dir = scratch("imports"); + std::fs::create_dir_all(dir.join("lib")).unwrap(); + std::fs::write(dir.join("lib/helper.py"), "def double(n):\n return n * 2\n").unwrap(); + std::fs::write(dir.join("main.py"), "from \"./lib/helper.py\" import double\nprint(double(21))\n").unwrap(); + let (out, _, code) = run_in(&dir, &["run", "main.py"], None); + assert_eq!(out, "42\n"); + assert_eq!(code, 0); +} + +#[test] +fn sleep_waits_on_the_wall_clock() { + let dir = scratch("sleep"); + std::fs::write(dir.join("main.py"), "await sleep(0.3)\nprint(\"woke\")\n").unwrap(); + let started = std::time::Instant::now(); + let (out, _, code) = run_in(&dir, &["run", "main.py"], None); + assert!(started.elapsed().as_secs_f64() >= 0.3); + assert_eq!(out, "woke\n"); + assert_eq!(code, 0); +} + +#[test] +fn system_exit_code_propagates() { + let dir = scratch("exit"); + std::fs::write(dir.join("main.py"), "raise SystemExit(7)\n").unwrap(); + let (_, _, code) = run_in(&dir, &["run", "main.py"], None); + assert_eq!(code, 7); +} + +#[test] +fn piped_stdin_is_the_script_when_no_path_is_given() { + let dir = scratch("stdin"); + let (out, _, code) = run_in(&dir, &["run"], Some("print(1 + 1)\n")); + assert_eq!(out, "2\n"); + assert_eq!(code, 0); +} + +#[test] +fn events_file_feeds_receive() { + let dir = scratch("events"); + std::fs::write(dir.join("main.py"), "msg = await receive()\nprint(\"got\", msg)\n").unwrap(); + std::fs::write(dir.join("events.txt"), "ping\n").unwrap(); + let (out, _, code) = run_in(&dir, &["run", "main.py", "--events", "events.txt"], None); + assert_eq!(out, "got ping\n"); + assert_eq!(code, 0); +} + +#[test] +fn snapshot_saves_and_restores_across_processes() { + let dir = scratch("snapshot"); + std::fs::write(dir.join("main.py"), "print(\"phase1\")\nmsg = await receive()\nprint(\"resumed\", msg)\n").unwrap(); + let (out, err, code) = run_in(&dir, &["run", "main.py", "--save-state", "state.bin"], None); + assert_eq!(out, "phase1\n"); + assert!(err.contains("state saved"), "stderr was: {err}"); + assert_eq!(code, 0); + std::fs::write(dir.join("ev.txt"), "later\n").unwrap(); + let (out, _, code) = run_in(&dir, &["run", "--restore-state", "state.bin", "--events", "ev.txt"], None); + assert_eq!(out, "resumed later\n"); + assert_eq!(code, 0); +} + +#[test] +fn repl_keeps_state_between_lines() { + let dir = scratch("repl"); + let (out, _, code) = run_in(&dir, &["repl"], Some("x = 40\nprint(x + 2)\n.exit\n")); + assert!(out.contains("42"), "stdout was: {out}"); + assert_eq!(code, 0); +} + +#[test] +fn test_runner_verdicts_come_from_system_exit() { + let dir = scratch("testrun"); + std::fs::write(dir.join("green_test.py"), "raise SystemExit(0)\n").unwrap(); + let (out, _, code) = run_in(&dir, &["test"], None); + assert!(out.contains("green_test.py"), "stdout was: {out}"); + assert_eq!(code, 0); + std::fs::write(dir.join("red_test.py"), "raise SystemExit(1)\n").unwrap(); + let (_, _, code) = run_in(&dir, &["test"], None); + assert_eq!(code, 1); +} + +#[test] +fn builtin_time_module_mirrors_the_web_api() { + let dir = scratch("host"); + std::fs::write(dir.join("main.py"), concat!( + "import time\n", + "t = time.gmtime(0)\n", + "print(t)\n", + "print(time.strftime(\"%Y-%m-%d\", t), time.tzname())\n", + "print(time.ctime(86400))\n", + )).unwrap(); + let (out, _, code) = run_in(&dir, &["run", "main.py"], None); + assert_eq!(out, "[1970,1,1,0,0,0,3,1,-1]\n1970-01-01 UTC\nFri Jan 2 00:00:00 1970\n"); + assert_eq!(code, 0); +} + +#[test] +fn web_only_imports_point_at_the_web_flag() { + let dir = scratch("webhint"); + std::fs::write(dir.join("main.py"), "import dom\n").unwrap(); + let (_, err, code) = run_in(&dir, &["run", "main.py"], None); + assert!(err.contains("requires the web runtime"), "stderr was: {err}"); + assert_eq!(code, 1); +} + +#[test] +fn native_only_flags_reject_web() { + let dir = scratch("guard"); + std::fs::write(dir.join("e.txt"), "x\n").unwrap(); + let (_, err, code) = run_in(&dir, &["run", "--web", "--events", "e.txt"], None); + assert!(err.contains("native-only"), "stderr was: {err}"); + assert_eq!(code, 1); +} + +// Locates a staged or locally built plugin, a staged one missing is a wiring bug. +fn std_plugin(pkg: &str) -> Option { + match std::env::var("EDGE_STD_DIR") { + // CI stages this run's artifacts here, a missing plugin is a wiring bug, not a skip. + Ok(d) => { + let staged = Path::new(&d).join("native").join(format!("{pkg}-{}.{}", std::env::consts::ARCH, std::env::consts::DLL_EXTENSION)); + assert!(staged.exists(), "EDGE_STD_DIR is set but {} is missing", staged.display()); + Some(staged) + } + Err(_) => { + // `struct` is a Rust keyword, so its crate and its local artifact carry the edge prefix. + let crate_name = if pkg == "struct" { "edge_struct" } else { pkg }; + let lib = format!("{}{crate_name}.{}", std::env::consts::DLL_PREFIX, std::env::consts::DLL_SUFFIX.trim_start_matches('.')); + let local = Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("../std/{pkg}/target/release")).join(lib); + local.exists().then_some(local) + } + } +} + +// Exercises dlopen plus the exported edge_* bridge end to end for every native std package. +#[test] +fn loads_std_plugins_from_disk() { + // A package whose exports are gated out of the native build would resolve to zero bindings. + let cases = [ + ("json", "import json\nprint(json.dumps({\"a\": [1, True, None]}))\n", "{\"a\":[1,true,null]}\n"), + ("re", "import re\nprint(re.search(r'\\d+', 'abc123def'))\n", "123\n"), + ("math", "import math\nprint(math.floor(2.7))\n", "2\n"), + ("struct", "import struct\nprint(struct.calcsize('i'))\n", "4\n"), + ]; + let mut ran = 0; + for (pkg, src, want) in cases { + let Some(so) = std_plugin(pkg) else { continue }; + let dir = scratch(pkg); + std::fs::write( + dir.join("packages.json"), + format!("{{ \"imports\": {{ \"{pkg}\": \"{}\" }} }}\n", so.display()), + ).unwrap(); + std::fs::write(dir.join("main.py"), src).unwrap(); + let (out, err, code) = run_in(&dir, &["run", "main.py"], None); + assert_eq!(out, want, "{pkg} stdout, stderr was: {err}"); + assert_eq!(code, 0, "{pkg} exit code, stderr was: {err}"); + ran += 1; + } + if ran == 0 { + eprintln!("skipping, build the std packages with cargo build --release first"); + } +} diff --git a/docs/app/layout.jsx b/docs/app/layout.jsx index 633b788e..cbc16751 100644 --- a/docs/app/layout.jsx +++ b/docs/app/layout.jsx @@ -19,7 +19,7 @@ const monoCode = JetBrains_Mono({ display: 'swap', }) -const DEFAULT_DESCRIPTION = 'Edge Python — a sandboxed Python scripting language compiled to WebAssembly for the edge.' +const DEFAULT_DESCRIPTION = 'Edge Python, a sandboxed Python subset that runs in the browser as WebAssembly and natively in the CLI, with full interpreter snapshots.' // Per-page comes from frontmatter via the catch-all's generateMetadata; this just supplies the suffix template and the fallback. export const metadata = { diff --git a/docs/content/getting-started/introduction.md b/docs/content/getting-started/introduction.md index 664a2728..82d9456d 100644 --- a/docs/content/getting-started/introduction.md +++ b/docs/content/getting-started/introduction.md @@ -3,7 +3,7 @@ title: "Introduction" description: "What Edge Python is and where to go next." --- -Welcome to the Edge Python docs! 👋 Edge is a sandboxed subset of Python, compiled to a ~200 KB WebAssembly binary and built in Rust to run in the browser. Embed your full business logic, run LLMs client-side, and build frontend apps. +Welcome to the Edge Python docs! 👋 Edge is a sandboxed subset of Python, built in Rust to run in the browser as a ~200 KB WebAssembly binary and natively inside the CLI. Embed your full business logic, run LLMs client-side, and build frontend apps. ## Ecosystem diff --git a/docs/content/getting-started/quickstart.md b/docs/content/getting-started/quickstart.md index 18d53cfa..05893542 100644 --- a/docs/content/getting-started/quickstart.md +++ b/docs/content/getting-started/quickstart.md @@ -50,7 +50,7 @@ set_text(query("#app"), "Hello from Python") `dom` is one of the official [host libraries](/reference/packages#host-libraries) (`dom`, `network`, `storage`...). Standard `.wasm` packages like [`json`](/reference/packages#json) sit alongside them. Official packages also resolve by bare name with no manifest, fetched lazily on first import (see [Defaults](/reference/packages#defaults)). -See [Official packages](/reference/packages) for the full catalog. See the [runtime README](https://github.com/dylan-sutton-chavez/edge-python/tree/main/js) for all `<edge-python>` attributes and the `imports` field for `.py` / `.wasm` modules. +See [Official packages](/reference/packages) for the full catalog. See the [runtime README](https://github.com/dylan-sutton-chavez/edge-python/tree/main/runtime) for all `<edge-python>` attributes and the `imports` field for `.py` / `.wasm` modules. ## Your first program diff --git a/docs/content/getting-started/what-it-is.md b/docs/content/getting-started/what-it-is.md index 0c73daf4..214e5c9f 100644 --- a/docs/content/getting-started/what-it-is.md +++ b/docs/content/getting-started/what-it-is.md @@ -21,7 +21,7 @@ It reads like Python: it parses Python syntax. It runs differently. What it exec - **Walrus operator**: `:=` in expressions (Name target only). - **Type annotations**: parsed and discarded, no runtime `__annotations__`, no enforcement. - **Module identity**: `__name__` is bound to `"__main__"` in the entry chunk and to the module's spec inside imported modules, so the canonical `if __name__ == "__main__":` guard works as expected. -- **Modules**: `import`, `from <spec> import names`, and `from <spec> import *` resolve at parse time through a host-injected resolver, with optional `#sha256-<hex>` integrity on URL specs. Two flavors: `.py` source modules and native modules. See [Imports](/reference/imports) for resolution semantics, [Writing modules](/reference/writing-modules) for the three delivery paths, and [Official packages](/reference/packages) for the ready-made modules (`json`, `dom`, `network`, `storage`, and more). +- **Modules**: `import`, `from <spec> import names`, and `from <spec> import *` resolve at parse time through a host-injected resolver, with optional `#sha256-<hex>` integrity on URL specs. Two flavors: `.py` source modules and native modules. See [Imports](/reference/imports) for resolution semantics, [Writing modules](/reference/writing-modules) for the delivery paths, and [Official packages](/reference/packages) for the ready-made modules (`json`, `dom`, `network`, `storage`, and more). - **State snapshots**: a parked program can be serialized whole, heap, globals, and suspended coroutines included, then restored later, even on a fresh page load, to continue from the pause point. A program parks on its own (`receive()`, `sleep()`, a host call) or because the host called `pause()`, which stops even a bare `while True:` that never suspends. See [Snapshots](/language/snapshots). ## What it doesn't support @@ -53,7 +53,8 @@ Inherits WASM-host guarantees: no syscalls, no FS, no network, isolated linear m One `.wasm` artifact (`compiler.wasm`, a ~200 KB release) runs anywhere WebAssembly does: -- **Browser**: served alongside the [`js/`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/js) JS package, which bridges `print()` and module imports across the `WASM <-> JS` boundary. The host runtime owns I/O, fetching, and module loading. +- **Browser**: served alongside the [`js/`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/runtime) JS package, which bridges `print()` and module imports across the `WASM <-> JS` boundary. The host runtime owns I/O, fetching, and module loading. +- **The `edge` CLI**: the same engine crate compiled natively rather than to WASM; `run`, `repl`, and `test` execute in-process by default ([native engine](/reference/native)). - **Embedded Rust apps**: load `compiler.wasm` via your runtime of choice, or declare the [`edge-python` crate](/reference/wasm-abi#consuming-the-release-from-a-rust-crate) as a Cargo dependency. Two ABIs sit on top: diff --git a/docs/content/language/snapshots.md b/docs/content/language/snapshots.md index 236f3e5f..35eb6a49 100644 --- a/docs/content/language/snapshots.md +++ b/docs/content/language/snapshots.md @@ -49,7 +49,7 @@ Here `items` and `total` live in the VM heap, and the only freeze point is the ` ## Save, persist, restore -The round trip runs from the host through [`createWorker`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/js#api). +The round trip runs from the host through [`createWorker`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/runtime#api). ```js import { createWorker } from "https://cdn.edgepython.com/runtime/src/index.js"; @@ -182,6 +182,10 @@ await store.put("cart", await worker.saveState()); // IndexedDB await fetch("/saves/cart", { method: "PUT", body: await worker.saveState() }); // server ``` +## The same blob from the CLI + +The [native engine](/reference/native#run-flags) works with snapshots as plain files, no JS involved: `edge run --save-state cart.bin` writes the blob when the script parks on a wait the CLI cannot serve, and `edge run --restore-state cart.bin` boots from it and keeps running. `--preempt <n>` is `setPreemptInterval(n)` as a flag, and `--events <f>` feeds `receive()` line by line. The blob layout is the same one `saveState` produces here. + ## Restore if present, else start fresh The common pattern resumes a saved session when one exists and otherwise begins a new run. A backend works the same way as a local store: it only holds bytes and never runs the VM. @@ -216,6 +220,6 @@ Pure Python state restores identically, while live host resources (DOM handles, ## See also -- [Runtime README](https://github.com/dylan-sutton-chavez/edge-python/tree/main/js#state-snapshots) for the worker and element API and the size limit. +- [Runtime README](https://github.com/dylan-sutton-chavez/edge-python/tree/main/runtime#state-snapshots) for the worker and element API and the size limit. - [Design](/implementation/design#snapshots) for the serializer internals. - [WASM module ABI](/reference/wasm-abi#snapshot-exports) for the `compiler.wasm` exports and blob layout. diff --git a/docs/content/reference/_meta.js b/docs/content/reference/_meta.js index d8cd555d..819ddcad 100644 --- a/docs/content/reference/_meta.js +++ b/docs/content/reference/_meta.js @@ -7,5 +7,6 @@ export default { 'writing-modules': 'Writing modules', cli: 'CLI', 'wasm-abi': 'WASM ABI', + native: 'Native port', 'limits-and-errors': 'Limits and errors', } diff --git a/docs/content/reference/cli.md b/docs/content/reference/cli.md index b6c4075e..16f07c2f 100644 --- a/docs/content/reference/cli.md +++ b/docs/content/reference/cli.md @@ -3,7 +3,7 @@ title: "Command line interface" description: "The Edge Python developer command line interface (CLI): run, serve, repl, init, package management, and build." --- -The `edge` developer CLI. Write `.py`, run it, serve it, ship it. You never compile anything yourself. `edge` hosts the [Edge Python runtime](/getting-started/what-it-is#where-it-runs) in a headless Chromium provisioned at install time, then runs your code against it. You point it at a file. +The `edge` developer CLI. Write `.py`, run it, serve it, ship it. You never compile anything yourself. `run`, `repl`, and `test` execute in the built-in [native engine](/reference/native) by default, in-process with millisecond startup; the `--web` flag hosts the [Edge Python runtime](/getting-started/what-it-is#where-it-runs) in a headless Chromium instead, for scripts that need the browser (`dom`, `frame()`, sockets). ```bash edge run app.py # run a script @@ -17,7 +17,7 @@ edge build # bundle to dist/ edge uninstall # remove the binary, PATH entry, optionally the bundled browser ``` -The runtime does the actual work. `edge` is the loop around it. It launches the headless browser, serves the runtime alongside your code, runs everything in that browser, and streams output back to your terminal. `edge serve` instead serves your project directory to your own browser, with live reload. +Under `--web` the browser runtime does the actual work and `edge` is the loop around it: it launches headless Chromium, serves the runtime alongside your code, runs everything there, and streams output back to your terminal. `edge serve` always targets your own browser, with live reload. ## Install @@ -31,7 +31,7 @@ cargo install --path cli `install.sh` drops the binary at `~/.local/bin/edge` and appends that directory (plus `EDGE_CHROME_PATH`, when the bundled browser is downloaded) to your `~/.bashrc` or `~/.zshrc` unless the file already has it. Open a new shell (or `source` the file it printed) and `edge --version` should work. Re-run the same `curl … | sh` line any time to upgrade. To remove everything: `curl -fsSL https://cdn.edgepython.com/cli/uninstall.sh | sh` (non-interactive: it leaves the bundled browser cache in place; `edge uninstall` asks before removing it). -`install.sh` also provisions a headless browser when none is reachable; details and overrides in [Bring your own browser](#bring-your-own-browser). +On Linux the installer probes your libc and picks the matching build: glibc 2.28+ gets the gnu binary (std `.so` plugins load natively), anything older or musl-based gets a fully static fallback. `install.sh` also provisions a headless browser when none is reachable — set `EDGE_NO_BROWSER=1` to skip it on servers that only use the native engine; details and overrides in [Bring your own browser](#bring-your-own-browser). ## `edge run`: run a Python file @@ -55,7 +55,7 @@ error: ZeroDivisionError: division by zero A `raise SystemExit(code)` with an integer (or no argument) exits cleanly with that code and no traceback. A string argument is reported as an error and exits 1. -Flags: `--packages <file>` (custom manifest). With no path, `edge run` reads from stdin if it is piped (`cat hello.py | edge run`). It errors out if stdin is a terminal. +Flags: `--packages <file>` (custom manifest), plus the native-engine flags `--events`, `--save-state`, `--restore-state`, and `--preempt` ([native engine](/reference/native#run-flags); they reject `--web`). With no path, `edge run` reads from stdin if it is piped (`cat hello.py | edge run`). It errors out if stdin is a terminal. With a path, piped stdin feeds [`input()`](/reference/builtins#input) one line per call. ## `edge serve`: local dev server @@ -95,11 +95,11 @@ Edge Python 0.1.0 · .reset to start fresh · .exit, Ctrl+C or Ctrl+D to qui History (arrow keys) is supported. Each line runs as one input, so compound statements go on a single line (`def double(n): return n * 2`). `.exit`, `Ctrl+C`, or `Ctrl+D` quit. `.reset` wipes the session state and clears the screen. Expression results are not auto-printed. Use `print()` explicitly. -The worker keeps one interpreter alive across prompts: each input compiles and runs **once**, and imports, definitions, and mutations persist in place. Side effects never re-fire, and an input that raises keeps the effects it made before the error. +The engine keeps one interpreter alive across prompts: each input compiles and runs **once**, and imports, definitions, and mutations persist in place. Side effects never re-fire, and an input that raises keeps the effects it made before the error. ## `edge test`: test runner -Discovers `*_test.py` files (recursively, skipping `dist/` and hidden directories), runs each in a fresh interpreter inside one shared browser session, and prints a verdict per file. `edge test path/` narrows discovery to a directory; `edge test file.py` runs one file. +Discovers `*_test.py` files (recursively, skipping `dist/` and hidden directories), runs each in a fresh interpreter inside one shared engine session, and prints a verdict per file. `edge test path/` narrows discovery to a directory; `edge test file.py` runs one file. ```text my-app/ @@ -139,7 +139,7 @@ def t_add(): A file that calls `run()` itself also works: either way its `SystemExit` code is the file's verdict, so the reported result never depends on parsing printed output. A file that registers no tests fails. -Exit codes: `0` every file passed, `1` a file failed or no `*_test.py` was found, `2` the browser session could not start. +Exit codes: `0` every file passed, `1` a file failed or no `*_test.py` was found, `2` the engine session could not start. ## `edge init`: scaffold a workspace @@ -208,6 +208,7 @@ Removes the binary and its `PATH` entry. Asks before removing the bundled `chrom | Flag | Effect | |------|--------| | `--packages <file>` | Use a specific manifest instead of `./packages.json` (read by `run`, `repl`, `test`, `add`, `remove`, `build`) | +| `--web` | Drive the browser runtime instead of the in-process [native engine](/reference/native) (`run`, `repl`, `test`) | | `--version` / `-V` | Print version | `Ctrl+C` cancels any running command cleanly. diff --git a/docs/content/reference/imports.md b/docs/content/reference/imports.md index 3a38c5dc..4f220955 100644 --- a/docs/content/reference/imports.md +++ b/docs/content/reference/imports.md @@ -48,7 +48,7 @@ from utils import * print(slugify("Hello world")) ``` -The names above (`json`, `utils`, `math`) are illustrative. `json` is an [official standard package](/reference/packages#json) that the browser runtime resolves by [default](/reference/packages#defaults). `utils` and `math` stand in for your own modules. Apart from the official defaults, every bare name must be declared in `packages.json` or supplied as a quoted path/URL. +The names above (`json`, `utils`, `math`) are illustrative. `json` is an [official standard package](/reference/packages#json) that both engines resolve by [default](/reference/packages#defaults). `utils` and `math` stand in for your own modules. Apart from the official defaults, every bare name must be declared in `packages.json` or supplied as a quoted path/URL. ## How resolution works @@ -81,14 +81,14 @@ Schema: - Top-level value is a JSON object. Empty `{}` is valid. - `imports` (optional): alias -> spec string. - `extends` (optional): directory whose `packages.json` is consulted when an alias isn't found locally. -- `host` (optional): name -> JS module URL. Read by the browser runtime's `<edge-python>` element to load [host libraries](/reference/packages#host-libraries) on the main thread (DOM, network, storage…). The compiler folds each `host` name into the import table as a main-thread spec (`mt:<name>`); loading the module is the runtime's job. See the [runtime README](https://github.com/dylan-sutton-chavez/edge-python/tree/main/js). +- `host` (optional): name -> JS module URL. Read by the browser runtime's `<edge-python>` element to load [host libraries](/reference/packages#host-libraries) on the main thread (DOM, network, storage…). The compiler folds each `host` name into the import table as a main-thread spec (`mt:<name>`); loading the module is the runtime's job. See the [runtime README](https://github.com/dylan-sutton-chavez/edge-python/tree/main/runtime). - Unknown top-level keys silently ignored (forward-compatible). - Booleans, numbers, arrays at any level are rejected. - String escapes: `\"`, `\\`, `\/`, `\n`, `\t`, `\r`. `\uXXXX` not supported. Paste UTF-8 literally. `from utils import x` resolves to `./lib/utils.py` relative to the entry script. `from math import add` loads `.wasm` per the [wire format](/reference/wasm-abi). -`packages.json` is optional. Scripts can use string-form paths directly without project config, and the browser runtime resolves the [official packages](/reference/packages#defaults) by bare name without it. +`packages.json` is optional. Scripts can use string-form paths directly without project config, and both engines resolve the [official packages](/reference/packages#defaults) by bare name without it. ### Walk-up resolution @@ -137,15 +137,17 @@ When multiple paths import the same module, it's fetched, parsed, and initialise ## Host responsibilities -The compiler is a WebAssembly module. Fetching bytes is the host's job: +The compiler never fetches. Bringing the bytes is the host's job: -- Browser runtime ([`js/`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/js)) uses `fetch()` in a Web Worker. +- Browser runtime ([`js/`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/runtime)) uses `fetch()` in a Web Worker. +- The CLI's [native engine](/reference/native#module-resolution) reads disk directly and downloads URL specs once into `~/.cache/edge-native`. - WASI hosts use their FS/network APIs. - Embedders pre-stage modules for `Resolver`. | Scenario | Who fetches | |---|---| | Browser playground | `js/` package, pre-fetches every spec the script imports. `.py` files register via `register_code_module`; `.wasm` files instantiate via `WebAssembly.instantiate` and register exports via `register_native_module`. | +| CLI native engine | The in-process resolver: quoted paths from disk, URL specs from the cache, official std `.wasm` specs swap to their `.so` twins ([native engine](/reference/native#module-resolution)). | | WASI runtime | Host program reads `.py` files from disk / network using `wasi_snapshot_preview1`. `.wasm` modules can be loaded via the runtime's WebAssembly engine. | | Production deploy | `edge build` bundles the runtime, `compiler.wasm`, scripts, and imported packages into a self-contained `dist/` ([CLI](/reference/cli)). | @@ -255,5 +257,5 @@ Runtime errors from native bindings (e.g., `upper()` with a non-string argument) ## See also - [Official packages](/reference/packages): the ready-made modules and the default registry. -- [Writing modules](/reference/writing-modules): the three delivery paths for native modules. +- [Writing modules](/reference/writing-modules): the delivery paths for native modules. - [WASM module ABI](/reference/wasm-abi): the wire contract behind `.wasm` imports. diff --git a/docs/content/reference/native.md b/docs/content/reference/native.md new file mode 100644 index 00000000..3ef69dda --- /dev/null +++ b/docs/content/reference/native.md @@ -0,0 +1,39 @@ +--- +title: "Native engine" +description: "The CLI's default in-process engine, its module resolution, built-in host modules, snapshots, and the std plugin libraries." +--- + +`edge run`, `edge repl`, and `edge test` execute in-process by default: no browser, no local server, millisecond startup. The [`--web` flag](/reference/cli#global-flags) restores the Chromium-driven runtime for scripts that need the browser. Web-only surface fails fast with a pointer — `import dom` and a parked `frame()` both report `requires the web runtime (run with --web)`. + +Everything runs under [sandbox limits](/reference/limits-and-errors#sandbox-limits) with a real wall clock, so `sleep()` and timeouts wait in real time, matching the web runtime. + +## Module resolution + +Imports resolve like the web runtime. Quoted paths load from disk relative to the importing file, bare names go through the [`packages.json`](/reference/imports#packagesjson) walk-up (`--packages <f>` replaces it), and `https://` specs (including `#sha256-` integrity fragments) download once into `~/.cache/edge-native`. A `.so` target loads as a native plugin; the official std `.wasm` specs written by `edge add` swap transparently to their `.so` twin, so one manifest serves both engines. With no manifest entry, `json`, `re`, `math`, and `struct` default to the CDN `.so` for the host architecture and `test` to the CDN `test.py`. + +Two modules are built into the binary, mirroring the [web host packages](/reference/packages) where a native equivalent makes sense. `time` carries the clocks (`time`, `time_ns`, `monotonic`, `perf_counter`, `sleep`) and the UTC calendar functions (`gmtime`, `localtime`, `mktime`, `strftime`, `strptime`, `asctime`, `ctime`; there is no timezone database, so `tzname()` is `"UTC"`). `network` exposes the blocking `fetch(url, options_json?)` with the web reply shape (`{id, ok, status, headers, body}`) plus `fetch_text` and `fetch_json`. A manifest entry with the same module name always wins. + +## Run flags + +| Flag | Effect | +| --- | --- | +| `--events <f>` | Each line (file or FIFO) feeds one `receive()`; end of input parks the script | +| `--save-state <f>` | On a wait the engine cannot serve, write a [snapshot](/language/snapshots) and exit 0 | +| `--restore-state <f>` | Boot from a snapshot instead of a script and keep running | +| `--preempt <n>` | Yield every `n` loop back-edges and resume, mirroring the web preempt interval | + +## std packages as native libraries + +Each tagged GitHub Release attaches the std packages as native plugin libraries for aarch64 and x86_64, as `.so` for Linux and `.dylib` for macOS (`json-<arch>.so`, `json-<arch>.dylib`, ...), and the CDN serves the same assets under `https://cdn.edgepython.com/native/<contract>/` (the runtime contract the CLI was built for, `0.1.0` today), refreshed on every push to `main`. They are the same crates shipped as `.wasm` [plugin modules](/reference/writing-modules), compiled for the host platform, with a glibc 2.28 floor on Linux. Each exports the [plugin ABI](/reference/wasm-abi) surface (`__edge_abi_version`, `__fn_<name>`, the allocator pair) and leaves the six `env` imports (`edge_op`, `edge_encode`, ...) undefined. The CLI provides them itself, re-exporting the `edge_*` symbols through `-rdynamic` so `dlopen` binds a plugin straight to the engine's shared bridge. An embedding host can do the same, or supply its own implementations. Either way the engine is single-threaded by contract: the bridge keeps its handles, error stash, and live VM pointer in process-wide statics, so the VM and every `edge_*` call must stay on one thread. + +Gnu CLI releases carry the same glibc 2.28 floor (linked through `cargo-zigbuild`); the static musl fallback runs everything except `.so` plugins, which need `dlopen`. The installer probes your libc and picks the right build. + +## Building from source + +```bash +cargo clippy --lib --features native # lint the engine module +cd cli && cargo build --release # the CLI embeds the native engine +cd std/json && cargo build --profile native # any std package as a .so +``` + +The `native` profile inherits `release` and raises `opt-level` to 3. The release profile tunes for `.wasm` size at a real runtime cost, a trade that makes no sense off the wire. CI builds each std target in [`actions/native`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/.github/actions/native) through `cargo-zigbuild` and adds the same nightly size flags as the `.wasm` release. End-to-end coverage lives in `cli/tests/native.rs`. diff --git a/docs/content/reference/packages.md b/docs/content/reference/packages.md index 3b74c892..bd2c09e9 100644 --- a/docs/content/reference/packages.md +++ b/docs/content/reference/packages.md @@ -9,14 +9,14 @@ Two families, matching two of the three [delivery paths](/reference/writing-modu | Family | Source | Form | Where it runs | Path | |---|---|---|---|---| -| **Standard packages** | [`std/`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/std) | `.wasm` (Rust) | Inside the WASM sandbox, any host | [Path A](/reference/writing-modules#path-a-wasm-module-by-url) | +| **Standard packages** | [`std/`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/std) | `.wasm` + native `.so` twin (Rust) | Inside the WASM sandbox, any host; the CLI loads the twin | [Path A](/reference/writing-modules#path-a-wasm-module-by-url) | | **Host libraries** | [`host/`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/host) | Plain ESM (JS) | Browser main thread | [Path C](/reference/writing-modules#path-c-js-host-module) | -Standard packages are host-agnostic (they run wherever WASM runs). Host libraries are browser-only: they reach `document` / `window` / `localStorage`, surfaces that don't exist in a non-browser host. +Standard packages are host-agnostic (they run wherever WASM runs, and the CLI's [native engine](/reference/native#module-resolution) loads their `.so` twins). Host libraries reach `document` / `window` / `localStorage`, surfaces that need a browser; the native engine builds in its own `time` and `network`, while `dom` and `storage` stay web-only. ## Standard packages -Language-agnostic `.wasm` plugins over the [WASM module ABI](/reference/wasm-abi). Import by bare name (the browser runtime resolves the official ones by default; see [Defaults](#defaults)), by URL, or via a `packages.json` alias. The host fetches the `.wasm` and treats its exports as native bindings. +Language-agnostic `.wasm` plugins over the [WASM module ABI](/reference/wasm-abi). Import by bare name (both engines resolve the official ones by default; see [Defaults](#defaults)), by URL, or via a `packages.json` alias. The host fetches the `.wasm` and treats its exports as native bindings. ### `json` @@ -32,7 +32,7 @@ print(dumps({"k": [1, 2, 3], "ok": True})) # {"k":[1,2,3],"ok":true} Pre-built `.wasm` is served from `https://cdn.edgepython.com/std/json.wasm`. Full API: [`std/json/README.md`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/std/json). -> **`json` is an external `.wasm` package, not built into `compiler.wasm`**. The browser runtime resolves it by [default](#defaults), so `from json import ...` works with no manifest. +> **`json` is an external `.wasm` package, not built into `compiler.wasm`**. Both engines resolve it by [default](#defaults), so `from json import ...` works with no manifest. ### `re` @@ -109,7 +109,7 @@ run() # prints PASS/FAIL lines and a summary, then raises SystemExit(0 if all pa `@fixture` registers a `def` under its name and injects it by keyword into the tests that ask for it. `@test(description, *uses)` registers a test plus the fixtures it pulls. `raises(ExcType)` is a context manager asserting the block raises `ExcType` (a subclass, or any type in a tuple). `run()` executes every registered test, prints `PASS` / `FAIL` / `ERROR` and a summary, then raises `SystemExit(1 if any failed, else 0)` so a host can read the result as a process exit code. [`edge test`](/reference/cli) drives `run()` for you across every `*_test.py` file. -Unlike the other standard packages, `test` ships as **pure Edge Python source** (`src/entry.py`), not a compiled `.wasm`, so there is no `cargo` build. It is served from `https://cdn.edgepython.com/std/test.py`. The browser runtime resolves it by default, importing the `.py` directly (see [Defaults](#defaults)). Full API: [`std/test/README.md`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/std/test). +Unlike the other standard packages, `test` ships as **pure Edge Python source** (`src/entry.py`), not a compiled `.wasm`, so there is no `cargo` build. It is served from `https://cdn.edgepython.com/std/test.py`. Both engines resolve it by default, importing the `.py` directly (see [Defaults](#defaults)). Full API: [`std/test/README.md`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/std/test). ## Host libraries @@ -191,20 +191,20 @@ Handlers: `time`, `time_ns`, `monotonic`, `monotonic_ns`, `perf_counter`, `perf_ } ``` -One manifest drives both directions: `imports` for worker-side `.py` / `.wasm` modules, `host` for main-thread libraries. See [Imports](/reference/imports) for resolution semantics and the full `packages.json` schema, and the [runtime README](https://github.com/dylan-sutton-chavez/edge-python/tree/main/js) for `<edge-python>` attributes and `createWorker` options. +One manifest drives both directions: `imports` for worker-side `.py` / `.wasm` modules, `host` for main-thread libraries. See [Imports](/reference/imports) for resolution semantics and the full `packages.json` schema, and the [runtime README](https://github.com/dylan-sutton-chavez/edge-python/tree/main/runtime) for `<edge-python>` attributes and `createWorker` options. ### Defaults -The browser runtime ships a built-in base manifest, so the official packages resolve by bare name with **no `packages.json` at all**: the std packages (`json`, `re`, `math`, `struct`, and the pure-Python `test`) and the host libraries (`dom`, `network`, `storage`, `time`). `dom` resolves to a pure-Python façade over the internal `_dom` host module, adding opt-in mutation batching. Three rules: +The browser runtime ships a built-in base manifest, so the official packages resolve by bare name with **no `packages.json` at all**: the std packages (`json`, `re`, `math`, `struct`, and the pure-Python `test`) and the host libraries (`dom`, `network`, `storage`, `time`). The CLI's [native engine](/reference/native#module-resolution) mirrors the same defaults, swapping std `.wasm` for the `.so` twins and serving `time` / `network` from built-ins. `dom` resolves to a pure-Python façade over the internal `_dom` host module, adding opt-in mutation batching. Three rules: - **Lazy.** A default is fetched only when a run actually imports it. Unused defaults never hit the network. - **Overridable.** Your `packages.json` (or `imports` / `hostModules`) wins for the same name, so you can pin a specific version or URL. - **Opt-out.** Pass `defaults: false` to `createWorker` to disable the base manifest entirely (e.g. offline or non-browser embedders). -Defaults are a convenience of the browser runtime, not the compiler: `compiler.wasm` stays hermetic and resolves bare names only through the manifest the host provides. Non-browser hosts decide their own defaults, if any. +Defaults are a convenience of the browser runtime and the CLI's native engine, not the compiler: `compiler.wasm` stays hermetic and resolves bare names only through the manifest the host provides. Other hosts decide their own defaults, if any. ## See also - [Imports](/reference/imports), import syntax, `packages.json`, integrity verification. -- [Writing modules](/reference/writing-modules), build your own package (the three delivery paths). +- [Writing modules](/reference/writing-modules), build your own package. - [WASM module ABI](/reference/wasm-abi), the contract standard `.wasm` packages implement. diff --git a/docs/content/reference/wasm-abi.md b/docs/content/reference/wasm-abi.md index 45cc4122..8153378e 100644 --- a/docs/content/reference/wasm-abi.md +++ b/docs/content/reference/wasm-abi.md @@ -388,7 +388,7 @@ For `from "<url>" import <names>` with a `.wasm` URL, the host: 4. Marshals args as handles. 5. Propagates results. -Reference browser shim: [`js/src/native.js`](https://github.com/dylan-sutton-chavez/edge-python/blob/main/js/src/native.js) (the `edge_*` guest imports and the built-in Path A loader). WASI hosts and Rust embedders mirror the shape. +Reference browser shim: [`runtime/src/native.js`](https://github.com/dylan-sutton-chavez/edge-python/blob/main/runtime/src/native.js) (the `edge_*` guest imports and the built-in Path A loader). The CLI's [native engine](/reference/native#std-packages-as-native-libraries) implements the same six imports over `dlopen`. WASI hosts and Rust embedders mirror the shape. ## Constraints and caveats @@ -457,7 +457,7 @@ Little-endian, self-contained, versioned. ## Consuming the release from a Rust crate -The `edge-python` crate is a `cdylib`: a Rust host can instantiate `compiler.wasm` and call the exports above directly, the same `.wasm` that ships to browsers; the host owns I/O. The crate declares `links = "compiler"` and its `build.rs` downloads the matching `compiler.wasm` from the GitHub Release for `CARGO_PKG_VERSION` into `OUT_DIR`. Downstream crates read the absolute path through `DEP_COMPILER_WASM`. +The `edge-python` crate is a `cdylib`: a Rust host can instantiate `compiler.wasm` and call the exports above directly, the same `.wasm` that ships to browsers; the host owns I/O. The crate builds no wasm of its own and fetches nothing at build time, so `cargo build` stays offline and reproducible. Take the artifact from the tagged GitHub Release, or from `https://cdn.edgepython.com/compiler.wasm` for the current `main`. ```toml # Downstream Cargo.toml @@ -465,16 +465,9 @@ The `edge-python` crate is a `cdylib`: a Rust host can instantiate `compiler.was edge-python = { git = "https://github.com/dylan-sutton-chavez/edge-python", tag = "v0.1.0" } ``` -```rust -// Downstream build.rs -fn main() { - println!("cargo::rerun-if-changed=build.rs"); - let wasm = std::env::var("DEP_COMPILER_WASM").expect("`DEP_COMPILER_WASM` unset, upstream `edge-python` must declare `links = \"compiler\"`"); - std::fs::copy(&wasm, "runtime/compiler.wasm").expect("copy failed"); -} -``` +Vendor the matching `compiler.wasm` next to your own sources and pin it by checksum, the same way the CLI pins native plugins with a `#sha256-` fragment. A release asset is immutable; the CDN path is not, so a checksum is the only thing that ties a build to a known engine. -The download URL is derived from `CARGO_PKG_VERSION`, so a tag bump is the only retarget. Use `branch = "main"` for unreleased work. Requires `curl` on PATH; gated by the default-on `prebuilt` feature. To add native modules from a Rust host, implement the `Resolver` trait; see [Writing modules](/reference/writing-modules). +To add native modules from a Rust host, implement the `Resolver` trait; see [Writing modules](/reference/writing-modules). ## See also diff --git a/docs/content/reference/writing-modules.md b/docs/content/reference/writing-modules.md index 159548a8..dc81257e 100644 --- a/docs/content/reference/writing-modules.md +++ b/docs/content/reference/writing-modules.md @@ -1,13 +1,13 @@ --- title: "Writing modules" -description: "Three paths to extend Edge Python: a `.wasm` module loaded by URL, a host capability bundled in a custom compiler, or a plain JS module that runs on the page's main thread." +description: "Three paths to extend Edge Python: a `.wasm` module loaded by URL (with a native `.so` twin for the CLI), a host capability bundled in a custom compiler, or a plain JS module that runs on the page's main thread." --- Edge Python has no bundled stdlib. Three ways to add native functionality: | Path | Distribution | Type coverage | Maintenance | |---|---|---|---| -| **`.wasm` module via URL** ([WASM ABI](/reference/wasm-abi)) | Publish `.wasm` to a CDN; any host loads dynamically | Transit values (None, bool, i128, f64, bytes/str, list, dict) | Reference [`wasm-pdk`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/pdk) (Rust), community PDKs, or hand-written wire boilerplate | +| **`.wasm` module via URL** ([WASM ABI](/reference/wasm-abi)) | Publish `.wasm` to a CDN; any host loads dynamically. The same crate builds as `.so`/`.dylib` for the CLI's [native engine](/reference/native) | Transit values (None, bool, i128, f64, bytes/str, list, dict) | Reference [`wasm-pdk`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/pdk) (Rust), community PDKs, or hand-written wire boilerplate | | **Host capability** | Custom `compiler.wasm` (additional host imports declared) + host runtime they bridge to | Transit values + access to host services (DOM, FS, fetch) through embedder host imports | You own embedder + host runtime; bindings travel together | | **JS host module** | Plain ESM via `createWorker` (`mainThreadModules` eager, `hostModules` lazy) or the `host` field of `packages.json` | Transit values (same as Path A) | Pure JS; no Rust, no `.wasm`, no build step | @@ -15,7 +15,7 @@ Edge Python has no bundled stdlib. Three ways to add native functionality: ## Path A: `.wasm` module by URL -Contract: the [WASM module ABI](/reference/wasm-abi), language-agnostic, three scalar types. Rust authors use the bundled [`wasm-pdk`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/pdk) macros ([the surface](/reference/wasm-abi#author-conveniences)). Other languages use community PDKs or hand-roll the boilerplate. +Contract: the [WASM module ABI](/reference/wasm-abi), language-agnostic, three scalar types. Rust authors use the bundled [`wasm-pdk`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/pdk) macros ([the surface](/reference/wasm-abi#author-conveniences)). Other languages use community PDKs or hand-roll the boilerplate. One source, two builds: the same crate also compiles to a native `.so`/`.dylib` plugin that the CLI's [in-process engine](/reference/native) loads through `dlopen`, identical ABI and exports, only the linking differs. Worked examples (with and without the SDK), encoding tables, and language-specific snippets: [WASM module ABI](/reference/wasm-abi). Script side: @@ -121,7 +121,7 @@ async def main(): run(main()) ``` -Or skip the manual wiring: the browser runtime's `<edge-python>` element loads these declaratively from a `host` field in `packages.json`. See the [runtime README](https://github.com/dylan-sutton-chavez/edge-python/tree/main/js). +Or skip the manual wiring: the browser runtime's `<edge-python>` element loads these declaratively from a `host` field in `packages.json`. See the [runtime README](https://github.com/dylan-sutton-chavez/edge-python/tree/main/runtime). Handlers take decoded JS values and return plain JS values. Supported tags: `None`, `bool`, `int` (i128; a JS `Number` within ±2^53, `BigInt` beyond), `float`, string, `bytes` (`Uint8Array`), plus nested `list` / `dict`. Opaque object references (DOM nodes, files, observers) model as integer IDs into a main-thread registry the handlers own (the `alloc` / `node` pattern above). diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 7e142d6a..61bf3dce 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -10,10 +10,8 @@ edition = "2024" [dependencies] afl = "0.18.2" -# `default-features = false` drops `prebuilt` so the build skips the wasm download. [dependencies.edge-python] path = ".." -default-features = false [[bin]] name = "afl-pipeline" diff --git a/host/.gitignore b/host/.gitignore deleted file mode 100644 index 6797899e..00000000 --- a/host/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Editor / IDE -.vscode/ -.idea/ -*.swp -*~ - -# OS -.DS_Store -Thumbs.db -desktop.ini diff --git a/host/LICENSE.txt b/host/LICENSE.txt deleted file mode 100644 index 83e0eefd..00000000 --- a/host/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2026 Dylan Sutton Chavez - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/host/tests/README.md b/host/tests/README.md index 976601aa..52bc773c 100644 --- a/host/tests/README.md +++ b/host/tests/README.md @@ -1,6 +1,6 @@ # Tests -Shared test harness for every host capability, modeled on [`edge-python/js/tests`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/js/tests). `index.html` is pure markup: it loads the runtime's `<edge-python>` tag and nothing else. `host.test.js` boots one tag per capability and drives it through the tag's programmatic API (`run`, `onOutput`). +Shared test harness for every host capability, modeled on [`edge-python/runtime/tests`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/runtime/tests). `index.html` is pure markup: it loads the runtime's `<edge-python>` tag and nothing else. `host.test.js` boots one tag per capability and drives it through the tag's programmatic API (`run`, `onOutput`). ## Layout diff --git a/host/tests/host.test.js b/host/tests/host.test.js index 85025520..0c8110ef 100644 --- a/host/tests/host.test.js +++ b/host/tests/host.test.js @@ -2,7 +2,7 @@ import { chromium } from "npm:playwright"; import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; -import { DEFAULT_IMPORTS } from "../../js/src/defaults.js"; +import { DEFAULT_IMPORTS } from "../../runtime/src/defaults.js"; const ROOT = new URL("../", import.meta.url).pathname; const RUNTIME = new URL("../../js/", import.meta.url).pathname; diff --git a/pdk/Cargo.toml b/pdk/Cargo.toml index ae7632e5..056e6cac 100644 --- a/pdk/Cargo.toml +++ b/pdk/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true -description = "Edge Python Plugin Development Kit, author-side runtime for writing native `.wasm` modules importable from Edge Python scripts. Provides `#[plugin_fn]` proc-macro, Handle/Value/Error types, and From/IntoValue traits over the v1 wasm-abi." +description = "Edge Python Plugin Development Kit, author-side runtime for writing plugin modules importable from Edge Python scripts, compiled to `.wasm` for the web runtime or to native `.so`/`.dylib` for the CLI's in-process engine. Provides `#[plugin_fn]` proc-macro, Handle/Value/Error types, and From/IntoValue traits over the v1 wasm-abi." [lib] crate-type = ["rlib"] diff --git a/js/README.md b/runtime/README.md similarity index 99% rename from js/README.md rename to runtime/README.md index dd39dce0..28bc3e5b 100644 --- a/js/README.md +++ b/runtime/README.md @@ -7,9 +7,9 @@ JS half of Edge Python: hosts `compiler.wasm` in a Web Worker, resolves and regi Requires [Deno v2](https://deno.com/) and Playwright's Chromium (installed on first run). ```bash -deno lint js/ # lint +deno lint runtime/ # lint deno run -A npm:playwright install --with-deps chromium # install Chromium (once) -deno test --allow-all js/tests/js.test.js # run tests +deno test --allow-all runtime/tests/runtime.test.js # run tests ``` ## Install @@ -18,7 +18,7 @@ No install, the official CDN serves both the runtime and matching `compiler.wasm ```js import { createWorker } from "https://cdn.edgepython.com/runtime/src/index.js"; -// Local checkout: import { createWorker } from "../../js/src/index.js"; +// Local checkout: import { createWorker } from "../../runtime/src/index.js"; ``` ## Usage diff --git a/js/src/cache/idb.js b/runtime/src/cache/idb.js similarity index 100% rename from js/src/cache/idb.js rename to runtime/src/cache/idb.js diff --git a/js/src/cache/memory.js b/runtime/src/cache/memory.js similarity index 100% rename from js/src/cache/memory.js rename to runtime/src/cache/memory.js diff --git a/js/src/defaults.js b/runtime/src/defaults.js similarity index 100% rename from js/src/defaults.js rename to runtime/src/defaults.js diff --git a/js/src/element.js b/runtime/src/element.js similarity index 100% rename from js/src/element.js rename to runtime/src/element.js diff --git a/js/src/env.js b/runtime/src/env.js similarity index 100% rename from js/src/env.js rename to runtime/src/env.js diff --git a/js/src/fetch.js b/runtime/src/fetch.js similarity index 100% rename from js/src/fetch.js rename to runtime/src/fetch.js diff --git a/js/src/index.js b/runtime/src/index.js similarity index 100% rename from js/src/index.js rename to runtime/src/index.js diff --git a/js/src/native.js b/runtime/src/native.js similarity index 100% rename from js/src/native.js rename to runtime/src/native.js diff --git a/js/src/prefetch.js b/runtime/src/prefetch.js similarity index 100% rename from js/src/prefetch.js rename to runtime/src/prefetch.js diff --git a/js/src/rt.js b/runtime/src/rt.js similarity index 100% rename from js/src/rt.js rename to runtime/src/rt.js diff --git a/js/src/specs.js b/runtime/src/specs.js similarity index 100% rename from js/src/specs.js rename to runtime/src/specs.js diff --git a/js/src/worker/engine.js b/runtime/src/worker/engine.js similarity index 100% rename from js/src/worker/engine.js rename to runtime/src/worker/engine.js diff --git a/js/src/worker/worker.js b/runtime/src/worker/worker.js similarity index 100% rename from js/src/worker/worker.js rename to runtime/src/worker/worker.js diff --git a/js/tests/app/calculator.py b/runtime/tests/app/calculator.py similarity index 100% rename from js/tests/app/calculator.py rename to runtime/tests/app/calculator.py diff --git a/js/tests/app/packages.json b/runtime/tests/app/packages.json similarity index 100% rename from js/tests/app/packages.json rename to runtime/tests/app/packages.json diff --git a/js/tests/app/ui.js b/runtime/tests/app/ui.js similarity index 100% rename from js/tests/app/ui.js rename to runtime/tests/app/ui.js diff --git a/js/tests/index.html b/runtime/tests/index.html similarity index 100% rename from js/tests/index.html rename to runtime/tests/index.html diff --git a/js/tests/js.json b/runtime/tests/runtime.json similarity index 100% rename from js/tests/js.json rename to runtime/tests/runtime.json diff --git a/js/tests/js.test.js b/runtime/tests/runtime.test.js similarity index 97% rename from js/tests/js.test.js rename to runtime/tests/runtime.test.js index c3c3d9c8..1ba4ab3a 100644 --- a/js/tests/js.test.js +++ b/runtime/tests/runtime.test.js @@ -1,7 +1,7 @@ /* -Drives <edge-python> through index.html: boots one tag, then feeds every js.json case to its worker +Drives <edge-python> through index.html: boots one tag, then feeds every runtime.json case to its worker via run(), comparing #app for output cases and the run trace for error cases. -Run: deno test --allow-all js/tests/js.test.js +Run: deno test --allow-all runtime/tests/runtime.test.js */ import { chromium } from "npm:playwright@latest"; @@ -12,7 +12,7 @@ import { DEFAULT_IMPORTS } from "../src/defaults.js"; const CDN_HOST = new URL(Object.values(DEFAULT_IMPORTS)[0]).host; const REPO = new URL("../../", import.meta.url).pathname; // edge-python/ repo root -const cases = JSON.parse(readFileSync(new URL("./js.json", import.meta.url))); +const cases = JSON.parse(readFileSync(new URL("./runtime.json", import.meta.url))); const PKG = JSON.parse(readFileSync(new URL("./app/packages.json", import.meta.url))); // star-import every module key, recursing through the imports/host category containers const star = (m) => Object.entries(m).flatMap(([k, v]) => (k === "imports" || k === "host" ? star(v) : `from ${k} import *`)); @@ -60,7 +60,7 @@ Deno.test("runtime: <edge-python> runs the corpus through index.html", async () try { return r.fulfill({ contentType: TYPES[ext] ?? "application/octet-stream", body: readFileSync(REPO + u.pathname.slice(1)) }); } catch { return r.fulfill({ status: 404 }); } }); - await page.goto("http://localhost/js/tests/index.html"); + await page.goto("http://localhost/runtime/tests/index.html"); try { // Boot one tag without an entry, then reuse its worker for every case via run(). diff --git a/src/wasm/abi_bridge.rs b/src/bridge.rs similarity index 75% rename from src/wasm/abi_bridge.rs rename to src/bridge.rs index 0f18a93e..5e35237c 100644 --- a/src/wasm/abi_bridge.rs +++ b/src/bridge.rs @@ -1,13 +1,154 @@ -use crate::abi::{classify_decode, classify_encode, DecodeBits, EncodeRequest, ErrorKind, Op, PrimitiveBytes, TAG_INVALID}; +// The FFI safety contract lives in docs/reference/wasm-abi.md, per-fn sections would duplicate it. +#![allow(clippy::missing_safety_doc)] + +use crate::abi::{classify_decode, classify_encode, DecodeBits, EncodeRequest, ErrorKind, ErrorStash, HandleTable, Op, PrimitiveBytes, TAG_INVALID}; use crate::vm::types::{DictMap, HeapObj, Val, VmErr}; -use crate::vm::handlers::methods::{lookup_method, dispatch_method}; -use crate::packages::NativeBinding; -use alloc::{rc::Rc, string::{String, ToString}, sync::Arc, vec::Vec}; +use crate::vm::methods::{lookup_method, dispatch_method}; +use crate::vm::VM; +use alloc::{rc::Rc, string::{String, ToString}, vec::Vec}; use core::cell::RefCell; +use core::ptr::NonNull; use crate::s; -use super::{get_val, host_call_native, in_vm, put_val, release_handles, safe_bytes, safe_handles, safe_str_owned, with_recv, with_runtime, with_vm}; -use super::errors::{error_from_kind, stash_error}; +/* All bridge state behind one accessor so `with_bridge` is the sole unsafe point. */ +pub(crate) struct BridgeState { + pub handles: HandleTable, + pub error_stash: ErrorStash, + /* Set and cleared by `VmGuard` or the paused-run stashes, deref only while its VM lives. */ + pub current_vm: Option<NonNull<VM<'static>>>, +} + +static mut BRIDGE: BridgeState = BridgeState { + handles: HandleTable::new(), + error_stash: ErrorStash::new(), + current_vm: None, +}; + +// SAFETY holds because WASM and the native engine are single-threaded, re-entry routes through `with_vm`. +pub(crate) fn with_bridge<R>(f: impl FnOnce(&mut BridgeState) -> R) -> R { + unsafe { f(&mut *core::ptr::addr_of_mut!(BRIDGE)) } +} + +pub fn put_val(v: Val) -> u32 { with_bridge(|b| b.handles.put(v.0)) } +pub fn get_val(h: u32) -> Option<Val> { with_bridge(|b| b.handles.get(h).map(Val)) } + +// Release a batch of handles in one bridge borrow. +pub fn release_handles(handles: &[u32]) { + with_bridge(|b| for &h in handles { b.handles.release(h); }); +} + +/* Drops handles, stash and VM pointer together, a paused run holding stale handles must go with them. */ +#[cfg(target_arch = "wasm32")] +pub(crate) fn reset() { + with_bridge(|b| { + b.handles.clear(); + b.error_stash.clear(); + b.current_vm = None; + }); +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn set_current_vm(ptr: Option<NonNull<VM<'static>>>) { + with_bridge(|b| b.current_vm = ptr); +} + +/* RAII publisher for the live VM pointer. Holding the guard across `run()` ensures a panic or early return cannot leave a stale pointer for later `host_edge_op` calls. */ +pub struct VmGuard; + +impl VmGuard { + pub fn new(vm: &mut VM<'_>) -> Self { + // 'static is storage-only, deref only inside the `run()` frame holding the guard. + let ptr: NonNull<VM<'static>> = NonNull::from(vm).cast(); + with_bridge(|b| b.current_vm = Some(ptr)); + Self + } +} + +impl Drop for VmGuard { + fn drop(&mut self) { + with_bridge(|b| b.current_vm = None); + } +} + +pub(crate) fn with_vm<R>(f: impl FnOnce(&mut VM<'static>) -> R) -> Option<R> { + // Drop the bridge borrow before `f`, VM dispatch re-enters `with_bridge`. + let ptr = with_bridge(|b| b.current_vm)?; + Some(f(unsafe { &mut *ptr.as_ptr() })) +} + +/* Builds a `&[u8]` from an FFI `(ptr, len)`, empty on null or zero length, `from_raw_parts` would UB on either. */ +pub(crate) unsafe fn safe_bytes<'a>(ptr: *const u8, len: u32) -> &'a [u8] { + if ptr.is_null() || len == 0 { return &[]; } + unsafe { core::slice::from_raw_parts(ptr, len as usize) } +} + +/* Same for `&[u32]` argv arrays. */ +pub(crate) unsafe fn safe_handles<'a>(ptr: *const u32, len: u32) -> &'a [u32] { + if ptr.is_null() || len == 0 { return &[]; } + unsafe { core::slice::from_raw_parts(ptr, len as usize) } +} + +/* Owned UTF-8 string from an FFI `(ptr, len)`; empty on null or invalid UTF-8. */ +pub(crate) unsafe fn safe_str_owned(ptr: *const u8, len: u32) -> String { + core::str::from_utf8(unsafe { safe_bytes(ptr, len) }).unwrap_or("").to_string() +} + +/* `with_vm` that errors when called outside run(). */ +pub(crate) fn in_vm(err: &'static str, f: impl FnOnce(&mut VM<'static>) -> Result<Val, VmErr>) -> Result<Val, VmErr> { + with_vm(f).ok_or(VmErr::Runtime(err))? +} + +/* `dispatch_*` prologue: resolve `recv_h` and run `f` against the live VM. Fails on stale handle or call outside `run()`. */ +pub(crate) fn with_recv<F>(invalid_recv_msg: &'static str, recv_h: u32, f: F) -> Result<Val, VmErr> +where F: FnOnce(&mut VM<'static>, Val) -> Result<Val, VmErr> +{ let recv = get_val(recv_h).ok_or(VmErr::Runtime(invalid_recv_msg))?; with_vm(|vm| f(vm, recv)).ok_or(VmErr::Runtime("edge_op called outside run()"))? } + +/* VmErr classifier for the ABI boundary. */ +pub(crate) fn err_to_kind(e: &VmErr) -> ErrorKind { + match e { + VmErr::Type(_) | VmErr::TypeMsg(_) => ErrorKind::Type, + VmErr::Value(_) => ErrorKind::Value, + VmErr::Runtime(_) => ErrorKind::Runtime, + VmErr::Attribute(_) | VmErr::Name(_) => ErrorKind::Attribute, + VmErr::Raised(s) => { + if s.starts_with("ValueError") { ErrorKind::Value } + else if s.starts_with("IndexError") { ErrorKind::Index } + else if s.starts_with("KeyError") { ErrorKind::Key } + else { ErrorKind::Runtime } + } + _ => ErrorKind::Runtime, + } +} + +pub(crate) fn stash_error(e: VmErr) { + let kind = err_to_kind(&e); + let msg = e.render(); + with_bridge(|b| b.error_stash.set_typed(kind, msg)); +} + +/* Message-only stash for contexts without a `VmErr`, e.g. the WASM panic handler. */ +#[cfg(target_arch = "wasm32")] +pub(crate) fn stash_raw_error(kind: u32, msg: String) { + with_bridge(|b| b.error_stash.set(kind, msg)); +} + +pub fn take_error() -> Option<(u32, String)> { + with_bridge(|b| b.error_stash.take()) +} + +/* Inverse of `err_to_kind`: rebuilds a `VmErr` from (kind, msg). Exhaustive over `ErrorKind` so new variants can't slip into `Raised`. */ +pub fn error_from_kind(kind: u32, msg: String) -> VmErr { + match ErrorKind::from_u32(kind) { + Some(ErrorKind::Type) => VmErr::TypeMsg(msg), + Some(ErrorKind::Value) => VmErr::Raised(s!("ValueError: ", str &msg)), + Some(ErrorKind::Runtime) => VmErr::Raised(s!("RuntimeError: ", str &msg)), + Some(ErrorKind::Attribute) => VmErr::Attribute(msg), + Some(ErrorKind::Index) => VmErr::Raised(s!("IndexError: ", str &msg)), + Some(ErrorKind::Key) => VmErr::Raised(s!("KeyError: ", str &msg)), + // Custom kinds carry the user-defined class name in `msg` (`<ClassName>: <text>`); pass through unchanged. + Some(ErrorKind::Custom) | None => VmErr::Raised(msg), + } +} // Universal dispatch. Returns 0 + handle in `*out_handle`, or 1 + stashed error. #[unsafe(no_mangle)] @@ -394,27 +535,27 @@ pub unsafe extern "C" fn host_edge_decode(h: u32, out_tag: *mut u32, dst: *mut u // Decrement refcount on a handle. No-op for invalid handles. #[unsafe(no_mangle)] pub unsafe extern "C" fn host_edge_release(h: u32) { - with_runtime(|rt| rt.handles.release(h)); + with_bridge(|b| b.handles.release(h)); } // Stash a guest error for the host. Overwrites any pending error. #[unsafe(no_mangle)] pub unsafe extern "C" fn host_edge_throw(kind: u32, msg_ptr: *const u8, msg_len: u32) { let msg = unsafe { safe_str_owned(msg_ptr, msg_len) }; - with_runtime(|rt| rt.error_stash.set(kind, msg)); + with_bridge(|b| b.error_stash.set(kind, msg)); } // Drain the most recent error. #[unsafe(no_mangle)] pub unsafe extern "C" fn host_edge_take_error(out_kind: *mut u32, dst: *mut u8, dst_max: u32) -> i32 { // Peek first so buffer-too-small callers can retry. - let (kind, len) = match with_runtime(|rt| rt.error_stash.peek().map(|(k, m)| (k, m.len()))) { + let (kind, len) = match with_bridge(|b| b.error_stash.peek().map(|(k, m)| (k, m.len()))) { Some(p) => p, None => return -1, }; if len > dst_max as usize { return -(len as i32); } // Buffer fits, drain and copy. None on `take()` means a lost peek/take race; return no-pending-error instead of panicking across FFI. - let Some((_, msg)) = with_runtime(|rt| rt.error_stash.take()) else { return -1; }; + let Some((_, msg)) = with_bridge(|b| b.error_stash.take()) else { return -1; }; let bytes = msg.as_bytes(); unsafe { *out_kind = kind; @@ -424,41 +565,3 @@ pub unsafe extern "C" fn host_edge_take_error(out_kind: *mut u32, dst: *mut u8, } bytes.len() as i32 } - -/* Builds a NativeBinding that marshals handles around `host_call_native`. Kept out of resolver.rs so the resolver stays ABI-agnostic. */ -pub(super) fn make_native_binding(name: String, id: u32) -> NativeBinding { - let closure = move |_: &mut crate::vm::types::HeapPool, args: &[Val], kwargs: Option<Val>| -> Result<Val, VmErr> { - /* 1. Register positional args as handles the guest will see; append the kwargs handle (0 means no kwargs). */ - let mut argv: Vec<u32> = args.iter().map(|v| put_val(*v)).collect(); - argv.push(kwargs.map_or(0, put_val)); - let mut out_handle: u32 = 0; - - // call_id is what call_extern will park with on defer; lets the host route the result back. - let call_id = with_vm(|vm| vm.next_host_call_id as u32).unwrap_or(0); - let status = unsafe { - host_call_native( - id, call_id, - argv.as_ptr(), argv.len() as u32, - &mut out_handle as *mut u32, - ) - }; - - /* 3. Read result BEFORE releasing argv: a returned input would point into slots we're about to free. */ - // Status 2 = DEFERRED: handler has captured what it needs; release argv and park the VM. - if status == 2 { - release_handles(&argv); - return Err(VmErr::HostCallDeferred); - } - if status != 0 { - release_handles(&argv); - let (kind, msg) = with_runtime(|rt| rt.error_stash.take()) - .unwrap_or((ErrorKind::Runtime as u32, String::from("native call failed"))); - return Err(error_from_kind(kind, msg)); - } - let result = get_val(out_handle).ok_or(VmErr::Runtime("native returned invalid handle"))?; - argv.push(out_handle); - release_handles(&argv); - Ok(result) - }; - NativeBinding { name, func: Arc::new(closure), pure: false } -} diff --git a/src/devkit.rs b/src/devkit.rs new file mode 100644 index 00000000..663bd579 --- /dev/null +++ b/src/devkit.rs @@ -0,0 +1,189 @@ +use std::path::{Path, PathBuf}; + +pub enum JsonVal { + Str(String), + Map(Vec<(String, String)>), +} + +// Runs registered tests, exit 3 flags an empty file. +pub const TEST_DRIVER: &str = "import test\nif not test._tests:\n raise SystemExit(3)\ntest.run()"; + +pub const SCAFFOLD_MAIN_PY: &str = "print(\"hello from edge python\")\n"; + +// Bump with runtime element.js and whenever the native plugin ABI changes. +pub const RUNTIME_CONTRACT: &str = "0.1.0"; + +pub const STD_PACKAGES: [&str; 5] = ["json", "re", "math", "struct", "test"]; + +pub const HOST_PACKAGES: [&str; 4] = ["dom", "network", "storage", "time"]; + +/* Collects *_test.py under `dir` recursively, skipping dist and hidden entries, sorted. */ +pub fn discover_tests(dir: &Path) -> Vec<PathBuf> { + let mut found = Vec::new(); + walk(dir, &mut found); + found.sort(); + found +} + +fn walk(dir: &Path, out: &mut Vec<PathBuf>) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.starts_with('.') || name == "dist" { continue; } + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else if name.ends_with("_test.py") { + out.push(path); + } + } +} + +pub fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + '\r' => out.push_str("\\r"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + _ => out.push(c), + } + } + out +} + +/* Tolerant reader for one json object whose values are strings or flat string maps. */ +pub fn parse_object(text: &str) -> Result<Vec<(String, JsonVal)>, String> { + let mut p = Cursor { b: text.as_bytes(), i: 0 }; + p.ws(); + p.eat(b'{', "expected '{'")?; + let mut out = Vec::new(); + p.ws(); + if p.peek() == Some(b'}') { return Ok(out); } + loop { + p.ws(); + let key = p.string()?; + p.ws(); + p.eat(b':', "expected ':' after key")?; + p.ws(); + let val = match p.peek() { + Some(b'"') => JsonVal::Str(p.string()?), + Some(b'{') => { + p.i += 1; + let mut m = Vec::new(); + p.ws(); + if p.peek() == Some(b'}') { p.i += 1; } else { + loop { + p.ws(); + let k = p.string()?; + p.ws(); + p.eat(b':', "expected ':' in nested object")?; + p.ws(); + let v = p.string()?; + m.push((k, v)); + p.ws(); + match p.peek() { + Some(b',') => { p.i += 1; } + Some(b'}') => { p.i += 1; break; } + _ => return Err("expected ',' or '}' in nested object".into()), + } + } + } + JsonVal::Map(m) + } + _ => return Err("only strings and string maps are supported".into()), + }; + out.push((key, val)); + p.ws(); + match p.peek() { + Some(b',') => { p.i += 1; } + Some(b'}') => return Ok(out), + _ => return Err("expected ',' or '}'".into()), + } + } +} + +struct Cursor<'a> { + b: &'a [u8], + i: usize, +} + +impl Cursor<'_> { + fn peek(&self) -> Option<u8> { self.b.get(self.i).copied() } + + fn ws(&mut self) { + while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) { self.i += 1; } + } + + fn eat(&mut self, c: u8, msg: &str) -> Result<(), String> { + if self.peek() == Some(c) { self.i += 1; Ok(()) } else { Err(msg.to_string()) } + } + + fn string(&mut self) -> Result<String, String> { + self.eat(b'"', "expected '\"'")?; + let mut out = String::new(); + loop { + match self.peek() { + None => return Err("unterminated string".into()), + Some(b'"') => { self.i += 1; return Ok(out); } + Some(b'\\') => { + self.i += 1; + if self.peek() == Some(b'u') { + self.i += 1; + out.push(self.unicode_escape()?); + continue; + } + match self.peek() { + Some(b'"') => out.push('"'), + Some(b'\\') => out.push('\\'), + Some(b'/') => out.push('/'), + Some(b'n') => out.push('\n'), + Some(b't') => out.push('\t'), + Some(b'r') => out.push('\r'), + _ => return Err("unsupported escape".into()), + } + self.i += 1; + } + Some(_) => { + let start = self.i; + while !matches!(self.peek(), None | Some(b'"') | Some(b'\\')) { self.i += 1; } + // Spans between ASCII structurals in a &str are always valid UTF-8. + out.push_str(core::str::from_utf8(&self.b[start..self.i]).unwrap_or("")); + } + } + } + } + + /* Four hex digits after `\u`, surrogate pairs combine, lone surrogates read as U+FFFD. */ + fn unicode_escape(&mut self) -> Result<char, String> { + let hi = self.hex4()?; + if (0xD800..0xDC00).contains(&hi) { + if self.b.get(self.i) == Some(&b'\\') && self.b.get(self.i + 1) == Some(&b'u') { + let save = self.i; + self.i += 2; + let lo = self.hex4()?; + if (0xDC00..0xE000).contains(&lo) { + return Ok(char::from_u32(0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)).unwrap_or('\u{FFFD}')); + } + self.i = save; + } + return Ok('\u{FFFD}'); + } + Ok(char::from_u32(hi).unwrap_or('\u{FFFD}')) + } + + fn hex4(&mut self) -> Result<u32, String> { + let mut v = 0u32; + for _ in 0..4 { + let d = self.peek().and_then(|c| (c as char).to_digit(16)) + .ok_or_else(|| "expected 4 hex digits after \\u".to_string())?; + v = v * 16 + d; + self.i += 1; + } + Ok(v) + } +} diff --git a/src/lib.rs b/src/lib.rs index 1ec55078..82517536 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,17 +4,34 @@ extern crate alloc; pub mod abi; +/* Host bridge shared by the WASM ABI and the native engine. */ +#[cfg(any(target_arch = "wasm32", feature = "native"))] +pub mod bridge; + #[cfg(target_arch = "wasm32")] pub mod wasm; +/* Dev-tooling constants and helpers shared by the edge CLI and the native engine. */ +#[cfg(not(target_arch = "wasm32"))] +pub mod devkit; + +/* The native engine, resolver, plugin loader, host modules and drive loop shared by every native front end. */ +#[cfg(all(feature = "native", not(target_arch = "wasm32")))] +pub mod native; + /* Internal compiler helpers (not Edge Python stdlib); separate from pipeline code. */ pub mod util { - pub mod fx; + pub mod hash; pub mod fstr; pub mod sha256; } +/* NaN-boxed values and the mark-and-sweep heap; the layer both the frontend and the VM build on. */ +pub mod value; + pub mod lexer; -pub mod vm; pub mod parser; +/* Post-SSA passes, run between parse and boot; touches no VM state. */ +pub mod optimizer; +pub mod vm; pub mod packages; diff --git a/src/native/builtins/mod.rs b/src/native/builtins/mod.rs new file mode 100644 index 00000000..bb503352 --- /dev/null +++ b/src/native/builtins/mod.rs @@ -0,0 +1,42 @@ +use crate::packages::Resolved; +use crate::vm::types::{HeapObj, HeapPool, Val, VmErr}; + +mod network; +mod time; + +/* Built-in host modules, resolved after the manifest so user entries always win. */ +pub(crate) fn resolve(name: &str) -> Option<Resolved> { + let bindings = match name { + "time" => time::bindings(), + "network" => network::bindings(), + _ => return None, + }; + Some(Resolved::Native { bindings, classes: Vec::new(), consts: Vec::new(), canonical: format!("native:{name}") }) +} + +fn str_arg(heap: &HeapPool, args: &[Val], i: usize, who: &'static str) -> Result<String, VmErr> { + if let Some(v) = args.get(i) + && v.is_heap() + && let HeapObj::Str(s) = heap.get(*v) { + return Ok(s.clone()); + } + Err(VmErr::TypeMsg(format!("{who} expects a str at argument {}", i + 1))) +} + +fn opt_str_arg(heap: &HeapPool, args: &[Val], i: usize, who: &'static str) -> Result<Option<String>, VmErr> { + match args.get(i) { + None => Ok(None), + Some(v) if v.is_none() => Ok(None), + Some(_) => str_arg(heap, args, i, who).map(Some), + } +} + +/* Reads an int or float argument as f64, the numeric shapes web clocks accept. */ +fn num_arg(heap: &HeapPool, args: &[Val], i: usize, who: &'static str) -> Result<Option<f64>, VmErr> { + let Some(v) = args.get(i) else { return Ok(None) }; + if v.is_none() { return Ok(None) } + if v.is_float() { return Ok(Some(v.as_float())) } + if v.is_int() { return Ok(Some(v.as_int() as f64)) } + if v.is_heap() && let HeapObj::LongInt(n) = heap.get(*v) { return Ok(Some(*n as f64)) } + Err(VmErr::TypeMsg(format!("{who} expects a number at argument {}", i + 1))) +} diff --git a/src/native/builtins/network.rs b/src/native/builtins/network.rs new file mode 100644 index 00000000..a392a97e --- /dev/null +++ b/src/native/builtins/network.rs @@ -0,0 +1,101 @@ +use crate::packages::NativeBinding; +use crate::vm::types::{HeapObj, HeapPool, Val, VmErr}; +use std::io::Read; +use std::sync::atomic::{AtomicI64, Ordering}; + +use super::{opt_str_arg, str_arg}; + +// Bounds a runaway response body, mirrors the resolver's download cap. +const MAX_BODY_BYTES: u64 = 64 << 20; + +/* Blocking http with the web reply shapes, sockets and streams stay web-only. */ +pub(super) fn bindings() -> Vec<NativeBinding> { + vec![ + NativeBinding::from_fn("fetch", net_fetch, false), + NativeBinding::from_fn("fetch_text", |h, a, k| fetch_body(h, a, k, "network.fetch_text"), false), + NativeBinding::from_fn("fetch_json", |h, a, k| fetch_body(h, a, k, "network.fetch_json"), false), + ] +} + +struct Options { + method: String, + headers: Vec<(String, String)>, + body: Option<String>, +} + +/* RequestInit subset from the options json, method plus headers plus string body. */ +fn parse_options(json: Option<String>, who: &'static str) -> Result<Options, VmErr> { + let mut opts = Options { method: "GET".into(), headers: Vec::new(), body: None }; + let Some(json) = json else { return Ok(opts) }; + if json.trim().is_empty() { + return Ok(opts); + } + let pairs = crate::devkit::parse_object(&json) + .map_err(|e| VmErr::Raised(format!("RuntimeError: {who} options, {e}")))?; + for (k, v) in pairs { + match (k.as_str(), v) { + ("method", crate::devkit::JsonVal::Str(m)) => opts.method = m, + ("body", crate::devkit::JsonVal::Str(b)) => opts.body = Some(b), + ("headers", crate::devkit::JsonVal::Map(h)) => opts.headers = h, + _ => {} + } + } + Ok(opts) +} + +/* One status-tolerant agent, error replies keep their bodies and callers judge the code. */ +fn send(url: &str, opts: &Options) -> Result<ureq::http::Response<ureq::Body>, Box<ureq::Error>> { + let run = || -> Result<ureq::http::Response<ureq::Body>, ureq::Error> { + let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into(); + let mut b = ureq::http::Request::builder().method(opts.method.as_str()).uri(url); + for (k, v) in &opts.headers { + b = b.header(k.as_str(), v.as_str()); + } + let req = b.body(opts.body.clone().unwrap_or_default())?; + agent.run(req) + }; + run().map_err(Box::new) +} + +fn read_body(resp: &mut ureq::http::Response<ureq::Body>) -> Result<String, VmErr> { + let mut text = String::new(); + resp.body_mut().as_reader().take(MAX_BODY_BYTES).read_to_string(&mut text) + .map_err(|e| VmErr::Raised(format!("RuntimeError: network body, {e}")))?; + Ok(text) +} + +/* Web reply shape, `{id, ok, status, headers, body}`, an `error` field replaces them on transport failure. */ +fn net_fetch(heap: &mut HeapPool, args: &[Val], _: Option<Val>) -> Result<Val, VmErr> { + static NEXT_ID: AtomicI64 = AtomicI64::new(0); + let url = str_arg(heap, args, 0, "network.fetch")?; + let options = opt_str_arg(heap, args, 1, "network.fetch")?; + let opts = parse_options(options, "network.fetch")?; + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let esc = crate::devkit::escape; + let json = match send(&url, &opts) { + Ok(mut resp) => { + let (ok, status) = (resp.status().is_success(), resp.status().as_u16()); + let headers: Vec<String> = resp.headers().iter() + .map(|(k, v)| format!("\"{}\":\"{}\"", esc(k.as_str()), esc(v.to_str().unwrap_or("")))) + .collect(); + let body = read_body(&mut resp)?; + format!("{{\"id\":{id},\"ok\":{ok},\"status\":{status},\"headers\":{{{}}},\"body\":\"{}\"}}", headers.join(","), esc(&body)) + } + Err(e) => format!("{{\"id\":{id},\"ok\":false,\"status\":0,\"error\":\"{}\"}}", esc(&e.to_string())), + }; + heap.alloc(HeapObj::Str(json)) +} + +/* Body-only fetch, raises on non-2xx like the web host. */ +fn fetch_body(heap: &mut HeapPool, args: &[Val], _: Option<Val>, who: &'static str) -> Result<Val, VmErr> { + let url = str_arg(heap, args, 0, who)?; + let options = opt_str_arg(heap, args, 1, who)?; + let opts = parse_options(options, who)?; + let mut resp = send(&url, &opts) + .map_err(|e| VmErr::Raised(format!("RuntimeError: {who} {url}, {e}")))?; + if !resp.status().is_success() { + return Err(VmErr::Raised(format!("RuntimeError: HTTP {}", resp.status().as_u16()))); + } + let body = read_body(&mut resp)?; + heap.alloc(HeapObj::Str(body)) +} diff --git a/src/native/builtins/time.rs b/src/native/builtins/time.rs new file mode 100644 index 00000000..1cae8a18 --- /dev/null +++ b/src/native/builtins/time.rs @@ -0,0 +1,277 @@ +use crate::packages::NativeBinding; +use crate::vm::types::{HeapObj, HeapPool, Val, VmErr}; +use std::sync::OnceLock; + +use super::{num_arg, opt_str_arg, str_arg}; + +const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +const DAYS_ABBR: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +const MONTHS_ABBR: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/* UTC only, the engine carries no timezone database, so localtime mirrors gmtime and tzname is UTC. */ +pub(super) fn bindings() -> Vec<NativeBinding> { + vec![ + NativeBinding::from_fn("time", |_, _, _| Ok(Val::float(crate::native::now_ns() as f64 / 1e9)), false), + NativeBinding::from_fn("time_ns", |h, _, _| h.alloc(HeapObj::Str(crate::native::now_ns().to_string())), false), + NativeBinding::from_fn("monotonic", |_, _, _| Ok(Val::float(mono_secs())), false), + NativeBinding::from_fn("monotonic_ns", |h, _, _| int_val(h, (mono_secs() * 1e9) as i128), false), + NativeBinding::from_fn("perf_counter", |_, _, _| Ok(Val::float(mono_secs())), false), + NativeBinding::from_fn("perf_counter_ns", |h, _, _| int_val(h, (mono_secs() * 1e9) as i128), false), + NativeBinding::from_fn("sleep", time_sleep, false), + NativeBinding::from_fn("timezone", |_, _, _| Ok(Val::int(0)), false), + NativeBinding::from_fn("altzone", |_, _, _| Ok(Val::int(0)), false), + NativeBinding::from_fn("daylight", |_, _, _| Ok(Val::int(0)), false), + NativeBinding::from_fn("tzname", |h, _, _| h.alloc(HeapObj::Str("UTC".into())), false), + NativeBinding::from_fn("gmtime", to_tuple_fn, false), + NativeBinding::from_fn("localtime", to_tuple_fn, false), + NativeBinding::from_fn("mktime", time_mktime, false), + NativeBinding::from_fn("strftime", time_strftime, false), + NativeBinding::from_fn("strptime", time_strptime, false), + NativeBinding::from_fn("asctime", time_asctime, false), + NativeBinding::from_fn("ctime", time_ctime, false), + ] +} + +fn mono_secs() -> f64 { + static BASE: OnceLock<std::time::Instant> = OnceLock::new(); + BASE.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() +} + +fn int_val(heap: &mut HeapPool, n: i128) -> Result<Val, VmErr> { + match Val::int_checked(n as i64) { + Some(v) if n >= i64::MIN as i128 && n <= i64::MAX as i128 => Ok(v), + _ => heap.alloc(HeapObj::LongInt(n)), + } +} + +// Blocking by design, a parked native fetch or sleep stalls every coroutine, unlike the web host. +fn time_sleep(heap: &mut HeapPool, args: &[Val], _: Option<Val>) -> Result<Val, VmErr> { + let secs = num_arg(heap, args, 0, "time.sleep")?.unwrap_or(0.0); + if secs > 0.0 { + std::thread::sleep(std::time::Duration::from_secs_f64(secs.min(3600.0))); + } + Ok(Val::none()) +} + +/* Days since epoch to civil (y, m, d), Hinnant's algorithm. */ +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = if m > 2 { m - 3 } else { m + 9 } as i64; + let doy = (153 * mp + 2) / 5 + d as i64 - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/* struct_time fields in the web tuple order (Y, m, d, H, M, S, wday, yday, isdst). */ +struct Tm { + y: i64, + mo: u32, + d: u32, + h: u32, + mi: u32, + s: u32, + wday: u32, + yday: u32, +} + +fn tm_from_secs(secs: f64) -> Tm { + let total = secs.floor() as i64; + let days = total.div_euclid(86_400); + let rem = total.rem_euclid(86_400); + let (y, mo, d) = civil_from_days(days); + // Epoch day zero was a Thursday, js getDay counts from Sunday. + let js_wday = (days.rem_euclid(7) + 4) % 7; + Tm { + y, mo, d, + h: (rem / 3600) as u32, + mi: (rem / 60 % 60) as u32, + s: (rem % 60) as u32, + wday: ((js_wday + 6) % 7) as u32, + yday: (days - days_from_civil(y, 1, 1) + 1) as u32, + } +} + +fn tm_json(t: &Tm) -> String { + format!("[{},{},{},{},{},{},{},{},-1]", t.y, t.mo, t.d, t.h, t.mi, t.s, t.wday, t.yday) +} + +/* Parses the 9-tuple JSON string back into fields, recomputing wday and yday from the date. */ +fn tm_parse(json: &str, who: &'static str) -> Result<Tm, VmErr> { + let inner = json.trim().trim_start_matches('[').trim_end_matches(']'); + let nums: Vec<i64> = inner.split(',').filter_map(|p| p.trim().parse().ok()).collect(); + if nums.len() < 6 { + return Err(VmErr::Raised(format!("RuntimeError: {who} expects a struct_time JSON tuple"))); + } + let (y, mo, d) = (nums[0], nums[1].clamp(1, 12) as u32, nums[2].clamp(1, 31) as u32); + let days = days_from_civil(y, mo, d); + let js_wday = (days.rem_euclid(7) + 4) % 7; + Ok(Tm { + y, mo, d, + h: nums[3].clamp(0, 23) as u32, + mi: nums[4].clamp(0, 59) as u32, + s: nums[5].clamp(0, 61) as u32, + wday: ((js_wday + 6) % 7) as u32, + yday: (days - days_from_civil(y, 1, 1) + 1) as u32, + }) +} + +fn resolve_arg(heap: &HeapPool, args: &[Val], who: &'static str) -> Result<Tm, VmErr> { + match opt_str_arg(heap, args, 0, who)? { + Some(json) => tm_parse(&json, who), + None => Ok(tm_from_secs(crate::native::now_ns() as f64 / 1e9)), + } +} + +fn to_tuple_fn(heap: &mut HeapPool, args: &[Val], _: Option<Val>) -> Result<Val, VmErr> { + let secs = num_arg(heap, args, 0, "time.gmtime")?.unwrap_or(crate::native::now_ns() as f64 / 1e9); + heap.alloc(HeapObj::Str(tm_json(&tm_from_secs(secs)))) +} + +fn time_mktime(heap: &mut HeapPool, args: &[Val], _: Option<Val>) -> Result<Val, VmErr> { + let json = str_arg(heap, args, 0, "time.mktime")?; + let t = tm_parse(&json, "time.mktime")?; + let secs = days_from_civil(t.y, t.mo, t.d) * 86_400 + t.h as i64 * 3600 + t.mi as i64 * 60 + t.s as i64; + Ok(Val::float(secs as f64)) +} + +fn js_wday(cpy: u32) -> usize { ((cpy + 1) % 7) as usize } + +fn fmt_tuple(t: &Tm, fmt: &str) -> String { + let mut out = String::with_capacity(fmt.len() + 8); + let mut chars = fmt.chars(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + match chars.next() { + Some('Y') => out.push_str(&t.y.to_string()), + Some('y') => out.push_str(&format!("{:02}", t.y.rem_euclid(100))), + Some('m') => out.push_str(&format!("{:02}", t.mo)), + Some('d') => out.push_str(&format!("{:02}", t.d)), + Some('H') => out.push_str(&format!("{:02}", t.h)), + Some('M') => out.push_str(&format!("{:02}", t.mi)), + Some('S') => out.push_str(&format!("{:02}", t.s)), + Some('I') => out.push_str(&format!("{:02}", if t.h.is_multiple_of(12) { 12 } else { t.h % 12 })), + Some('p') => out.push_str(if t.h < 12 { "AM" } else { "PM" }), + Some('j') => out.push_str(&format!("{:03}", t.yday)), + Some('w') => out.push_str(&js_wday(t.wday).to_string()), + Some('a') => out.push_str(DAYS_ABBR[js_wday(t.wday)]), + Some('A') => out.push_str(DAYS[js_wday(t.wday)]), + Some('b') => out.push_str(MONTHS_ABBR[(t.mo - 1) as usize]), + Some('B') => out.push_str(MONTHS[(t.mo - 1) as usize]), + Some('%') => out.push('%'), + Some(other) => { out.push('%'); out.push(other); } + None => out.push('%'), + } + } + out +} + +fn asc_form(t: &Tm) -> String { + format!( + "{} {} {:>2} {:02}:{:02}:{:02} {}", + DAYS_ABBR[js_wday(t.wday)], MONTHS_ABBR[(t.mo - 1) as usize], t.d, t.h, t.mi, t.s, t.y, + ) +} + +fn time_strftime(heap: &mut HeapPool, args: &[Val], _: Option<Val>) -> Result<Val, VmErr> { + let fmt = str_arg(heap, args, 0, "time.strftime")?; + let t = match opt_str_arg(heap, args, 1, "time.strftime")? { + Some(json) => tm_parse(&json, "time.strftime")?, + None => tm_from_secs(crate::native::now_ns() as f64 / 1e9), + }; + heap.alloc(HeapObj::Str(fmt_tuple(&t, &fmt))) +} + +fn time_asctime(heap: &mut HeapPool, args: &[Val], _: Option<Val>) -> Result<Val, VmErr> { + let t = resolve_arg(heap, args, "time.asctime")?; + heap.alloc(HeapObj::Str(asc_form(&t))) +} + +fn time_ctime(heap: &mut HeapPool, args: &[Val], _: Option<Val>) -> Result<Val, VmErr> { + let secs = num_arg(heap, args, 0, "time.ctime")?.unwrap_or(crate::native::now_ns() as f64 / 1e9); + heap.alloc(HeapObj::Str(asc_form(&tm_from_secs(secs)))) +} + +fn month_index(name: &str) -> Option<u32> { + let lower = name.to_lowercase(); + MONTHS.iter().position(|m| m.to_lowercase() == lower) + .or_else(|| MONTHS_ABBR.iter().position(|m| m.to_lowercase() == lower)) + .map(|i| i as u32 + 1) +} + +/* Mirrors the web strptime directives, unknown ones match literally, names bind months only. */ +fn time_strptime(heap: &mut HeapPool, args: &[Val], _: Option<Val>) -> Result<Val, VmErr> { + let s = str_arg(heap, args, 0, "time.strptime")?; + let fmt = str_arg(heap, args, 1, "time.strptime")?; + let no_match = || VmErr::Raised(format!("RuntimeError: time data '{s}' does not match format '{fmt}'")); + let (mut y, mut mo, mut d, mut h, mut mi, mut sec) = (1900i64, 1u32, 1u32, 0u32, 0u32, 0u32); + let input: Vec<char> = s.chars().collect(); + let mut pos = 0usize; + let mut fchars = fmt.chars().peekable(); + while let Some(c) = fchars.next() { + if c != '%' { + if input.get(pos) != Some(&c) { return Err(no_match()) } + pos += 1; + continue; + } + let Some(dir) = fchars.next() else { return Err(no_match()) }; + match dir { + 'Y' | 'y' | 'm' | 'd' | 'H' | 'M' | 'S' | 'j' => { + let width = if dir == 'Y' { 4 } else if dir == 'j' { 3 } else { 2 }; + let start = pos; + while pos < input.len() && pos - start < width && input[pos].is_ascii_digit() { pos += 1; } + if pos == start { return Err(no_match()) } + let v: i64 = input[start..pos].iter().collect::<String>().parse().map_err(|_| no_match())?; + match dir { + 'Y' => y = v, + 'y' => y = 2000 + v, + 'm' => mo = v.clamp(1, 12) as u32, + 'd' => d = v.clamp(1, 31) as u32, + 'H' => h = v as u32, + 'M' => mi = v as u32, + 'S' => sec = v as u32, + _ => {} + } + } + 'b' | 'B' | 'a' | 'A' => { + let start = pos; + while pos < input.len() && input[pos].is_alphabetic() { pos += 1; } + if pos == start { return Err(no_match()) } + let word: String = input[start..pos].iter().collect(); + if matches!(dir, 'b' | 'B') { + mo = month_index(&word).ok_or_else(no_match)?; + } + } + '%' => { + if input.get(pos) != Some(&'%') { return Err(no_match()) } + pos += 1; + } + other => { + if input.get(pos) != Some(&'%') || input.get(pos + 1) != Some(&other) { return Err(no_match()) } + pos += 2; + } + } + } + if pos != input.len() { return Err(no_match()) } + let days = days_from_civil(y, mo, d); + let secs = days * 86_400 + h as i64 * 3600 + mi as i64 * 60 + sec as i64; + heap.alloc(HeapObj::Str(tm_json(&tm_from_secs(secs as f64)))) +} diff --git a/src/native/driver.rs b/src/native/driver.rs new file mode 100644 index 00000000..ce8b82d1 --- /dev/null +++ b/src/native/driver.rs @@ -0,0 +1,152 @@ +use crate::bridge::VmGuard; +use crate::vm::snapshot; +use crate::vm::types::{SchedulerStatus, VmErr}; +use crate::vm::VM; +use std::io::BufRead; + + +use super::RunOpts; + +fn pending_name(status: &SchedulerStatus) -> &'static str { + match status { + SchedulerStatus::PendingTimer(_) => "a timer", + SchedulerStatus::PendingFrame => "a render frame", + SchedulerStatus::PendingEvent => "an event", + SchedulerStatus::PendingHostCall => "a host call", + SchedulerStatus::Preempted => "a preempt resume", + SchedulerStatus::Done => "nothing", + } +} + +/* Serves one run to completion, `src` and `name` feed tracebacks, `opts` wires events and snapshots. */ +pub fn drive(vm: &mut VM<'static>, src: &str, name: Option<&str>, opts: &RunOpts) -> i32 { + let mut events: Option<std::io::BufReader<std::fs::File>> = None; + loop { + // The guard publishes the VM so `.so` plugin callbacks can re-enter through `edge_op`. + let result = { let _guard = VmGuard::new(vm); vm.run() }; + match result { + Ok(_) | Err(VmErr::HostYield(SchedulerStatus::Done)) => return 0, + Err(VmErr::HostYield(SchedulerStatus::PendingTimer(deadline))) => { + let now = super::now_ns(); + if deadline > now { std::thread::sleep(std::time::Duration::from_nanos(deadline - now)); } + } + Err(VmErr::HostYield(SchedulerStatus::Preempted)) => {} + Err(VmErr::HostYield(SchedulerStatus::PendingEvent)) if opts.events.is_some() => { + match next_event(&mut events, opts.events.as_deref().unwrap_or_default()) { + Some(line) => { + if let Err(e) = vm.push_event(&line) { + eprintln!("error: cannot inject event: {}", e.render()); + return 1; + } + } + // A drained events file can never serve the wait, park terminally. + None => return park(vm, src, &SchedulerStatus::PendingEvent, opts), + } + } + Err(VmErr::HostYield(status)) => return park(vm, src, &status, opts), + Err(e) => { + if let Some(code) = vm.system_exit_code() { + return (code & 0xFF) as i32; + } + let traceback = e.render_traceback(src, vm.error_pos(), name, vm.call_stack_frames(), vm.function_names_ref()); + eprintln!("{traceback}"); + return 1; + } + } + } +} + +/* The park report, web-only waits point at --web, host-servable ones at their flag. */ +fn suspend_message(status: &SchedulerStatus) -> String { + match status { + SchedulerStatus::PendingFrame => "script suspended awaiting a render frame, requires the web runtime (run with --web)".to_string(), + SchedulerStatus::PendingEvent => "script suspended awaiting an event (wire --events <file>)".to_string(), + s => format!("script suspended awaiting {}, nothing can resume it here", pending_name(s)), + } +} + +/* Unservable park, snapshot when asked, otherwise report the missing wait and fail. */ +fn park(vm: &VM<'static>, src: &str, status: &SchedulerStatus, opts: &RunOpts) -> i32 { + if let Some(file) = &opts.save_state { + let blob = snapshot::save(vm, src); + if let Err(e) = std::fs::write(file, blob) { + eprintln!("error: cannot write state to '{file}': {e}"); + return 1; + } + eprintln!("suspended awaiting {}, state saved to '{file}'", pending_name(status)); + return 0; + } + eprintln!("error: {}", suspend_message(status)); + 1 +} + +/* Lazy line reader over `--events`, a FIFO blocks until a writer shows up, a file replays. */ +fn next_event(reader: &mut Option<std::io::BufReader<std::fs::File>>, path: &str) -> Option<String> { + if reader.is_none() { + match std::fs::File::open(path) { + Ok(f) => *reader = Some(std::io::BufReader::new(f)), + Err(e) => { eprintln!("error: cannot open events '{path}': {e}"); return None; } + } + } + let mut line = String::new(); + match reader.as_mut()?.read_line(&mut line) { + Ok(0) | Err(_) => None, + Ok(_) => Some(line.trim_end_matches('\n').to_string()), + } +} + +pub enum Step { + Done, + Exit(u8), + Error(String), + Suspended(String), +} + +/* Session driver for repl and test, serves timers and preempt, hands everything else back as text. */ +pub fn drive_session(vm: &mut VM<'static>, src: &str, name: Option<&str>) -> Step { + loop { + let result = { let _guard = VmGuard::new(vm); vm.run() }; + match result { + Ok(_) | Err(VmErr::HostYield(SchedulerStatus::Done)) => return Step::Done, + Err(VmErr::HostYield(SchedulerStatus::PendingTimer(deadline))) => { + let now = super::now_ns(); + if deadline > now { std::thread::sleep(std::time::Duration::from_nanos(deadline - now)); } + } + Err(VmErr::HostYield(SchedulerStatus::Preempted)) => {} + Err(VmErr::HostYield(status)) => return Step::Suspended(suspend_message(&status)), + Err(e) => { + if let Some(code) = vm.system_exit_code() { + return Step::Exit((code & 0xFF) as u8); + } + let traceback = e.render_traceback(src, vm.error_pos(), name, vm.call_stack_frames(), vm.function_names_ref()); + return Step::Error(traceback); + } + } + } +} + +/* Boot from the blob's embedded source, overlay its saved state, keep driving. */ +pub fn restore_and_run(file: &str, opts: &RunOpts) -> i32 { + let blob = match std::fs::read(file) { + Ok(b) => b, + Err(e) => { eprintln!("error: cannot read state '{file}': {e}"); return 2; } + }; + let source = match snapshot::source_of(&blob) { + Ok(s) => s.to_string(), + Err(e) => { eprintln!("error: {e}"); return 1; } + }; + let limits = match snapshot::limits_of(&blob) { + Ok(l) => l, + Err(e) => { eprintln!("error: {e}"); return 1; } + }; + let chunk = match super::parse_source(&source, "", opts.packages.as_deref()) { + Ok(c) => c, + Err(_) => { eprintln!("error: snapshot source no longer parses; was it saved by another compiler version?"); return 1; } + }; + let mut vm = super::boot_vm(chunk, limits, opts.preempt); + if let Err(e) = snapshot::restore(&mut vm, &blob) { + eprintln!("error: {e}"); + return 1; + } + drive(&mut vm, &source, None, opts) +} diff --git a/src/native/loader.rs b/src/native/loader.rs new file mode 100644 index 00000000..c519f3a5 --- /dev/null +++ b/src/native/loader.rs @@ -0,0 +1,113 @@ +use crate::abi::{ErrorKind, EDGE_ABI_VERSION}; +use crate::bridge::{error_from_kind, get_val, put_val, release_handles, take_error}; +use crate::packages::NativeBinding; +use crate::vm::types::{HeapPool, Val, VmErr}; +use std::path::Path; +use std::sync::Arc; + +type PluginFn = unsafe extern "C" fn(*const u32, u32, *mut u32) -> i32; + +// A dlopen'd plugin resolves these against the host binary, exported via -rdynamic. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn edge_op(op: u32, recv: u32, name_ptr: *const u8, name_len: u32, argv_ptr: *const u32, argc: u32, out: *mut u32) -> i32 { + unsafe { crate::bridge::host_edge_op(op, recv, name_ptr, name_len, argv_ptr, argc, out) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn edge_encode(tag: u32, ptr: *const u8, len: u32) -> u32 { + unsafe { crate::bridge::host_edge_encode(tag, ptr, len) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn edge_decode(h: u32, out_tag: *mut u32, dst: *mut u8, dst_max: u32) -> i32 { + unsafe { crate::bridge::host_edge_decode(h, out_tag, dst, dst_max) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn edge_release(h: u32) { + unsafe { crate::bridge::host_edge_release(h) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn edge_throw(kind: u32, msg_ptr: *const u8, msg_len: u32) { + unsafe { crate::bridge::host_edge_throw(kind, msg_ptr, msg_len) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn edge_take_error(out_kind: *mut u32, dst: *mut u8, dst_max: u32) -> i32 { + unsafe { crate::bridge::host_edge_take_error(out_kind, dst, dst_max) } +} + +/* Opens the plugin, checks its ABI version, and binds every convention export by raw name. */ +pub(super) fn load(path: &Path) -> Result<Vec<NativeBinding>, String> { + // Touching the exports keeps them linked into the final binary for dlopen to resolve. + std::hint::black_box([edge_op as *const (), edge_encode as *const (), edge_decode as *const (), edge_release as *const (), edge_throw as *const (), edge_take_error as *const ()]); + let names = candidate_exports(path)?; + let lib: &'static libloading::Library = Box::leak(Box::new( + unsafe { libloading::Library::new(path) }.map_err(|e| format!("dlopen '{}': {e}", path.display()))?, + )); + let version: unsafe extern "C" fn() -> u32 = *unsafe { lib.get(b"__edge_abi_version") } + .map_err(|_| format!("'{}' is missing __edge_abi_version", path.display()))?; + let got = unsafe { version() }; + if got != EDGE_ABI_VERSION { + return Err(format!("'{}' speaks ABI v{got}, this engine expects v{EDGE_ABI_VERSION}", path.display())); + } + let mut bindings = Vec::new(); + for name in names { + // dlsym is the ground truth, a candidate that fails to resolve was string noise. + let Ok(sym) = (unsafe { lib.get::<PluginFn>(name.as_bytes()) }) else { continue }; + let f: PluginFn = *sym; + bindings.push(bind(name, f)); + } + if bindings.is_empty() { + return Err(format!("'{}' exports no __fn_/__class_/__const_ symbols", path.display())); + } + Ok(bindings) +} + +/* Marshals handles around one plugin call, mirroring the wasm-side extern dispatch. */ +fn bind(name: String, f: PluginFn) -> NativeBinding { + let closure = move |_: &mut HeapPool, args: &[Val], kwargs: Option<Val>| -> Result<Val, VmErr> { + let mut argv: Vec<u32> = args.iter().map(|v| put_val(*v)).collect(); + argv.push(kwargs.map_or(0, put_val)); + let mut out: u32 = 0; + let status = unsafe { f(argv.as_ptr(), argv.len() as u32, &mut out) }; + // Status 2 means deferred, the scheduler parks the calling coroutine. + if status == 2 { + release_handles(&argv); + return Err(VmErr::HostCallDeferred); + } + if status != 0 { + release_handles(&argv); + let (kind, msg) = take_error() + .unwrap_or((ErrorKind::Runtime as u32, String::from("native call failed"))); + return Err(error_from_kind(kind, msg)); + } + let result = get_val(out).ok_or(VmErr::Runtime("native returned invalid handle"))?; + argv.push(out); + release_handles(&argv); + Ok(result) + }; + NativeBinding { name, func: Arc::new(closure), pure: false } +} + +/* Scans raw bytes for convention-prefixed names, format-agnostic since exported names must exist verbatim. */ +fn candidate_exports(path: &Path) -> Result<Vec<String>, String> { + let b = std::fs::read(path).map_err(|e| format!("cannot read '{}': {e}", path.display()))?; + let mut names: Vec<String> = Vec::new(); + for prefix in [b"__fn_".as_slice(), b"__class_", b"__const_"] { + let mut at = 0; + while let Some(hit) = b[at..].windows(prefix.len()).position(|w| w == prefix) { + let start = at + hit; + let end = start + b[start..].iter().take_while(|&&c| c.is_ascii_alphanumeric() || c == b'_').count(); + at = end; + if end > start + prefix.len() + && let Ok(name) = core::str::from_utf8(&b[start..end]) { + names.push(name.to_string()); + } + } + } + names.sort(); + names.dedup(); + Ok(names) +} diff --git a/src/native/mod.rs b/src/native/mod.rs new file mode 100644 index 00000000..2f7a9ab6 --- /dev/null +++ b/src/native/mod.rs @@ -0,0 +1,68 @@ +use crate::lexer::lex; +use crate::parser::{Diagnostic, Parser, SSAChunk}; +use crate::vm::{Limits, VM}; +use std::io::Write; + +mod driver; +mod builtins; +mod loader; +mod resolver; + +pub use driver::{drive, drive_session, restore_and_run, Step}; + +use resolver::FileResolver; + +/* Run flags, every path is host-side, none reach the sandboxed script. */ +#[derive(Default)] +pub struct RunOpts { + pub packages: Option<String>, + pub preempt: usize, + pub events: Option<String>, + pub save_state: Option<String>, + pub restore_state: Option<String>, +} + +// Streams one print payload to stdout, flushed so it interleaves with stderr. +fn stream_stdout(s: &str) { + let mut out = std::io::stdout().lock(); + let _ = out.write_all(s.as_bytes()); + let _ = out.flush(); +} + +// Wall-clock ns, the same base PendingTimer deadlines are minted against. +pub(crate) fn now_ns() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as u64) +} + +/* Lex and parse with the disk resolver, Err is rendered diagnostics. */ +pub fn parse_source(src: &str, dir: &str, packages: Option<&str>) -> Result<SSAChunk, String> { + let (tokens, lex_errs) = lex(src); + let r = FileResolver::new(dir, packages); + let mut p = Parser::with_resolver(src, tokens.into_iter(), Box::new(r)); + for e in lex_errs { + p.errors.push(Diagnostic { start: e.start, end: e.end, msg: e.msg.into() }); + } + let (mut chunk, errs) = p.parse(); + if !errs.is_empty() { + let mut buf = String::new(); + for (i, e) in errs.iter().enumerate() { + if i > 0 { buf.push('\n'); } + buf.push_str(&e.render(src, None)); + } + return Err(buf); + } + crate::vm::optimizer::constant_fold(&mut chunk); + Ok(chunk) +} + +/* Leak the chunk so plugin callbacks and snapshots outlive this frame, then boot with host hooks. */ +pub fn boot_vm(chunk: SSAChunk, limits: Limits, preempt: usize) -> VM<'static> { + let chunk_static: &'static SSAChunk = Box::leak(Box::new(chunk)); + let mut vm = VM::with_limits(chunk_static, limits); + vm.strict_input = true; + vm.print_hook = Some(stream_stdout); + vm.set_time_hook(now_ns); + vm.set_preempt_interval(preempt); + vm +} diff --git a/src/native/resolver.rs b/src/native/resolver.rs new file mode 100644 index 00000000..f9d75a35 --- /dev/null +++ b/src/native/resolver.rs @@ -0,0 +1,215 @@ +use crate::packages::{dir_of, join_relative, parse_manifest, parse_integrity, partition_bindings, walk_up_dirs, Resolved, Resolver}; +use crate::util::sha256::{hex_encode, sha256}; +use std::collections::HashSet; +use std::io::Read; +use std::path::PathBuf; + +// Cap on the packages.json `extends` chain, 32 dwarfs real workspace depth. +const MAX_PACKAGES_HOPS: u32 = 32; + +// Bounds a runaway CDN response, the largest std .so is about 1 MB. +const MAX_FETCH_BYTES: u64 = 64 << 20; + +pub struct FileResolver { + dir: String, + packages: Option<String>, +} + +impl FileResolver { + pub fn new(dir: &str, packages: Option<&str>) -> Self { + Self { dir: dir.to_string(), packages: packages.map(String::from) } + } +} + +impl Resolver for FileResolver { + fn resolve(&mut self, spec: &str) -> Result<Resolved, String> { + if !spec.contains('/') { + let dir = self.dir.clone(); + return self.resolve_bare(spec, &dir); + } + let canonical = if spec.contains("://") || spec.starts_with('/') { + spec.to_string() + } else { + join_relative(&self.dir, spec) + }; + self.resolve_canonical(&canonical) + } + + fn fetch_bytes(&mut self, spec: &str, expected_hash: Option<[u8; 32]>) -> Result<Vec<u8>, String> { + let (target, frag_hash) = parse_integrity(spec)?; + let bytes = read_spec(target)?; + if let Some(h) = expected_hash.or(frag_hash) + && sha256(&bytes) != h { + return Err(format!("sha256 mismatch for '{target}'")); + } + Ok(bytes) + } + + fn child(&self, spec: &str) -> Box<dyn Resolver> { + Box::new(FileResolver { dir: dir_of(spec).to_string(), packages: self.packages.clone() }) + } +} + +impl FileResolver { + /* Bare names try the manifest first, then built-in host modules, then std defaults. */ + fn resolve_bare(&mut self, name: &str, start_dir: &str) -> Result<Resolved, String> { + if let Some((dir, target)) = self.lookup_bare(name, start_dir)? { + let canonical = join_relative(&dir, &target); + return self.resolve_canonical(&canonical); + } + if let Some(resolved) = super::builtins::resolve(name) { + return Ok(resolved); + } + if let Some(url) = std_default(name) { + return self.resolve_canonical(&url); + } + // Browser-bound host packages fail with the canonical marker instead of a manifest hint. + if crate::devkit::HOST_PACKAGES.contains(&name) { + return Err(format!("module '{name}' requires the web runtime (run with --web)")); + } + Err(format!("no packages.json above '{start_dir}' declares '{name}'\nhelp: declare it, or use a quoted path")) + } + + /* Returns (manifest dir, target) following `extends`, None when no manifest declares `name`. */ + fn lookup_bare(&mut self, name: &str, start_dir: &str) -> Result<Option<(String, String)>, String> { + let mut visited: HashSet<String> = HashSet::new(); + let mut search: Option<String> = None; + let mut hops: u32 = 0; + loop { + if hops > MAX_PACKAGES_HOPS { + return Err(format!("packages.json walk-up exceeded {MAX_PACKAGES_HOPS} hops resolving '{name}'")); + } + hops += 1; + let packages = self.packages.clone(); + let hit = match (&packages, &search) { + // A --packages override replaces the walk-up entirely, later hops follow its `extends`. + (Some(m), None) => self.lookup_in_manifest(m, name)?.map(|(t, e)| (dir_of(m).to_string(), t, e)), + _ => { + let from = search.clone().unwrap_or_else(|| start_dir.to_string()); + let mut found = None; + for dir in walk_up_dirs(&from) { + let m_spec = format!("{dir}packages.json"); + if let Some((target, ext)) = self.lookup_in_manifest(&m_spec, name)? { + found = Some((dir, target, ext)); + break; + } + } + found + } + }; + let Some((dir, target, ext)) = hit else { return Ok(None) }; + if let Some(target) = target { + return Ok(Some((dir, target))); + } + let Some(ext) = ext else { return Ok(None) }; + let m_spec = format!("{dir}packages.json"); + if !visited.insert(m_spec) { + return Err("circular extends chain in packages.json".to_string()); + } + let mut next = join_relative(&dir, &ext); + if !next.ends_with('/') { next.push('/'); } + search = Some(next); + } + } + + /* One manifest probe, Ok(None) when the file is absent, inner Options are (target, extends). */ + #[allow(clippy::type_complexity)] + fn lookup_in_manifest(&mut self, m_spec: &str, name: &str) -> Result<Option<(Option<String>, Option<String>)>, String> { + let Ok(bytes) = std::fs::read(m_spec) else { return Ok(None) }; + let parsed = parse_manifest(&bytes).map_err(|e| format!("packages.json at '{m_spec}': {e}"))?; + let target = parsed.imports.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone()); + Ok(Some((target, parsed.extends))) + } + + fn resolve_canonical(&mut self, spec: &str) -> Result<Resolved, String> { + let (target, frag_hash) = parse_integrity(spec)?; + if target.ends_with(".py") { + let bytes = self.fetch_bytes(spec, None)?; + let src = String::from_utf8(bytes).map_err(|_| format!("module '{target}' is not valid UTF-8"))?; + return Ok(Resolved::Code { src, canonical: spec.to_string() }); + } + if target.ends_with(".so") || target.ends_with(".dylib") { + let path = if target.contains("://") { fetch_cached(target)? } else { PathBuf::from(target) }; + // dlopen runs arbitrary code, so a pinned hash must gate it. + if let Some(h) = frag_hash { + let bytes = std::fs::read(&path).map_err(|e| format!("cannot read module '{target}': {e}"))?; + if sha256(&bytes) != h { + return Err(format!("sha256 mismatch for '{target}'")); + } + } + let all = super::loader::load(&path)?; + let (bindings, classes, consts) = partition_bindings(all); + return Ok(Resolved::Native { bindings, classes, consts, canonical: spec.to_string() }); + } + if target.ends_with(".wasm") { + // Official std .wasm specs written by `edge add` swap to their native twin for this platform. + if let Some(name) = target.strip_prefix("https://cdn.edgepython.com/std/").and_then(|r| r.strip_suffix(".wasm")) { + return self.resolve_canonical(&native_std_spec(name)); + } + return Err(format!("module '{target}' is a .wasm build; the native engine loads native plugins (or run with --web)")); + } + Err(format!("module '{target}' has an unsupported extension (expected .py, .so, or .dylib)")) + } +} + +/* Official std packages resolvable by bare name with no manifest, mirrors runtime/src/defaults.js. */ +fn std_default(name: &str) -> Option<String> { + match name { + "json" | "re" | "math" | "struct" => Some(native_std_spec(name)), + "test" => Some(match local_std_dir() { + Some(d) => format!("{d}/std/test.py"), + None => "https://cdn.edgepython.com/std/test.py".to_string(), + }), + _ => None, + } +} + +/* Native twin of an official std package: local under EDGE_STD_DIR, contract-versioned CDN otherwise. */ +fn native_std_spec(name: &str) -> String { + let file = format!("{name}-{}.{}", std::env::consts::ARCH, std::env::consts::DLL_EXTENSION); + match local_std_dir() { + Some(d) => format!("{d}/native/{file}"), + None => format!("https://cdn.edgepython.com/native/{}/{file}", crate::devkit::RUNTIME_CONTRACT), + } +} + +// Test hook mirroring the web engine's EDGE_RUNTIME_DIR, std assets come from a local dir. +fn local_std_dir() -> Option<String> { + std::env::var("EDGE_STD_DIR").ok() +} + +/* Spec bytes, a cached download for URLs, a plain read for paths. */ +fn read_spec(target: &str) -> Result<Vec<u8>, String> { + if target.contains("://") { + let path = fetch_cached(target)?; + return std::fs::read(&path).map_err(|e| format!("cannot read cached '{target}': {e}")); + } + std::fs::read(target).map_err(|e| format!("cannot read module '{target}': {e}")) +} + +/* Downloads once into the user cache, the file name is the url hash plus its extension. */ +pub fn fetch_cached(url: &str) -> Result<PathBuf, String> { + let dir = cache_dir()?; + let ext = url.rsplit('.').next().unwrap_or("bin"); + let file = dir.join(format!("{}.{ext}", hex_encode(&sha256(url.as_bytes())))); + if file.exists() { + return Ok(file); + } + let mut resp = ureq::get(url).call().map_err(|e| format!("fetching '{url}': {e}"))?; + let mut bytes = Vec::new(); + resp.body_mut().as_reader().take(MAX_FETCH_BYTES).read_to_end(&mut bytes).map_err(|e| format!("reading '{url}': {e}"))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("creating cache dir: {e}"))?; + // Temp plus rename keeps a truncated download out of the shared cache. + let tmp = file.with_extension(format!("{ext}.{}.tmp", std::process::id())); + std::fs::write(&tmp, &bytes).map_err(|e| format!("writing cache for '{url}': {e}"))?; + std::fs::rename(&tmp, &file).map_err(|e| format!("writing cache for '{url}': {e}"))?; + Ok(file) +} + +fn cache_dir() -> Result<PathBuf, String> { + if let Ok(x) = std::env::var("XDG_CACHE_HOME") { + return Ok(PathBuf::from(x).join("edge-native")); + } + let home = std::env::var("HOME").map_err(|_| "cannot locate a cache dir (no HOME)".to_string())?; + Ok(PathBuf::from(home).join(".cache").join("edge-native")) +} diff --git a/src/vm/optimizer.rs b/src/optimizer.rs similarity index 99% rename from src/vm/optimizer.rs rename to src/optimizer.rs index fec36aa2..9ae5d9e1 100644 --- a/src/vm/optimizer.rs +++ b/src/optimizer.rs @@ -4,7 +4,7 @@ LoadName preserved for IC/super-ops/templates. Dead instructions removed, jump o */ use crate::parser::{OpCode, SSAChunk, Instruction, Value}; -use super::types::Val; +use crate::value::Val; use alloc::{vec, vec::Vec}; pub fn constant_fold(chunk: &mut SSAChunk) { diff --git a/src/packages/mod.rs b/src/packages/mod.rs index 9fddbe03..9396f26e 100644 --- a/src/packages/mod.rs +++ b/src/packages/mod.rs @@ -5,7 +5,7 @@ Parse-time import: resolver returns Code (spliced .py) or Native (extern_table); use alloc::{boxed::Box, string::{String, ToString}, sync::Arc, vec::Vec}; use crate::s; -use crate::vm::types::{HeapPool, Val, VmErr}; +use crate::value::{HeapPool, Val, VmErr}; use crate::lexer::{lex, Token, TokenType}; pub mod manifest; @@ -133,8 +133,8 @@ impl Default for Box<dyn Resolver> { } /* Converts public NativeBinding into internal ExternFn; two structs separate host API from VM storage. */ -pub(crate) fn binding_to_extern(b: &NativeBinding) -> crate::vm::types::ExternFn { - crate::vm::types::ExternFn { +pub(crate) fn binding_to_extern(b: &NativeBinding) -> crate::value::ExternFn { + crate::value::ExternFn { name: b.name.clone(), func: b.func.clone(), pure: b.pure, diff --git a/src/parser/imports.rs b/src/parser/imports.rs index 75dc77bc..a331815c 100644 --- a/src/parser/imports.rs +++ b/src/parser/imports.rs @@ -9,7 +9,7 @@ use super::types::{Diagnostic, ImportEntry, ImportKind, NativeClassEntry, OpCode use crate::lexer::{Token, TokenType, lex}; use crate::packages::{Resolved, binding_to_extern, parse_integrity}; use crate::util::sha256::{sha256, hex_encode}; -use crate::util::fx::FxHashSet; +use crate::util::hash::FxHashSet; use alloc::{string::{String, ToString}, vec::Vec}; diff --git a/src/parser/literals.rs b/src/parser/literals.rs index 312003a3..7270c14d 100644 --- a/src/parser/literals.rs +++ b/src/parser/literals.rs @@ -5,7 +5,7 @@ use super::types::builtin; use super::types::{OpCode, Value, SSAChunk, Instruction}; use crate::lexer::{Token, TokenType}; -use crate::util::fx::FxHashMap as HashMap; +use crate::util::hash::FxHashMap as HashMap; use alloc::{string::{String, ToString}, vec::Vec}; @@ -717,7 +717,7 @@ impl<'src, I: Iterator<Item = Token>> Parser<'src, I> { _ => n, } } - let mut locals: crate::util::fx::FxHashSet<&str> = + let mut locals: crate::util::hash::FxHashSet<&str> = params.iter().map(|p| super::types::param_base_name(p)).collect(); for ins in &body.instructions { if ins.opcode == OpCode::StoreName diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 3f3ec715..5dbfdddd 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -10,7 +10,7 @@ pub use types::*; use crate::s; use crate::lexer::{Token, TokenType}; -use crate::util::fx::FxHashMap as HashMap; +use crate::util::hash::FxHashMap as HashMap; use crate::packages::{Resolver, NoopResolver}; use alloc::{boxed::Box, string::{String, ToString}, vec::Vec}; @@ -48,7 +48,7 @@ pub struct Parser<'src, I: Iterator<Item = Token>> { pub(super) chunk: SSAChunk, pub(super) ssa_versions: HashMap<String, u32>, /* Names declared `global` in the current function body; redirects load/store to `self.globals`. */ - pub(super) globals_decl: crate::util::fx::FxHashSet<String>, + pub(super) globals_decl: crate::util::hash::FxHashSet<String>, pub(super) join_stack: Vec<JoinNode>, pub(super) loop_starts: Vec<u16>, pub(super) last_line: usize, @@ -87,6 +87,10 @@ impl<'src, I: Iterator<Item = Token>> Parser<'src, I> { let name_bytes = name.as_bytes(); let cap = buf.len(); let mut n = name_bytes.len().min(cap); + // Identifiers are utf8, so back off to a codepoint boundary before truncating. + while n > 0 && n < name_bytes.len() && (name_bytes[n] & 0xC0) == 0x80 { + n -= 1; + } buf[..n].copy_from_slice(&name_bytes[..n]); if n < cap { buf[n] = b'_'; @@ -97,6 +101,7 @@ impl<'src, I: Iterator<Item = Token>> Parser<'src, I> { let take = s.len().min(cap - n); buf[n..n + take].copy_from_slice(&s[..take]); n += take; + // SAFETY holds because the name prefix ends on a codepoint boundary and the suffix is ascii. unsafe { core::str::from_utf8_unchecked(&buf[..n]) } } @@ -158,7 +163,7 @@ impl<'src, I: Iterator<Item = Token>> Parser<'src, I> { /* Propagate the body's free names to the parent chunk, register the function, and emit its make-op. `fname` is None for lambdas (anonymous, sentinel slot); Some(name) registers a store slot for a def. Order matches the historic def path: free-name propagation, then slot, then push. */ pub(super) fn push_function(&mut self, params: Vec<String>, body: SSAChunk, defaults: u16, fname: Option<&str>, op: OpCode) { - let param_slots: crate::util::fx::FxHashSet<String> = params.iter() + let param_slots: crate::util::hash::FxHashSet<String> = params.iter() .map(|p| s!(str types::param_base_name(p), "_0")).collect(); for name in &body.names { if !param_slots.contains(name.as_str()) { @@ -510,7 +515,7 @@ impl<'src, I: Iterator<Item = Token>> Parser<'src, I> { tokens: iter.peekable(), chunk, ssa_versions: HashMap::default(), - globals_decl: crate::util::fx::FxHashSet::default(), + globals_decl: crate::util::hash::FxHashSet::default(), join_stack: Vec::new(), loop_starts: Vec::new(), loop_breaks: Vec::new(), diff --git a/src/parser/types.rs b/src/parser/types.rs index b7ba9072..1d95309e 100644 --- a/src/parser/types.rs +++ b/src/parser/types.rs @@ -1,6 +1,6 @@ use crate::s; -use crate::util::fx::FxHashMap as HashMap; -use crate::vm::types::ExternFn; +use crate::util::hash::FxHashMap as HashMap; +use crate::value::ExternFn; use alloc::{string::{String, ToString}, vec, vec::Vec}; @@ -124,13 +124,13 @@ pub struct ImportEntry { #[derive(Clone)] pub struct NativeClassEntry { pub name: String, - pub methods: Vec<crate::vm::types::ExternFn>, + pub methods: Vec<crate::value::ExternFn>, } #[derive(Clone)] pub enum ImportKind { Code(alloc::rc::Rc<SSAChunk>), - Native { funcs: Vec<crate::vm::types::ExternFn>, classes: Vec<NativeClassEntry>, consts: Vec<crate::vm::types::ExternFn> }, + Native { funcs: Vec<crate::value::ExternFn>, classes: Vec<NativeClassEntry>, consts: Vec<crate::value::ExternFn> }, } // SSA chunk: instructions, constant/name pools, Phi metadata, nested functions/classes. diff --git a/src/util/fstr.rs b/src/util/fstr.rs index 8dc46d8c..cbe28cd7 100644 --- a/src/util/fstr.rs +++ b/src/util/fstr.rs @@ -66,22 +66,3 @@ macro_rules! s { (cap: $c:expr; $($t:tt)*) => {{ let mut _s = alloc::string::String::with_capacity($c); $crate::s!(@b _s; $($t)*); _s }}; ($($t:tt)*) => {{ let mut _s = alloc::string::String::new(); $crate::s!(@b _s; $($t)*); _s }}; } - -pub enum E { - Custom { msg: alloc::string::String }, -} - -impl E { - pub fn message(&self) -> alloc::string::String { - match self { - Self::Custom { msg } => msg.clone(), - } - } -} - -impl From<E> for alloc::string::String { fn from(e: E) -> Self { e.message() } } - -#[macro_export] -macro_rules! err { - ($($t:tt)*) => { $crate::util::fstr::E::Custom { msg: $crate::s!($($t)*) } }; -} diff --git a/src/util/fx.rs b/src/util/hash.rs similarity index 100% rename from src/util/fx.rs rename to src/util/hash.rs diff --git a/src/vm/types/eq.rs b/src/value/eq.rs similarity index 92% rename from src/vm/types/eq.rs rename to src/value/eq.rs index ed92be2a..fb0ae1a0 100644 --- a/src/vm/types/eq.rs +++ b/src/value/eq.rs @@ -1,13 +1,13 @@ use super::{DictMap, HeapObj, HeapPool, Val, ValSet, as_i128}; -pub(in crate::vm) fn eq_seq(a: &[Val], b: &[Val], eq: impl Fn(Val,Val)->bool) -> bool { +pub(crate) fn eq_seq(a: &[Val], b: &[Val], eq: impl Fn(Val,Val)->bool) -> bool { a.len() == b.len() && a.iter().zip(b).all(|(x,y)| eq(*x,*y)) } -pub(in crate::vm) fn eq_dict(a: &DictMap, b: &DictMap, heap: &HeapPool, eq: impl Fn(Val,Val)->bool) -> bool { +pub(crate) fn eq_dict(a: &DictMap, b: &DictMap, heap: &HeapPool, eq: impl Fn(Val,Val)->bool) -> bool { a.len() == b.len() && a.iter().all(|(k,v)| b.get(&k, heap).is_some_and(|&v2| eq(v,v2))) } /* Content set-equality: same size and every element of `a` content-matches one in `b`. */ -pub(in crate::vm) fn eq_set(a: &ValSet, b: &ValSet, eq: impl Fn(Val,Val)->bool) -> bool { +pub(crate) fn eq_set(a: &ValSet, b: &ValSet, eq: impl Fn(Val,Val)->bool) -> bool { a.len() == b.len() && a.iter().all(|&x| b.iter().any(|&y| eq(x, y))) } @@ -24,7 +24,7 @@ pub fn hash_val_with_heap(v: Val, heap: &HeapPool) -> u64 { } fn hash_depth(v: Val, heap: &HeapPool, depth: usize) -> u64 { use core::hash::Hasher; - let mut h = crate::util::fx::FxHasher::default(); + let mut h = crate::util::hash::FxHasher::default(); // Numeric unification: int / bool / integral-float / in-range LongInt all hash as the same i64. if v.is_int() { h.write_i64(v.as_int()); return h.finish(); } if v.is_bool() { h.write_i64(v.as_bool() as i64); return h.finish(); } @@ -51,7 +51,7 @@ fn hash_depth(v: Val, heap: &HeapPool, depth: usize) -> u64 { } /* f64 view of any numeric Val (int/bool/float/LongInt); None for non-numerics. */ -pub(in crate::vm) fn num_as_f64(v: Val, heap: &HeapPool) -> Option<f64> { +pub(crate) fn num_as_f64(v: Val, heap: &HeapPool) -> Option<f64> { if v.is_float() { Some(v.as_float()) } else if v.is_int() { Some(v.as_int() as f64) } else if v.is_bool() { Some(v.as_bool() as i64 as f64) } diff --git a/src/vm/types/err.rs b/src/value/err.rs similarity index 100% rename from src/vm/types/err.rs rename to src/value/err.rs diff --git a/src/vm/types/coro.rs b/src/value/frames.rs similarity index 98% rename from src/vm/types/coro.rs rename to src/value/frames.rs index b2e7e6ad..b9971527 100644 --- a/src/vm/types/coro.rs +++ b/src/value/frames.rs @@ -127,7 +127,7 @@ impl IterFrame { // Clamp past the i64 edge so the next `done` check ends the range, never overflows. *cur = cur.checked_add(*step).unwrap_or(if *step > 0 { i64::MAX } else { i64::MIN }); // Promote magnitudes beyond the 47-bit inline range to LongInt. - Ok(Some(crate::vm::builtins::sequence::range_int(heap, v)?)) + Ok(Some(crate::vm::globals::sequence::range_int(heap, v)?)) } } } diff --git a/src/vm/types/math.rs b/src/value/math.rs similarity index 100% rename from src/vm/types/math.rs rename to src/value/math.rs diff --git a/src/vm/types/mod.rs b/src/value/mod.rs similarity index 99% rename from src/vm/types/mod.rs rename to src/value/mod.rs index ed32f0cf..5346aa4a 100644 --- a/src/vm/types/mod.rs +++ b/src/value/mod.rs @@ -1,14 +1,14 @@ use alloc::{rc::Rc, string::String, vec::Vec}; use core::cell::RefCell; -use crate::util::fx::FxHashMap as HashMap; +use crate::util::hash::FxHashMap as HashMap; -pub mod coro; +pub mod frames; pub mod eq; pub mod err; pub mod math; pub mod scheduler; -pub use coro::*; +pub use frames::*; pub use eq::*; pub use err::*; pub use math::*; @@ -184,7 +184,7 @@ pub enum HeapObj { Extern(ExternFn), } -pub use crate::vm::handlers::methods::BuiltinMethodId; +pub use crate::vm::methods::BuiltinMethodId; // Single source of truth per builtin: variant => name, arity (`var` = variadic, validated in handler). macro_rules! builtins { diff --git a/src/vm/types/scheduler.rs b/src/value/scheduler.rs similarity index 100% rename from src/vm/types/scheduler.rs rename to src/value/scheduler.rs diff --git a/src/vm/cache.rs b/src/vm/cache.rs index 45bc8b5e..87dd58d6 100644 --- a/src/vm/cache.rs +++ b/src/vm/cache.rs @@ -280,10 +280,6 @@ impl Templates { } } - pub fn count(&self) -> usize { - self.slots.iter().flat_map(|v| v.iter()).filter(|e| e.hits >= TPL_THRESH).count() - } - pub fn mark_all(&self, heap: &mut super::types::HeapPool) { for slot in &self.slots { for e in slot { diff --git a/src/vm/dispatch.rs b/src/vm/dispatch.rs index 57dd5862..f8e3c2d7 100644 --- a/src/vm/dispatch.rs +++ b/src/vm/dispatch.rs @@ -3,7 +3,7 @@ use alloc::{string::{String, ToString}, vec::Vec}; use crate::parser::{OpCode, SSAChunk, Instruction, ssa_strip}; -use super::{ExceptionFrame, VM, handlers}; +use super::{ExceptionFrame, VM, opcodes}; use super::types::*; use super::cache::{OpcodeCache, FastOp, InstanceCache}; @@ -309,8 +309,8 @@ impl<'a> VM<'a> { Err(other) => return Err(other), }; match lookup { - handlers::methods::AttrLookup::ModuleAttr(callee) - | handlers::methods::AttrLookup::ClassMember(callee) => { + opcodes::attr_lookup::AttrLookup::ModuleAttr(callee) + | opcodes::attr_lookup::AttrLookup::ClassMember(callee) => { // Direct call on the resolved value, no `self` prepended. self.push(callee); for a in &positional { self.push(*a); } @@ -319,7 +319,7 @@ impl<'a> VM<'a> { let encoded = ((kw_flat.len() as u16 / 2) << 8) | argc; self.exec_call(encoded, chunk, slots) } - handlers::methods::AttrLookup::InstanceMethod { recv, func, class } => { + opcodes::attr_lookup::AttrLookup::InstanceMethod { recv, func, class } => { // Prepend `self`; kw pairs re-pushed like the unfused BoundUserMethod path. `super()` reads the binding off `pending`. self.pending.method_binding = Some((class, recv)); self.push(func); @@ -334,12 +334,12 @@ impl<'a> VM<'a> { self.pending_exec_safe = false; called } - handlers::methods::AttrLookup::BuiltinMethod(id) => { + opcodes::attr_lookup::AttrLookup::BuiltinMethod(id) => { // sort runs user __lt__, so it needs chunk/slots the builtin-method table can't carry. if id.name() == "sort" { return self.exec_sort(obj, &positional, &kw_flat, chunk, slots); } self.exec_bound_method(obj, id, &positional, &kw_flat) } - handlers::methods::AttrLookup::InstanceField(field) => { + opcodes::attr_lookup::AttrLookup::InstanceField(field) => { // Instance-attribute callable: call directly, no `self` prepended (only class-level functions bind); exec_call reports non-callables as TypeError, matching `obj.attr(...)` and `g = obj.attr; g()`. self.push(field); for a in &positional { self.push(*a); } @@ -348,12 +348,12 @@ impl<'a> VM<'a> { let encoded = ((kw_flat.len() as u16 / 2) << 8) | argc; self.exec_call(encoded, chunk, slots) } - handlers::methods::AttrLookup::ExcArgs(_) | handlers::methods::AttrLookup::Name(_) => { + opcodes::attr_lookup::AttrLookup::ExcArgs(_) | opcodes::attr_lookup::AttrLookup::Name(_) => { // `e.args()` / `f.__name__()`: the value isn't callable, reports as missing attribute. let ty = self.type_name(obj); Err(VmErr::Attribute(s!("'", str ty, "' object has no attribute '", str &name, "'"))) } - handlers::methods::AttrLookup::PropertyGet { recv, getter } => { + opcodes::attr_lookup::AttrLookup::PropertyGet { recv, getter } => { // Materialise the value first, then call it with the user's args, `foo.prop(arg)` where `prop` returns a callable. if self.depth >= self.max_calls { return Err(cold_depth()); } self.push(getter); @@ -367,7 +367,7 @@ impl<'a> VM<'a> { let encoded = ((kw_flat.len() as u16 / 2) << 8) | argc; self.exec_call(encoded, chunk, slots) } - handlers::methods::AttrLookup::PropertySetterRef(prop) => { + opcodes::attr_lookup::AttrLookup::PropertySetterRef(prop) => { let v = self.heap.alloc(HeapObj::PropertySetter(prop))?; self.push(v); for a in &positional { self.push(*a); } @@ -1003,7 +1003,7 @@ impl<'a> VM<'a> { self.live_slots.truncate(snap); exec_result?; // Members are exactly the slots the body itself stores; loads of builtins or injected globals never leak into the class namespace. - let mut member_slots: crate::util::fx::FxHashSet<u16> = crate::util::fx::FxHashSet::default(); + let mut member_slots: crate::util::hash::FxHashSet<u16> = crate::util::hash::FxHashSet::default(); for ins in &body.instructions { if matches!(ins.opcode, OpCode::StoreName | OpCode::Phi) { member_slots.insert(ins.operand); diff --git a/src/vm/handlers/format.rs b/src/vm/format_spec.rs similarity index 100% rename from src/vm/handlers/format.rs rename to src/vm/format_spec.rs diff --git a/src/vm/builtins/async_ops.rs b/src/vm/globals/async_ops.rs similarity index 100% rename from src/vm/builtins/async_ops.rs rename to src/vm/globals/async_ops.rs diff --git a/src/vm/builtins/attr.rs b/src/vm/globals/attr.rs similarity index 97% rename from src/vm/builtins/attr.rs rename to src/vm/globals/attr.rs index e7a5f70a..95f0fae7 100644 --- a/src/vm/builtins/attr.rs +++ b/src/vm/globals/attr.rs @@ -63,7 +63,7 @@ impl<'a> VM<'a> { return Ok(()); } let ty = self.type_name(obj); - if let Some(method_id) = super::super::handlers::methods::lookup_method(ty, &name) { + if let Some(method_id) = crate::vm::methods::lookup_method(ty, &name) { let bound = self.heap.alloc(HeapObj::BoundMethod(obj, method_id))?; self.push(bound); return Ok(()); @@ -99,7 +99,7 @@ impl<'a> VM<'a> { _ => false, }; let ty = self.type_name(obj); - let exists = is_class_attr || is_func_attr || is_bound_attr || super::super::handlers::methods::lookup_method(ty, &name).is_some(); + let exists = is_class_attr || is_func_attr || is_bound_attr || crate::vm::methods::lookup_method(ty, &name).is_some(); self.push(Val::bool(exists)); Ok(()) } @@ -227,7 +227,7 @@ impl<'a> VM<'a> { /* `globals()`, module-level bindings as a dict. User top-level names only (entry-chunk slots + module state); builtins live in a separate namespace, matching CPython. Returned dict is a copy. */ pub fn call_globals(&mut self, chunk: &crate::parser::SSAChunk, slots: &[Val]) -> Result<(), VmErr> { - let mut out: crate::util::fx::FxHashMap<String, Val> = crate::util::fx::FxHashMap::default(); + let mut out: crate::util::hash::FxHashMap<String, Val> = crate::util::hash::FxHashMap::default(); // Inside a function, entry slots sit at the bottom of `live_slots`; at top-level, use `slots` as-is. let (entry_chunk, entry_slots): (&crate::parser::SSAChunk, &[Val]) = if core::ptr::eq(chunk as *const _, self.chunk as *const _) { @@ -261,7 +261,7 @@ impl<'a> VM<'a> { /* `locals()`, frame bindings as a dict. Dedupes SSA versions (`x_0`, `x_1`, ...) to the highest live one. Filters synthetic `#`-slots and unrebound builtins (same Val as the global). */ pub fn call_locals(&mut self, chunk: &crate::parser::SSAChunk, slots: &[Val]) -> Result<(), VmErr> { // Map bare-name -> (best version, val) so we keep only the latest. - let mut latest: crate::util::fx::FxHashMap<String, (i64, Val)> = crate::util::fx::FxHashMap::default(); + let mut latest: crate::util::hash::FxHashMap<String, (i64, Val)> = crate::util::hash::FxHashMap::default(); for (i, name) in chunk.names.iter().enumerate() { let v = match slots.get(i) { Some(v) if !v.is_undef() => *v, diff --git a/src/vm/builtins/bytes_helpers.rs b/src/vm/globals/bytes_helpers.rs similarity index 100% rename from src/vm/builtins/bytes_helpers.rs rename to src/vm/globals/bytes_helpers.rs diff --git a/src/vm/builtins/container.rs b/src/vm/globals/container.rs similarity index 100% rename from src/vm/builtins/container.rs rename to src/vm/globals/container.rs diff --git a/src/vm/builtins/conversion.rs b/src/vm/globals/conversion.rs similarity index 95% rename from src/vm/builtins/conversion.rs rename to src/vm/globals/conversion.rs index f7bafc00..43c43868 100644 --- a/src/vm/builtins/conversion.rs +++ b/src/vm/globals/conversion.rs @@ -10,7 +10,7 @@ impl<'a> VM<'a> { // `str(bytes, encoding[, errors])` decodes, mirroring `bytes.decode`. let args = self.pop_n(argc as usize)?; if args[0].is_heap() && matches!(self.heap.get(args[0]), HeapObj::Bytes(_)) { - return crate::vm::handlers::builtin_methods::bytes::decode(self, args[0], &args[1..]); + return crate::vm::methods::bytes::decode(self, args[0], &args[1..]); } return Err(cold_type("decoding to str: need a bytes-like object")); } diff --git a/src/vm/builtins/identity.rs b/src/vm/globals/identity.rs similarity index 99% rename from src/vm/builtins/identity.rs rename to src/vm/globals/identity.rs index 2adb3adb..aa805726 100644 --- a/src/vm/builtins/identity.rs +++ b/src/vm/globals/identity.rs @@ -112,7 +112,7 @@ impl<'a> VM<'a> { if o.is_int() { let n = o.as_int(); self.push(Val::int(if n == -1 { -2 } else { n })); return Ok(()); } if o.is_bool() { self.push(Val::int(o.as_bool() as i64)); return Ok(()); } - let mut h = crate::util::fx::FxHasher::default(); + let mut h = crate::util::hash::FxHasher::default(); if o.is_float() { o.as_float().to_bits().hash(&mut h); } else if o.is_none() { 0u64.hash(&mut h); } else if o.is_heap() { diff --git a/src/vm/builtins/io.rs b/src/vm/globals/io.rs similarity index 100% rename from src/vm/builtins/io.rs rename to src/vm/globals/io.rs diff --git a/src/vm/builtins/mod.rs b/src/vm/globals/mod.rs similarity index 94% rename from src/vm/builtins/mod.rs rename to src/vm/globals/mod.rs index aea76a92..d7c90dff 100644 --- a/src/vm/builtins/mod.rs +++ b/src/vm/globals/mod.rs @@ -1,4 +1,4 @@ -// Builtins split by domain. Submodules add `impl VM` methods to the same type. +// Global functions split by domain. Submodules add `impl VM` methods to the same type. use super::VM; @@ -8,7 +8,6 @@ pub mod bytes_helpers; pub mod container; pub mod conversion; pub mod identity; -pub mod index; pub mod io; pub mod numeric; pub mod sequence; diff --git a/src/vm/builtins/numeric.rs b/src/vm/globals/numeric.rs similarity index 100% rename from src/vm/builtins/numeric.rs rename to src/vm/globals/numeric.rs diff --git a/src/vm/builtins/sequence.rs b/src/vm/globals/sequence.rs similarity index 100% rename from src/vm/builtins/sequence.rs rename to src/vm/globals/sequence.rs diff --git a/src/vm/handlers/mod.rs b/src/vm/handlers/mod.rs deleted file mode 100644 index 70290095..00000000 --- a/src/vm/handlers/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -pub(crate) mod arith; -pub(crate) mod builtin_methods; -pub(crate) mod data; -pub(crate) mod dunder; -pub(crate) mod format; -pub(crate) mod function; -pub(crate) mod methods; -mod methods_helpers; - -pub(super) use crate::vm::{ - VM, Val, VmErr, HeapObj, DictMap, cache, ops, - types::{BodyRef, ExceptionFrame, IterFrame, SyncFrame, HeapPool, ValSet, cold_depth, cold_type, cold_value, cold_runtime, cold_overflow, eq_vals_with_heap, ffloor} -}; - -pub(super) use crate::parser::{OpCode, SSAChunk, ssa_strip}; -pub(super) use alloc::{rc::Rc, string::String, vec, vec::Vec}; -pub(super) use core::cell::RefCell; diff --git a/src/vm/helpers.rs b/src/vm/helpers.rs index afa9c8a2..796f79ca 100644 --- a/src/vm/helpers.rs +++ b/src/vm/helpers.rs @@ -3,7 +3,7 @@ use alloc::{string::{String, ToString}, vec, vec::Vec}; use super::VM; use super::types::*; -use super::builtins::sequence::range_int; +use super::globals::sequence::range_int; impl<'a> VM<'a> { @@ -40,9 +40,6 @@ impl<'a> VM<'a> { match self.time_hook { Some(h) => h(), None => self.virtual_clock_ns } } - pub fn heap_usage(&self) -> usize { self.heap.usage() } - pub fn cache_stats(&self) -> (usize, usize) { (self.templates.count(), self.chunk.instructions.len()) } - // Stack helpers. #[inline] pub(crate) fn push(&mut self, v: Val) { self.stack.push(v); } diff --git a/src/vm/init.rs b/src/vm/init.rs index 94b5c865..ab526d25 100644 --- a/src/vm/init.rs +++ b/src/vm/init.rs @@ -9,7 +9,7 @@ use super::types::*; // `_`-prefixed names stay: the free-name fallback resolves module functions here. pub(crate) fn collect_module_attrs(chunk: &SSAChunk, slots: &[Val]) -> Vec<(String, Val)> { let mut attrs: Vec<(String, Val)> = Vec::new(); - let mut seen: crate::util::fx::FxHashSet<String> = crate::util::fx::FxHashSet::default(); + let mut seen: crate::util::hash::FxHashSet<String> = crate::util::hash::FxHashSet::default(); for ins in &chunk.instructions { if !matches!(ins.opcode, OpCode::StoreName) { continue; } let Some(name) = chunk.names.get(ins.operand as usize) else { continue; }; @@ -43,7 +43,7 @@ impl<'a> VM<'a> { self.fn_index.push((chunk as *const _, indices)); // Bare-name index so the free-load fallback is O(1) instead of re-parsing per miss. - let mut name_versions: super::NameVersionIndex = crate::util::fx::FxHashMap::default(); + let mut name_versions: super::NameVersionIndex = crate::util::hash::FxHashMap::default(); for (si, sname) in chunk.names.iter().enumerate() { if let Some(parsed) = crate::parser::SsaName::parse(sname) { name_versions @@ -150,7 +150,7 @@ impl<'a> VM<'a> { let fresh_entry = self.scheduler.is_empty(); if fresh_entry { // Fresh entry. Initialise imports before user code; DFS gives topological order naturally. - let mut in_progress: crate::util::fx::FxHashSet<String> = crate::util::fx::FxHashSet::default(); + let mut in_progress: crate::util::hash::FxHashSet<String> = crate::util::hash::FxHashSet::default(); self.init_modules(self.chunk, &mut in_progress)?; // Wrap the module body as an implicit coroutine; lets top-level statements suspend on deferred host calls (DOM, sleep, receive) through the same scheduler path as `async def`. let slots = self.fill_builtins(&self.chunk.names); @@ -193,7 +193,7 @@ impl<'a> VM<'a> { } /* Init each unique import once; code modules run their top-level, native ones just bind. `in_progress` catches cycles cleanly. */ - fn init_modules(&mut self, chunk: &SSAChunk, in_progress: &mut crate::util::fx::FxHashSet<String>) -> Result<(), VmErr> { + fn init_modules(&mut self, chunk: &SSAChunk, in_progress: &mut crate::util::hash::FxHashSet<String>) -> Result<(), VmErr> { for entry in &chunk.imports { if self.module_table.contains_key(&entry.spec) { continue; } if !in_progress.insert(entry.spec.clone()) { diff --git a/src/vm/handlers/builtin_methods/bytes.rs b/src/vm/methods/bytes.rs similarity index 100% rename from src/vm/handlers/builtin_methods/bytes.rs rename to src/vm/methods/bytes.rs diff --git a/src/vm/handlers/builtin_methods/dict.rs b/src/vm/methods/dict.rs similarity index 100% rename from src/vm/handlers/builtin_methods/dict.rs rename to src/vm/methods/dict.rs diff --git a/src/vm/handlers/builtin_methods/list.rs b/src/vm/methods/list.rs similarity index 100% rename from src/vm/handlers/builtin_methods/list.rs rename to src/vm/methods/list.rs diff --git a/src/vm/handlers/builtin_methods/mod.rs b/src/vm/methods/mod.rs similarity index 99% rename from src/vm/handlers/builtin_methods/mod.rs rename to src/vm/methods/mod.rs index d0a7c075..20cb9731 100644 --- a/src/vm/handlers/builtin_methods/mod.rs +++ b/src/vm/methods/mod.rs @@ -4,6 +4,7 @@ Descriptor: name + fn ptr + mutating flag + arity range; dispatcher checks arity */ mod prelude; +mod recv; pub mod bytes; pub mod dict; pub mod list; diff --git a/src/vm/handlers/builtin_methods/numeric.rs b/src/vm/methods/numeric.rs similarity index 100% rename from src/vm/handlers/builtin_methods/numeric.rs rename to src/vm/methods/numeric.rs diff --git a/src/vm/handlers/builtin_methods/prelude.rs b/src/vm/methods/prelude.rs similarity index 60% rename from src/vm/handlers/builtin_methods/prelude.rs rename to src/vm/methods/prelude.rs index a3dffe32..244498ca 100644 --- a/src/vm/handlers/builtin_methods/prelude.rs +++ b/src/vm/methods/prelude.rs @@ -1,9 +1,9 @@ /* -Internal prelude for `builtin_methods`. Per-type files do `use super::prelude::*;` for VM, Val, HeapObj, helpers, receiver-unwrap primitives. +Internal prelude for `vm/methods`. Per-type files do `use super::prelude::*;` for VM, Val, HeapObj, helpers, receiver-unwrap primitives. */ -pub(super) use super::super::{VM, Val, VmErr, HeapObj, DictMap}; -pub(super) use super::super::methods_helpers::{ +pub(super) use crate::vm::{VM, Val, VmErr, HeapObj, DictMap}; +pub(super) use super::recv::{ recv_str, recv_bytes, val_to_str, extract_sequence, list_clone, list_mut, dict_entries, dict_mut, set_clone, set_mut, iter_to_vec, capitalize_first, title_case, diff --git a/src/vm/handlers/methods_helpers.rs b/src/vm/methods/recv.rs similarity index 97% rename from src/vm/handlers/methods_helpers.rs rename to src/vm/methods/recv.rs index 192ec087..cc2633b9 100644 --- a/src/vm/handlers/methods_helpers.rs +++ b/src/vm/methods/recv.rs @@ -1,10 +1,11 @@ /* -Receiver-type unwrap / arity-check primitives shared by the method dispatcher (methods.rs) +Receiver-type unwrap / arity-check primitives shared by the method dispatcher in mod.rs */ use alloc::{string::{String, ToString}, vec::Vec}; -use super::*; +use crate::vm::{VM, Val, VmErr, HeapObj, DictMap}; +use crate::vm::types::{ValSet, HeapPool, cold_type}; #[inline] pub(super) fn recv_str(vm: &VM, recv: Val) -> Result<String, VmErr> { diff --git a/src/vm/handlers/builtin_methods/set.rs b/src/vm/methods/set.rs similarity index 100% rename from src/vm/handlers/builtin_methods/set.rs rename to src/vm/methods/set.rs diff --git a/src/vm/handlers/builtin_methods/string.rs b/src/vm/methods/string.rs similarity index 99% rename from src/vm/handlers/builtin_methods/string.rs rename to src/vm/methods/string.rs index 16f08b21..72f19cab 100644 --- a/src/vm/handlers/builtin_methods/string.rs +++ b/src/vm/methods/string.rs @@ -272,7 +272,7 @@ pub fn format(vm: &mut VM, recv: Val, pos: &[Val]) -> Result<(), VmErr> { let rendered = if spec.is_empty() { vm.display(target) } else { - crate::vm::handlers::format::format_value(target, &spec, &vm.heap).map_err(crate::vm::handlers::format::fmt_err)? + crate::vm::format_spec::format_value(target, &spec, &vm.heap).map_err(crate::vm::format_spec::fmt_err)? }; out.push_str(&rendered); } else if c == '}' { diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 9f0007ac..8a68a945 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -1,9 +1,16 @@ -pub mod types; +/* The value model moved to `crate::value`; kept here so existing embedder paths keep resolving. */ +#[doc(hidden)] +pub use crate::value as types; +/* The optimizer moved to `crate::optimizer`; kept here so existing embedder paths keep resolving. */ +#[doc(hidden)] +pub use crate::optimizer; + mod cache; -mod ops; -mod builtins; -pub(crate) mod handlers; -pub mod optimizer; +mod value_ops; +mod format_spec; +pub(crate) mod globals; +pub(crate) mod opcodes; +pub(crate) mod methods; pub mod snapshot; mod dispatch; @@ -13,7 +20,7 @@ mod init; use crate::s; use crate::parser::{SSAChunk, BUILTIN_TYPES}; -use crate::util::fx::FxHashMap as HashMap; +use crate::util::hash::FxHashMap as HashMap; pub use types::{Val, HeapObj, HeapPool, VmErr, Limits}; @@ -76,7 +83,7 @@ impl Pending { } /* `bare_name -> [(version, slot), ...]` for one chunk's `chunk.names`. */ -pub(crate) type NameVersionIndex = crate::util::fx::FxHashMap<String, Vec<(i64, usize)>>; +pub(crate) type NameVersionIndex = crate::util::hash::FxHashMap<String, Vec<(i64, usize)>>; /* Free-load propagation entry: (bare name, body slot, referenced version, caller [(version, slot)] candidates). */ pub(crate) type FreeLoadEntry = (String, u32, i64, Vec<(i64, u32)>); @@ -133,7 +140,7 @@ pub struct VM<'a> { /* Free-variable body slots (bare_name, slot, referenced version); used for caller-chunk base-name fallback. */ pub(crate) body_free_loads: Vec<Vec<(String, usize, i64)>>, /* Per-chunk bare names the chunk itself binds (stores, Phi, params); drives closure-cell capture. */ - pub(crate) chunk_local_binds: HashMap<*const SSAChunk, alloc::rc::Rc<crate::util::fx::FxHashSet<String>>>, + pub(crate) chunk_local_binds: HashMap<*const SSAChunk, alloc::rc::Rc<crate::util::hash::FxHashSet<String>>>, /* Coroutines currently inside `resume_coroutine`; re-entry raises like CPython's already-executing guard. Transient, never snapshotted. */ pub(crate) executing_coros: Vec<u64>, /* True once any builtin name is rebound at module scope; fused call sites then consult `module_state` first. */ @@ -356,7 +363,7 @@ impl<'a> VM<'a> { // True iff the body references names not in params/builtins/captures. let new: Vec<bool> = (start..end).map(|fi| { let (params, body, _, _) = self.functions[fi]; - let param_names: crate::util::fx::FxHashSet<&str> = params.iter().map(|p| crate::parser::types::param_base_name(p)).collect(); + let param_names: crate::util::hash::FxHashSet<&str> = params.iter().map(|p| crate::parser::types::param_base_name(p)).collect(); body.names.iter().any(|n| { let base = crate::parser::ssa_strip(n); !param_names.contains(base) && !self.globals.contains_key(n) @@ -380,7 +387,7 @@ impl<'a> VM<'a> { let new: Vec<Vec<(String, usize, i64)>> = (start..end).map(|fi| { let (_, body, _, _) = self.functions[fi]; let param_bm = &self.is_param_slot[fi]; - let mut written: crate::util::fx::FxHashSet<usize> = crate::util::fx::FxHashSet::default(); + let mut written: crate::util::hash::FxHashSet<usize> = crate::util::hash::FxHashSet::default(); for ins in &body.instructions { if matches!(ins.opcode, crate::parser::OpCode::StoreName | crate::parser::OpCode::Phi) { written.insert(ins.operand as usize); @@ -429,7 +436,7 @@ impl<'a> VM<'a> { }).collect(); self.slot_templates.truncate(start); self.slot_templates.extend(new); - let mut seen: crate::util::fx::FxHashSet<u64> = crate::util::fx::FxHashSet::default(); + let mut seen: crate::util::hash::FxHashSet<u64> = crate::util::hash::FxHashSet::default(); self.template_roots = self.slot_templates.iter().flatten() .filter(|v| !v.is_undef() && seen.insert(v.0)) .copied() diff --git a/src/vm/handlers/arith.rs b/src/vm/opcodes/arith.rs similarity index 99% rename from src/vm/handlers/arith.rs rename to src/vm/opcodes/arith.rs index f50af34d..182ee2b7 100644 --- a/src/vm/handlers/arith.rs +++ b/src/vm/opcodes/arith.rs @@ -1,7 +1,7 @@ use super::*; use cache::OpcodeCache; -use ops::cached_binop; +use value_ops::cached_binop; /* IC: forward dunder name only; reflected ops are handled by the slow path's `NotImplemented` deopt. */ fn binary_dunder_name(op: OpCode) -> Option<&'static str> { @@ -200,7 +200,7 @@ impl<'a> VM<'a> { spec.push_str(&width); if has_prec { spec.push('.'); spec.push_str(if prec.is_empty() { "0" } else { &prec }); } if let Some(t) = ty { spec.push(t); } - let rendered = super::format::format_value(fval, &spec, &self.heap).map_err(super::format::fmt_err)?; + let rendered = crate::vm::format_spec::format_value(fval, &spec, &self.heap).map_err(crate::vm::format_spec::fmt_err)?; out.push_str(&rendered); } // Every supplied arg must be consumed, like CPython. diff --git a/src/vm/handlers/methods.rs b/src/vm/opcodes/attr_lookup.rs similarity index 98% rename from src/vm/handlers/methods.rs rename to src/vm/opcodes/attr_lookup.rs index 83a8ad2d..f9d65e20 100644 --- a/src/vm/handlers/methods.rs +++ b/src/vm/opcodes/attr_lookup.rs @@ -1,13 +1,13 @@ /* -Attribute resolution for `LoadAttr` / `CallMethod`. Built-in method bodies live in `builtin_methods/`; this file owns `AttrLookup`, the resolver, and the `__getattr__` fallback. +Attribute resolution for `LoadAttr` / `CallMethod`. Built-in method bodies live in `vm/methods/`; this file owns `AttrLookup`, the resolver, and the `__getattr__` fallback. */ use super::*; use crate::alloc::string::ToString; use crate::s; -pub use super::builtin_methods::BuiltinMethodId; -pub(crate) use super::builtin_methods::{dispatch_method, lookup_method}; +pub use crate::vm::methods::BuiltinMethodId; +use crate::vm::methods::lookup_method; // `resolve_attr` result, every shape LoadAttr / CallMethod dispatches on. pub(crate) enum AttrLookup { diff --git a/src/vm/handlers/dunder.rs b/src/vm/opcodes/dunder.rs similarity index 99% rename from src/vm/handlers/dunder.rs rename to src/vm/opcodes/dunder.rs index 4308e00b..a3039f3a 100644 --- a/src/vm/handlers/dunder.rs +++ b/src/vm/opcodes/dunder.rs @@ -345,7 +345,7 @@ impl<'a> VM<'a> { return self.require_str(r, "__format__"); } } - super::format::format_value(v, spec, &self.heap).map_err(super::format::fmt_err) + crate::vm::format_spec::format_value(v, spec, &self.heap).map_err(crate::vm::format_spec::fmt_err) } /* Coerce a `__len__` / `__length_hint__` return value to bool semantics; rejects negatives. */ diff --git a/src/vm/handlers/function.rs b/src/vm/opcodes/function.rs similarity index 98% rename from src/vm/handlers/function.rs rename to src/vm/opcodes/function.rs index 151dc9a6..2abaa898 100644 --- a/src/vm/handlers/function.rs +++ b/src/vm/opcodes/function.rs @@ -129,8 +129,8 @@ impl<'a> VM<'a> { } } - pub(crate) fn exec_bound_method(&mut self, recv: Val, id: super::methods::BuiltinMethodId, pos: &[Val], kw: &[Val]) -> Result<(), VmErr> { - super::methods::dispatch_method(self, id, recv, pos, kw) + pub(crate) fn exec_bound_method(&mut self, recv: Val, id: crate::vm::methods::BuiltinMethodId, pos: &[Val], kw: &[Val]) -> Result<(), VmErr> { + crate::vm::methods::dispatch_method(self, id, recv, pos, kw) } fn exec_make_function(&mut self, opcode: OpCode, operand: u16, chunk: &SSAChunk, slots: &[Val]) -> Result<(), VmErr> { @@ -149,13 +149,13 @@ impl<'a> VM<'a> { let defaults = if n_defaults > 0 { self.pop_n(n_defaults)? } else { vec![] }; let (params, body, _, _) = self.functions[global]; - let param_names: crate::util::fx::FxHashSet<String> = params.iter().map(|p| s!(str crate::parser::types::param_base_name(p), "_0")).collect(); + let param_names: crate::util::hash::FxHashSet<String> = params.iter().map(|p| s!(str crate::parser::types::param_base_name(p), "_0")).collect(); let mut captures: Vec<(usize, Val)> = Vec::new(); // Cells only make sense for variables of an enclosing FUNCTION scope. Module and class bodies are late-bound: their names resolve live at call time, never freeze. let defined_in_fn = self.body_to_fi.contains_key(&chunk_ptr); let parent_locals = if defined_in_fn { Some(self.chunk_locals(chunk)) } else { None }; // Capture once per canonical slot, skipping formal params. Linear scan over `chunk.names` beats a HashMap at typical body sizes (<30) and avoids a per-call monomorphisation. - let mut seen_canonical: crate::util::fx::FxHashSet<usize> = crate::util::fx::FxHashSet::default(); + let mut seen_canonical: crate::util::hash::FxHashSet<usize> = crate::util::hash::FxHashSet::default(); // Root the in-progress cells: each is reachable only via this local Vec until the Func is allocated, so a GC during the loop's own allocs could otherwise sweep them. let roots_base = self.temp_roots.len(); if let Some(locals) = parent_locals { @@ -208,10 +208,10 @@ impl<'a> VM<'a> { } /* Bare names a chunk binds itself: StoreName/Phi targets plus formal params. Cached per chunk pointer. */ - fn chunk_locals(&mut self, chunk: &SSAChunk) -> alloc::rc::Rc<crate::util::fx::FxHashSet<String>> { + fn chunk_locals(&mut self, chunk: &SSAChunk) -> alloc::rc::Rc<crate::util::hash::FxHashSet<String>> { let key = chunk as *const SSAChunk; if let Some(s) = self.chunk_local_binds.get(&key) { return s.clone(); } - let mut set: crate::util::fx::FxHashSet<String> = crate::util::fx::FxHashSet::default(); + let mut set: crate::util::hash::FxHashSet<String> = crate::util::hash::FxHashSet::default(); for ins in &chunk.instructions { if matches!(ins.opcode, OpCode::StoreName | OpCode::Phi) && let Some(n) = chunk.names.get(ins.operand as usize) @@ -658,8 +658,8 @@ impl<'a> VM<'a> { /* Push caller slots into body slots. Same scope: late-binding, overwrite freely. Different scope: skip capture-filled slots (fixes stacked-decorator clobber). */ fn apply_caller_slot_propagation(&mut self, fi: usize, captures: &[(usize, Val)], chunk: &SSAChunk, slots: &[Val], fn_slots: &mut [Val]) { let info = self.propagation_map(fi, chunk); - let captured_set: crate::util::fx::FxHashSet<usize> = if info.same_scope { - crate::util::fx::FxHashSet::default() + let captured_set: crate::util::hash::FxHashSet<usize> = if info.same_scope { + crate::util::hash::FxHashSet::default() } else { captures.iter().map(|(s, _)| *s).collect() }; diff --git a/src/vm/opcodes/mod.rs b/src/vm/opcodes/mod.rs new file mode 100644 index 00000000..c612b7b8 --- /dev/null +++ b/src/vm/opcodes/mod.rs @@ -0,0 +1,15 @@ +pub(crate) mod arith; +pub(crate) mod attr_lookup; +pub(crate) mod dunder; +pub(crate) mod function; +pub(crate) mod stack; +pub(crate) mod subscript; + +pub(super) use crate::vm::{ + VM, Val, VmErr, HeapObj, DictMap, cache, value_ops, + types::{BodyRef, ExceptionFrame, IterFrame, SyncFrame, cold_depth, cold_type, cold_value, cold_runtime, cold_overflow, eq_vals_with_heap, ffloor} +}; + +pub(super) use crate::parser::{OpCode, SSAChunk, ssa_strip}; +pub(super) use alloc::{rc::Rc, string::String, vec, vec::Vec}; +pub(super) use core::cell::RefCell; diff --git a/src/vm/handlers/data.rs b/src/vm/opcodes/stack.rs similarity index 99% rename from src/vm/handlers/data.rs rename to src/vm/opcodes/stack.rs index 6c155eee..5926d3a4 100644 --- a/src/vm/handlers/data.rs +++ b/src/vm/opcodes/stack.rs @@ -61,7 +61,7 @@ impl<'a> VM<'a> { 1 => { let s = self.repr_op(v, chunk, slots)?; self.charge_steps(s.len())?; self.heap.alloc(HeapObj::Str(s))? } 2 => { let s = self.display_op(v, chunk, slots)?; self.charge_steps(s.len())?; self.heap.alloc(HeapObj::Str(s))? } 3 => { - let raw = super::format::display_inline(v, &self.heap); + let raw = crate::vm::format_spec::display_inline(v, &self.heap); self.charge_steps(raw.len())?; self.heap.alloc(HeapObj::Str(raw.escape_default().collect::<String>()))? } diff --git a/src/vm/builtins/index.rs b/src/vm/opcodes/subscript.rs similarity index 100% rename from src/vm/builtins/index.rs rename to src/vm/opcodes/subscript.rs diff --git a/src/vm/snapshot.rs b/src/vm/snapshot.rs index f5c1079b..1fd281ff 100644 --- a/src/vm/snapshot.rs +++ b/src/vm/snapshot.rs @@ -5,7 +5,7 @@ use core::cell::RefCell; use core::hash::Hasher; use crate::parser::types::{ImportKind, SSAChunk}; -use crate::util::fx::{FxHashMap, FxHasher}; +use crate::util::hash::{FxHashMap, FxHasher}; use super::{Pending, VM}; use super::types::*; diff --git a/src/vm/ops.rs b/src/vm/value_ops.rs similarity index 100% rename from src/vm/ops.rs rename to src/vm/value_ops.rs diff --git a/src/wasm/errors.rs b/src/wasm/errors.rs deleted file mode 100644 index cd0ea5d6..00000000 --- a/src/wasm/errors.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crate::abi::ErrorKind; -use crate::vm::types::VmErr; -use crate::s; -use alloc::string::String; - -use super::with_runtime; - -/* VmErr classifier for the ABI boundary. */ -pub(super) fn err_to_kind(e: &VmErr) -> ErrorKind { - match e { - VmErr::Type(_) | VmErr::TypeMsg(_) => ErrorKind::Type, - VmErr::Value(_) => ErrorKind::Value, - VmErr::Runtime(_) => ErrorKind::Runtime, - VmErr::Attribute(_) | VmErr::Name(_) => ErrorKind::Attribute, - VmErr::Raised(s) => { - if s.starts_with("ValueError") { ErrorKind::Value } - else if s.starts_with("IndexError") { ErrorKind::Index } - else if s.starts_with("KeyError") { ErrorKind::Key } - else { ErrorKind::Runtime } - } - _ => ErrorKind::Runtime, - } -} - -pub(super) fn stash_error(e: VmErr) { - let kind = err_to_kind(&e); - let msg = e.render(); - with_runtime(|rt| rt.error_stash.set_typed(kind, msg)); -} - -/* Inverse of `err_to_kind`: rebuilds a `VmErr` from (kind, msg). Exhaustive over `ErrorKind` so new variants can't slip into `Raised`. */ -pub(super) fn error_from_kind(kind: u32, msg: String) -> VmErr { - match ErrorKind::from_u32(kind) { - Some(ErrorKind::Type) => VmErr::TypeMsg(msg), - Some(ErrorKind::Value) => VmErr::Raised(s!("ValueError: ", str &msg)), - Some(ErrorKind::Runtime) => VmErr::Raised(s!("RuntimeError: ", str &msg)), - Some(ErrorKind::Attribute) => VmErr::Attribute(msg), - Some(ErrorKind::Index) => VmErr::Raised(s!("IndexError: ", str &msg)), - Some(ErrorKind::Key) => VmErr::Raised(s!("KeyError: ", str &msg)), - // Custom kinds carry the user-defined class name in `msg` (`<ClassName>: <text>`); pass through unchanged. - Some(ErrorKind::Custom) | None => VmErr::Raised(msg), - } -} diff --git a/src/wasm/exports.rs b/src/wasm/exports.rs index b3123162..d0a53ade 100644 --- a/src/wasm/exports.rs +++ b/src/wasm/exports.rs @@ -6,8 +6,9 @@ use alloc::{boxed::Box, string::{String, ToString}}; use core::ptr::NonNull; use crate::s; -use super::{ModuleEntry, PausedRun, SZ, VmGuard, now_ns_host, safe_bytes, safe_str_owned, stream_print, with_runtime, write_out}; +use super::{ModuleEntry, PausedRun, SZ, now_ns_host, stream_print, with_runtime, write_out}; use super::resolver::WasmHostResolver; +use crate::bridge::{self, VmGuard, safe_bytes, safe_str_owned}; /* Packed `u32` from `run_start` / `run_resume`: top 3 bits = kind, low 29 = out-buffer length. */ const STATUS_KIND_SHIFT: u32 = 29; @@ -124,15 +125,13 @@ pub unsafe extern "C" fn reset_modules() { with_runtime(|rt| { rt.registry.clear(); rt.manifests.clear(); - rt.handles.clear(); - rt.error_stash.clear(); // Paused run references the now-stale module table; drop it for a clean reset. rt.paused_run = None; rt.repl_vm = None; rt.repl_mode = false; - // current_vm may have pointed into the dropped paused VM; clear it so a stray host_edge_* sees None instead of dangling. - rt.current_vm = None; }); + // The bridge current_vm may have pointed into the dropped paused VM, reset clears it too. + bridge::reset(); } /* Copies up to SZ bytes from the host SRC buffer into an owned `String` so the caller can drop the runtime borrow before parsing. */ @@ -206,10 +205,8 @@ fn step_vm(mut vm: VM<'static>, src: &str, prev_paused: Option<Box<PausedRun>>) }; // Re-publish `current_vm` to the boxed VM so embedder calls like `host_edge_encode` (run between this yield and the next `run_resume`) can still allocate into the heap. The Box's address is stable across the move into rt. let vm_ptr = paused.vm.as_mut().map(|v| NonNull::from(v).cast::<VM<'static>>()); - with_runtime(|rt| { - rt.paused_run = Some(paused); - rt.current_vm = vm_ptr; - }); + with_runtime(|rt| rt.paused_run = Some(paused)); + bridge::set_current_vm(vm_ptr); kind } Err(e) => { @@ -233,13 +230,12 @@ fn step_vm(mut vm: VM<'static>, src: &str, prev_paused: Option<Box<PausedRun>>) /* Terminal-state VM: the REPL keeps it for the next input, one-shot runs drop it. */ fn park_repl_or_drop(mut vm: VM<'static>) { - with_runtime(|rt| { - if rt.repl_mode { - vm.clear_error_state(); - rt.current_vm = None; - rt.repl_vm = Some(Box::new(vm)); - } - }); + let repl_mode = with_runtime(|rt| rt.repl_mode); + if repl_mode { + vm.clear_error_state(); + bridge::set_current_vm(None); + with_runtime(|rt| rt.repl_vm = Some(Box::new(vm))); + } } /* REPL entry: first call boots the interpreter, later calls adopt each input as a new entry chunk on the SAME interpreter, so history never re-executes. */ @@ -332,8 +328,8 @@ pub unsafe extern "C" fn run_push_event(ptr: *const u8, len: u32) -> i32 { /* `set_host_*` prologue: take `handle`'s Val (1 = stale) and run `f` on the paused VM (3 = no paused run). */ fn with_paused_vm(handle: u32, f: impl FnOnce(&mut VM<'static>, crate::vm::types::Val) -> i32) -> i32 { - let Some(val) = super::get_val(handle) else { return 1; }; - super::with_runtime(|rt| { rt.handles.release(handle); }); + let Some(val) = bridge::get_val(handle) else { return 1; }; + bridge::release_handles(&[handle]); super::with_runtime(|rt| { let Some(paused) = rt.paused_run.as_mut() else { return 3; }; let Some(vm) = paused.vm.as_mut() else { return 3; }; @@ -361,7 +357,7 @@ pub unsafe extern "C" fn set_host_error_by_id(id: u32, kind: u32, msg_handle: u3 HeapObj::Str(s) => s.clone(), _ => String::new(), }; - let e = super::errors::error_from_kind(kind, msg); + let e = bridge::error_from_kind(kind, msg); if vm.inject_host_error_by_id(id as u64, e) { 0 } else { 2 } }) } @@ -405,10 +401,8 @@ pub unsafe extern "C" fn snapshot_ptr() -> *const u8 { /* Boot from the blob's embedded source, overlay its saved state. */ #[unsafe(no_mangle)] pub unsafe extern "C" fn restore_state(len: usize) -> u32 { - with_runtime(|rt| { - rt.paused_run = None; - rt.current_vm = None; - }); + with_runtime(|rt| rt.paused_run = None); + bridge::set_current_vm(None); let blob: alloc::vec::Vec<u8> = with_runtime(|rt| rt.src[..len.min(SZ)].to_vec()); let source = match snapshot::source_of(&blob) { Ok(s) => s.to_string(), diff --git a/src/wasm/mod.rs b/src/wasm/mod.rs index d32242f1..f888563b 100644 --- a/src/wasm/mod.rs +++ b/src/wasm/mod.rs @@ -3,15 +3,10 @@ WASM bridge: wires parser/VM to host via the handle ABI. Wire contract lives in `crate::abi`; extend there, never here. */ -use crate::abi::{ErrorStash, HandleTable}; use crate::vm::VM; -use crate::vm::types::{Val, VmErr}; use crate::packages::Manifest; -use alloc::{boxed::Box, string::{String, ToString}, vec::Vec}; -use core::ptr::NonNull; +use alloc::{boxed::Box, string::String, vec::Vec}; -mod abi_bridge; -mod errors; mod exports; mod resolver; @@ -46,9 +41,7 @@ static A: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc; #[panic_handler] fn panic(info: &core::panic::PanicInfo) -> ! { let msg = alloc::format!("internal panic: {}", info.message()); - with_runtime(|rt| { - rt.error_stash.set(crate::abi::ErrorKind::Runtime as u32, msg); - }); + crate::bridge::stash_raw_error(crate::abi::ErrorKind::Runtime as u32, msg); core::arch::wasm32::unreachable() } @@ -67,7 +60,7 @@ pub(super) struct PausedRun { pub last_yield_deadline_ns: u64, } -/* All mutable WASM-bridge state in one struct so every accessor funnels through `with_runtime`, the sole `unsafe` point, instead of N independent statics. */ +/* Mutable WASM-host state behind `with_runtime`, handles, stash and live-VM pointer live in `crate::bridge`. */ pub(super) struct WasmRuntime { pub src: [u8; SZ], pub out: [u8; SZ], @@ -77,13 +70,9 @@ pub(super) struct WasmRuntime { pub manifests: Vec<(String, Manifest)>, /* Entry dir rooting the source's quoted imports. */ pub entry_dir: String, - pub handles: HandleTable, - pub error_stash: ErrorStash, /* Last `save_state` blob, read via `snapshot_ptr`. */ pub snapshot: Vec<u8>, - /* Set/cleared exclusively by `VmGuard`. The `'static` is storage-only, the pointer is dereferenced only inside the `run()` scope that built it. */ - pub current_vm: Option<NonNull<VM<'static>>>, - /* Owned across `run_start` / `run_resume`; mutually exclusive with `current_vm`. */ + /* Owned across `run_start` / `run_resume`; mutually exclusive with the bridge's `current_vm`. */ pub paused_run: Option<Box<PausedRun>>, /* REPL: the interpreter kept alive between `repl_eval` inputs. */ pub repl_vm: Option<Box<VM<'static>>>, @@ -102,10 +91,7 @@ impl WasmRuntime { registry: Vec::new(), manifests: Vec::new(), entry_dir: String::new(), - handles: HandleTable::new(), - error_stash: ErrorStash::new(), snapshot: Vec::new(), - current_vm: None, paused_run: None, repl_vm: None, repl_mode: false, @@ -121,68 +107,9 @@ pub(super) fn with_runtime<R>(f: impl FnOnce(&mut WasmRuntime) -> R) -> R { unsafe { f(&mut *core::ptr::addr_of_mut!(RUNTIME)) } } -pub(super) fn put_val(v: Val) -> u32 { with_runtime(|rt| rt.handles.put(v.0)) } -pub(super) fn get_val(h: u32) -> Option<Val> { with_runtime(|rt| rt.handles.get(h).map(Val)) } - -/* RAII publisher for the live VM pointer. Holding the guard across `run()` ensures a panic or early return cannot leave a stale pointer for later `host_edge_op` calls. */ -pub(super) struct VmGuard; - -impl VmGuard { - pub(super) fn new(vm: &mut VM<'_>) -> Self { - // 'static is storage-only, deref only inside the `run()` frame holding the guard. - let ptr: NonNull<VM<'static>> = NonNull::from(vm).cast(); - with_runtime(|rt| rt.current_vm = Some(ptr)); - Self - } -} - -impl Drop for VmGuard { - fn drop(&mut self) { - with_runtime(|rt| rt.current_vm = None); - } -} - -pub(super) fn with_vm<R>(f: impl FnOnce(&mut VM<'static>) -> R) -> Option<R> { - // Drop the runtime borrow before `f`, VM dispatch re-enters `with_runtime`. - let ptr = with_runtime(|rt| rt.current_vm)?; - Some(f(unsafe { &mut *ptr.as_ptr() })) -} - -/* Builds a `&[u8]` from an FFI `(ptr, len)`, empty on null or zero length, `from_raw_parts` would UB on either. */ -pub(super) unsafe fn safe_bytes<'a>(ptr: *const u8, len: u32) -> &'a [u8] { - if ptr.is_null() || len == 0 { return &[]; } - unsafe { core::slice::from_raw_parts(ptr, len as usize) } -} - -/* Same for `&[u32]` argv arrays. */ -pub(super) unsafe fn safe_handles<'a>(ptr: *const u32, len: u32) -> &'a [u32] { - if ptr.is_null() || len == 0 { return &[]; } - unsafe { core::slice::from_raw_parts(ptr, len as usize) } -} - -/* Owned UTF-8 string from an FFI `(ptr, len)`; empty on null or invalid UTF-8. */ -pub(super) unsafe fn safe_str_owned(ptr: *const u8, len: u32) -> String { - core::str::from_utf8(unsafe { safe_bytes(ptr, len) }).unwrap_or("").to_string() -} - -// Release a batch of handles in one runtime borrow. -pub(super) fn release_handles(handles: &[u32]) { - with_runtime(|rt| for &h in handles { rt.handles.release(h); }); -} - -/* `with_vm` that errors when called outside run(). */ -pub(super) fn in_vm(err: &'static str, f: impl FnOnce(&mut VM<'static>) -> Result<Val, VmErr>) -> Result<Val, VmErr> { - with_vm(f).ok_or(VmErr::Runtime(err))? -} - pub(super) unsafe fn write_out(s: &str) -> usize { let b = s.as_bytes(); let n = b.len().min(SZ); with_runtime(|rt| rt.out[..n].copy_from_slice(&b[..n])); n } - -/* `dispatch_*` prologue: resolve `recv_h` and run `f` against the live VM. Fails on stale handle or call outside `run()`. */ -pub(super) fn with_recv<F>(invalid_recv_msg: &'static str, recv_h: u32, f: F) -> Result<Val, VmErr> -where F: FnOnce(&mut VM<'static>, Val) -> Result<Val, VmErr> -{ let recv = get_val(recv_h).ok_or(VmErr::Runtime(invalid_recv_msg))?; with_vm(|vm| f(vm, recv)).ok_or(VmErr::Runtime("edge_op called outside run()"))? } diff --git a/src/wasm/resolver.rs b/src/wasm/resolver.rs index 4345aa13..94d0c1ce 100644 --- a/src/wasm/resolver.rs +++ b/src/wasm/resolver.rs @@ -1,11 +1,14 @@ use crate::packages::{NativeBinding, Resolved, Resolver, partition_bindings, parse_manifest, walk_up_dirs, dir_of, join_relative}; -use crate::util::fx::FxHashSet; +use crate::util::hash::FxHashSet; use alloc::{boxed::Box, string::{String, ToString}, vec::Vec}; use crate::s; use super::{ModuleEntry, host_fetch_bytes, with_runtime}; -use super::abi_bridge::make_native_binding; use super::exports::wasm_free; +use crate::abi::ErrorKind; +use crate::bridge::{error_from_kind, get_val, put_val, release_handles, take_error, with_vm}; +use crate::vm::types::{Val, VmErr}; +use alloc::sync::Arc; // Cap on packages.json `extends` chain, bounds attacker-crafted loops; 32 dwarfs real workspace depth. const MAX_PACKAGES_HOPS: u32 = 32; @@ -136,3 +139,41 @@ impl WasmHostResolver { } } +/* Builds a NativeBinding that marshals handles around `host_call_native`. Lives here so the bridge stays host-import-free. */ +fn make_native_binding(name: String, id: u32) -> NativeBinding { + let closure = move |_: &mut crate::vm::types::HeapPool, args: &[Val], kwargs: Option<Val>| -> Result<Val, VmErr> { + /* 1. Register positional args as handles the guest will see; append the kwargs handle (0 means no kwargs). */ + let mut argv: Vec<u32> = args.iter().map(|v| put_val(*v)).collect(); + argv.push(kwargs.map_or(0, put_val)); + let mut out_handle: u32 = 0; + + // call_id is what call_extern will park with on defer; lets the host route the result back. + let call_id = with_vm(|vm| vm.next_host_call_id as u32).unwrap_or(0); + let status = unsafe { + super::host_call_native( + id, call_id, + argv.as_ptr(), argv.len() as u32, + &mut out_handle as *mut u32, + ) + }; + + /* 3. Read result BEFORE releasing argv: a returned input would point into slots we're about to free. */ + // Status 2 = DEFERRED: handler has captured what it needs; release argv and park the VM. + if status == 2 { + release_handles(&argv); + return Err(VmErr::HostCallDeferred); + } + if status != 0 { + release_handles(&argv); + let (kind, msg) = take_error() + .unwrap_or((ErrorKind::Runtime as u32, String::from("native call failed"))); + return Err(error_from_kind(kind, msg)); + } + let result = get_val(out_handle).ok_or(VmErr::Runtime("native returned invalid handle"))?; + argv.push(out_handle); + release_handles(&argv); + Ok(result) + }; + NativeBinding { name, func: Arc::new(closure), pure: false } +} + diff --git a/std/.gitignore b/std/.gitignore deleted file mode 100644 index 30257796..00000000 --- a/std/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -# Rust -**/target/ -Cargo.lock - -# Editor / IDE -.vscode/ -.idea/ -*.swp -*~ - -# OS -.DS_Store -Thumbs.db -desktop.ini diff --git a/std/README.md b/std/README.md index 449326ce..811294c0 100644 --- a/std/README.md +++ b/std/README.md @@ -23,7 +23,7 @@ Native packages build independently; the agnostic runner asserts against the pro ( cd json && cargo build --release --target wasm32-unknown-unknown ) # One command, drives all corpora through the shared runner. -deno test --allow-all tests/ +deno test --allow-all harness/ ``` The runner discovers packages by walking `std/` for `<name>/<name>.json` corpora. CI runs this matrix via `.github/workflows/`. @@ -35,11 +35,11 @@ For a native (wasm) package: 1. Create `<name>/` under `std/` with `Cargo.toml` (`name = "<name>"`, or a non-keyword variant like `edge-struct` when `<name>` is a Rust keyword, `crate-type = ["cdylib"]`, `wasm-pdk` dep) and a `src/lib.rs` exporting via `#[plugin_fn]`. 2. Drop `<name>/<name>.json` with the corpus (Edge Python source + expected `output` / `error` per case). 3. Run `cargo build --release --target wasm32-unknown-unknown` inside the package folder. -4. Run `deno test --allow-all tests/` from the repo root. +4. Run `deno test --allow-all harness/` from `std/`. -For a pure-Python package, skip the crate: add `<name>/src/entry.py` plus `<name>/<name>.json`, then run `deno test --allow-all tests/`. The runner routes `.py` packages to their source and skips the wasm build. +For a pure-Python package, skip the crate: add `<name>/src/entry.py` plus `<name>/<name>.json`, then run `deno test --allow-all harness/`. The runner routes `.py` packages to their source and skips the wasm build. -No edits to `tests/`. +No edits to `harness/`. ## License diff --git a/std/tests/README.md b/std/harness/README.md similarity index 82% rename from std/tests/README.md rename to std/harness/README.md index b9d1f944..f80657d1 100644 --- a/std/tests/README.md +++ b/std/harness/README.md @@ -1,6 +1,6 @@ # Tests -Shared test harness for every stdpkg, modeled on [`edge-python/js/tests`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/js/tests). `index.html` is pure markup: it loads the runtime's `<edge-python>` tag and nothing else. `std.test.js` boots one tag per package and drives it through the tag's programmatic API (`run`, `onOutput`). +Shared test harness for every stdpkg, modeled on [`edge-python/runtime/tests`](https://github.com/dylan-sutton-chavez/edge-python/tree/main/runtime/tests). `index.html` is pure markup: it loads the runtime's `<edge-python>` tag and nothing else. `std.test.js` boots one tag per package and drives it through the tag's programmatic API (`run`, `onOutput`). ## Layout @@ -17,7 +17,7 @@ The driver serves the repo over `http://localhost` (a secure context, so the run ```bash ( cd json && cargo build --release --target wasm32-unknown-unknown ) # native packages only -deno test --allow-all tests/ +deno test --allow-all harness/ ``` `STDPKG=<name>` narrows discovery to a single package; CI uses it to fan out the matrix. diff --git a/std/tests/index.html b/std/harness/index.html similarity index 100% rename from std/tests/index.html rename to std/harness/index.html diff --git a/std/tests/std.test.js b/std/harness/std.test.js similarity index 99% rename from std/tests/std.test.js rename to std/harness/std.test.js index 09d98e64..242f5c93 100644 --- a/std/tests/std.test.js +++ b/std/harness/std.test.js @@ -2,7 +2,7 @@ import { chromium } from "npm:playwright@latest"; import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; -import { DEFAULT_IMPORTS } from "../../js/src/defaults.js"; +import { DEFAULT_IMPORTS } from "../../runtime/src/defaults.js"; const ROOT = new URL("../", import.meta.url).pathname; const RUNTIME = new URL("../../js/", import.meta.url).pathname; diff --git a/std/re/src/engine.rs b/std/re/src/engine.rs index ac9f4456..f4e04228 100644 --- a/std/re/src/engine.rs +++ b/std/re/src/engine.rs @@ -41,12 +41,7 @@ impl Regex { pub fn group_count(&self) -> usize { self.prog.group_count } } -/* Single match in the requested mode. */ -pub fn find(pattern: &str, text: &str, mode: Mode) -> Result<Option<Found>, ReError> { - find_rx(&Regex::compile(pattern)?, text, mode) -} - -/* `find` over an already-compiled regex. */ +/* Single match in the requested mode, over an already-compiled regex. */ pub fn find_rx(re: &Regex, text: &str, mode: Mode) -> Result<Option<Found>, ReError> { let chars: Vec<char> = text.chars().collect(); let m = Matcher::new(&chars, re.prog.flags); @@ -62,12 +57,7 @@ pub fn find_rx(re: &Regex, text: &str, mode: Mode) -> Result<Option<Found>, ReEr } } -/* All non overlapping matches, plus the group count for output shaping. */ -pub fn find_all(pattern: &str, text: &str) -> Result<(Vec<Found>, usize), ReError> { - find_all_rx(&Regex::compile(pattern)?, text) -} - -/* `find_all` over an already-compiled regex. */ +/* All non overlapping matches over a compiled regex, plus the group count for output shaping. */ pub fn find_all_rx(re: &Regex, text: &str) -> Result<(Vec<Found>, usize), ReError> { let chars: Vec<char> = text.chars().collect(); let m = Matcher::new(&chars, re.prog.flags); @@ -89,12 +79,7 @@ pub fn find_all_rx(re: &Regex, text: &str) -> Result<(Vec<Found>, usize), ReErro Ok((out, re.prog.group_count)) } -/* Replace every match, expanding backreferences in the template. */ -pub fn sub(pattern: &str, repl: &str, text: &str) -> Result<String, ReError> { - sub_rx(&Regex::compile(pattern)?, repl, text) -} - -/* `sub` over an already-compiled regex. */ +/* Replace every match over a compiled regex, expanding backreferences in the template. */ pub fn sub_rx(re: &Regex, repl: &str, text: &str) -> Result<String, ReError> { let chars: Vec<char> = text.chars().collect(); let repl_chars: Vec<char> = repl.chars().collect(); diff --git a/std/re/src/lib.rs b/std/re/src/lib.rs index d362b99c..d27f7d8d 100644 --- a/std/re/src/lib.rs +++ b/std/re/src/lib.rs @@ -14,8 +14,7 @@ pub mod engine; pub mod matcher; pub mod parser; -/* Exports compile only for wasm32 so the engine stays native testable. */ -#[cfg(target_arch = "wasm32")] +/* Exports build for wasm and native alike so the CLI can dlopen this package. */ // Class exports follow the `__class_<Name>_<method>` ABI convention, not snake case. #[allow(non_snake_case)] mod wasm_api { diff --git a/tests/cases/parser.json b/tests/cases/parser.json index 31269e8a..fef6636f 100644 --- a/tests/cases/parser.json +++ b/tests/cases/parser.json @@ -3,518 +3,445 @@ "src": "x = 42", "constants": ["42"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 1\nx = 2", "constants": ["1", "2"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 10\ny = x", "constants": ["10"], "names": ["x_1", "y_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "y = x", "constants": [], "names": ["x_0", "y_1"], - "instructions": [["LoadName",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "a = 1\nb = 2\na = b\nb = a", "constants": ["1", "2"], "names": ["a_1", "b_1", "a_2", "b_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["StoreName",1], ["LoadName",1], ["StoreName",0], ["LoadName",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["StoreName",1], ["LoadName",1], ["StoreName",0], ["LoadName",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "x: int = 5", "constants": ["5"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {"x": "int"} + "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x: int", "constants": [], "names": [], - "instructions": [["ReturnValue",0]], - "annotations": {"x": "int"} + "instructions": [["ReturnValue",0]] }, { "src": "counter: int = 0\nname: str = 'hello'\nratio: float = 3.14", "constants": ["0", "hello", "3.14"], "names": ["counter_1", "name_1", "ratio_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["StoreName",1], ["LoadConst",2], ["StoreName",2], ["ReturnValue",0]], - "annotations": {"counter": "int", "name": "str", "ratio": "float"} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["StoreName",1], ["LoadConst",2], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = 'hello'", "constants": ["hello"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "a = True\nb = False", "constants": [], "names": ["a_1", "b_1"], - "instructions": [["LoadTrue",0], ["StoreName",0], ["LoadFalse",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadTrue",0], ["StoreName",0], ["LoadFalse",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "x = None", "constants": [], "names": ["x_1"], - "instructions": [["LoadNone",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadNone",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = -5", "constants": ["5"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["Minus",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["Minus",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 1 + 2", "constants": ["1", "2"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["Add",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["Add",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 5 - 3", "constants": ["5", "3"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["Sub",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["Sub",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 2 * 3", "constants": ["2", "3"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["Mul",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["Mul",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 6 / 2", "constants": ["6", "2"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["Div",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["Div",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = a > b", "constants": [], "names": ["a_0", "b_0", "x_1"], - "instructions": [["LoadName",0], ["LoadName",1], ["Gt",0], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["Gt",0], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = a < b", "constants": [], "names": ["a_0", "b_0", "x_1"], - "instructions": [["LoadName",0], ["LoadName",1], ["Lt",0], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["Lt",0], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = a == b", "constants": [], "names": ["a_0", "b_0", "x_1"], - "instructions": [["LoadName",0], ["LoadName",1], ["Eq",0], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["Eq",0], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = 1\nx += 2", "constants": ["1", "2"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["InPlaceAdd",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["InPlaceAdd",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 10\nx -= 3", "constants": ["10", "3"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Sub",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Sub",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 2\nx *= 5", "constants": ["2", "5"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Mul",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Mul",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 10\nx /= 2", "constants": ["10", "2"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Div",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Div",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "print(42)", "constants": ["42"], "names": [], - "instructions": [["LoadConst",0], ["CallPrint",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["CallPrint",1], ["ReturnValue",0]] }, { "src": "print(1, 2)", "constants": ["1", "2"], "names": [], - "instructions": [["LoadConst",0], ["LoadConst",1], ["CallPrint",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["CallPrint",2], ["ReturnValue",0]] }, { "src": "abs(-5)", "constants": ["5"], "names": [], - "instructions": [["LoadConst",0], ["Minus",0], ["CallAbs",1], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["Minus",0], ["CallAbs",1], ["PopTop",0], ["ReturnValue",0]] }, { "src": "value: int = abs(-42)", "constants": ["42"], "names": ["value_1"], - "instructions": [["LoadConst",0], ["Minus",0], ["CallAbs",1], ["StoreName",0], ["ReturnValue",0]], - "annotations": {"value": "int"} + "instructions": [["LoadConst",0], ["Minus",0], ["CallAbs",1], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = len(y)", "constants": [], "names": ["y_0", "x_1"], - "instructions": [["LoadName",0], ["CallLen",1], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["CallLen",1], ["StoreName",1], ["ReturnValue",0]] }, { "src": "string_euler: str = str(-2.718281)", "constants": ["2.718281"], "names": ["string_euler_1"], - "instructions": [["LoadConst",0], ["Minus",0], ["CallStr",1], ["StoreName",0], ["ReturnValue",0]], - "annotations": {"string_euler": "str"} + "instructions": [["LoadConst",0], ["Minus",0], ["CallStr",1], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = int('42')", "constants": ["42"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["CallInt",1], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["CallInt",1], ["StoreName",0], ["ReturnValue",0]] }, { "src": "range(4)", "constants": ["4"], "names": [], - "instructions": [["LoadConst",0], ["CallRange",1], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["CallRange",1], ["PopTop",0], ["ReturnValue",0]] }, { "src": "dict()", "constants": [], "names": [], - "instructions": [["CallDict",0], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["CallDict",0], ["PopTop",0], ["ReturnValue",0]] }, { "src": "dict(a=1)", "constants": ["a", "1"], "names": [], - "instructions": [["LoadConst",0], ["LoadConst",1], ["CallDict",256], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["CallDict",256], ["PopTop",0], ["ReturnValue",0]] }, { "src": "x = {'a': 1}", "constants": ["a", "1"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["BuildDict",1], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["BuildDict",1], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = [1, 2, 3]", "constants": ["1", "2", "3"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildList",3], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildList",3], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = d[0]", "constants": ["0"], "names": ["d_0", "x_1"], - "instructions": [["LoadName",0], ["LoadConst",0], ["GetItem",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadConst",0], ["GetItem",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "range(5)", "constants": ["5"], "names": [], - "instructions": [["LoadConst",0], ["CallRange",1], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["CallRange",1], ["PopTop",0], ["ReturnValue",0]] }, { "src": "name: str = 'Dylan'\nprint(f'Hey, I am: {name}.')", "constants": ["Dylan", "Hey, I am: ", "."], "names": ["name_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["LoadName",0], ["FormatValue",0], ["LoadConst",2], ["BuildString",3], ["CallPrint",1], ["ReturnValue",0]], - "annotations": {"name": "str"} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["LoadName",0], ["FormatValue",0], ["LoadConst",2], ["BuildString",3], ["CallPrint",1], ["ReturnValue",0]] }, { "src": "print(f\"{1 + 2}\")", "constants": ["1", "2"], "names": [], - "instructions": [["LoadConst",0], ["LoadConst",1], ["Add",0], ["FormatValue",0], ["BuildString",1], ["CallPrint",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["Add",0], ["FormatValue",0], ["BuildString",1], ["CallPrint",1], ["ReturnValue",0]] }, { "src": "x = f\"a {b} c {d} e\"", "constants": ["a ", " c ", " e"], "names": ["b_0", "d_0", "x_1"], - "instructions": [["LoadConst",0], ["LoadName",0], ["FormatValue",0], ["LoadConst",1], ["LoadName",1], ["FormatValue",0], ["LoadConst",2], ["BuildString",5], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadName",0], ["FormatValue",0], ["LoadConst",1], ["LoadName",1], ["FormatValue",0], ["LoadConst",2], ["BuildString",5], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = f\"{len(s)}\"", "constants": [], "names": ["s_0", "x_1"], - "instructions": [["LoadName",0], ["CallLen",1], ["FormatValue",0], ["BuildString",1], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["CallLen",1], ["FormatValue",0], ["BuildString",1], ["StoreName",1], ["ReturnValue",0]] }, { "src": "x = f\"result: {a + b * c}\"", "constants": ["result: "], "names": ["a_0", "b_0", "c_0", "x_1"], - "instructions": [["LoadConst",0], ["LoadName",0], ["LoadName",1], ["LoadName",2], ["Mul",0], ["Add",0], ["FormatValue",0], ["BuildString",2], ["StoreName",3], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadName",0], ["LoadName",1], ["LoadName",2], ["Mul",0], ["Add",0], ["FormatValue",0], ["BuildString",2], ["StoreName",3], ["ReturnValue",0]] }, { "src": "def add(a, b): return a + b\nresult = add(1, 2)", "constants": ["1", "2"], "names": ["add_1", "result_1"], "instructions": [["MakeFunction",0], ["StoreName",0], ["LoadName",0], ["BeginArgs",0], ["LoadConst",0], ["LoadConst",1], ["Call",2], ["StoreName",1], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { "src": "if a:\nx = 1\nelse:\nx = 2", "constants": ["1", "2"], "names": ["a_0", "x_1", "x_2", "x_3"], - "instructions": [["LoadName",0], ["JumpIfFalse",5], ["LoadConst",0], ["StoreName",1], ["Jump",7], ["LoadConst",1], ["StoreName",1], ["Phi",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["JumpIfFalse",5], ["LoadConst",0], ["StoreName",1], ["Jump",7], ["LoadConst",1], ["StoreName",1], ["Phi",1], ["ReturnValue",0]] }, { "src": "if a:\nx = 1", "constants": ["1"], "names": ["a_0", "x_1", "x_0", "x_2"], - "instructions": [["LoadName",0], ["JumpIfFalse",4], ["LoadConst",0], ["StoreName",2], ["Phi",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["JumpIfFalse",4], ["LoadConst",0], ["StoreName",2], ["Phi",2], ["ReturnValue",0]] }, { "src": "if a:\nx = 1\nelif b:\nx = 2\nelse:\nx = 3", "constants": ["1", "2", "3"], "names": ["a_0", "x_1", "b_0", "x_2", "x_3", "x_4"], - "instructions": [["LoadName",0], ["JumpIfFalse",5], ["LoadConst",0], ["StoreName",1], ["Jump",12], ["LoadName",2], ["JumpIfFalse",10], ["LoadConst",1], ["StoreName",1], ["Jump",12], ["LoadConst",2], ["StoreName",1], ["Phi",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["JumpIfFalse",5], ["LoadConst",0], ["StoreName",1], ["Jump",12], ["LoadName",2], ["JumpIfFalse",10], ["LoadConst",1], ["StoreName",1], ["Jump",12], ["LoadConst",2], ["StoreName",1], ["Phi",1], ["ReturnValue",0]] }, { "src": "x = 0\nif a:\nx = 1\nelse:\nx = 2\ny = x", "constants": ["0", "1", "2"], "names": ["x_1", "a_0", "x_2", "x_3", "x_4", "y_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",1], ["JumpIfFalse",7], ["LoadConst",1], ["StoreName",0], ["Jump",9], ["LoadConst",2], ["StoreName",0], ["Phi",0], ["LoadName",0], ["StoreName",5], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",1], ["JumpIfFalse",7], ["LoadConst",1], ["StoreName",0], ["Jump",9], ["LoadConst",2], ["StoreName",0], ["Phi",0], ["LoadName",0], ["StoreName",5], ["ReturnValue",0]] }, { "src": "total = 0\nfor i in range(5):\ntotal = total + i", "constants": ["0", "5"], "names": ["total_1", "i_1", "total_2", "i_0", "i_2", "total_3"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["CallRange",1], ["GetIter",0], ["ForIter",12], ["StoreName",3], ["LoadName",0], ["LoadName",3], ["Add",0], ["StoreName",0], ["Jump",5], ["Phi",3], ["Phi",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["CallRange",1], ["GetIter",0], ["ForIter",12], ["StoreName",3], ["LoadName",0], ["LoadName",3], ["Add",0], ["StoreName",0], ["Jump",5], ["Phi",3], ["Phi",0], ["ReturnValue",0]] }, { "src": "for i in range(3):\nprint(i)", "constants": ["3"], "names": ["i_1", "i_0", "i_2"], - "instructions": [["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",8], ["StoreName",1], ["LoadName",1], ["CallPrint",1], ["Jump",3], ["Phi",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",8], ["StoreName",1], ["LoadName",1], ["CallPrint",1], ["Jump",3], ["Phi",1], ["ReturnValue",0]] }, { "src": "forty_two = int('42')\nseven = int(-7)\npi = int(3.14)", "constants": ["42", "7", "3.14"], "names": ["forty_two_1", "seven_1", "pi_1"], - "instructions": [["LoadConst",0], ["CallInt",1], ["StoreName",0], ["LoadConst",1], ["Minus",0], ["CallInt",1], ["StoreName",1], ["LoadConst",2], ["CallInt",1], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["CallInt",1], ["StoreName",0], ["LoadConst",1], ["Minus",0], ["CallInt",1], ["StoreName",1], ["LoadConst",2], ["CallInt",1], ["StoreName",2], ["ReturnValue",0]] }, { "src": "d = {1: 'one', 2: True, 'x': 3.14}\nprint(d)\nprint(d[2])\nprint(d['x'])", "constants": ["1", "one", "2", "x", "3.14", "2", "x"], "names": ["d_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["LoadTrue",0], ["LoadConst",3], ["LoadConst",4], ["BuildDict",3], ["StoreName",0], ["LoadName",0], ["CallPrint",1], ["LoadName",0], ["LoadConst",5], ["GetItem",0], ["CallPrint",1], ["LoadName",0], ["LoadConst",6], ["GetItem",0], ["CallPrint",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["LoadTrue",0], ["LoadConst",3], ["LoadConst",4], ["BuildDict",3], ["StoreName",0], ["LoadName",0], ["CallPrint",1], ["LoadName",0], ["LoadConst",5], ["GetItem",0], ["CallPrint",1], ["LoadName",0], ["LoadConst",6], ["GetItem",0], ["CallPrint",1], ["ReturnValue",0]] }, { "src": "dict()\ndict(a=1)\n{'a': 1}\ndict({'a': 1})", "constants": ["a", "1", "a", "1", "a", "1"], "names": [], - "instructions": [["CallDict",0], ["PopTop",0], ["LoadConst",0], ["LoadConst",1], ["CallDict",256], ["PopTop",0], ["LoadConst",2], ["LoadConst",3], ["BuildDict",1], ["PopTop",0], ["LoadConst",4], ["LoadConst",5], ["BuildDict",1], ["CallDict",1], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["CallDict",0], ["PopTop",0], ["LoadConst",0], ["LoadConst",1], ["CallDict",256], ["PopTop",0], ["LoadConst",2], ["LoadConst",3], ["BuildDict",1], ["PopTop",0], ["LoadConst",4], ["LoadConst",5], ["BuildDict",1], ["CallDict",1], ["PopTop",0], ["ReturnValue",0]] }, { "src": "x = 10\nif x > 20:\ny = 1\nelif x > 5:\ny = 2\nelse:\ny = 3", "constants": ["10", "20", "1", "5", "2", "3"], "names": ["x_1", "y_1", "y_2", "y_3", "y_4"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Gt",0], ["JumpIfFalse",9], ["LoadConst",2], ["StoreName",1], ["Jump",18], ["LoadName",0], ["LoadConst",3], ["Gt",0], ["JumpIfFalse",16], ["LoadConst",4], ["StoreName",1], ["Jump",18], ["LoadConst",5], ["StoreName",1], ["Phi",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Gt",0], ["JumpIfFalse",9], ["LoadConst",2], ["StoreName",1], ["Jump",18], ["LoadName",0], ["LoadConst",3], ["Gt",0], ["JumpIfFalse",16], ["LoadConst",4], ["StoreName",1], ["Jump",18], ["LoadConst",5], ["StoreName",1], ["Phi",1], ["ReturnValue",0]] }, { "src": "x = 0\nif a:\nx = 1\nelif b:\nx = 2\nelse:\nx = 3\ny = x", "constants": ["0", "1", "2", "3"], "names": ["x_1", "a_0", "x_2", "b_0", "x_3", "x_4", "x_5", "y_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",1], ["JumpIfFalse",7], ["LoadConst",1], ["StoreName",0], ["Jump",14], ["LoadName",3], ["JumpIfFalse",12], ["LoadConst",2], ["StoreName",0], ["Jump",14], ["LoadConst",3], ["StoreName",0], ["Phi",0], ["LoadName",0], ["StoreName",7], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",1], ["JumpIfFalse",7], ["LoadConst",1], ["StoreName",0], ["Jump",14], ["LoadName",3], ["JumpIfFalse",12], ["LoadConst",2], ["StoreName",0], ["Jump",14], ["LoadConst",3], ["StoreName",0], ["Phi",0], ["LoadName",0], ["StoreName",7], ["ReturnValue",0]] }, { "src": "a = 1\nb = 2\nc = 3\nd = a + b * c - 1", "constants": ["1", "2", "3", "1"], "names": ["a_1", "b_1", "c_1", "d_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["StoreName",1], ["LoadConst",2], ["StoreName",2], ["LoadName",0], ["LoadName",1], ["LoadName",2], ["Mul",0], ["Add",0], ["LoadConst",3], ["Sub",0], ["StoreName",3], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["StoreName",1], ["LoadConst",2], ["StoreName",2], ["LoadName",0], ["LoadName",1], ["LoadName",2], ["Mul",0], ["Add",0], ["LoadConst",3], ["Sub",0], ["StoreName",3], ["ReturnValue",0]] }, { "src": "s = 0\nfor i in range(10):\ns += i", "constants": ["0", "10"], "names": ["s_1", "i_1", "s_2", "i_0", "i_2", "s_3"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["CallRange",1], ["GetIter",0], ["ForIter",12], ["StoreName",3], ["LoadName",0], ["LoadName",3], ["InPlaceAdd",0], ["StoreName",0], ["Jump",5], ["Phi",3], ["Phi",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadConst",1], ["CallRange",1], ["GetIter",0], ["ForIter",12], ["StoreName",3], ["LoadName",0], ["LoadName",3], ["InPlaceAdd",0], ["StoreName",0], ["Jump",5], ["Phi",3], ["Phi",0], ["ReturnValue",0]] }, { "src": "x = 1\nx = x + 1\nx = x + 1\ny = x", "constants": ["1", "1", "1"], "names": ["x_1", "x_2", "x_3", "y_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Add",0], ["StoreName",0], ["LoadName",0], ["LoadConst",2], ["Add",0], ["StoreName",0], ["LoadName",0], ["StoreName",3], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Add",0], ["StoreName",0], ["LoadName",0], ["LoadConst",2], ["Add",0], ["StoreName",0], ["LoadName",0], ["StoreName",3], ["ReturnValue",0]] }, { "src": "x = int(str(42))", "constants": ["42"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["CallStr",1], ["CallInt",1], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["CallStr",1], ["CallInt",1], ["StoreName",0], ["ReturnValue",0]] }, { "src": "items = [1, 2, 3]\nn = len(items)\nprint(n)", "constants": ["1", "2", "3"], "names": ["items_1", "n_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildList",3], ["StoreName",0], ["LoadName",0], ["CallLen",1], ["StoreName",1], ["LoadName",1], ["CallPrint",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildList",3], ["StoreName",0], ["LoadName",0], ["CallLen",1], ["StoreName",1], ["LoadName",1], ["CallPrint",1], ["ReturnValue",0]] }, { "src": "x = 10\nx += 1\nx -= 2\nx *= 3\nx /= 4", "constants": ["10", "1", "2", "3", "4"], "names": ["x_1", "x_2", "x_3", "x_4", "x_5"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["InPlaceAdd",0], ["StoreName",0], ["LoadName",0], ["LoadConst",2], ["Sub",0], ["StoreName",0], ["LoadName",0], ["LoadConst",3], ["Mul",0], ["StoreName",0], ["LoadName",0], ["LoadConst",4], ["Div",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["InPlaceAdd",0], ["StoreName",0], ["LoadName",0], ["LoadConst",2], ["Sub",0], ["StoreName",0], ["LoadName",0], ["LoadConst",3], ["Mul",0], ["StoreName",0], ["LoadName",0], ["LoadConst",4], ["Div",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 1\nif True:\n x = 2\ny = x", "constants": ["1", "2"], "names": ["x_1", "x_2", "x_3", "y_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadTrue",0], ["JumpIfFalse",6], ["LoadConst",1], ["StoreName",0], ["Phi",0], ["LoadName",0], ["StoreName",3], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadTrue",0], ["JumpIfFalse",6], ["LoadConst",1], ["StoreName",0], ["Phi",0], ["LoadName",0], ["StoreName",3], ["ReturnValue",0]] }, { "src": "if True:\n x = 1\n y = 2\nz = x", "constants": ["1", "2"], "names": ["x_1", "y_1", "x_0", "x_2", "y_0", "y_2", "z_1"], - "instructions": [["LoadTrue",0], ["JumpIfFalse",6], ["LoadConst",0], ["StoreName",2], ["LoadConst",1], ["StoreName",4], ["Phi",2], ["Phi",4], ["LoadName",2], ["StoreName",6], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadTrue",0], ["JumpIfFalse",6], ["LoadConst",0], ["StoreName",2], ["LoadConst",1], ["StoreName",4], ["Phi",2], ["Phi",4], ["LoadName",2], ["StoreName",6], ["ReturnValue",0]] }, { "src": "for i in range(3):\n x = i\n print(x)", "constants": ["3"], "names": ["i_1", "x_1", "i_0", "i_2", "x_0", "x_2"], - "instructions": [["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",10], ["StoreName",2], ["LoadName",2], ["StoreName",4], ["LoadName",4], ["CallPrint",1], ["Jump",3], ["Phi",2], ["Phi",4], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",10], ["StoreName",2], ["LoadName",2], ["StoreName",4], ["LoadName",4], ["CallPrint",1], ["Jump",3], ["Phi",2], ["Phi",4], ["ReturnValue",0]] }, { "src": "x = a.b", "constants": [], "names": ["a_0", "b", "x_1"], - "instructions": [["LoadName",0], ["LoadAttr",1], ["StoreName",2], ["ReturnValue",0]], "annotations": {}}, + "instructions": [["LoadName",0], ["LoadAttr",1], ["StoreName",2], ["ReturnValue",0]]}, { "src": "x = a.b.c", "constants": [], "names": ["a_0", "b", "c", "x_1"], - "instructions": [["LoadName",0], ["LoadAttr",1], ["LoadAttr",2], ["StoreName",3], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadAttr",1], ["LoadAttr",2], ["StoreName",3], ["ReturnValue",0]] }, { "src": "x = s.upper()", "constants": [], "names": ["s_0", "upper", "x_1"], - "instructions": [["LoadName",0], ["LoadAttr",1], ["Call",0], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadAttr",1], ["Call",0], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = s.replace('a', 'b')", "constants": ["a", "b"], "names": ["s_0", "replace", "x_1"], - "instructions": [["LoadName",0], ["LoadAttr",1], ["BeginArgs",0], ["LoadConst",0], ["LoadConst",1], ["Call",2], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadAttr",1], ["BeginArgs",0], ["LoadConst",0], ["LoadConst",1], ["Call",2], ["StoreName",2], ["ReturnValue",0]] }, { "src": "print(s.upper())", "constants": [], "names": ["s_0", "upper"], - "instructions": [["LoadName",0], ["LoadAttr",1], ["Call",0], ["CallPrint",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadAttr",1], ["Call",0], ["CallPrint",1], ["ReturnValue",0]] }, { "src": "self.name = value", "constants": [], "names": ["self_0", "value_0", "name"], - "instructions": [["LoadName",0], ["LoadName",1], ["StoreAttr",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["StoreAttr",2], ["ReturnValue",0]] }, { "src": "x = a[1:3]", "constants": ["1", "3"], "names": ["a_0", "x_1"], - "instructions": [["LoadName",0], ["LoadConst",0], ["LoadConst",1], ["BuildSlice",2], ["GetItem",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadConst",0], ["LoadConst",1], ["BuildSlice",2], ["GetItem",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "x = a[:3]", "constants": ["3"], "names": ["a_0", "x_1"], - "instructions": [["LoadName",0], ["LoadNone",0], ["LoadConst",0], ["BuildSlice",2], ["GetItem",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadNone",0], ["LoadConst",0], ["BuildSlice",2], ["GetItem",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "x = a[::2]", "constants": ["2"], "names": ["a_0", "x_1"], - "instructions": [["LoadName",0], ["LoadNone",0], ["LoadNone",0], ["LoadConst",0], ["BuildSlice",3], ["GetItem",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadNone",0], ["LoadNone",0], ["LoadConst",0], ["BuildSlice",3], ["GetItem",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "x = a[1:]", "constants": ["1"], "names": ["a_0", "x_1"], - "instructions": [["LoadName",0], ["LoadConst",0], ["LoadNone",0], ["BuildSlice",2], ["GetItem",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadConst",0], ["LoadNone",0], ["BuildSlice",2], ["GetItem",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "class Dog:\n pass", "constants": [], "names": ["Dog_1"], "instructions": [["MakeClass",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "classes": 1 }, { @@ -522,22 +449,19 @@ "constants": [], "names": ["Animal_0", "Dog_1"], "instructions": [["LoadName",0], ["MakeClass",256], ["StoreName",1], ["ReturnValue",0]], - "annotations": {}, "classes": 1 }, { "src": "try:\n x = 1\nexcept:\n x = 2", "constants": ["1", "2"], "names": ["x_1", "x_2", "x_3"], - "instructions": [["SetupFinally",11], ["SetupExcept",6], ["LoadConst",0], ["StoreName",0], ["PopExcept",0], ["Jump",9], ["PopTop",0], ["LoadConst",1], ["StoreName",0], ["PopExcept",0], ["BeginFinally",0], ["EndFinally",0], ["Phi",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["SetupFinally",11], ["SetupExcept",6], ["LoadConst",0], ["StoreName",0], ["PopExcept",0], ["Jump",9], ["PopTop",0], ["LoadConst",1], ["StoreName",0], ["PopExcept",0], ["BeginFinally",0], ["EndFinally",0], ["Phi",0], ["ReturnValue",0]] }, { "src": "import os", "constants": [], "names": [], "instructions": [["ReturnValue",0]], - "annotations": {}, "errors": ["module 'os' not found (no resolver configured)"] }, { @@ -545,7 +469,6 @@ "constants": [], "names": [], "instructions": [["ReturnValue",0]], - "annotations": {}, "errors": ["module 'os' not found (no resolver configured)"] }, { @@ -553,7 +476,6 @@ "constants": [], "names": [], "instructions": [["ReturnValue",0]], - "annotations": {}, "errors": ["module 'os' not found (no resolver configured)"] }, { @@ -561,7 +483,6 @@ "constants": [], "names": [], "instructions": [["ReturnValue",0]], - "annotations": {}, "errors": ["module 'os' not found (no resolver configured)"] }, { @@ -569,7 +490,6 @@ "constants": [], "names": [], "instructions": [["ReturnValue",0]], - "annotations": {}, "errors": ["module 'os.path' not found (no resolver configured)"] }, { @@ -577,7 +497,6 @@ "constants": [], "names": [], "instructions": [["ReturnValue",0]], - "annotations": {}, "errors": ["module 'numpy' not found (no resolver configured)"] }, { @@ -585,113 +504,97 @@ "constants": [], "names": [], "instructions": [["ReturnValue",0]], - "annotations": {}, "errors": ["module 'os' not found (no resolver configured)"] }, { "src": "x = a & b", "constants": [], "names": ["a_0", "b_0", "x_1"], - "instructions": [["LoadName",0], ["LoadName",1], ["BitAnd",0], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["BitAnd",0], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = a | b", "constants": [], "names": ["a_0", "b_0", "x_1"], - "instructions": [["LoadName",0], ["LoadName",1], ["BitOr",0], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["BitOr",0], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = a ^ b", "constants": [], "names": ["a_0", "b_0", "x_1"], - "instructions": [["LoadName",0], ["LoadName",1], ["BitXor",0], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["BitXor",0], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = ~a", "constants": [], "names": ["a_0", "x_1"], - "instructions": [["LoadName",0], ["BitNot",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["BitNot",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "x = a << 2", "constants": ["2"], "names": ["a_0", "x_1"], - "instructions": [["LoadName",0], ["LoadConst",0], ["Shl",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadConst",0], ["Shl",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "x = a >> 2", "constants": ["2"], "names": ["a_0", "x_1"], - "instructions": [["LoadName",0], ["LoadConst",0], ["Shr",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadConst",0], ["Shr",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "x = a in b", "constants": [], "names": ["a_0", "b_0", "x_1"], - "instructions": [["LoadName",0], ["LoadName",1], ["In",0], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["In",0], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = a not in b", "constants": [], "names": ["a_0", "b_0", "x_1"], - "instructions": [["LoadName",0], ["LoadName",1], ["NotIn",0], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["NotIn",0], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = a is b", "constants": [], "names": ["a_0", "b_0", "x_1"], - "instructions": [["LoadName",0], ["LoadName",1], ["Is",0], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["Is",0], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = a is not b", "constants": [], "names": ["a_0", "b_0", "x_1"], - "instructions": [["LoadName",0], ["LoadName",1], ["IsNot",0], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["IsNot",0], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = 1 if True else 2", "constants": ["1", "2"], "names": ["x_1"], - "instructions": [["LoadTrue",0], ["JumpIfFalse",4], ["LoadConst",0], ["Jump",5], ["LoadConst",1], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadTrue",0], ["JumpIfFalse",4], ["LoadConst",0], ["Jump",5], ["LoadConst",1], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = a if a else b", "constants": [], "names": ["a_0", "b_0", "x_1"], - "instructions": [["LoadName",0], ["JumpIfFalse",4], ["LoadName",0], ["Jump",5], ["LoadName",1], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["JumpIfFalse",4], ["LoadName",0], ["Jump",5], ["LoadName",1], ["StoreName",2], ["ReturnValue",0]] }, { "src": "a, b = 1, 2", "constants": ["1", "2"], "names": ["a_1", "b_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["BuildTuple",2], ["UnpackSequence",2], ["StoreName",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["BuildTuple",2], ["UnpackSequence",2], ["StoreName",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "a, b, c = 1, 2, 3", "constants": ["1", "2", "3"], "names": ["a_1", "b_1", "c_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildTuple",3], ["UnpackSequence",3], ["StoreName",0], ["StoreName",1], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildTuple",3], ["UnpackSequence",3], ["StoreName",0], ["StoreName",1], ["StoreName",2], ["ReturnValue",0]] }, { "src": "def f(x, y=1):\n return x + y", "constants": ["1"], "names": ["f_1"], "instructions": [["LoadConst",0], ["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -706,7 +609,6 @@ "constants": ["1", "2"], "names": ["f_1"], "instructions": [["LoadConst",0], ["LoadConst",1], ["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -714,7 +616,6 @@ "constants": ["10"], "names": ["f_1"], "instructions": [["LoadConst",0], ["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -722,7 +623,6 @@ "constants": [], "names": ["f_1"], "instructions": [["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -730,7 +630,6 @@ "constants": [], "names": ["f_1"], "instructions": [["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -738,99 +637,85 @@ "constants": [], "names": ["f_1"], "instructions": [["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { "src": "with x as f:\n pass", "constants": [], "names": ["x_0", "f_1"], - "instructions": [["LoadName",0], ["WithEnter",0], ["Call",1], ["StoreName",1], ["SetupFinally",7], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["WithEnter",0], ["Call",1], ["StoreName",1], ["SetupFinally",7], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["ReturnValue",0]] }, { "src": "with x:\n pass", "constants": [], "names": ["x_0"], - "instructions": [["LoadName",0], ["WithEnter",0], ["Call",1], ["PopTop",0], ["SetupFinally",7], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["WithEnter",0], ["Call",1], ["PopTop",0], ["SetupFinally",7], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["ReturnValue",0]] }, { "src": "yield 1", "constants": ["1"], "names": [], - "instructions": [["LoadConst",0], ["Yield",0], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["Yield",0], ["PopTop",0], ["ReturnValue",0]] }, { "src": "yield", "constants": [], "names": [], - "instructions": [["LoadNone",0], ["Yield",0], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadNone",0], ["Yield",0], ["PopTop",0], ["ReturnValue",0]] }, { "src": "del x", "constants": [], "names": ["x_0"], - "instructions": [["Del",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["Del",0], ["ReturnValue",0]] }, { "src": "x = 1\ndel x", "constants": ["1"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["Del",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["Del",0], ["ReturnValue",0]] }, { "src": "assert x", "constants": [], "names": ["x_0"], - "instructions": [["LoadName",0], ["Assert",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["Assert",0], ["ReturnValue",0]] }, { "src": "assert x > 0", "constants": ["0"], "names": ["x_0"], - "instructions": [["LoadName",0], ["LoadConst",0], ["Gt",0], ["Assert",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadConst",0], ["Gt",0], ["Assert",0], ["ReturnValue",0]] }, { "src": "assert x, 'msg'", "constants": ["msg"], "names": ["x_0", "AssertionError"], - "instructions": [["LoadName",0], ["JumpIfFalse",3], ["Jump",7], ["LoadName",1], ["LoadConst",0], ["Call",1], ["Raise",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["JumpIfFalse",3], ["Jump",7], ["LoadName",1], ["LoadConst",0], ["Call",1], ["Raise",0], ["ReturnValue",0]] }, { "src": "global x, y", "constants": [], "names": ["x", "y"], - "instructions": [["Global",0], ["Global",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["Global",0], ["Global",1], ["ReturnValue",0]] }, { "src": "nonlocal x", "constants": [], "names": ["x"], - "instructions": [["Nonlocal",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["Nonlocal",0], ["ReturnValue",0]] }, { "src": "nonlocal x, y", "constants": [], "names": ["x", "y"], - "instructions": [["Nonlocal",0], ["Nonlocal",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["Nonlocal",0], ["Nonlocal",1], ["ReturnValue",0]] }, { "src": "@dec\ndef f():\n pass", "constants": [], "names": ["dec_0", "f_1"], "instructions": [["LoadName",0], ["MakeFunction",0], ["Call",1], ["StoreName",1], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -838,7 +723,6 @@ "constants": [], "names": ["a_0", "b_0", "f_1"], "instructions": [["LoadName",0], ["LoadName",1], ["MakeFunction",0], ["Call",1], ["Call",1], ["StoreName",2], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -846,7 +730,6 @@ "constants": [], "names": ["a_0", "b_0", "c_0", "f_1"], "instructions": [["LoadName",0], ["LoadName",1], ["LoadName",2], ["MakeFunction",0], ["Call",1], ["Call",1], ["Call",1], ["StoreName",3], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -854,7 +737,6 @@ "constants": ["7"], "names": ["deco_0", "f_1"], "instructions": [["LoadName",0], ["BeginArgs",0], ["LoadConst",0], ["Call",1], ["MakeFunction",0], ["Call",1], ["StoreName",1], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -862,7 +744,6 @@ "constants": ["1", "2"], "names": ["a_0", "b_0", "f_1"], "instructions": [["LoadName",0], ["BeginArgs",0], ["LoadConst",0], ["Call",1], ["LoadName",1], ["BeginArgs",0], ["LoadConst",1], ["Call",1], ["MakeFunction",0], ["Call",1], ["Call",1], ["StoreName",2], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -870,7 +751,6 @@ "constants": [], "names": ["f_1"], "instructions": [["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -878,7 +758,6 @@ "constants": [], "names": ["f_1"], "instructions": [["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -886,65 +765,58 @@ "constants": [], "names": ["f_1"], "instructions": [["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { "src": "f(*x)", "constants": [], "names": ["f_0", "x_0"], - "instructions": [["LoadName",0], ["BeginArgs",0], ["LoadName",1], ["UnpackArgs",1], ["Call",1], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["BeginArgs",0], ["LoadName",1], ["UnpackArgs",1], ["Call",1], ["PopTop",0], ["ReturnValue",0]] }, { "src": "f(**d)", "constants": [], "names": ["f_0", "d_0"], - "instructions": [["LoadName",0], ["BeginArgs",0], ["LoadName",1], ["UnpackArgs",2], ["Call",1], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["BeginArgs",0], ["LoadName",1], ["UnpackArgs",2], ["Call",1], ["PopTop",0], ["ReturnValue",0]] }, { "src": "x = [i for i in range(3)]", "constants": ["3"], "names": ["i_0", "i_1", "x_1"], - "instructions": [["BuildList",0], ["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",9], ["StoreName",0], ["LoadName",0], ["ListAppend",0], ["Jump",4], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["BuildList",0], ["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",9], ["StoreName",0], ["LoadName",0], ["ListAppend",0], ["Jump",4], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = (y := 5)", "constants": ["5"], "names": ["y_1", "x_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["StoreName",1], ["ReturnValue",0]], "annotations": {}}, + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["StoreName",1], ["ReturnValue",0]]}, { "src": "x = (1, 2)", "constants": ["1", "2"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["BuildTuple",2], ["StoreName",0], ["ReturnValue",0]], "annotations": {}}, + "instructions": [["LoadConst",0], ["LoadConst",1], ["BuildTuple",2], ["StoreName",0], ["ReturnValue",0]]}, { "src": "x = (1, 2, 3)", "constants": ["1", "2", "3"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildTuple",3], ["StoreName",0], ["ReturnValue",0]], "annotations": {}}, + "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildTuple",3], ["StoreName",0], ["ReturnValue",0]]}, { "src": "x = (1,)", "constants": ["1"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["BuildTuple",1], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["BuildTuple",1], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = {1, 2, 3}", "constants": ["1", "2", "3"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildSet",3], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildSet",3], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = {1, 2}", "constants": ["1", "2"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["BuildSet",2], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["BuildSet",2], ["StoreName",0], ["ReturnValue",0]] }, { "src": "match x:\n case 1:\n y = 10\n case _:\n y = 20", @@ -962,43 +834,37 @@ "src": "raise x from y", "constants": [], "names": ["x_0", "y_0"], - "instructions": [["LoadName",0], ["LoadName",1], ["RaiseFrom",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadName",1], ["RaiseFrom",0], ["ReturnValue",0]] }, { "src": "raise x", "constants": [], "names": ["x_0"], - "instructions": [["LoadName",0], ["Raise",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["Raise",0], ["ReturnValue",0]] }, { "src": "for i in range(3):\n pass\nelse:\n x = 1", "constants": ["3", "1"], "names": ["i_1", "x_1", "i_0", "i_2", "x_0", "x_2"], - "instructions": [["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",6], ["StoreName",2], ["Jump",3], ["LoadConst",1], ["StoreName",4], ["Phi",2], ["Phi",4], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",6], ["StoreName",2], ["Jump",3], ["LoadConst",1], ["StoreName",4], ["Phi",2], ["Phi",4], ["ReturnValue",0]] }, { "src": "while x:\n pass\nelse:\n y = 1", "constants": ["1"], "names": ["x_0", "y_1", "y_0", "y_2"], - "instructions": [["LoadName",0], ["JumpIfFalse",3], ["Jump",0], ["LoadConst",0], ["StoreName",2], ["Phi",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["JumpIfFalse",3], ["Jump",0], ["LoadConst",0], ["StoreName",2], ["Phi",2], ["ReturnValue",0]] }, { "src": "try:\n x = 1\nexcept:\n x = 2\nelse:\n x = 3", "constants": ["1", "2", "3"], "names": ["x_1", "x_2", "x_3", "x_4"], - "instructions": [["SetupFinally",14], ["SetupExcept",6], ["LoadConst",0], ["StoreName",0], ["PopExcept",0], ["Jump",10], ["PopTop",0], ["LoadConst",1], ["StoreName",0], ["Jump",12], ["LoadConst",2], ["StoreName",0], ["PopExcept",0], ["BeginFinally",0], ["EndFinally",0], ["Phi",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["SetupFinally",14], ["SetupExcept",6], ["LoadConst",0], ["StoreName",0], ["PopExcept",0], ["Jump",10], ["PopTop",0], ["LoadConst",1], ["StoreName",0], ["Jump",12], ["LoadConst",2], ["StoreName",0], ["PopExcept",0], ["BeginFinally",0], ["EndFinally",0], ["Phi",0], ["ReturnValue",0]] }, { "src": "class C(A, B):\n pass", "constants": [], "names": ["A_0", "B_0", "C_1"], "instructions": [["LoadName",0], ["LoadName",1], ["MakeClass",512], ["StoreName",2], ["ReturnValue",0]], - "annotations": {}, "classes": 1 }, { @@ -1006,120 +872,103 @@ "constants": [], "names": ["A_0", "B_0", "D_0", "C_1"], "instructions": [["LoadName",0], ["LoadName",1], ["LoadName",2], ["MakeClass",768], ["StoreName",3], ["ReturnValue",0]], - "annotations": {}, "classes": 1 }, { "src": "y = f\"{x:.2f}\"", "constants": [".2f"], "names": ["x_0", "y_1"], - "instructions": [["LoadName",0], ["LoadConst",0], ["FormatValue",1], ["BuildString",1], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadConst",0], ["FormatValue",1], ["BuildString",1], ["StoreName",1], ["ReturnValue",0]] }, { "src": "y = f\"{x:>10}\"", "constants": [">10"], "names": ["x_0", "y_1"], - "instructions": [["LoadName",0], ["LoadConst",0], ["FormatValue",1], ["BuildString",1], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadConst",0], ["FormatValue",1], ["BuildString",1], ["StoreName",1], ["ReturnValue",0]] }, { "src": "y = f\"{x:04d}\"", "constants": ["04d"], "names": ["x_0", "y_1"], - "instructions": [["LoadName",0], ["LoadConst",0], ["FormatValue",1], ["BuildString",1], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadConst",0], ["FormatValue",1], ["BuildString",1], ["StoreName",1], ["ReturnValue",0]] }, { "src": "*a, b = [1, 2, 3]", "constants": ["1", "2", "3"], "names": ["a_1", "b_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildList",3], ["UnpackEx",1], ["StoreName",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildList",3], ["UnpackEx",1], ["StoreName",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "a, *b = [1, 2, 3]", "constants": ["1", "2", "3"], "names": ["a_1", "b_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildList",3], ["UnpackEx",256], ["StoreName",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["BuildList",3], ["UnpackEx",256], ["StoreName",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "a, *b, c = [1, 2, 3, 4]", "constants": ["1", "2", "3", "4"], "names": ["a_1", "b_1", "c_1"], - "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["LoadConst",3], ["BuildList",4], ["UnpackEx",257], ["StoreName",0], ["StoreName",1], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["LoadConst",1], ["LoadConst",2], ["LoadConst",3], ["BuildList",4], ["UnpackEx",257], ["StoreName",0], ["StoreName",1], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = d[0] + 1", "constants": ["0", "1"], "names": ["d_0", "x_1"], - "instructions": [["LoadName",0], ["LoadConst",0], ["GetItem",0], ["LoadConst",1], ["Add",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadConst",0], ["GetItem",0], ["LoadConst",1], ["Add",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "x = []", "constants": [], "names": ["x_1"], - "instructions": [["BuildList",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["BuildList",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = [i for i in range(3) for j in range(2)]", "constants": ["3", "2"], "names": ["i_0", "i_1", "j_1", "x_1"], - "instructions": [["BuildList",0], ["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",15], ["StoreName",0], ["LoadConst",1], ["CallRange",1], ["GetIter",0], ["ForIter",14], ["StoreName",2], ["LoadName",0], ["ListAppend",0], ["Jump",9], ["Jump",4], ["StoreName",3], ["ReturnValue",0]], - "annotations": {} + "instructions": [["BuildList",0], ["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",15], ["StoreName",0], ["LoadConst",1], ["CallRange",1], ["GetIter",0], ["ForIter",14], ["StoreName",2], ["LoadName",0], ["ListAppend",0], ["Jump",9], ["Jump",4], ["StoreName",3], ["ReturnValue",0]] }, { "src": "with a, b:\n pass", "constants": [], "names": ["a_0", "b_0"], - "instructions": [["LoadName",0], ["WithEnter",0], ["Call",1], ["PopTop",0], ["SetupFinally",18], ["LoadName",1], ["WithEnter",0], ["Call",1], ["PopTop",0], ["SetupFinally",12], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["WithEnter",0], ["Call",1], ["PopTop",0], ["SetupFinally",18], ["LoadName",1], ["WithEnter",0], ["Call",1], ["PopTop",0], ["SetupFinally",12], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["ReturnValue",0]] }, { "src": "with a as x, b as y:\n pass", "constants": [], "names": ["a_0", "x_1", "b_0", "y_1"], - "instructions": [["LoadName",0], ["WithEnter",0], ["Call",1], ["StoreName",1], ["SetupFinally",18], ["LoadName",2], ["WithEnter",0], ["Call",1], ["StoreName",3], ["SetupFinally",12], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["WithEnter",0], ["Call",1], ["StoreName",1], ["SetupFinally",18], ["LoadName",2], ["WithEnter",0], ["Call",1], ["StoreName",3], ["SetupFinally",12], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["PopExcept",0], ["BeginFinally",0], ["WithExit",0], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["ReturnValue",0]] }, { "src": "x = 'hello' ' world'", "constants": ["hello world"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = ...", "constants": [], "names": ["x_1"], - "instructions": [["LoadEllipsis",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadEllipsis",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = [i for i in range(10) if i > 2 if i < 8]", "constants": ["10", "2", "8"], "names": ["i_0", "i_1", "x_1"], - "instructions": [["BuildList",0], ["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",17], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Gt",0], ["JumpIfFalse",4], ["LoadName",0], ["LoadConst",2], ["Lt",0], ["JumpIfFalse",4], ["LoadName",0], ["ListAppend",0], ["Jump",4], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["BuildList",0], ["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",17], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Gt",0], ["JumpIfFalse",4], ["LoadName",0], ["LoadConst",2], ["Lt",0], ["JumpIfFalse",4], ["LoadName",0], ["ListAppend",0], ["Jump",4], ["StoreName",2], ["ReturnValue",0]] }, { "src": "x = (i for i in range(3))", "constants": ["3"], "names": ["i_0", "i_1", "x_1"], - "instructions": [["BuildList",0], ["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",9], ["StoreName",0], ["LoadName",0], ["ListAppend",0], ["Jump",4], ["StoreName",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["BuildList",0], ["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",9], ["StoreName",0], ["LoadName",0], ["ListAppend",0], ["Jump",4], ["StoreName",2], ["ReturnValue",0]] }, { "src": "def f(x: int, y: str = 'a') -> bool:\n pass", "constants": ["a"], "names": ["f_1"], "instructions": [["LoadConst",0], ["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -1127,29 +976,25 @@ "constants": [], "names": ["f_1"], "instructions": [["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { "src": "for a, b in x:\n pass", "constants": [], "names": ["x_0", "a_1", "b_1", "a_0", "a_2", "b_0", "b_2"], - "instructions": [["LoadName",0], ["GetIter",0], ["ForIter",7], ["UnpackSequence",2], ["StoreName",3], ["StoreName",5], ["Jump",2], ["Phi",3], ["Phi",5], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["GetIter",0], ["ForIter",7], ["UnpackSequence",2], ["StoreName",3], ["StoreName",5], ["Jump",2], ["Phi",3], ["Phi",5], ["ReturnValue",0]] }, { "src": "for a, b, c in x:\n pass", "constants": [], "names": ["x_0", "a_1", "b_1", "c_1", "a_0", "a_2", "b_0", "b_2", "c_0", "c_2"], - "instructions": [["LoadName",0], ["GetIter",0], ["ForIter",8], ["UnpackSequence",3], ["StoreName",4], ["StoreName",6], ["StoreName",8], ["Jump",2], ["Phi",4], ["Phi",6], ["Phi",8], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["GetIter",0], ["ForIter",8], ["UnpackSequence",3], ["StoreName",4], ["StoreName",6], ["StoreName",8], ["Jump",2], ["Phi",4], ["Phi",6], ["Phi",8], ["ReturnValue",0]] }, { "src": "async def f():\n pass", "constants": [], "names": ["f_1"], "instructions": [["MakeCoroutine",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -1157,29 +1002,25 @@ "constants": [], "names": ["x_0", "f_1"], "instructions": [["MakeCoroutine",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { "src": "x = await y", "constants": [], "names": ["y_0", "x_1"], - "instructions": [["LoadName",0], ["Await",0], ["StoreName",1], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["Await",0], ["StoreName",1], ["ReturnValue",0]] }, { "src": "await f()", "constants": [], "names": ["f_0"], - "instructions": [["LoadName",0], ["BeginArgs",0], ["Call",0], ["Await",0], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["BeginArgs",0], ["Call",0], ["Await",0], ["PopTop",0], ["ReturnValue",0]] }, { "src": "def f(a, b, /, c):\n pass", "constants": [], "names": ["f_1"], "instructions": [["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { @@ -1187,445 +1028,382 @@ "constants": [], "names": ["f_1"], "instructions": [["MakeFunction",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { "src": "for (a, b) in x:\n pass", "constants": [], "names": ["x_0", "a_1", "b_1", "a_0", "a_2", "b_0", "b_2"], - "instructions": [["LoadName",0], ["GetIter",0], ["ForIter",7], ["UnpackSequence",2], ["StoreName",3], ["StoreName",5], ["Jump",2], ["Phi",3], ["Phi",5], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["GetIter",0], ["ForIter",7], ["UnpackSequence",2], ["StoreName",3], ["StoreName",5], ["Jump",2], ["Phi",3], ["Phi",5], ["ReturnValue",0]] }, { "src": "for a, *b in x:\n pass", "constants": [], "names": ["x_0", "a_1", "b_1", "a_0", "a_2", "b_0", "b_2"], - "instructions": [["LoadName",0], ["GetIter",0], ["ForIter",7], ["UnpackEx",256], ["StoreName",3], ["StoreName",5], ["Jump",2], ["Phi",3], ["Phi",5], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["GetIter",0], ["ForIter",7], ["UnpackEx",256], ["StoreName",3], ["StoreName",5], ["Jump",2], ["Phi",3], ["Phi",5], ["ReturnValue",0]] }, { "src": "x = 10\nx //= 3", "constants": ["10", "3"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["FloorDiv",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["FloorDiv",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 10\nx %= 3", "constants": ["10", "3"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Mod",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Mod",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 2\nx **= 3", "constants": ["2", "3"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Pow",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Pow",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 7\nx &= 3", "constants": ["7", "3"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["InPlaceBitAnd",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["InPlaceBitAnd",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 5\nx |= 3", "constants": ["5", "3"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["InPlaceBitOr",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["InPlaceBitOr",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 7\nx ^= 3", "constants": ["7", "3"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["InPlaceBitXor",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["InPlaceBitXor",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 1\nx <<= 3", "constants": ["1", "3"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Shl",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Shl",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = 8\nx >>= 2", "constants": ["8", "2"], "names": ["x_1", "x_2"], - "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Shr",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["LoadName",0], ["LoadConst",1], ["Shr",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "yield from x", "constants": [], "names": ["x_0"], - "instructions": [["LoadName",0], ["GetIter",0], ["ForIter",6], ["Yield",0], ["PopTop",0], ["Jump",2], ["LoadYieldFrom",0], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["GetIter",0], ["ForIter",6], ["Yield",0], ["PopTop",0], ["Jump",2], ["LoadYieldFrom",0], ["PopTop",0], ["ReturnValue",0]] }, { "src": "yield from range(3)", "constants": ["3"], "names": [], - "instructions": [["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",7], ["Yield",0], ["PopTop",0], ["Jump",3], ["LoadYieldFrom",0], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",7], ["Yield",0], ["PopTop",0], ["Jump",3], ["LoadYieldFrom",0], ["PopTop",0], ["ReturnValue",0]] }, { "src": "async for a in x:\n pass", "constants": [], "names": ["x_0", "a_1", "a_0", "a_2"], - "instructions": [["LoadName",0], ["GetIter",1], ["ForIter",5], ["StoreName",2], ["Jump",2], ["Phi",2], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["GetIter",1], ["ForIter",5], ["StoreName",2], ["Jump",2], ["Phi",2], ["ReturnValue",0]] }, { "src": "async with a as x:\n pass", "constants": [], "names": ["a_0", "x_1"], - "instructions": [["LoadName",0], ["WithEnter",1], ["Call",1], ["StoreName",1], ["SetupFinally",7], ["PopExcept",0], ["BeginFinally",0], ["WithExit",1], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["WithEnter",1], ["Call",1], ["StoreName",1], ["SetupFinally",7], ["PopExcept",0], ["BeginFinally",0], ["WithExit",1], ["Call",4], ["WithJudge",0], ["EndFinally",0], ["ReturnValue",0]] }, { "src": "break", "constants": [], "names": [], "instructions": [["ReturnValue",0]], - "annotations": {}, "errors": ["'break' outside loop"] }, { "src": "pass", "constants": [], "names": [], - "instructions": [["ReturnValue",0]], - "annotations": {} + "instructions": [["ReturnValue",0]] }, { "src": "0", "constants": ["0"], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "[]", "constants": [], "names": [], - "instructions": [["BuildList",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["BuildList",0],["PopTop",0],["ReturnValue",0]] }, { "src": "{}", "constants": [], "names": [], - "instructions": [["BuildDict",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["BuildDict",0],["PopTop",0],["ReturnValue",0]] }, { "src": "...", "constants": [], "names": [], - "instructions": [["LoadEllipsis",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadEllipsis",0],["PopTop",0],["ReturnValue",0]] }, { "src": "''", "constants": [""], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "not True", "constants": [], "names": [], - "instructions": [["LoadTrue",0],["Not",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadTrue",0],["Not",0],["PopTop",0],["ReturnValue",0]] }, { "src": "~0", "constants": ["0"], "names": [], - "instructions": [["LoadConst",0],["BitNot",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["BitNot",0],["PopTop",0],["ReturnValue",0]] }, { "src": "-1", "constants": ["1"], "names": [], - "instructions": [["LoadConst",0],["Minus",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["Minus",0],["PopTop",0],["ReturnValue",0]] }, { "src": "2 ** 3", "constants": ["2","3"], "names": [], - "instructions": [["LoadConst",0],["LoadConst",1],["Pow",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["LoadConst",1],["Pow",0],["PopTop",0],["ReturnValue",0]] }, { "src": "assert True", "constants": [], "names": [], - "instructions": [["LoadTrue",0],["Assert",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadTrue",0],["Assert",0],["ReturnValue",0]] }, { "src": "global x", "constants": [], "names": ["x"], - "instructions": [["Global",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["Global",0],["ReturnValue",0]] }, { "src": "raise", "constants": [], "names": [], - "instructions": [["Raise",1],["ReturnValue",0]], - "annotations": {} + "instructions": [["Raise",1],["ReturnValue",0]] }, { "src": "(1,)", "constants": ["1"], "names": [], - "instructions": [["LoadConst",0],["BuildTuple",1],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["BuildTuple",1],["PopTop",0],["ReturnValue",0]] }, { "src": "continue", "constants": [], "names": [], "instructions": [["ReturnValue",0]], - "annotations": {}, "errors": ["'continue' outside loop"] }, { "src": "if True:\n if False:\n pass", "constants": [], "names": [], - "instructions": [["LoadTrue",0],["JumpIfFalse",4],["LoadFalse",0],["JumpIfFalse",4],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadTrue",0],["JumpIfFalse",4],["LoadFalse",0],["JumpIfFalse",4],["ReturnValue",0]] }, { "src": "def f():\n f()", "constants": [], "names": ["f_0", "f_1"], "instructions": [["MakeFunction",0],["StoreName",0],["ReturnValue",0]], - "annotations": {}, "functions": 1 }, { "src": "((((1))))", "constants": ["1"], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "1+2+3+4+5", "constants": ["1","2","3","4","5"], "names": [], - "instructions": [["LoadConst",0],["LoadConst",1],["Add",0],["LoadConst",2],["Add",0],["LoadConst",3],["Add",0],["LoadConst",4],["Add",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["LoadConst",1],["Add",0],["LoadConst",2],["Add",0],["LoadConst",3],["Add",0],["LoadConst",4],["Add",0],["PopTop",0],["ReturnValue",0]] }, { "src": "a.b.c.d", "constants": [], "names": ["a_0","b","c","d"], - "instructions": [["LoadName",0],["LoadAttr",1],["LoadAttr",2],["LoadAttr",3],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0],["LoadAttr",1],["LoadAttr",2],["LoadAttr",3],["PopTop",0],["ReturnValue",0]] }, { "src": "a[0][1][2]", "constants": ["0","1","2"], "names": ["a_0"], - "instructions": [["LoadName",0],["LoadConst",0],["GetItem",0],["LoadConst",1],["GetItem",0],["LoadConst",2],["GetItem",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0],["LoadConst",0],["GetItem",0],["LoadConst",1],["GetItem",0],["LoadConst",2],["GetItem",0],["PopTop",0],["ReturnValue",0]] }, { "src": "0xDEAD_BEEF", "constants": ["3735928559"], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "0o777", "constants": ["511"], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "0b1010_1010", "constants": ["170"], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "1_000_000", "constants": ["1000000"], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "2 ** 3 ** 2", "constants": ["2","3","2"], "names": [], - "instructions": [["LoadConst",0],["LoadConst",1],["LoadConst",2],["Pow",0],["Pow",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["LoadConst",1],["LoadConst",2],["Pow",0],["Pow",0],["PopTop",0],["ReturnValue",0]] }, { "src": "not not False", "constants": [], "names": [], - "instructions": [["LoadFalse",0],["Not",0],["Not",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadFalse",0],["Not",0],["Not",0],["PopTop",0],["ReturnValue",0]] }, { "src": "x is not None", "constants": [], "names": ["x_0"], - "instructions": [["LoadName",0],["LoadNone",0],["IsNot",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0],["LoadNone",0],["IsNot",0],["PopTop",0],["ReturnValue",0]] }, { "src": "'hello' 'world'", "constants": ["helloworld"], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "r'raw\\n'", "constants": ["raw\\n"], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "b'bytes'", "constants": ["b\"bytes\""], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "\"\"\"\"\"\"", "constants": [""], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "f''", "constants": [""], "names": [], - "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["PopTop",0],["ReturnValue",0]] }, { "src": "x = a and b or c", "constants": [], "names": ["a_0","b_0","c_0","x_1"], - "instructions": [["LoadName", 0], ["JumpIfFalseOrPop", 3], ["LoadName", 1], ["JumpIfTrueOrPop", 5], ["LoadName", 2], ["StoreName", 3], ["ReturnValue", 0]], - "annotations": {} + "instructions": [["LoadName", 0], ["JumpIfFalseOrPop", 3], ["LoadName", 1], ["JumpIfTrueOrPop", 5], ["LoadName", 2], ["StoreName", 3], ["ReturnValue", 0]] }, { "src": "x = a or b and c", "constants": [], "names": ["a_0","b_0","c_0","x_1"], - "instructions": [["LoadName", 0], ["JumpIfTrueOrPop", 5], ["LoadName", 1], ["JumpIfFalseOrPop", 5], ["LoadName", 2], ["StoreName", 3], ["ReturnValue", 0]], - "annotations": {} + "instructions": [["LoadName", 0], ["JumpIfTrueOrPop", 5], ["LoadName", 1], ["JumpIfFalseOrPop", 5], ["LoadName", 2], ["StoreName", 3], ["ReturnValue", 0]] }, { "src": "x = a[0].b", "constants": ["0"], "names": ["a_0","b","x_1"], - "instructions": [["LoadName",0],["LoadConst",0],["GetItem",0],["LoadAttr",1],["StoreName",2],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0],["LoadConst",0],["GetItem",0],["LoadAttr",1],["StoreName",2],["ReturnValue",0]] }, { "src": "x = a.b[0]", "constants": ["0"], "names": ["a_0","b","x_1"], - "instructions": [["LoadName",0],["LoadAttr",1],["LoadConst",0],["GetItem",0],["StoreName",2],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0],["LoadAttr",1],["LoadConst",0],["GetItem",0],["StoreName",2],["ReturnValue",0]] }, { "src": "x = not a and not b", "constants": [], "names": ["a_0","b_0","x_1"], - "instructions": [["LoadName", 0], ["Not", 0], ["JumpIfFalseOrPop", 5], ["LoadName", 1], ["Not", 0], ["StoreName", 2], ["ReturnValue", 0]], - "annotations": {} + "instructions": [["LoadName", 0], ["Not", 0], ["JumpIfFalseOrPop", 5], ["LoadName", 1], ["Not", 0], ["StoreName", 2], ["ReturnValue", 0]] }, { "src": "x = 1 + 2 * 3 ** 4", "constants": ["1","2","3","4"], "names": ["x_1"], - "instructions": [["LoadConst",0],["LoadConst",1],["LoadConst",2],["LoadConst",3],["Pow",0],["Mul",0],["Add",0],["StoreName",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["LoadConst",1],["LoadConst",2],["LoadConst",3],["Pow",0],["Mul",0],["Add",0],["StoreName",0],["ReturnValue",0]] }, { "src": "x = {k: v for k, v in items}", "constants": [], "names": ["k_0", "v_0", "items_0", "k_1", "v_1", "x_1"], - "instructions": [["BuildDict",0], ["LoadName",2], ["GetIter",0], ["ForIter",11], ["UnpackSequence",2], ["StoreName",0], ["StoreName",1], ["LoadName",0], ["LoadName",1], ["MapAdd",0], ["Jump",3], ["StoreName",5], ["ReturnValue",0]], - "annotations": {} + "instructions": [["BuildDict",0], ["LoadName",2], ["GetIter",0], ["ForIter",11], ["UnpackSequence",2], ["StoreName",0], ["StoreName",1], ["LoadName",0], ["LoadName",1], ["MapAdd",0], ["Jump",3], ["StoreName",5], ["ReturnValue",0]] }, { "src": "a[0] = 1", "constants": ["0","1"], "names": ["a_0"], - "instructions": [["LoadName",0],["LoadConst",0],["LoadConst",1],["StoreItem",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0],["LoadConst",0],["LoadConst",1],["StoreItem",0],["ReturnValue",0]] }, { "src": "d['key'] = True", "constants": ["key"], "names": ["d_0"], - "instructions": [["LoadName",0],["LoadConst",0],["LoadTrue",0],["StoreItem",0],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0],["LoadConst",0],["LoadTrue",0],["StoreItem",0],["ReturnValue",0]] }, { "src": "a.x += 1", "constants": ["1"], "names": ["a_0","x"], - "instructions": [["LoadName",0],["LoadName",0],["LoadAttr",1],["LoadConst",0],["Add",0],["StoreAttr",1],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0],["LoadName",0],["LoadAttr",1],["LoadConst",0],["Add",0],["StoreAttr",1],["ReturnValue",0]] }, { "src": "obj.count -= 1", "constants": ["1"], "names": ["obj_0","count"], - "instructions": [["LoadName",0],["LoadName",0],["LoadAttr",1],["LoadConst",0],["Sub",0],["StoreAttr",1],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0],["LoadName",0],["LoadAttr",1],["LoadConst",0],["Sub",0],["StoreAttr",1],["ReturnValue",0]] }, { "src": "a = b = 1", "constants": ["1"], "names": ["b_1","a_1"], - "instructions": [["LoadConst",0],["StoreName",0],["LoadName",0],["StoreName",1],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["StoreName",0],["LoadName",0],["StoreName",1],["ReturnValue",0]] }, { "src": "a = b = c = 1", "constants": ["1"], "names": ["c_1","b_1","a_1"], - "instructions": [["LoadConst",0],["StoreName",0],["LoadName",0],["StoreName",1],["LoadName",1],["StoreName",2],["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0],["StoreName",0],["LoadName",0],["StoreName",1],["LoadName",1],["StoreName",2],["ReturnValue",0]] }, { "src": "break\nx = 1", "constants": [], "names": [], "instructions": [["ReturnValue",0]], - "annotations": {}, "errors": ["'break' outside loop"] }, { @@ -1633,56 +1411,48 @@ "constants": [], "names": [], "instructions": [["ReturnValue", 0]], - "annotations": {}, "errors": ["'(' was never closed"] }, { "src": "x = '\\x41'", "constants": ["A"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "x = '\\u00e9'", "constants": ["é"], "names": ["x_1"], - "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadConst",0], ["StoreName",0], ["ReturnValue",0]] }, { "src": "a[0] += 1", "constants": ["0", "1"], "names": ["a_0"], - "instructions": [["LoadName",0], ["LoadConst",0], ["Dup2",0], ["GetItem",0], ["LoadConst",1], ["Add",0], ["StoreItem",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["LoadName",0], ["LoadConst",0], ["Dup2",0], ["GetItem",0], ["LoadConst",1], ["Add",0], ["StoreItem",0], ["ReturnValue",0]] }, { "src": "x: list[int] = [1, 2]", "constants": ["1", "2"], "names": ["x_1"], - "instructions": [["LoadConst", 0], ["LoadConst", 1], ["BuildList", 2], ["StoreName", 0], ["ReturnValue", 0]], - "annotations": {"x": "list"} + "instructions": [["LoadConst", 0], ["LoadConst", 1], ["BuildList", 2], ["StoreName", 0], ["ReturnValue", 0]] }, { "src": "x: dict[str, int] = {}", "constants": [], "names": ["x_1"], - "instructions": [["BuildDict", 0], ["StoreName", 0], ["ReturnValue", 0]], - "annotations": {"x": "dict"} + "instructions": [["BuildDict", 0], ["StoreName", 0], ["ReturnValue", 0]] }, { "src": "x: tuple[int, str, float] = [1]", "constants": ["1"], "names": ["x_1"], - "instructions": [["LoadConst", 0], ["BuildList", 1], ["StoreName", 0], ["ReturnValue", 0]], - "annotations": {"x": "tuple"} + "instructions": [["LoadConst", 0], ["BuildList", 1], ["StoreName", 0], ["ReturnValue", 0]] }, { "src": "sum(i for i in range(3))", "constants": ["3"], "names": ["i_0", "i_1"], - "instructions": [["BuildList",0], ["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",9], ["StoreName",0], ["LoadName",0], ["ListAppend",0], ["Jump",4], ["CallSum",1], ["PopTop",0], ["ReturnValue",0]], - "annotations": {} + "instructions": [["BuildList",0], ["LoadConst",0], ["CallRange",1], ["GetIter",0], ["ForIter",9], ["StoreName",0], ["LoadName",0], ["ListAppend",0], ["Jump",4], ["CallSum",1], ["PopTop",0], ["ReturnValue",0]] } ] diff --git a/tests/devkit.rs b/tests/devkit.rs new file mode 100644 index 00000000..c0c99999 --- /dev/null +++ b/tests/devkit.rs @@ -0,0 +1,26 @@ +use compiler::devkit::{escape, parse_object, JsonVal}; + +#[test] +fn parse_object_keeps_unicode_and_escapes() { + // Raw multibyte UTF-8, a basic \u escape, and a surrogate pair, all in one value. + let src = "{\"body\": \"a\u{f1}o \\u00e9 \\ud83d\\ude00\", \"headers\": {\"se\u{f1}al\": \"\u{2192}\"}}"; + let pairs = parse_object(src).unwrap(); + match &pairs[0].1 { + JsonVal::Str(s) => assert_eq!(s, "a\u{f1}o \u{e9} \u{1f600}"), + _ => panic!("body should be a string"), + } + match &pairs[1].1 { + JsonVal::Map(m) => assert_eq!(m[0], ("se\u{f1}al".to_string(), "\u{2192}".to_string())), + _ => panic!("headers should be a map"), + } +} + +#[test] +fn escape_round_trips_through_parse() { + let raw = "line\n\u{f1}\t\"q\" \u{1}"; + let json = format!("{{\"v\": \"{}\"}}", escape(raw)); + match &parse_object(&json).unwrap()[0].1 { + JsonVal::Str(s) => assert_eq!(s, raw), + _ => panic!("expected string"), + } +} diff --git a/tests/lexer.rs b/tests/lexer.rs index 6c2abfb6..5a5ceb80 100644 --- a/tests/lexer.rs +++ b/tests/lexer.rs @@ -4,6 +4,7 @@ mod test { use compiler::lexer::lex; #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] struct Case { src: String, tokens: Vec<String>, diff --git a/tests/main.rs b/tests/main.rs index c743803d..c53d6c51 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -6,3 +6,4 @@ mod parser; mod vm; mod packages; mod snapshot; +mod devkit; diff --git a/tests/packages.rs b/tests/packages.rs index cb0f7a8e..815dc786 100644 --- a/tests/packages.rs +++ b/tests/packages.rs @@ -17,6 +17,7 @@ mod test { /* `native`/`code` drive resolve(); optional `bytes` drives fetch_bytes() for `#sha256-` cases. */ #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] #[serde(untagged)] enum ModuleDef { Native { @@ -33,6 +34,7 @@ mod test { /* Per-directory `packages.json` for walk-up cases; flat fixtures use `aliases` instead. */ #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] struct ManifestDef { #[serde(default)] imports: HashMap<String, String>, @@ -41,6 +43,7 @@ mod test { } #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] struct Case { src: String, #[serde(default)] @@ -72,6 +75,7 @@ mod test { } #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] struct Delivery { id: u32, #[serde(default)] diff --git a/tests/parser.rs b/tests/parser.rs index c71afda0..984ee643 100644 --- a/tests/parser.rs +++ b/tests/parser.rs @@ -5,6 +5,7 @@ mod test { use compiler::parser::{Parser, Value, Diagnostic}; #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] struct Case { src: String, constants: Vec<String>, @@ -97,6 +98,18 @@ mod test { ); } + // A multibyte identifier past the SSA name buffer must truncate on a codepoint boundary. + #[test] + fn long_multibyte_names_stay_valid_utf8() { + let name = "\u{4E2D}".repeat(43); + let src = format!("{name} = 1\n{name} = 2\nprint({name})\n"); + let (tokens, _) = lex(&src); + let (chunk, _) = Parser::new(&src, tokens.into_iter()).parse(); + for n in &chunk.names { + assert!(core::str::from_utf8(n.as_bytes()).is_ok(), "invalid utf8 in SSA name {n:?}"); + } + } + // Deep prefix / right-associative operator chains must hit the expr-depth guard and error, not overflow the parser's native/WASM stack. #[test] fn deep_operator_chains_error_not_overflow() { diff --git a/tests/snapshot.rs b/tests/snapshot.rs index 1fd3b44a..549a6cfd 100644 --- a/tests/snapshot.rs +++ b/tests/snapshot.rs @@ -8,6 +8,7 @@ mod test { use compiler::vm::types::{Limits, SchedulerStatus, VmErr}; #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] struct Case { src: String, #[serde(default)] diff --git a/tests/vm.rs b/tests/vm.rs index ba6e8740..90891113 100644 --- a/tests/vm.rs +++ b/tests/vm.rs @@ -8,6 +8,7 @@ mod test { use compiler::vm::types::{SchedulerStatus, VmErr}; #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] struct Case { src: String, output: Vec<String>,