From 5e35a91c2cac5e34a82c05233b88365e072cd7e7 Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Wed, 25 Feb 2026 12:24:13 +0100 Subject: [PATCH 1/2] Added documentation for easier onboarding. Added Makefile, as Makefiles are leeted --- Makefile | 59 +++++++++++++++++++++++ README.md | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..658a829 --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +# B2 — scripting runtime +# Usage: make [target]; run script with: make run SCRIPT=hello.s + +.PHONY: build test run repl install check fmt clean help + +# Default: build +all: build + +build: + cargo build + +# Release build +release: + cargo build --release + +test: + cargo test + +# Run a script (e.g. make run SCRIPT=hello.s) +run: + @if [ -z "$(SCRIPT)" ]; then \ + echo "Usage: make run SCRIPT=path/to/script.s"; \ + exit 1; \ + fi + cargo run -- $(SCRIPT) + +# Run REPL +repl: + cargo run -- repl + +# Install binary to cargo bin dir +install: + cargo install --path . + +# Lint and format check (no write) +check: + cargo clippy -- -D warnings + cargo fmt -- --check + +# Format code +fmt: + cargo fmt + +# Remove build artifacts +clean: + cargo clean + +help: + @echo "B2 Makefile targets:" + @echo " make build - build debug binary (default)" + @echo " make release - build release binary" + @echo " make test - run tests" + @echo " make run SCRIPT=file.s - run a B2 script" + @echo " make repl - start interactive REPL" + @echo " make install - install binary (cargo install --path .)" + @echo " make check - clippy + fmt check" + @echo " make fmt - format code" + @echo " make clean - remove build artifacts" + @echo " make help - show this help" diff --git a/README.md b/README.md index 8714e95..c1a6bfb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,50 @@ -# B2 +# B2 -A toy scripting language runtime in Rust. Spiritual successor to Shelltrac, which was intended to easily shell or ssh out and script with those results, but faster. +A toy scripting language runtime in Rust. B2 is built for **shell and SSH orchestration**: run local or remote commands, get structured results (stdout, stderr, exit code), then process them with a simple language—functions, arrays, loops, and parallel iteration. +Spiritual successor to Shelltrac: script with shell/SSH results, but faster. -## Running Scripts: +--- + +## Prerequisites + +- [Rust](https://rustup.rs) (1.85+ for edition 2024; or set `edition = "2021"` in `Cargo.toml` for older toolchains) + +--- + +## Quick start + +**Run a script:** + +```bash +cargo run -- hello.s +``` + +**Interactive REPL:** + +```bash +cargo run -- repl +``` + +**Run from stdin:** + +```bash +cargo run -- < script.s +``` + +--- + +## Language basics + +- **Variables:** `let x = 10` +- **Functions:** `fn greet(name, suffix = "!") { return "hello, #{name}#{suffix}" }` +- **Interpolation:** `"hello, #{name}"` +- **Arrays:** `[1, 2, 3]` +- **Named args:** `greet(name = "world", suffix = "!")` +- **Lambdas:** `find(nums, n -> n > 3)` +- **Control flow:** `if` / `else`, `for`, `while`, `try` / `catch` / `finally` + +Example: ```bash cat > hello.s <<'EOF' @@ -21,10 +62,102 @@ EOF cargo run -- hello.s ``` -Explore the code to discover: `sh`/`ssh` blocks, `parallel for` loops. +--- + +## Shell and SSH + +### `sh` — run local commands + +Run a command string or a block. Returns a value with `stdout`, `stderr`, `exit_code`, and `duration_ms`. + +**Expression form:** + +```text +let r = sh "ls -la /tmp" +log r.stdout +if r.exit_code != 0 { log "Failed: " + r.stderr } +``` + +**Block form (multi-line):** + +```text +let r = sh { + echo "hello" + whoami + date +} +log r.stdout +``` + +### `ssh` — run remote commands + +Same idea, with a host first: `ssh host command` or `ssh host { ... }`. + +```text +let r = ssh "myserver" "uptime" +log r.stdout + +parallel for host in ["server1", "server2"] { + let r = ssh host "df -h /" + log host + ": " + r.stdout +} +``` + +--- + +## Parallel loops + +`parallel for` runs loop iterations in parallel (threads). Use it to run the same task across many items without waiting for each one in sequence. + +```text +parallel for host in ["host1", "host2", "host3"] { + let r = ssh host "uptime" + log host + " -> " + r.stdout +} +``` + +--- + +## Built-in functions + +| Function | Description | +|----------|-------------| +| `log(x)` | Print with timestamp (or no args for newline). | +| `echo(x)` | Print value, no timestamp. | +| `read_file(path)` | Read file contents as a string. | +| `wait(ms)` / `wait(duration)` | Sleep (e.g. `wait(100)` or `wait("2s")`). | +| `getType(x)` | Return type name as string. | +| `unwrap(x)` | Unwrap a nominal wrapper value. | +| `map(iterable, fn)` | Map over collection. | +| `filter(iterable, fn)` | Filter by predicate. | +| `find(iterable, fn)` | First element matching predicate (or null). | +| `findIndex(iterable, fn)` | Index of first match, or -1. | +| `reduce(iterable, fn, initial)` | Reduce to single value. | +| `flatMap(iterable, fn)` | Map and flatten. | +| `any(iterable, fn)` | True if any element matches. | +| `all(iterable, fn)` | True if all match. | +| `sort(iterable)` / `sort(iterable, comparator)` | Sort (optional comparator). | +| `sortBy(iterable, selector, descending?)` | Sort by key. | +| `groupBy(iterable, selector)` | Group into dict by key. | +| `partition(iterable, predicate)` | Split into [pass, fail]. | +| `distinct(iterable)` / `distinct(iterable, selector)` | Unique elements. | +| `collect(iterable)` | Collect iterator/generator to array. | +| `next(generator)` | Advance generator. | +| `validateType(value, typeName)` | Type check, returns bool. | + +**String methods** (e.g. `s.method(args)`): `length`, `toUpper`, `toLower`, `trim`, `split(sep)`, `lines`, `contains(sub)`, `startsWith(sub)`, `endsWith(sub)`, `replace(from, to)`, `substring(start, len)`, `indexOf(sub)`, `join(array)`, `padLeft(n)`, `padRight(n)`, `remove(start, len)`. + +**Number methods**: `abs`, `floor`, `ceil`, `round`, `min(x)`, `max(x)`, `clamp(lo, hi)`, `pow(exp)`, `sqrt`, `toInt`, `toFloat`, `isInt`, `toString`. + +**`net` module** (e.g. `net.http(url)`): `ping(host)`, `http(url | options)`, `tcp_connect(host, port)`, `tcp_listen(host, port)`. + +--- ## REPL ```bash cargo run -- repl ``` + +Evaluate expressions, call `sh`/`ssh`, inspect results. Use REPL commands (e.g. `vars`) if the REPL supports them—see the REPL help or source. + From 7430df73bbe91dc50ef61c5e9bc40444dd61516a Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Wed, 25 Feb 2026 13:23:30 +0100 Subject: [PATCH 2/2] Added documentation for easier onboarding. Added Makefile, as Makefiles are leeted. Added small feature plan, mostly around things I'd use personally with the help of asking Claude. No code exposed to Claude. --- .github/workflows/ci.yml | 68 ++ .gitignore | 7 + Cargo.lock | 1825 +++++++++++++++++++++++++++++++ docs/feature-plan.md | 126 +++ src/cli/io.rs | 3 +- src/lexer/scanner.rs | 128 ++- src/lib.rs | 39 + src/main.rs | 38 +- src/parser/ast.rs | 249 ++++- src/parser/parser.rs | 573 ++++++++-- src/repl/mod.rs | 74 +- src/runtime/builtins/core.rs | 71 +- src/runtime/builtins/net.rs | 102 +- src/runtime/builtins/numbers.rs | 5 +- src/runtime/builtins/strings.rs | 40 +- src/runtime/errors.rs | 30 +- src/runtime/executor.rs | 806 ++++++++++---- src/runtime/shell/result.rs | 10 +- src/runtime/shell/sh.rs | 15 +- src/runtime/shell/ssh.rs | 15 +- src/runtime/value.rs | 111 +- tests/e2e.rs | 72 ++ 22 files changed, 3926 insertions(+), 481 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Cargo.lock create mode 100644 docs/feature-plan.md create mode 100644 src/lib.rs create mode 100644 tests/e2e.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a946d07 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +# CI runs test, clippy, and fmt. Uses rustup and run steps only (no Node-based actions) so it works with act. +# Container is ubuntu:22.04 (has curl; node image has no curl). Cache omitted so we don't need Node. On Apple M-series: act --container-architecture linux/amd64 + +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ubuntu-latest + container: + image: ubuntu:22.04 + steps: + - uses: actions/checkout@v4 + + - name: Install curl, build-essential, and Rust + run: | + apt-get update && apt-get install -y curl build-essential + curl -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + "$HOME/.cargo/bin/rustup" component add rustfmt clippy + + - name: cargo test + run: . "$HOME/.cargo/env" && cargo test + + clippy: + name: Clippy + runs-on: ubuntu-latest + container: + image: ubuntu:22.04 + steps: + - uses: actions/checkout@v4 + + - name: Install curl, build-essential, and Rust + run: | + apt-get update && apt-get install -y curl build-essential + curl -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + "$HOME/.cargo/bin/rustup" component add clippy + + - name: cargo clippy + run: . "$HOME/.cargo/env" && cargo clippy + + fmt: + name: Format + runs-on: ubuntu-latest + container: + image: ubuntu:22.04 + steps: + - uses: actions/checkout@v4 + + - name: Install curl, build-essential, and Rust + run: | + apt-get update && apt-get install -y curl build-essential + curl -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + "$HOME/.cargo/bin/rustup" component add rustfmt + + - name: cargo fmt --check + run: . "$HOME/.cargo/env" && cargo fmt --all -- --check diff --git a/.gitignore b/.gitignore index 2f7896d..f8ff3d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ target/ + +# macOS +.DS_Store + +# IDE +.idea/ +.vscode/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5ca82b3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1825 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "v2r" +version = "0.1.0" +dependencies = [ + "crossterm", + "ratatui", + "regex", + "reqwest", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/docs/feature-plan.md b/docs/feature-plan.md new file mode 100644 index 0000000..4a6b23f --- /dev/null +++ b/docs/feature-plan.md @@ -0,0 +1,126 @@ +# B2 product feature plan + +Jan and Niek are boring as hell. + +--- + +## Intro + +B2 today gives you: a small language (functions, lambdas, arrays/dicts, control flow, types/records), **sh** / **ssh** returning `{ stdout, stderr, exit_code, duration_ms }`, **parallel for**, builtins (log, read_file, map/filter/sort/etc.), **net** (ping, http, tcp), string/number methods, a TUI REPL, and script imports. There is **read_file** but no **write_file**; no script arguments or env access; no JSON; no timeout or cwd for shell; REPL has no way to load a script file. + +--- + +## 1. High value, low effort + +**File I/O** + +- **write_file(path, contents)** — Scripts often need to write results or configs. Today only [core.rs](../src/runtime/builtins/core.rs) has `read_file`. Add a native that does `fs::write`. Optional: append mode. +- **list_dir(path)** — Return array of filenames (or names + metadata) for "run command per file" or "find files matching" workflows. +- **path_exists(path)** / **is_file** / **is_dir** — Avoid calling `sh "test -f ..."` for simple checks. + +**Script arguments and env** + +- **argv** — Global or builtin that exposes script arguments (e.g. `cargo run -- script.s --foo bar` → script can read args). Requires [args.rs](../src/cli/args.rs) and [io.rs](../src/cli/io.rs) to pass args through to the executor and expose them (e.g. as a global array or `args()` builtin). +- **env** — Read env vars from script (e.g. `env("HOME")`) so scripts can adapt to environment without `sh "echo $HOME"`. + +**Shell result ergonomics** + +- **.ok** on the dict returned by `sh`/`ssh` — e.g. `r.ok` true when `exit_code == 0`. Convenience for conditionals. +- **.lines** — Return array of lines from `stdout` (or a method on string that's already there, used on `r.stdout`). Very common after running a command. + +These fit the "script with shell results" story and are straightforward to add in the runtime and builtins. + +--- + +## 2. High value, mid effort + +**JSON** + +- **parse_json(string)** and **to_json(value)** — Many scripts consume API output or config files. Today you'd have to call an external tool. Adding a dependency (e.g. `serde_json`) and two builtins gives a big upgrade for "script + HTTP + data" use cases. + +**Shell/SSH upgrades** + +- **Timeout** for `sh` and `ssh` — Optional timeout argument so long-running or stuck commands don't hang the script. [sh.rs](../src/runtime/shell/sh.rs) and [ssh.rs](../src/runtime/shell/ssh.rs) would need to run the command with a timeout (e.g. Rust timeout or background thread + kill). +- **Working directory** for `sh` — Optional `cwd` so scripts can run commands in a given directory without building cd into the command string. +- **SSH options** — Key path, port, user (or parse `user@host`). Makes SSH usable in more real-world setups. + +**Stdio** + +- **stdin()** — Read stdin (e.g. whole input or line-by-line) so B2 scripts can be used in pipes: `cat list.txt | b2 script.s`. +- **stderr** builtin or channel — Explicit "log to stderr" for tooling (vs log to stdout). + +**REPL** + +- **Load script** — Command or builtin to evaluate a `.s` file inside the REPL (e.g. `:load script.s`) so you can experiment on top of a script's definitions. + +--- + +## 3. Medium value, variable effort + +**Concurrency control** + +- **Rate limit / semaphore for parallel for** — Cap concurrency (e.g. "at most 5 SSH at a time") to avoid thundering herd and respect remote limits. Could be a modifier on `parallel for` or a wrapper builtin. + +**Assertions and testing** + +- **assert(condition, message?)** — Fail script with a clear message. Useful for scripts that validate state. +- **Test runner** — Optional `cargo test`-driven runner for `.s` files (run script, assert on exit code or stdout). Builds on existing e2e-style tests. + +**Packaging and distribution** + +- **Shebang** — Support `#!/usr/bin/env b2` so scripts can be executed directly (e.g. `./script.s`) once `b2` is on PATH. +- **Install** — Document or streamline `cargo install --path .` so users get a single `b2` (or `v2r`) binary. + +**Docs and discovery** + +- **REPL help** — `:help` or `?log` showing builtin/module docs. Requires storing doc strings and exposing them in the REPL. +- **Standard library doc** — One place (e.g. README or `docs/stdlib.md`) listing builtins and `net` with signatures and short examples. + +--- + +## 4. Large/niche additions + +- **Regex builtin** — Match/split/replace by regex without calling out to `grep`/`sed`. Depends on whether you want to keep "shell-oriented" or add more string power in-process. +- **Config file** — Optional config (e.g. default SSH key, timeout, parallelism) so scripts don't hardcode. +- **Debug / trace** — `trace(expr)` or `breakpoint()` that prints value and span; or a debugger hook for stepping (much larger). +- **More net** — HTTPS client options (headers, POST body, auth), or a small WebSocket helper if needed for scripting UIs or services. + +--- + +## Prio + +1. **Quick wins** — `write_file`, `path_exists`, `argv`, `env`, and `.ok` / `.lines` on shell results. These directly support "script that runs commands and writes results or branches on args/env." +2. **JSON** — `parse_json` / `to_json` plus `net.http` make "call API and process JSON" a first-class workflow. +3. **Shell/SSH** — Timeout and optional cwd for `sh`; SSH key/port/user. Then stdin/stderr for pipes and tooling. +4. **REPL and DX** — Load script in REPL, assert builtin, then REPL help and stdlib doc. +5. **Scale and polish** — Concurrency cap for parallel for, shebang, test runner, then config and niche features as needed. + +--- + +## Summary diagram + +```mermaid +flowchart LR + subgraph quick [Quick wins] + write_file[write_file] + list_dir[list_dir] + path_exists[path_exists] + argv[argv] + env[env] + shell_ok[.ok / .lines] + end + subgraph next [Next] + json[JSON parse/to_json] + sh_timeout[sh/ssh timeout] + sh_cwd[sh cwd] + ssh_opts[SSH key/port] + stdin[stdin] + end + subgraph dx [DX] + repl_load[REPL load script] + assert_builtin[assert] + repl_help[REPL help] + end + quick --> next + next --> dx +``` diff --git a/src/cli/io.rs b/src/cli/io.rs index 1049595..15c1663 100644 --- a/src/cli/io.rs +++ b/src/cli/io.rs @@ -5,8 +5,7 @@ use super::args::CliArgs; pub fn load_source(args: &CliArgs) -> Result<(String, String), String> { if let Some(path) = &args.script_path { - let src = fs::read_to_string(path) - .map_err(|e| format!("Failed to read {path}: {e}"))?; + let src = fs::read_to_string(path).map_err(|e| format!("Failed to read {path}: {e}"))?; return Ok((src, path.clone())); } diff --git a/src/lexer/scanner.rs b/src/lexer/scanner.rs index 0ea1c34..c13b03a 100644 --- a/src/lexer/scanner.rs +++ b/src/lexer/scanner.rs @@ -30,7 +30,12 @@ impl<'a> Scanner<'a> { None => {} } } - tokens.push(Token::new(TokenKind::Eof, self.line, self.column, self.current)); + tokens.push(Token::new( + TokenKind::Eof, + self.line, + self.column, + self.current, + )); Ok(tokens) } @@ -200,7 +205,9 @@ impl<'a> Scanner<'a> { } let slice = &self.source[self.start..self.current]; if is_time_literal(slice) { - return Ok(Some(self.token_from(TokenKind::TimeLiteral(slice.to_string())))); + return Ok(Some( + self.token_from(TokenKind::TimeLiteral(slice.to_string())), + )); } self.current = suffix_start; } @@ -271,16 +278,28 @@ impl<'a> Scanner<'a> { } fn peek(&self) -> char { - if self.is_at_end() { '\0' } else { self.chars[self.current] } + if self.is_at_end() { + '\0' + } else { + self.chars[self.current] + } } fn peek_next(&self) -> char { - if self.current + 1 >= self.chars.len() { '\0' } else { self.chars[self.current + 1] } + if self.current + 1 >= self.chars.len() { + '\0' + } else { + self.chars[self.current + 1] + } } fn match_char(&mut self, expected: char) -> bool { - if self.is_at_end() { return false; } - if self.chars[self.current] != expected { return false; } + if self.is_at_end() { + return false; + } + if self.chars[self.current] != expected { + return false; + } self.current += 1; self.column += 1; true @@ -321,9 +340,102 @@ fn unescape_string(s: &str) -> String { fn is_time_literal(s: &str) -> bool { let mut idx = 0; for c in s.chars() { - if c.is_ascii_digit() { idx += 1; } else { break; } + if c.is_ascii_digit() { + idx += 1; + } else { + break; + } + } + if idx == 0 || idx >= s.len() { + return false; } - if idx == 0 || idx >= s.len() { return false; } let unit = &s[idx..]; matches!(unit, "ms" | "s" | "m" | "h" | "d") } + +#[cfg(test)] +mod tests { + use super::Scanner; + use crate::lexer::token::TokenKind; + + fn token_kinds(source: &str) -> Vec { + let mut scanner = Scanner::new(source); + let tokens = scanner.scan_tokens().unwrap(); + tokens.into_iter().map(|t| t.kind).collect() + } + + #[test] + fn scan_number() { + let kinds = token_kinds("42"); + assert_eq!(kinds.len(), 2); // Number, Eof + assert!(matches!(&kinds[0], TokenKind::Number(s) if s == "42")); + } + + #[test] + fn scan_arithmetic() { + let kinds = token_kinds("1 + 2"); + assert_eq!(kinds.len(), 4); // Number, Plus, Number, Eof + assert!(matches!(&kinds[0], TokenKind::Number(_))); + assert_eq!(kinds[1], TokenKind::Plus); + assert!(matches!(&kinds[2], TokenKind::Number(_))); + assert_eq!(kinds[3], TokenKind::Eof); + } + + #[test] + fn scan_identifiers_and_keywords() { + let kinds = token_kinds("let x = fn"); + assert_eq!(kinds[0], TokenKind::Let); + assert!(matches!(&kinds[1], TokenKind::Identifier(s) if s == "x")); + assert_eq!(kinds[2], TokenKind::Equal); + assert_eq!(kinds[3], TokenKind::Fn); + } + + #[test] + fn scan_string_literal() { + let kinds = token_kinds(r#" "hello" "#); + assert_eq!(kinds.len(), 2); + assert!(matches!(&kinds[0], TokenKind::String(s) if s == "hello")); + } + + #[test] + fn scan_true_false() { + let kinds = token_kinds("true false"); + assert_eq!(kinds[0], TokenKind::True); + assert_eq!(kinds[1], TokenKind::False); + } + + #[test] + fn scan_comparison_ops() { + let kinds = token_kinds("== != < <= > >="); + assert_eq!(kinds[0], TokenKind::EqualEqual); + assert_eq!(kinds[1], TokenKind::NotEqual); + assert_eq!(kinds[2], TokenKind::Less); + assert_eq!(kinds[3], TokenKind::LessEqual); + assert_eq!(kinds[4], TokenKind::Greater); + assert_eq!(kinds[5], TokenKind::GreaterEqual); + } + + #[test] + fn scan_sh_ssh_keywords() { + let kinds = token_kinds("sh ssh parallel"); + assert_eq!(kinds[0], TokenKind::Sh); + assert_eq!(kinds[1], TokenKind::Ssh); + assert_eq!(kinds[2], TokenKind::Parallel); + } + + #[test] + fn scan_brackets_and_braces() { + let kinds = token_kinds("[ ] { }"); + assert_eq!(kinds[0], TokenKind::LBracket); + assert_eq!(kinds[1], TokenKind::RBracket); + assert_eq!(kinds[2], TokenKind::LBrace); + assert_eq!(kinds[3], TokenKind::RBrace); + } + + #[test] + fn scan_arrow_and_fat_arrow() { + let kinds = token_kinds("-> =>"); + assert_eq!(kinds[0], TokenKind::Arrow); + assert_eq!(kinds[1], TokenKind::FatArrow); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..45633c5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,39 @@ +pub mod cli; +pub mod lexer; +pub mod parser; +pub mod repl; +pub mod runtime; +pub mod utils; + +use std::sync::{Arc, Mutex}; + +/// Run a script with default behavior (log/echo go to stdout). Returns an error on parse or runtime failure. +pub fn run_script(source: &str, filename: &str) -> Result<(), String> { + let mut scanner = lexer::scanner::Scanner::new(source); + let tokens = scanner.scan_tokens()?; + let mut parser = parser::parser::Parser::new(tokens, source, filename.to_string()); + let program = parser.parse_program()?; + let mut executor = runtime::executor::Executor::new(filename.to_string(), source.to_string()); + runtime::builtins::register_builtins(&mut executor); + executor.execute(&program).map_err(|e| e.to_string())?; + Ok(()) +} + +/// Run a script and capture all log/echo output lines. Returns error on parse or runtime failure. +pub fn run_script_capture(source: &str) -> Result, String> { + let mut scanner = lexer::scanner::Scanner::new(source); + let tokens = scanner.scan_tokens()?; + let mut parser = parser::parser::Parser::new(tokens, source, "".to_string()); + let program = parser.parse_program()?; + let mut executor = runtime::executor::Executor::new("".to_string(), source.to_string()); + runtime::builtins::register_builtins(&mut executor); + let output = Arc::new(Mutex::new(Vec::::new())); + let out = output.clone(); + executor.set_output_sink(Some(Arc::new(move |line: String| { + if let Ok(mut v) = out.lock() { + v.push(line); + } + }))); + executor.execute(&program).map_err(|e| e.to_string())?; + Ok(output.lock().map(|v| v.clone()).unwrap_or_default()) +} diff --git a/src/main.rs b/src/main.rs index f6bd110..4fa4f57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,7 @@ -mod cli; -mod lexer; -mod parser; -mod repl; -mod runtime; -mod utils; - -use cli::args::{CliArgs, CliCommand}; -use cli::io::load_source; -use runtime::builtins::register_builtins; -use runtime::executor::Executor; +use v2r::cli::args::{CliArgs, CliCommand}; +use v2r::cli::io::load_source; +use v2r::repl; +use v2r::run_script; fn main() { let args = CliArgs::parse(); @@ -28,28 +21,7 @@ fn main() { } }; - let mut scanner = lexer::scanner::Scanner::new(&source); - let tokens = match scanner.scan_tokens() { - Ok(t) => t, - Err(err) => { - eprintln!("{err}"); - std::process::exit(1); - } - }; - - let mut parser = parser::parser::Parser::new(tokens, &source, filename.clone()); - let program = match parser.parse_program() { - Ok(p) => p, - Err(err) => { - eprintln!("{err}"); - std::process::exit(1); - } - }; - - let mut executor = Executor::new(filename, source); - register_builtins(&mut executor); - - if let Err(err) = executor.execute(&program) { + if let Err(err) = run_script(&source, &filename) { eprintln!("{err}"); std::process::exit(1); } diff --git a/src/parser/ast.rs b/src/parser/ast.rs index b4557a6..e529ea5 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -50,15 +50,54 @@ pub enum ImportSource { #[derive(Debug, Clone)] pub enum Stmt { - Expr { expr: Expr, span: Span }, - Let { name: String, expr: Expr, span: Span }, - LetDestructure { pattern: Pattern, expr: Expr, span: Span }, - Assign { name: String, expr: Expr, span: Span }, - IndexAssign { target: Expr, index: Expr, expr: Expr, span: Span }, - If { cond: Expr, then_branch: Vec, else_branch: Vec, span: Span }, - For { pattern: Pattern, iterable: Expr, body: Vec, span: Span }, - ParallelFor { pattern: Pattern, iterable: Expr, body: Vec, span: Span }, - While { cond: Expr, body: Vec, span: Span }, + Expr { + expr: Expr, + span: Span, + }, + Let { + name: String, + expr: Expr, + span: Span, + }, + LetDestructure { + pattern: Pattern, + expr: Expr, + span: Span, + }, + Assign { + name: String, + expr: Expr, + span: Span, + }, + IndexAssign { + target: Expr, + index: Expr, + expr: Expr, + span: Span, + }, + If { + cond: Expr, + then_branch: Vec, + else_branch: Vec, + span: Span, + }, + For { + pattern: Pattern, + iterable: Expr, + body: Vec, + span: Span, + }, + ParallelFor { + pattern: Pattern, + iterable: Expr, + body: Vec, + span: Span, + }, + While { + cond: Expr, + body: Vec, + span: Span, + }, Try { try_block: Vec, catch_name: Option, @@ -74,33 +113,113 @@ pub enum Stmt { is_generator: bool, span: Span, }, - Return { value: Option, span: Span }, - Yield { value: Option, span: Span }, - Continue { span: Span }, - Break { span: Span }, - Throw { value: Expr, span: Span }, - Defer { body: Vec, span: Span }, - Use { module: String, alias: Option, span: Span }, - ImportNamed { items: Vec, source: ImportSource, span: Span }, - ImportNamespace { alias: String, source: ImportSource, span: Span }, - TypeAlias { name: String, target: TypeExpr, span: Span }, - RecordDef { name: String, fields: Vec, span: Span }, - Invoke { name: String, expr: Expr, span: Span }, + Return { + value: Option, + span: Span, + }, + Yield { + value: Option, + span: Span, + }, + Continue { + span: Span, + }, + Break { + span: Span, + }, + Throw { + value: Expr, + span: Span, + }, + Defer { + body: Vec, + span: Span, + }, + Use { + module: String, + alias: Option, + span: Span, + }, + ImportNamed { + items: Vec, + source: ImportSource, + span: Span, + }, + ImportNamespace { + alias: String, + source: ImportSource, + span: Span, + }, + TypeAlias { + name: String, + target: TypeExpr, + span: Span, + }, + RecordDef { + name: String, + fields: Vec, + span: Span, + }, + Invoke { + name: String, + expr: Expr, + span: Span, + }, } #[derive(Debug, Clone)] pub enum Expr { - Literal { value: Value, span: Span }, - Var { name: String, span: Span }, - Binary { left: Box, op: String, right: Box, span: Span }, - Unary { op: String, expr: Box, span: Span }, - Call { callee: Box, args: Vec, span: Span }, - Member { object: Box, name: String, span: Span }, - NamespaceMember { object: Box, name: String, span: Span }, - Index { object: Box, index: Box, span: Span }, - Array { items: Vec, span: Span }, - Tuple { items: Vec, span: Span }, - Dict { items: Vec<(Expr, Expr)>, span: Span }, + Literal { + value: Value, + span: Span, + }, + Var { + name: String, + span: Span, + }, + Binary { + left: Box, + op: String, + right: Box, + span: Span, + }, + Unary { + op: String, + expr: Box, + span: Span, + }, + Call { + callee: Box, + args: Vec, + span: Span, + }, + Member { + object: Box, + name: String, + span: Span, + }, + NamespaceMember { + object: Box, + name: String, + span: Span, + }, + Index { + object: Box, + index: Box, + span: Span, + }, + Array { + items: Vec, + span: Span, + }, + Tuple { + items: Vec, + span: Span, + }, + Dict { + items: Vec<(Expr, Expr)>, + span: Span, + }, ArrayComprehension { pattern: Pattern, iterable: Box, @@ -108,17 +227,61 @@ pub enum Expr { map_expr: Box, span: Span, }, - Range { start: Box, end: Box, span: Span }, - IfExpr { cond: Box, then_branch: Vec, else_branch: Vec, span: Span }, - Match { subject: Box, arms: Vec, span: Span }, - ParallelFor { pattern: Pattern, iterable: Box, body: Vec, span: Span }, - TaskBlock { body: Vec, span: Span }, - TaskCall { callee: Box, args: Vec, span: Span }, - Await { expr: Box, safe: bool, span: Span }, - Lambda { params: Vec, return_type: Option, body: Vec, span: Span }, - InterpolatedString { parts: Vec, span: Span }, - Sh { command: Box, span: Span }, - Ssh { host: Box, command: Box, span: Span }, + Range { + start: Box, + end: Box, + span: Span, + }, + IfExpr { + cond: Box, + then_branch: Vec, + else_branch: Vec, + span: Span, + }, + Match { + subject: Box, + arms: Vec, + span: Span, + }, + ParallelFor { + pattern: Pattern, + iterable: Box, + body: Vec, + span: Span, + }, + TaskBlock { + body: Vec, + span: Span, + }, + TaskCall { + callee: Box, + args: Vec, + span: Span, + }, + Await { + expr: Box, + safe: bool, + span: Span, + }, + Lambda { + params: Vec, + return_type: Option, + body: Vec, + span: Span, + }, + InterpolatedString { + parts: Vec, + span: Span, + }, + Sh { + command: Box, + span: Span, + }, + Ssh { + host: Box, + command: Box, + span: Span, + }, } #[derive(Debug, Clone)] diff --git a/src/parser/parser.rs b/src/parser/parser.rs index c2dfc5d..dda69b3 100644 --- a/src/parser/parser.rs +++ b/src/parser/parser.rs @@ -1,6 +1,9 @@ +use super::ast::{ + CallArg, Expr, ImportItem, ImportSource, InterpPart, MatchArm, MatchArmKind, ParamSpec, + Pattern, Program, RecordField, Span, Stmt, +}; use crate::lexer::token::{Token, TokenKind}; use crate::runtime::value::Value; -use super::ast::{CallArg, Expr, ImportItem, ImportSource, InterpPart, MatchArm, MatchArmKind, ParamSpec, Pattern, Program, RecordField, Span, Stmt}; pub struct Parser<'a> { tokens: Vec, @@ -11,13 +14,20 @@ pub struct Parser<'a> { impl<'a> Parser<'a> { pub fn new(tokens: Vec, source: &'a str, filename: String) -> Self { - Parser { tokens, current: 0, source, filename } + Parser { + tokens, + current: 0, + source, + filename, + } } pub fn parse_program(&mut self) -> Result { let mut statements = Vec::new(); while !self.is_at_end() { - if self.match_kind(&TokenKind::Semicolon) { continue; } + if self.match_kind(&TokenKind::Semicolon) { + continue; + } statements.push(self.parse_stmt()?); } Ok(Program { statements }) @@ -27,14 +37,21 @@ impl<'a> Parser<'a> { if self.match_kind(&TokenKind::Let) { let span = self.prev_span(); // Destructuring if next tokens indicate a pattern - if self.check(&TokenKind::LParen) || self.check(&TokenKind::Underscore) || self.check(&TokenKind::Identifier(String::new())) { + if self.check(&TokenKind::LParen) + || self.check(&TokenKind::Underscore) + || self.check(&TokenKind::Identifier(String::new())) + { // Peek for comma to distinguish simple let let is_destructure = self.lookahead_has_comma_before_equal(); if is_destructure { let pattern = self.parse_pattern_list()?; self.consume(&TokenKind::Equal, "Expected '=' after pattern")?; let expr = self.parse_expression()?; - return Ok(Stmt::LetDestructure { pattern, expr, span }); + return Ok(Stmt::LetDestructure { + pattern, + expr, + span, + }); } } let name = self.consume_identifier("Expected variable name after 'let'")?; @@ -66,9 +83,15 @@ impl<'a> Parser<'a> { let value = if values.len() == 1 { values.remove(0) } else { - Expr::Tuple { items: values, span } + Expr::Tuple { + items: values, + span, + } }; - return Ok(Stmt::Return { value: Some(value), span }); + return Ok(Stmt::Return { + value: Some(value), + span, + }); } if self.match_kind(&TokenKind::Yield) { let span = self.prev_span(); @@ -155,12 +178,20 @@ impl<'a> Parser<'a> { if self.match_kind(&TokenKind::Log) { let span = self.prev_span(); let expr = self.parse_expression()?; - return Ok(Stmt::Invoke { name: "log".to_string(), expr, span }); + return Ok(Stmt::Invoke { + name: "log".to_string(), + expr, + span, + }); } if self.match_kind(&TokenKind::Echo) { let span = self.prev_span(); let expr = self.parse_expression()?; - return Ok(Stmt::Invoke { name: "echo".to_string(), expr, span }); + return Ok(Stmt::Invoke { + name: "echo".to_string(), + expr, + span, + }); } // assignment or expression @@ -171,14 +202,22 @@ impl<'a> Parser<'a> { let span = assignable.span(); return match assignable { Expr::Var { name, .. } => Ok(Stmt::Assign { name, expr, span }), - Expr::Index { object, index, .. } => Ok(Stmt::IndexAssign { target: *object, index: *index, expr, span }), + Expr::Index { object, index, .. } => Ok(Stmt::IndexAssign { + target: *object, + index: *index, + expr, + span, + }), _ => Err(self.error_at_current("Invalid assignment target")), }; } } self.current = saved; let expr = self.parse_expression()?; - Ok(Stmt::Expr { expr: expr.clone(), span: expr.span() }) + Ok(Stmt::Expr { + expr: expr.clone(), + span: expr.span(), + }) } fn parse_function_stmt(&mut self) -> Result { @@ -256,7 +295,12 @@ impl<'a> Parser<'a> { } else { Vec::new() }; - Ok(Stmt::If { cond, then_branch, else_branch, span }) + Ok(Stmt::If { + cond, + then_branch, + else_branch, + span, + }) } fn parse_for_stmt(&mut self) -> Result { @@ -268,7 +312,12 @@ impl<'a> Parser<'a> { } let iterable = self.parse_expression()?; let body = self.parse_block()?; - Ok(Stmt::For { pattern, iterable, body, span }) + Ok(Stmt::For { + pattern, + iterable, + body, + span, + }) } fn parse_parallel_for_stmt(&mut self) -> Result { @@ -281,7 +330,12 @@ impl<'a> Parser<'a> { } let iterable = self.parse_expression()?; let body = self.parse_block()?; - Ok(Stmt::ParallelFor { pattern, iterable, body, span }) + Ok(Stmt::ParallelFor { + pattern, + iterable, + body, + span, + }) } fn parse_parallel_for_expr(&mut self) -> Result { @@ -294,7 +348,12 @@ impl<'a> Parser<'a> { } let iterable = self.parse_expression()?; let body = self.parse_block()?; - Ok(Expr::ParallelFor { pattern, iterable: Box::new(iterable), body, span }) + Ok(Expr::ParallelFor { + pattern, + iterable: Box::new(iterable), + body, + span, + }) } fn parse_while_stmt(&mut self) -> Result { @@ -312,7 +371,11 @@ impl<'a> Parser<'a> { } else { None }; - Ok(Stmt::Use { module, alias, span }) + Ok(Stmt::Use { + module, + alias, + span, + }) } fn parse_import_stmt(&mut self) -> Result { @@ -322,14 +385,22 @@ impl<'a> Parser<'a> { let alias = self.consume_identifier("Expected alias name after 'as'")?; self.consume(&TokenKind::From, "Expected 'from' in import statement")?; let source = self.parse_import_source()?; - return Ok(Stmt::ImportNamespace { alias, source, span }); + return Ok(Stmt::ImportNamespace { + alias, + source, + span, + }); } if self.match_kind(&TokenKind::LBrace) { let items = self.parse_import_items()?; self.consume(&TokenKind::From, "Expected 'from' in import statement")?; let source = self.parse_import_source()?; - return Ok(Stmt::ImportNamed { items, source, span }); + return Ok(Stmt::ImportNamed { + items, + source, + span, + }); } let name = self.consume_identifier("Expected imported symbol after 'import'")?; @@ -392,7 +463,12 @@ impl<'a> Parser<'a> { let op = self.previous_lexeme(); let right = self.parse_and()?; let span = expr.span(); - expr = Expr::Binary { left: Box::new(expr), op, right: Box::new(right), span }; + expr = Expr::Binary { + left: Box::new(expr), + op, + right: Box::new(right), + span, + }; } Ok(expr) } @@ -403,7 +479,12 @@ impl<'a> Parser<'a> { let op = self.previous_lexeme(); let right = self.parse_equality()?; let span = expr.span(); - expr = Expr::Binary { left: Box::new(expr), op, right: Box::new(right), span }; + expr = Expr::Binary { + left: Box::new(expr), + op, + right: Box::new(right), + span, + }; } Ok(expr) } @@ -414,18 +495,33 @@ impl<'a> Parser<'a> { let op = self.previous_lexeme(); let right = self.parse_comparison()?; let span = expr.span(); - expr = Expr::Binary { left: Box::new(expr), op, right: Box::new(right), span }; + expr = Expr::Binary { + left: Box::new(expr), + op, + right: Box::new(right), + span, + }; } Ok(expr) } fn parse_comparison(&mut self) -> Result { let mut expr = self.parse_term()?; - while self.match_any(&[TokenKind::Less, TokenKind::LessEqual, TokenKind::Greater, TokenKind::GreaterEqual]) { + while self.match_any(&[ + TokenKind::Less, + TokenKind::LessEqual, + TokenKind::Greater, + TokenKind::GreaterEqual, + ]) { let op = self.previous_lexeme(); let right = self.parse_term()?; let span = expr.span(); - expr = Expr::Binary { left: Box::new(expr), op, right: Box::new(right), span }; + expr = Expr::Binary { + left: Box::new(expr), + op, + right: Box::new(right), + span, + }; } Ok(expr) } @@ -436,7 +532,12 @@ impl<'a> Parser<'a> { let op = self.previous_lexeme(); let right = self.parse_factor()?; let span = expr.span(); - expr = Expr::Binary { left: Box::new(expr), op, right: Box::new(right), span }; + expr = Expr::Binary { + left: Box::new(expr), + op, + right: Box::new(right), + span, + }; } Ok(expr) } @@ -447,7 +548,12 @@ impl<'a> Parser<'a> { let op = self.previous_lexeme(); let right = self.parse_unary()?; let span = expr.span(); - expr = Expr::Binary { left: Box::new(expr), op, right: Box::new(right), span }; + expr = Expr::Binary { + left: Box::new(expr), + op, + right: Box::new(right), + span, + }; } Ok(expr) } @@ -457,13 +563,21 @@ impl<'a> Parser<'a> { let span = self.prev_span(); let safe = self.match_kind(&TokenKind::Question); let expr = self.parse_unary()?; - return Ok(Expr::Await { expr: Box::new(expr), safe, span }); + return Ok(Expr::Await { + expr: Box::new(expr), + safe, + span, + }); } if self.match_any(&[TokenKind::Bang, TokenKind::Minus]) { let op = self.previous_lexeme(); let span = self.prev_span(); let expr = self.parse_unary()?; - return Ok(Expr::Unary { op, expr: Box::new(expr), span }); + return Ok(Expr::Unary { + op, + expr: Box::new(expr), + span, + }); } self.parse_postfix() } @@ -486,33 +600,53 @@ impl<'a> Parser<'a> { if self.match_kind(&TokenKind::Dot) { let span = self.prev_span(); let name = self.consume_identifier("Expected member name after '.'")?; - expr = Expr::Member { object: Box::new(expr), name, span }; + expr = Expr::Member { + object: Box::new(expr), + name, + span, + }; continue; } if self.match_kind(&TokenKind::ColonColon) { let span = self.prev_span(); let name = self.consume_identifier("Expected namespace member after '::'")?; - expr = Expr::NamespaceMember { object: Box::new(expr), name, span }; + expr = Expr::NamespaceMember { + object: Box::new(expr), + name, + span, + }; continue; } if self.match_kind(&TokenKind::LParen) { let span = self.prev_span(); let args = self.parse_arguments()?; self.consume(&TokenKind::RParen, "Expected ')' after arguments")?; - expr = Expr::Call { callee: Box::new(expr), args, span }; + expr = Expr::Call { + callee: Box::new(expr), + args, + span, + }; continue; } if self.match_kind(&TokenKind::LBracket) { let span = self.prev_span(); let index = self.parse_expression()?; self.consume(&TokenKind::RBracket, "Expected ']' after index")?; - expr = Expr::Index { object: Box::new(expr), index: Box::new(index), span }; + expr = Expr::Index { + object: Box::new(expr), + index: Box::new(index), + span, + }; continue; } if self.match_kind(&TokenKind::DotDot) { let span = self.prev_span(); let end = self.parse_expression()?; - expr = Expr::Range { start: Box::new(expr), end: Box::new(end), span }; + expr = Expr::Range { + start: Box::new(expr), + end: Box::new(end), + span, + }; continue; } break; @@ -536,7 +670,10 @@ impl<'a> Parser<'a> { } return Err(self.error_at_current("Expected ',' or '}' in record constructor")); } - self.consume(&TokenKind::RBrace, "Expected '}' after record constructor fields")?; + self.consume( + &TokenKind::RBrace, + "Expected '}' after record constructor fields", + )?; Ok(args) } @@ -561,29 +698,46 @@ impl<'a> Parser<'a> { fn parse_primary(&mut self) -> Result { if self.match_kind(&TokenKind::True) { let span = self.prev_span(); - return Ok(Expr::Literal { value: Value::Bool(true), span }); + return Ok(Expr::Literal { + value: Value::Bool(true), + span, + }); } if self.match_kind(&TokenKind::False) { let span = self.prev_span(); - return Ok(Expr::Literal { value: Value::Bool(false), span }); + return Ok(Expr::Literal { + value: Value::Bool(false), + span, + }); } if self.match_kind(&TokenKind::Number(String::new())) { if let TokenKind::Number(text) = self.previous().kind.clone() { let span = self.prev_span(); - let val: f64 = text.parse().map_err(|_| self.error_at_current("Invalid number"))?; - return Ok(Expr::Literal { value: Value::Number(val), span }); + let val: f64 = text + .parse() + .map_err(|_| self.error_at_current("Invalid number"))?; + return Ok(Expr::Literal { + value: Value::Number(val), + span, + }); } } if self.match_kind(&TokenKind::TimeLiteral(String::new())) { if let TokenKind::TimeLiteral(text) = self.previous().kind.clone() { let span = self.prev_span(); - return Ok(Expr::Literal { value: Value::Duration(Value::parse_duration(&text)?), span }); + return Ok(Expr::Literal { + value: Value::Duration(Value::parse_duration(&text)?), + span, + }); } } if self.match_kind(&TokenKind::String(String::new())) { if let TokenKind::String(text) = self.previous().kind.clone() { let span = self.prev_span(); - return Ok(Expr::Literal { value: Value::String(text), span }); + return Ok(Expr::Literal { + value: Value::String(text), + span, + }); } } if self.match_kind(&TokenKind::InterpolatedString(String::new())) { @@ -609,8 +763,17 @@ impl<'a> Parser<'a> { let span = self.prev_span(); let cond = self.parse_expression()?; let then_branch = self.parse_block()?; - let else_branch = if self.match_kind(&TokenKind::Else) { self.parse_block()? } else { Vec::new() }; - return Ok(Expr::IfExpr { cond: Box::new(cond), then_branch, else_branch, span }); + let else_branch = if self.match_kind(&TokenKind::Else) { + self.parse_block()? + } else { + Vec::new() + }; + return Ok(Expr::IfExpr { + cond: Box::new(cond), + then_branch, + else_branch, + span, + }); } if self.match_kind(&TokenKind::Match) { return self.parse_match_expr(); @@ -628,18 +791,28 @@ impl<'a> Parser<'a> { let mut args = self.parse_arguments()?; self.consume(&TokenKind::RParen, "Expected ')' after task arguments")?; if args.is_empty() { - return Err(self.error_at_current("task(...) expects callable as first argument")); + return Err( + self.error_at_current("task(...) expects callable as first argument") + ); } let callee = match args.remove(0) { CallArg::Positional(expr) => expr, CallArg::Spread(_) => { - return Err(self.error_at_current("task(...) expects callable as first positional argument")); + return Err(self.error_at_current( + "task(...) expects callable as first positional argument", + )); } CallArg::Named { .. } => { - return Err(self.error_at_current("task(...) expects callable as first positional argument")); + return Err(self.error_at_current( + "task(...) expects callable as first positional argument", + )); } }; - return Ok(Expr::TaskCall { callee: Box::new(callee), args, span }); + return Ok(Expr::TaskCall { + callee: Box::new(callee), + args, + span, + }); } return Err(self.error_at_current("Expected '{' or '(' after 'task'")); } @@ -654,7 +827,12 @@ impl<'a> Parser<'a> { None }; let body = self.parse_block()?; - return Ok(Expr::Lambda { params, return_type, body, span }); + return Ok(Expr::Lambda { + params, + return_type, + body, + span, + }); } if self.match_kind(&TokenKind::Sh) { let span = self.prev_span(); @@ -663,7 +841,10 @@ impl<'a> Parser<'a> { } else { self.parse_expression()? }; - return Ok(Expr::Sh { command: Box::new(command), span }); + return Ok(Expr::Sh { + command: Box::new(command), + span, + }); } if self.match_kind(&TokenKind::Ssh) { let span = self.prev_span(); @@ -673,7 +854,11 @@ impl<'a> Parser<'a> { } else { self.parse_expression()? }; - return Ok(Expr::Ssh { host: Box::new(host), command: Box::new(command), span }); + return Ok(Expr::Ssh { + host: Box::new(host), + command: Box::new(command), + span, + }); } if self.match_kind(&TokenKind::LParen) { let span = self.prev_span(); @@ -699,7 +884,10 @@ impl<'a> Parser<'a> { } let elements = self.parse_elements()?; self.consume(&TokenKind::RBracket, "Expected ']' after array")?; - return Ok(Expr::Array { items: elements, span }); + return Ok(Expr::Array { + items: elements, + span, + }); } if self.match_kind(&TokenKind::LBrace) { let span = self.prev_span(); @@ -769,7 +957,11 @@ impl<'a> Parser<'a> { )); } - Ok(Expr::Match { subject: Box::new(subject), arms, span }) + Ok(Expr::Match { + subject: Box::new(subject), + arms, + span, + }) } fn parse_array_comprehension(&mut self, span: Span) -> Result { @@ -786,7 +978,10 @@ impl<'a> Parser<'a> { }; self.consume(&TokenKind::FatArrow, "Expected '=>' in array comprehension")?; let map_expr = self.parse_expression()?; - self.consume(&TokenKind::RBracket, "Expected ']' after array comprehension")?; + self.consume( + &TokenKind::RBracket, + "Expected ']' after array comprehension", + )?; Ok(Expr::ArrayComprehension { pattern, iterable: Box::new(iterable), @@ -801,7 +996,10 @@ impl<'a> Parser<'a> { return self.parse_block(); } let expr = self.parse_expression()?; - Ok(vec![Stmt::Expr { expr: expr.clone(), span: expr.span() }]) + Ok(vec![Stmt::Expr { + expr: expr.clone(), + span: expr.span(), + }]) } fn parse_param_list(&mut self) -> Result, String> { @@ -812,7 +1010,9 @@ impl<'a> Parser<'a> { loop { let variadic = self.match_kind(&TokenKind::Ellipsis); if saw_variadic { - return Err(self.error_at_current("Variadic parameter must be the final parameter")); + return Err( + self.error_at_current("Variadic parameter must be the final parameter") + ); } let name = self.consume_identifier("Expected parameter name")?; let ty = if self.match_kind(&TokenKind::Colon) { @@ -822,7 +1022,9 @@ impl<'a> Parser<'a> { }; let default = if self.match_kind(&TokenKind::Equal) { if variadic { - return Err(self.error_at_current("Variadic parameter cannot have a default value")); + return Err( + self.error_at_current("Variadic parameter cannot have a default value") + ); } saw_default = true; Some(self.parse_expression()?) @@ -833,10 +1035,19 @@ impl<'a> Parser<'a> { saw_variadic = true; } if saw_default && default.is_none() { - return Err(self.error_at_current("Required parameters cannot follow parameters with defaults")); + return Err(self.error_at_current( + "Required parameters cannot follow parameters with defaults", + )); + } + params.push(ParamSpec { + name, + ty, + default, + variadic, + }); + if !self.match_kind(&TokenKind::Comma) { + break; } - params.push(ParamSpec { name, ty, default, variadic }); - if !self.match_kind(&TokenKind::Comma) { break; } } } Ok(params) @@ -855,7 +1066,9 @@ impl<'a> Parser<'a> { args.push(CallArg::Named { name, value }); } else { if saw_named { - return Err(self.error_at_current("Positional arguments cannot appear after named arguments")); + return Err(self.error_at_current( + "Positional arguments cannot appear after named arguments", + )); } if self.match_kind(&TokenKind::Ellipsis) { let spread_expr = self.parse_expression()?; @@ -864,7 +1077,9 @@ impl<'a> Parser<'a> { args.push(CallArg::Positional(self.parse_expression()?)); } } - if !self.match_kind(&TokenKind::Comma) { break; } + if !self.match_kind(&TokenKind::Comma) { + break; + } } } Ok(args) @@ -875,7 +1090,9 @@ impl<'a> Parser<'a> { if !self.check(&TokenKind::RBracket) { loop { elements.push(self.parse_expression()?); - if !self.match_kind(&TokenKind::Comma) { break; } + if !self.match_kind(&TokenKind::Comma) { + break; + } } } Ok(elements) @@ -889,23 +1106,32 @@ impl<'a> Parser<'a> { self.consume(&TokenKind::Colon, "Expected ':' in dict")?; let value = self.parse_expression()?; pairs.push((key, value)); - if !self.match_kind(&TokenKind::Comma) { break; } + if !self.match_kind(&TokenKind::Comma) { + break; + } } } Ok(pairs) } fn parse_dict_key(&mut self) -> Result { - if self.check(&TokenKind::Identifier(String::new())) && matches!(self.peek_next().kind, TokenKind::Colon) { + if self.check(&TokenKind::Identifier(String::new())) + && matches!(self.peek_next().kind, TokenKind::Colon) + { let key = self.consume_identifier("Expected dict key")?; let span = self.prev_span(); - return Ok(Expr::Literal { value: Value::String(key), span }); + return Ok(Expr::Literal { + value: Value::String(key), + span, + }); } self.parse_expression() } fn is_dict_start(&self) -> bool { - if self.check(&TokenKind::RBrace) { return true; } + if self.check(&TokenKind::RBrace) { + return true; + } match &self.peek().kind { TokenKind::Identifier(_) | TokenKind::String(_) => { matches!(self.peek_next().kind, TokenKind::Colon) @@ -931,8 +1157,11 @@ impl<'a> Parser<'a> { let bytes = self.source.as_bytes(); while pos < bytes.len() && depth > 0 { let c = bytes[pos] as char; - if c == '{' { depth += 1; } - else if c == '}' { depth -= 1; } + if c == '{' { + depth += 1; + } else if c == '}' { + depth -= 1; + } pos += 1; } if depth != 0 { @@ -945,9 +1174,15 @@ impl<'a> Parser<'a> { if raw.contains("#{") { let parts = self.parse_interpolated_string(raw)?; - Ok(Expr::InterpolatedString { parts, span: self.span_from_token(&open) }) + Ok(Expr::InterpolatedString { + parts, + span: self.span_from_token(&open), + }) } else { - Ok(Expr::Literal { value: Value::String(raw.trim().to_string()), span: self.span_from_token(&open) }) + Ok(Expr::Literal { + value: Value::String(raw.trim().to_string()), + span: self.span_from_token(&open), + }) } } @@ -964,8 +1199,12 @@ impl<'a> Parser<'a> { let mut depth = 1; while j < raw.len() && depth > 0 { let ch = raw.as_bytes()[j] as char; - if ch == '{' { depth += 1; } - if ch == '}' { depth -= 1; } + if ch == '{' { + depth += 1; + } + if ch == '}' { + depth -= 1; + } j += 1; } if depth != 0 { @@ -992,7 +1231,12 @@ impl<'a> Parser<'a> { self.consume(&TokenKind::Arrow, "Expected '->' after parameter")?; let body = self.parse_lambda_body()?; Ok(Expr::Lambda { - params: vec![ParamSpec { name: param, ty: None, default: None, variadic: false }], + params: vec![ParamSpec { + name: param, + ty: None, + default: None, + variadic: false, + }], return_type: None, body, span, @@ -1006,7 +1250,12 @@ impl<'a> Parser<'a> { self.consume(&TokenKind::RParen, "Expected ')' after lambda params")?; self.consume(&TokenKind::Arrow, "Expected '->' after lambda params")?; let body = self.parse_lambda_body()?; - Ok(Expr::Lambda { params, return_type: None, body, span }) + Ok(Expr::Lambda { + params, + return_type: None, + body, + span, + }) } fn parse_lambda_body(&mut self) -> Result, String> { @@ -1014,7 +1263,10 @@ impl<'a> Parser<'a> { return self.parse_block(); } let expr = self.parse_expression()?; - Ok(vec![Stmt::Return { value: Some(expr.clone()), span: expr.span() }]) + Ok(vec![Stmt::Return { + value: Some(expr.clone()), + span: expr.span(), + }]) } fn parse_type(&mut self) -> Result { @@ -1073,7 +1325,9 @@ impl<'a> Parser<'a> { if !self.check(&TokenKind::Greater) { loop { args.push(self.parse_type()?); - if !self.match_kind(&TokenKind::Comma) { break; } + if !self.match_kind(&TokenKind::Comma) { + break; + } } } self.consume(&TokenKind::Greater, "Expected '>' after fn")?; @@ -1106,7 +1360,9 @@ impl<'a> Parser<'a> { if !self.check(&TokenKind::RParen) { loop { parts.push(self.parse_pattern()?); - if !self.match_kind(&TokenKind::Comma) { break; } + if !self.match_kind(&TokenKind::Comma) { + break; + } } } self.consume(&TokenKind::RParen, "Expected ')' after pattern")?; @@ -1140,13 +1396,19 @@ impl<'a> Parser<'a> { let index = self.parse_expression()?; self.consume(&TokenKind::RBracket, "Expected ']' after index")?; let span = e.span(); - e = Expr::Index { object: Box::new(e), index: Box::new(index), span }; + e = Expr::Index { + object: Box::new(e), + index: Box::new(index), + span, + }; } Ok(e) } fn consume(&mut self, kind: &TokenKind, message: &str) -> Result { - if self.check(kind) { return Ok(self.advance().clone()); } + if self.check(kind) { + return Ok(self.advance().clone()); + } Err(self.error_at_current(message)) } @@ -1159,23 +1421,35 @@ impl<'a> Parser<'a> { } fn match_kind(&mut self, kind: &TokenKind) -> bool { - if self.check(kind) { self.advance(); true } else { false } + if self.check(kind) { + self.advance(); + true + } else { + false + } } fn match_any(&mut self, kinds: &[TokenKind]) -> bool { for k in kinds { - if self.check(k) { self.advance(); return true; } + if self.check(k) { + self.advance(); + return true; + } } false } fn check(&self, kind: &TokenKind) -> bool { - if self.is_at_end() { return false; } + if self.is_at_end() { + return false; + } std::mem::discriminant(&self.peek().kind) == std::mem::discriminant(kind) } fn advance(&mut self) -> &Token { - if !self.is_at_end() { self.current += 1; } + if !self.is_at_end() { + self.current += 1; + } self.previous() } @@ -1188,7 +1462,11 @@ impl<'a> Parser<'a> { } fn peek_next(&self) -> &Token { - if self.current + 1 >= self.tokens.len() { self.peek() } else { &self.tokens[self.current + 1] } + if self.current + 1 >= self.tokens.len() { + self.peek() + } else { + &self.tokens[self.current + 1] + } } fn previous(&self) -> &Token { @@ -1233,7 +1511,10 @@ impl<'a> Parser<'a> { } fn span_from_token(&self, tok: &Token) -> Span { - Span { line: tok.line, col: tok.column } + Span { + line: tok.line, + col: tok.column, + } } fn peek_next_is_arrow(&self) -> bool { @@ -1270,7 +1551,9 @@ impl<'a> Parser<'a> { fn parse_expression_from_str(expr_text: &str, filename: &str) -> Result { let mut scanner = crate::lexer::scanner::Scanner::new(expr_text); - let tokens = scanner.scan_tokens().map_err(|e| format!("{filename}: {e}"))?; + let tokens = scanner + .scan_tokens() + .map_err(|e| format!("{filename}: {e}"))?; let mut parser = Parser::new(tokens, expr_text, filename.to_string()); parser.parse_expression() } @@ -1317,10 +1600,11 @@ fn stmt_contains_yield(stmt: &Stmt) -> bool { | Stmt::Throw { value: expr, .. } | Stmt::Invoke { expr, .. } => expr_contains_yield(expr), Stmt::IndexAssign { - target, index, expr, .. - } => { - expr_contains_yield(target) || expr_contains_yield(index) || expr_contains_yield(expr) - } + target, + index, + expr, + .. + } => expr_contains_yield(target) || expr_contains_yield(index) || expr_contains_yield(expr), Stmt::Return { value: None, .. } | Stmt::Continue { .. } | Stmt::Break { .. } @@ -1344,8 +1628,12 @@ fn expr_contains_yield(expr: &Expr) -> bool { CallArg::Named { value, .. } => expr_contains_yield(value), }) } - Expr::Member { object, .. } | Expr::NamespaceMember { object, .. } => expr_contains_yield(object), - Expr::Index { object, index, .. } => expr_contains_yield(object) || expr_contains_yield(index), + Expr::Member { object, .. } | Expr::NamespaceMember { object, .. } => { + expr_contains_yield(object) + } + Expr::Index { object, index, .. } => { + expr_contains_yield(object) || expr_contains_yield(index) + } Expr::Array { items, .. } | Expr::Tuple { items, .. } => { items.iter().any(expr_contains_yield) } @@ -1371,16 +1659,22 @@ fn expr_contains_yield(expr: &Expr) -> bool { then_branch, else_branch, .. - } => expr_contains_yield(cond) || contains_yield(then_branch) || contains_yield(else_branch), + } => { + expr_contains_yield(cond) || contains_yield(then_branch) || contains_yield(else_branch) + } Expr::Match { subject, arms, .. } => { expr_contains_yield(subject) || arms.iter().any(|arm| match &arm.kind { MatchArmKind::Value(e) => expr_contains_yield(e) || contains_yield(&arm.body), - MatchArmKind::Compare { rhs, .. } => expr_contains_yield(rhs) || contains_yield(&arm.body), + MatchArmKind::Compare { rhs, .. } => { + expr_contains_yield(rhs) || contains_yield(&arm.body) + } MatchArmKind::Wildcard => contains_yield(&arm.body), }) } - Expr::ParallelFor { iterable, body, .. } => expr_contains_yield(iterable) || contains_yield(body), + Expr::ParallelFor { iterable, body, .. } => { + expr_contains_yield(iterable) || contains_yield(body) + } Expr::TaskBlock { .. } => false, Expr::TaskCall { callee, args, .. } => { expr_contains_yield(callee) @@ -1396,7 +1690,9 @@ fn expr_contains_yield(expr: &Expr) -> bool { InterpPart::Expr(e) => expr_contains_yield(e), }), Expr::Sh { command, .. } => expr_contains_yield(command), - Expr::Ssh { host, command, .. } => expr_contains_yield(host) || expr_contains_yield(command), + Expr::Ssh { host, command, .. } => { + expr_contains_yield(host) || expr_contains_yield(command) + } Expr::Literal { .. } | Expr::Var { .. } => false, } } @@ -1419,3 +1715,92 @@ fn shorten(s: &str) -> String { s.to_string() } } + +#[cfg(test)] +mod tests { + use super::Parser; + use crate::lexer::scanner::Scanner; + use crate::parser::ast::Stmt; + + fn parse_program(source: &str) -> Result { + let mut scanner = Scanner::new(source); + let tokens = scanner.scan_tokens()?; + let mut parser = Parser::new(tokens, source, "".to_string()); + parser.parse_program() + } + + fn parse_program_ok(source: &str) -> crate::parser::ast::Program { + parse_program(source).unwrap() + } + + #[test] + fn parse_let() { + let program = parse_program_ok("let x = 1"); + assert_eq!(program.statements.len(), 1); + assert!(matches!(&program.statements[0], Stmt::Let { name, .. } if name == "x")); + } + + #[test] + fn parse_fn() { + let program = parse_program_ok("fn add(a, b) { return a + b }"); + assert_eq!(program.statements.len(), 1); + assert!(matches!(&program.statements[0], Stmt::Function { name, .. } if name == "add")); + } + + #[test] + fn parse_expr_stmt() { + // "log 42" is parsed as Stmt::Invoke (log is a keyword), not a bare Expr + let program = parse_program_ok("log 42"); + assert_eq!(program.statements.len(), 1); + assert!(matches!(&program.statements[0], Stmt::Invoke { name, .. } if name == "log")); + // A bare expression like "1 + 2" becomes Stmt::Expr + let program2 = parse_program_ok("1 + 2"); + assert_eq!(program2.statements.len(), 1); + assert!(matches!(&program2.statements[0], Stmt::Expr { .. })); + } + + #[test] + fn parse_multiple_stmts() { + let program = parse_program_ok("let a = 1; let b = 2"); + assert_eq!(program.statements.len(), 2); + } + + #[test] + fn parse_if_stmt() { + let program = parse_program_ok("if true { log 1 }"); + assert_eq!(program.statements.len(), 1); + assert!(matches!(&program.statements[0], Stmt::If { .. })); + } + + #[test] + fn parse_for_stmt() { + let program = parse_program_ok("for x in [1,2,3] { log x }"); + assert_eq!(program.statements.len(), 1); + assert!(matches!(&program.statements[0], Stmt::For { .. })); + } + + #[test] + fn parse_while_stmt() { + let program = parse_program_ok("while false { log 0 }"); + assert_eq!(program.statements.len(), 1); + assert!(matches!(&program.statements[0], Stmt::While { .. })); + } + + #[test] + fn parse_array_and_dict_expr() { + let program = parse_program_ok("let a = [1, 2]; let b = { \"k\": 1 }"); + assert_eq!(program.statements.len(), 2); + } + + #[test] + fn parse_lambda() { + let program = parse_program_ok("let f = x -> x + 1"); + assert_eq!(program.statements.len(), 1); + } + + #[test] + fn parse_invalid_fails() { + assert!(parse_program("let x = ").is_err()); + assert!(parse_program("fn add( ").is_err()); + } +} diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 2821081..a65c997 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -6,13 +6,15 @@ use std::time::{Duration, Instant}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::execute; -use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::style::{Color, Style}; use ratatui::text::Line; use ratatui::widgets::{Paragraph, Wrap}; -use ratatui::Terminal; use crate::lexer::scanner::Scanner; use crate::parser::parser::Parser; @@ -40,9 +42,11 @@ pub fn run() -> Result<(), String> { enable_raw_mode().map_err(|e| format!("Failed to enable raw mode: {e}"))?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen).map_err(|e| format!("Failed to enter alt screen: {e}"))?; + execute!(stdout, EnterAlternateScreen) + .map_err(|e| format!("Failed to enter alt screen: {e}"))?; let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend).map_err(|e| format!("Failed to create terminal: {e}"))?; + let mut terminal = + Terminal::new(backend).map_err(|e| format!("Failed to create terminal: {e}"))?; let run_result = tui_loop(&mut terminal, &mut app); @@ -108,7 +112,9 @@ impl ReplApp { output_exec_hint: None, }; app.push_info("v2r repl"); - app.push_info("Notebook mode: Enter newline | Ctrl+R / Ctrl+Enter / F5 run cell | Ctrl+D quit"); + app.push_info( + "Notebook mode: Enter newline | Ctrl+R / Ctrl+Enter / F5 run cell | Ctrl+D quit", + ); app } @@ -120,7 +126,12 @@ impl ReplApp { self.push_entry_with_exec(kind, text, None); } - fn push_entry_with_exec(&mut self, kind: EntryKind, text: impl Into, exec_id: Option) { + fn push_entry_with_exec( + &mut self, + kind: EntryKind, + text: impl Into, + exec_id: Option, + ) { self.entries.push(ReplEntry { kind, text: text.into(), @@ -178,7 +189,13 @@ impl ReplApp { self.output_exec_hint = None; } - fn eval_source(&mut self, filename: String, source: String, auto_print_result: bool, exec_id: u64) { + fn eval_source( + &mut self, + filename: String, + source: String, + auto_print_result: bool, + exec_id: u64, + ) { let start = Instant::now(); self.executor.set_context(filename.clone(), source.clone()); @@ -374,7 +391,10 @@ impl ReplApp { lines.push(Line::styled(format!("{prefix}{first}"), style)); } for cont in iter { - lines.push(Line::styled(format!("{}{}", " ".repeat(prefix.len()), cont), style)); + lines.push(Line::styled( + format!("{}{}", " ".repeat(prefix.len()), cont), + style, + )); } } let draft_prefix = format!("In [{}]: ", self.next_exec_id); @@ -521,14 +541,18 @@ fn tui_loop( continue; } match (key.code, key.modifiers) { - (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) => app.should_quit = true, + (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) => { + app.should_quit = true + } (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { app.input.clear(); app.input_cursor = 0; } (KeyCode::Char('f'), m) if m.contains(KeyModifiers::CONTROL) => app.move_right(), (KeyCode::Char('b'), m) if m.contains(KeyModifiers::CONTROL) => app.move_left(), - (KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => app.move_line_start(), + (KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => { + app.move_line_start() + } (KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => app.move_line_end(), (KeyCode::Char('p'), m) if m.contains(KeyModifiers::CONTROL) => app.move_up(), (KeyCode::Char('n'), m) if m.contains(KeyModifiers::CONTROL) => app.move_down(), @@ -642,7 +666,10 @@ fn notebook_cursor_position(app: &ReplApp) -> (usize, u16) { let prefix = format!("In [{}]: ", app.next_exec_id); let (_, _, col, line_offset) = line_bounds_and_col(&app.input, app.input_cursor); - (line_idx + line_offset, (prefix.chars().count() + col) as u16) + ( + line_idx + line_offset, + (prefix.chars().count() + col) as u16, + ) } fn char_to_byte_idx(s: &str, char_idx: usize) -> usize { @@ -797,12 +824,16 @@ fn run_line_repl() -> Result<(), String> { let mut buffer = String::new(); println!("v2r repl (line mode)"); - println!("commands: :help :quit :clear :vars :load :ast :tokens :time :history export :history import "); + println!( + "commands: :help :quit :clear :vars :load :ast :tokens :time :history export :history import " + ); loop { let prompt = if buffer.is_empty() { "v2r> " } else { "...> " }; print!("{prompt}"); - io::stdout().flush().map_err(|e| format!("Failed to flush stdout: {e}"))?; + io::stdout() + .flush() + .map_err(|e| format!("Failed to flush stdout: {e}"))?; let mut line = String::new(); let read = io::stdin() @@ -921,12 +952,16 @@ fn line_mode_command( match cmd { "help" => { - println!(":help :quit :clear :vars :load :ast :tokens :time :history export :history import "); + println!( + ":help :quit :clear :vars :load :ast :tokens :time :history export :history import " + ); } "quit" | "q" | "exit" => return Ok(true), "clear" => { print!("\x1B[2J\x1B[1;1H"); - io::stdout().flush().map_err(|e| format!("Failed to flush stdout: {e}"))?; + io::stdout() + .flush() + .map_err(|e| format!("Failed to flush stdout: {e}"))?; } "vars" => { let vars = executor.list_globals(); @@ -943,7 +978,14 @@ fn line_mode_command( eprintln!("Usage: :load "); } else { match fs::read_to_string(arg) { - Ok(src) => line_mode_eval(executor, pending_output, arg.to_string(), src, false, *timing_enabled), + Ok(src) => line_mode_eval( + executor, + pending_output, + arg.to_string(), + src, + false, + *timing_enabled, + ), Err(err) => eprintln!("Failed to read {arg}: {err}"), } } diff --git a/src/runtime/builtins/core.rs b/src/runtime/builtins/core.rs index 9f934a9..921b16f 100644 --- a/src/runtime/builtins/core.rs +++ b/src/runtime/builtins/core.rs @@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; -use crate::runtime::executor::{Executor, RuntimeCallArg}; use crate::runtime::errors::RuntimeError; +use crate::runtime::executor::{Executor, RuntimeCallArg}; use crate::runtime::value::Value; pub fn register(exec: &mut Executor) { @@ -30,8 +30,8 @@ pub fn register(exec: &mut Executor) { Some(v) => v.as_string(), None => return Err("read_file expects path".to_string()), }; - let contents = fs::read_to_string(&path) - .map_err(|e| format!("Failed to read {path}: {e}"))?; + let contents = + fs::read_to_string(&path).map_err(|e| format!("Failed to read {path}: {e}"))?; Ok(Value::String(contents)) }); @@ -111,7 +111,8 @@ pub fn register(exec: &mut Executor) { let items = iter_values(iterable, exec, span)?; let mut out = Vec::new(); for item in items { - let result = exec.call_value(func.clone(), vec![RuntimeCallArg::Positional(item.clone())])?; + let result = + exec.call_value(func.clone(), vec![RuntimeCallArg::Positional(item.clone())])?; if result.is_truthy() { out.push(item); } @@ -121,7 +122,10 @@ pub fn register(exec: &mut Executor) { exec.register_native_exec("reduce", |exec, args, span| { if args.len() < 3 { - return Err(exec.make_error("reduce expects iterable, fn, initial (or iterable, initial, fn)", span)); + return Err(exec.make_error( + "reduce expects iterable, fn, initial (or iterable, initial, fn)", + span, + )); } let iterable = args[0].clone(); let (func, mut acc) = match (&args[1], &args[2]) { @@ -137,7 +141,10 @@ pub fn register(exec: &mut Executor) { for item in items { let result = exec.call_value( func.clone(), - vec![RuntimeCallArg::Positional(acc), RuntimeCallArg::Positional(item)], + vec![ + RuntimeCallArg::Positional(acc), + RuntimeCallArg::Positional(item), + ], )?; acc = result; } @@ -271,7 +278,9 @@ pub fn register(exec: &mut Executor) { if descending { decorated.reverse(); } - Ok(Value::array(decorated.into_iter().map(|(item, _)| item).collect())) + Ok(Value::array( + decorated.into_iter().map(|(item, _)| item).collect(), + )) }); exec.register_native_exec("groupBy", |exec, args, span| { @@ -342,7 +351,9 @@ pub fn register(exec: &mut Executor) { fn chrono_now() -> String { use std::time::{SystemTime, UNIX_EPOCH}; - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); let secs = now.as_secs() % 86400; let h = secs / 3600; let m = (secs % 3600) / 60; @@ -350,7 +361,11 @@ fn chrono_now() -> String { format!("{:02}:{:02}:{:02}", h, m, s) } -fn expect_two(mut args: Vec, exec: &Executor, span: crate::parser::ast::Span) -> Result<(Value, Value), RuntimeError> { +fn expect_two( + mut args: Vec, + exec: &Executor, + span: crate::parser::ast::Span, +) -> Result<(Value, Value), RuntimeError> { if args.len() < 2 { return Err(exec.make_error("Expected 2 arguments", span)); } @@ -359,7 +374,12 @@ fn expect_two(mut args: Vec, exec: &Executor, span: crate::parser::ast::S Ok((first, second)) } -fn expect_callable(value: &Value, exec: &Executor, span: crate::parser::ast::Span, name: &str) -> Result<(), RuntimeError> { +fn expect_callable( + value: &Value, + exec: &Executor, + span: crate::parser::ast::Span, + name: &str, +) -> Result<(), RuntimeError> { match value { Value::Function(_) | Value::NativeFunction(_) | Value::NativeFunctionExec(_) => Ok(()), _ => Err(exec.make_error(&format!("{name} expects a callable argument"), span)), @@ -370,14 +390,24 @@ fn call_unary(exec: &mut Executor, func: &Value, arg: Value) -> Result Result { +fn call_binary( + exec: &mut Executor, + func: &Value, + a: Value, + b: Value, +) -> Result { exec.call_value( func.clone(), vec![RuntimeCallArg::Positional(a), RuntimeCallArg::Positional(b)], ) } -fn expect_number(value: &Value, exec: &Executor, span: crate::parser::ast::Span, where_: &str) -> Result { +fn expect_number( + value: &Value, + exec: &Executor, + span: crate::parser::ast::Span, + where_: &str, +) -> Result { match value { Value::Number(n) => Ok(*n), _ => Err(exec.make_error(&format!("{where_} must return number"), span)), @@ -393,7 +423,12 @@ fn compare_values(a: &Value, b: &Value) -> Ordering { } } -fn as_hash_key(value: &Value, exec: &Executor, span: crate::parser::ast::Span, where_: &str) -> Result { +fn as_hash_key( + value: &Value, + exec: &Executor, + span: crate::parser::ast::Span, + where_: &str, +) -> Result { match value { Value::Null => Ok("null:null".to_string()), Value::Bool(b) => Ok(format!("bool:{b}")), @@ -426,7 +461,11 @@ where Ok(()) } -fn iter_values(value: Value, exec: &Executor, span: crate::parser::ast::Span) -> Result, RuntimeError> { +fn iter_values( + value: Value, + exec: &Executor, + span: crate::parser::ast::Span, +) -> Result, RuntimeError> { exec.collect_iterable(value, span) } @@ -445,7 +484,9 @@ fn value_type_label(value: &Value) -> String { Value::Task(_) => "task".to_string(), Value::Generator(_) => "generator".to_string(), Value::Module(_) => "module".to_string(), - Value::Function(_) | Value::NativeFunction(_) | Value::NativeFunctionExec(_) => "fn".to_string(), + Value::Function(_) | Value::NativeFunction(_) | Value::NativeFunctionExec(_) => { + "fn".to_string() + } Value::Ufcs { .. } => "ufcs".to_string(), } } diff --git a/src/runtime/builtins/net.rs b/src/runtime/builtins/net.rs index 90f9fcf..78ce2f0 100644 --- a/src/runtime/builtins/net.rs +++ b/src/runtime/builtins/net.rs @@ -23,7 +23,10 @@ pub fn register(exec: &mut Executor) { fn native_exec(f: F) -> Value where - F: Fn(&mut Executor, Vec, crate::parser::ast::Span) -> Result + Send + Sync + 'static, + F: Fn(&mut Executor, Vec, crate::parser::ast::Span) -> Result + + Send + + Sync + + 'static, { Value::NativeFunctionExec(Arc::new(f)) } @@ -47,7 +50,10 @@ fn expect_string(value: Option<&Value>, name: &str) -> Result { fn expect_number(value: Option<&Value>, name: &str) -> Result { match value { Some(Value::Number(n)) => Ok(*n), - Some(v) => v.as_string().parse::().map_err(|_| format!("Expected numeric {name}")), + Some(v) => v + .as_string() + .parse::() + .map_err(|_| format!("Expected numeric {name}")), None => Err(format!("Expected numeric {name}")), } } @@ -112,7 +118,11 @@ fn parse_ping_latency_ms(stdout: &str) -> Option { None } -fn net_ping(exec: &mut Executor, args: Vec, span: crate::parser::ast::Span) -> Result { +fn net_ping( + exec: &mut Executor, + args: Vec, + span: crate::parser::ast::Span, +) -> Result { let host = expect_string(args.get(0), "host").map_err(|e| err(exec, span, &e))?; let timeout = optional_timeout_ms(args.get(1)).map_err(|e| err(exec, span, &e))?; let timeout_secs = timeout.as_secs().max(1); @@ -128,8 +138,12 @@ fn net_ping(exec: &mut Executor, args: Vec, span: crate::parser::ast::Spa .output() .map_err(|e| err(exec, span, &format!("Failed to run ping: {e}")))?; let duration = start.elapsed(); - let stdout = String::from_utf8_lossy(&output.stdout).trim_end().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim_end().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout) + .trim_end() + .to_string(); + let stderr = String::from_utf8_lossy(&output.stderr) + .trim_end() + .to_string(); let exit_code = output.status.code().unwrap_or(-1); let mut out = HashMap::new(); @@ -143,13 +157,23 @@ fn net_ping(exec: &mut Executor, args: Vec, span: crate::parser::ast::Spa } out.insert("stdout".to_string(), Value::String(stdout)); out.insert("stderr".to_string(), Value::String(stderr)); - out.insert("duration_ms".to_string(), Value::Number(duration.as_millis() as f64)); + out.insert( + "duration_ms".to_string(), + Value::Number(duration.as_millis() as f64), + ); Ok(value_dict(out)) } -fn net_http(exec: &mut Executor, args: Vec, span: crate::parser::ast::Span) -> Result { +fn net_http( + exec: &mut Executor, + args: Vec, + span: crate::parser::ast::Span, +) -> Result { let req_map = match args.get(0) { - Some(Value::Dict(map)) => map.lock().map_err(|_| err(exec, span, "Request map lock poisoned"))?.clone(), + Some(Value::Dict(map)) => map + .lock() + .map_err(|_| err(exec, span, "Request map lock poisoned"))? + .clone(), _ => return Err(err(exec, span, "net.http expects a request dict")), }; @@ -159,7 +183,8 @@ fn net_http(exec: &mut Executor, args: Vec, span: crate::parser::ast::Spa .unwrap_or_else(|| "GET".to_string()); let method = reqwest::Method::from_bytes(method_text.as_bytes()) .map_err(|e| err(exec, span, &format!("Invalid HTTP method: {e}")))?; - let timeout = optional_timeout_ms(dict_get(&req_map, "timeout_ms")).map_err(|e| err(exec, span, &e))?; + let timeout = + optional_timeout_ms(dict_get(&req_map, "timeout_ms")).map_err(|e| err(exec, span, &e))?; let client = reqwest::blocking::Client::builder() .timeout(timeout) @@ -168,7 +193,9 @@ fn net_http(exec: &mut Executor, args: Vec, span: crate::parser::ast::Spa let mut request = client.request(method, &url); if let Some(Value::Dict(headers)) = dict_get(&req_map, "headers") { - let hdrs = headers.lock().map_err(|_| err(exec, span, "Headers map lock poisoned"))?; + let hdrs = headers + .lock() + .map_err(|_| err(exec, span, "Headers map lock poisoned"))?; for (k, v) in hdrs.iter() { request = request.header(k, v.as_string()); } @@ -179,7 +206,9 @@ fn net_http(exec: &mut Executor, args: Vec, span: crate::parser::ast::Spa } let started = Instant::now(); - let response = request.send().map_err(|e| err(exec, span, &format!("HTTP request failed: {e}")))?; + let response = request + .send() + .map_err(|e| err(exec, span, &format!("HTTP request failed: {e}")))?; let duration = started.elapsed(); let status = response.status().as_u16() as f64; @@ -200,11 +229,17 @@ fn net_http(exec: &mut Executor, args: Vec, span: crate::parser::ast::Spa let mut out = HashMap::new(); out.insert("status".to_string(), Value::Number(status)); - out.insert("ok".to_string(), Value::Bool((200.0..300.0).contains(&status))); + out.insert( + "ok".to_string(), + Value::Bool((200.0..300.0).contains(&status)), + ); out.insert("headers".to_string(), value_dict(headers_map)); out.insert("body_text".to_string(), Value::String(body_text)); out.insert("body_bytes".to_string(), bytes_to_value_array(&body_bytes)); - out.insert("duration_ms".to_string(), Value::Number(duration.as_millis() as f64)); + out.insert( + "duration_ms".to_string(), + Value::Number(duration.as_millis() as f64), + ); Ok(value_dict(out)) } @@ -212,10 +247,15 @@ fn resolve_addr(addr: &str) -> Result { let mut iter = addr .to_socket_addrs() .map_err(|e| format!("Invalid address '{addr}': {e}"))?; - iter.next().ok_or_else(|| format!("Could not resolve address '{addr}'")) + iter.next() + .ok_or_else(|| format!("Could not resolve address '{addr}'")) } -fn net_tcp_connect(exec: &mut Executor, args: Vec, span: crate::parser::ast::Span) -> Result { +fn net_tcp_connect( + exec: &mut Executor, + args: Vec, + span: crate::parser::ast::Span, +) -> Result { let addr = expect_string(args.get(0), "addr").map_err(|e| err(exec, span, &e))?; let timeout = optional_timeout_ms(args.get(1)).map_err(|e| err(exec, span, &e))?; let socket_addr = resolve_addr(&addr).map_err(|e| err(exec, span, &e))?; @@ -230,13 +270,22 @@ fn net_tcp_connect(exec: &mut Executor, args: Vec, span: crate::parser::a Ok(make_connection_value(stream, timeout)) } -fn net_tcp_listen(exec: &mut Executor, args: Vec, span: crate::parser::ast::Span) -> Result { +fn net_tcp_listen( + exec: &mut Executor, + args: Vec, + span: crate::parser::ast::Span, +) -> Result { let addr = expect_string(args.get(0), "addr").map_err(|e| err(exec, span, &e))?; let accept_timeout = optional_timeout_ms(args.get(1)).map_err(|e| err(exec, span, &e))?; - let listener = TcpListener::bind(&addr).map_err(|e| err(exec, span, &format!("tcp_listen failed: {e}")))?; - listener - .set_nonblocking(true) - .map_err(|e| err(exec, span, &format!("Failed to set nonblocking listener: {e}")))?; + let listener = TcpListener::bind(&addr) + .map_err(|e| err(exec, span, &format!("tcp_listen failed: {e}")))?; + listener.set_nonblocking(true).map_err(|e| { + err( + exec, + span, + &format!("Failed to set nonblocking listener: {e}"), + ) + })?; Ok(make_listener_value(listener, accept_timeout)) } @@ -274,7 +323,8 @@ fn make_connection_value(stream: TcpStream, default_timeout: Duration) -> Value map.insert( "recv".to_string(), native_exec(move |exec, args, span| { - let max = expect_number(args.first(), "max_bytes").map_err(|e| err(exec, span, &e))? as i64; + let max = expect_number(args.first(), "max_bytes") + .map_err(|e| err(exec, span, &e))? as i64; if max <= 0 { return Err(err(exec, span, "max_bytes must be > 0")); } @@ -297,7 +347,10 @@ fn make_connection_value(stream: TcpStream, default_timeout: Duration) -> Value let mut buf = vec![0u8; max as usize]; let n = match stream.read(&mut buf) { Ok(n) => n, - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut => { + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { return Err(err(exec, span, "recv timed out")); } Err(e) => return Err(err(exec, span, &format!("recv failed: {e}"))), @@ -305,7 +358,10 @@ fn make_connection_value(stream: TcpStream, default_timeout: Duration) -> Value buf.truncate(n); let mut out = HashMap::new(); - out.insert("text".to_string(), Value::String(String::from_utf8_lossy(&buf).to_string())); + out.insert( + "text".to_string(), + Value::String(String::from_utf8_lossy(&buf).to_string()), + ); out.insert("bytes".to_string(), bytes_to_value_array(&buf)); out.insert("count".to_string(), Value::Number(n as f64)); Ok(value_dict(out)) diff --git a/src/runtime/builtins/numbers.rs b/src/runtime/builtins/numbers.rs index 7d60940..a4a279a 100644 --- a/src/runtime/builtins/numbers.rs +++ b/src/runtime/builtins/numbers.rs @@ -49,7 +49,10 @@ where fn expect_number(value: Option<&Value>) -> Result { match value { Some(Value::Number(n)) => Ok(*n), - Some(v) => v.as_string().parse::().map_err(|_| "Expected number argument".to_string()), + Some(v) => v + .as_string() + .parse::() + .map_err(|_| "Expected number argument".to_string()), None => Err("Expected number argument".to_string()), } } diff --git a/src/runtime/builtins/strings.rs b/src/runtime/builtins/strings.rs index 83ed4be..8dea53d 100644 --- a/src/runtime/builtins/strings.rs +++ b/src/runtime/builtins/strings.rs @@ -13,10 +13,16 @@ pub fn get_method(name: &str, receiver: &str) -> Option { "split" => native(move |args| { let sep = expect_string(args.get(0))?; if sep.is_empty() { - let parts: Vec = receiver.chars().map(|c| Value::String(c.to_string())).collect(); + let parts: Vec = receiver + .chars() + .map(|c| Value::String(c.to_string())) + .collect(); Ok(Value::Array(Arc::new(Mutex::new(parts)))) } else { - let parts: Vec = receiver.split(&sep).map(|s| Value::String(s.to_string())).collect(); + let parts: Vec = receiver + .split(&sep) + .map(|s| Value::String(s.to_string())) + .collect(); Ok(Value::Array(Arc::new(Mutex::new(parts)))) } }), @@ -48,7 +54,9 @@ pub fn get_method(name: &str, receiver: &str) -> Option { let start = expect_usize(args.get(0))?; let len = expect_usize(args.get(1))?; let chars: Vec = receiver.chars().collect(); - if start >= chars.len() { return Ok(Value::String("".to_string())); } + if start >= chars.len() { + return Ok(Value::String("".to_string())); + } let end = (start + len).min(chars.len()); Ok(Value::String(chars[start..end].iter().collect())) }), @@ -88,7 +96,9 @@ pub fn get_method(name: &str, receiver: &str) -> Option { let start = expect_usize(args.get(0))?; let len = expect_usize(args.get(1))?; let chars: Vec = receiver.chars().collect(); - if start >= chars.len() { return Ok(Value::String(receiver.clone())); } + if start >= chars.len() { + return Ok(Value::String(receiver.clone())); + } let end = (start + len).min(chars.len()); let mut out = String::new(); out.push_str(&chars[..start].iter().collect::()); @@ -102,9 +112,15 @@ pub fn get_method(name: &str, receiver: &str) -> Option { let len = chars.len() as isize; let mut s = if start < 0 { len + start } else { start }; let mut e = if end < 0 { len + end } else { end }; - if s < 0 { s = 0; } - if e < s { e = s; } - if s as usize >= chars.len() { return Ok(Value::String("".to_string())); } + if s < 0 { + s = 0; + } + if e < s { + e = s; + } + if s as usize >= chars.len() { + return Ok(Value::String("".to_string())); + } let e_usize = (e as usize).min(chars.len()); Ok(Value::String(chars[s as usize..e_usize].iter().collect())) }), @@ -141,7 +157,10 @@ fn expect_string_opt(value: Option<&Value>) -> Option { fn expect_usize(value: Option<&Value>) -> Result { match value { Some(Value::Number(n)) => Ok(*n as usize), - Some(v) => v.as_string().parse::().map_err(|_| "Expected integer argument".to_string()), + Some(v) => v + .as_string() + .parse::() + .map_err(|_| "Expected integer argument".to_string()), None => Err("Expected integer argument".to_string()), } } @@ -149,7 +168,10 @@ fn expect_usize(value: Option<&Value>) -> Result { fn expect_isize(value: Option<&Value>) -> Result { match value { Some(Value::Number(n)) => Ok(*n as isize), - Some(v) => v.as_string().parse::().map_err(|_| "Expected integer argument".to_string()), + Some(v) => v + .as_string() + .parse::() + .map_err(|_| "Expected integer argument".to_string()), None => Err("Expected integer argument".to_string()), } } diff --git a/src/runtime/errors.rs b/src/runtime/errors.rs index 3098c36..d0203cd 100644 --- a/src/runtime/errors.rs +++ b/src/runtime/errors.rs @@ -18,21 +18,43 @@ pub struct RuntimeError { } impl RuntimeError { - pub fn new(message: String, filename: String, line: usize, col: usize, snippet: Option, stack: Vec) -> Self { - RuntimeError { message, filename, line, col, snippet, stack } + pub fn new( + message: String, + filename: String, + line: usize, + col: usize, + snippet: Option, + stack: Vec, + ) -> Self { + RuntimeError { + message, + filename, + line, + col, + snippet, + stack, + } } } impl fmt::Display for RuntimeError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "{}:{}:{}: {}", self.filename, self.line, self.col, self.message)?; + writeln!( + f, + "{}:{}:{}: {}", + self.filename, self.line, self.col, self.message + )?; if let Some(snippet) = &self.snippet { writeln!(f, "Snippet:\n{}", snippet)?; } if !self.stack.is_empty() { writeln!(f, "Stack trace:")?; for frame in &self.stack { - writeln!(f, " at {} ({}:{}:{})", frame.name, self.filename, frame.line, frame.col)?; + writeln!( + f, + " at {} ({}:{}:{})", + frame.name, self.filename, frame.line, frame.col + )?; } } Ok(()) diff --git a/src/runtime/executor.rs b/src/runtime/executor.rs index 1dcf841..1b77faa 100644 --- a/src/runtime/executor.rs +++ b/src/runtime/executor.rs @@ -1,15 +1,22 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}, mpsc}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + mpsc, +}; use std::thread; use std::time::Duration; use crate::lexer::scanner::Scanner; -use crate::parser::ast::{CallArg, Expr, ImportSource, InterpPart, MatchArmKind, ParamSpec, Pattern, Program, RecordField, Span, Stmt, TypeExpr}; +use crate::parser::ast::{ + CallArg, Expr, ImportSource, InterpPart, MatchArmKind, ParamSpec, Pattern, Program, + RecordField, Span, Stmt, TypeExpr, +}; use crate::parser::parser::Parser; -use crate::runtime::errors::{Frame, RuntimeError}; use crate::runtime::builtins::{numbers, strings}; +use crate::runtime::errors::{Frame, RuntimeError}; use crate::runtime::shell::{sh::run_sh, ssh::run_ssh}; use crate::runtime::value::{ModuleOrigin, ModuleValue, Value}; @@ -138,20 +145,17 @@ impl Executor { where F: Fn(Vec) -> Result + Send + Sync + 'static, { - self.scopes[0].insert( - name.to_string(), - Value::NativeFunction(Arc::new(func)), - ); + self.scopes[0].insert(name.to_string(), Value::NativeFunction(Arc::new(func))); } pub fn register_native_exec(&mut self, name: &str, func: F) where - F: Fn(&mut Executor, Vec, Span) -> Result + Send + Sync + 'static, + F: Fn(&mut Executor, Vec, Span) -> Result + + Send + + Sync + + 'static, { - self.scopes[0].insert( - name.to_string(), - Value::NativeFunctionExec(Arc::new(func)), - ); + self.scopes[0].insert(name.to_string(), Value::NativeFunctionExec(Arc::new(func))); } #[allow(dead_code)] @@ -165,13 +169,11 @@ impl Executor { exports, origin: ModuleOrigin::Builtin, }); - self.builtin_modules.insert(name.to_string(), module.clone()); + self.builtin_modules + .insert(name.to_string(), module.clone()); self.scopes[0].insert(name.to_string(), Value::Module(module.clone())); if let Ok(mut cache) = self.module_cache.lock() { - cache.insert( - format!("builtin:{name}"), - ModuleLoadState::Ready(module), - ); + cache.insert(format!("builtin:{name}"), ModuleLoadState::Ready(module)); } } @@ -186,10 +188,14 @@ impl Executor { match self.exec_stmt(stmt)? { ExecResult::Normal => {} ExecResult::Return(_) => {} - ExecResult::Continue => return Err(self.err("'continue' used outside loop", stmt.span())), + ExecResult::Continue => { + return Err(self.err("'continue' used outside loop", stmt.span())); + } ExecResult::Break => return Err(self.err("'break' used outside loop", stmt.span())), ExecResult::Throw(v) => { - return Err(self.err(&format!("Uncaught throw: {}", v.as_string()), stmt.span())) + return Err( + self.err(&format!("Uncaught throw: {}", v.as_string()), stmt.span()) + ); } } } @@ -207,10 +213,16 @@ impl Executor { _ => match self.exec_stmt(stmt)? { ExecResult::Normal => {} ExecResult::Return(_) => {} - ExecResult::Continue => return Err(self.err("'continue' used outside loop", stmt.span())), - ExecResult::Break => return Err(self.err("'break' used outside loop", stmt.span())), + ExecResult::Continue => { + return Err(self.err("'continue' used outside loop", stmt.span())); + } + ExecResult::Break => { + return Err(self.err("'break' used outside loop", stmt.span())); + } ExecResult::Throw(v) => { - return Err(self.err(&format!("Uncaught throw: {}", v.as_string()), stmt.span())) + return Err( + self.err(&format!("Uncaught throw: {}", v.as_string()), stmt.span()) + ); } }, } @@ -245,7 +257,11 @@ impl Executor { self.define(name, value); Ok(ExecResult::Normal) } - Stmt::LetDestructure { pattern, expr, span } => { + Stmt::LetDestructure { + pattern, + expr, + span, + } => { let value = self.eval_expr(expr)?; self.bind_pattern(pattern, value, *span)?; Ok(ExecResult::Normal) @@ -257,20 +273,36 @@ impl Executor { } Ok(ExecResult::Normal) } - Stmt::IndexAssign { target, index, expr, .. } => { + Stmt::IndexAssign { + target, + index, + expr, + .. + } => { let idx = self.eval_expr(index)?; let val = self.eval_expr(expr)?; match target { Expr::Var { name, .. } => { - let obj = self.lookup(name).ok_or_else(|| self.err("Undefined variable", target.span()))?; + let obj = self + .lookup(name) + .ok_or_else(|| self.err("Undefined variable", target.span()))?; let updated = self.assign_index(obj, idx, val)?; self.assign(name, updated); } - _ => return Err(self.err("Index assignment requires variable target", target.span())), + _ => { + return Err( + self.err("Index assignment requires variable target", target.span()) + ); + } } Ok(ExecResult::Normal) } - Stmt::If { cond, then_branch, else_branch, .. } => { + Stmt::If { + cond, + then_branch, + else_branch, + .. + } => { let c = self.eval_expr(cond)?; let res = if c.is_truthy() { self.exec_block(then_branch)? @@ -279,7 +311,12 @@ impl Executor { }; Ok(res) } - Stmt::For { pattern, iterable, body, span } => { + Stmt::For { + pattern, + iterable, + body, + span, + } => { let it = self.eval_expr(iterable)?; let items = self.iterate_value(it, *span)?; for item in items { @@ -308,14 +345,21 @@ impl Executor { } Ok(ExecResult::Normal) } - Stmt::ParallelFor { pattern, iterable, body, .. } => { + Stmt::ParallelFor { + pattern, + iterable, + body, + .. + } => { self.exec_parallel_for(pattern, iterable, body)?; Ok(ExecResult::Normal) } Stmt::While { cond, body, .. } => { loop { let c = self.eval_expr(cond)?; - if !c.is_truthy() { break; } + if !c.is_truthy() { + break; + } self.loop_depth += 1; let body_res = self.exec_block(body); self.loop_depth -= 1; @@ -335,10 +379,19 @@ impl Executor { } Ok(ExecResult::Normal) } - Stmt::Try { try_block, catch_name, catch_block, finally_block, .. } => { - let mut pending: Result = match self.exec_block(try_block) { + Stmt::Try { + try_block, + catch_name, + catch_block, + finally_block, + .. + } => { + let mut pending: Result = match self.exec_block(try_block) + { Ok(ExecResult::Throw(v)) => { - if let (Some(name), Some(block)) = (catch_name.as_ref(), catch_block.as_ref()) { + if let (Some(name), Some(block)) = + (catch_name.as_ref(), catch_block.as_ref()) + { self.push_scope(); self.define(name, v); let catch_res = self.exec_block(block); @@ -349,7 +402,9 @@ impl Executor { } Ok(other) => Ok(other), Err(e) => { - if let (Some(name), Some(block)) = (catch_name.as_ref(), catch_block.as_ref()) { + if let (Some(name), Some(block)) = + (catch_name.as_ref(), catch_block.as_ref()) + { let err_value = self.error_to_value(&e); self.push_scope(); self.define(name, err_value); @@ -406,7 +461,10 @@ impl Executor { } else { Value::Null }; - let ctx = self.generator_ctx.as_mut().expect("checked generator context"); + let ctx = self + .generator_ctx + .as_mut() + .expect("checked generator context"); if ctx.event_tx.send(GeneratorEvent::Yield(yielded)).is_err() { return Ok(ExecResult::Return(Value::Null)); } @@ -439,21 +497,36 @@ impl Executor { Err(self.err("No active scope for defer", stmt.span())) } } - Stmt::Use { module, alias, span } => { + Stmt::Use { + module, + alias, + span, + } => { self.exec_use(module, alias.as_deref(), *span)?; Ok(ExecResult::Normal) } - Stmt::ImportNamed { items, source, span } => { + Stmt::ImportNamed { + items, + source, + span, + } => { self.exec_import_named(items, source, *span)?; Ok(ExecResult::Normal) } - Stmt::ImportNamespace { alias, source, span } => { + Stmt::ImportNamespace { + alias, + source, + span, + } => { self.exec_import_namespace(alias, source, *span)?; Ok(ExecResult::Normal) } Stmt::TypeAlias { name, target, span } => { if self.record_defs.contains_key(name) { - return Err(self.err(&format!("Type name '{}' conflicts with existing record", name), *span)); + return Err(self.err( + &format!("Type name '{}' conflicts with existing record", name), + *span, + )); } if self.type_aliases.contains_key(name) { return Err(self.err(&format!("Type '{}' is already defined", name), *span)); @@ -463,7 +536,10 @@ impl Executor { } Stmt::RecordDef { name, fields, span } => { if self.type_aliases.contains_key(name) { - return Err(self.err(&format!("Record name '{}' conflicts with existing type alias", name), *span)); + return Err(self.err( + &format!("Record name '{}' conflicts with existing type alias", name), + *span, + )); } let mut seen = std::collections::HashSet::new(); for field in fields { @@ -471,10 +547,17 @@ impl Executor { return Err(self.err("Record field name '__type' is reserved", *span)); } if !seen.insert(field.name.clone()) { - return Err(self.err(&format!("Duplicate record field '{}'", field.name), *span)); + return Err( + self.err(&format!("Duplicate record field '{}'", field.name), *span) + ); } } - self.record_defs.insert(name.clone(), RecordDef { fields: fields.clone() }); + self.record_defs.insert( + name.clone(), + RecordDef { + fields: fields.clone(), + }, + ); Ok(ExecResult::Normal) } Stmt::Invoke { name, expr, span } => { @@ -513,7 +596,12 @@ impl Executor { self.exit_scope(pending) } - fn exec_parallel_for(&mut self, pattern: &Pattern, iterable: &Expr, body: &[Stmt]) -> Result<(), RuntimeError> { + fn exec_parallel_for( + &mut self, + pattern: &Pattern, + iterable: &Expr, + body: &[Stmt], + ) -> Result<(), RuntimeError> { let it = self.eval_expr(iterable)?; let items = self.iterate_value(it, iterable.span())?; if items.is_empty() { @@ -628,8 +716,15 @@ impl Executor { self.last_span = expr.span(); match expr { Expr::Literal { value, .. } => Ok(value.clone()), - Expr::Var { name, span } => self.lookup(name).ok_or_else(|| self.err("Undefined variable", *span)), - Expr::Binary { left, op, right, span } => { + Expr::Var { name, span } => self + .lookup(name) + .ok_or_else(|| self.err("Undefined variable", *span)), + Expr::Binary { + left, + op, + right, + span, + } => { if op == "&&" { let l = self.eval_expr(left)?; if !l.is_truthy() { @@ -695,19 +790,27 @@ impl Executor { let obj = self.eval_expr(object)?; self.namespace_access(obj, name, *span) } - Expr::Index { object, index, span } => { + Expr::Index { + object, + index, + span, + } => { let obj = self.eval_expr(object)?; let idx = self.eval_expr(index)?; self.index_access(obj, idx, *span) } Expr::Array { items, .. } => { let mut vals = Vec::new(); - for e in items { vals.push(self.eval_expr(e)?); } + for e in items { + vals.push(self.eval_expr(e)?); + } Ok(Value::Array(Arc::new(Mutex::new(vals)))) } Expr::Tuple { items, .. } => { let mut vals = Vec::new(); - for e in items { vals.push(self.eval_expr(e)?); } + for e in items { + vals.push(self.eval_expr(e)?); + } Ok(Value::tuple(vals)) } Expr::Dict { items, .. } => { @@ -752,9 +855,14 @@ impl Executor { Expr::Range { start, end, .. } => { let s = self.eval_expr(start)?; let e = self.eval_expr(end)?; - Ok(Value::Range(to_i64(s)? , to_i64(e)?)) + Ok(Value::Range(to_i64(s)?, to_i64(e)?)) } - Expr::IfExpr { cond, then_branch, else_branch, .. } => { + Expr::IfExpr { + cond, + then_branch, + else_branch, + .. + } => { let c = self.eval_expr(cond)?; if c.is_truthy() { self.eval_block_expr(then_branch) @@ -762,7 +870,11 @@ impl Executor { self.eval_block_expr(else_branch) } } - Expr::Match { subject, arms, span } => { + Expr::Match { + subject, + arms, + span, + } => { let subject_value = self.eval_expr(subject)?; for arm in arms { let matched = match &arm.kind { @@ -773,7 +885,8 @@ impl Executor { } MatchArmKind::Compare { op, rhs } => { let rhs_value = self.eval_expr(rhs)?; - let cmp = self.eval_binary(subject_value.clone(), op, rhs_value, *span)?; + let cmp = + self.eval_binary(subject_value.clone(), op, rhs_value, *span)?; cmp.is_truthy() } }; @@ -781,11 +894,17 @@ impl Executor { return self.eval_block_expr(&arm.body); } } - Err(self.err("No match arm matched and wildcard arm was not reached", *span)) - } - Expr::ParallelFor { pattern, iterable, body, .. } => { - self.eval_parallel_for_expr(pattern, iterable, body) + Err(self.err( + "No match arm matched and wildcard arm was not reached", + *span, + )) } + Expr::ParallelFor { + pattern, + iterable, + body, + .. + } => self.eval_parallel_for_expr(pattern, iterable, body), Expr::TaskBlock { body, .. } => { let body = body.clone(); let captured = self.flatten_scopes(); @@ -885,7 +1004,12 @@ impl Executor { _ => Err(self.err("await expects task", *span)), } } - Expr::Lambda { params, return_type, body, .. } => { + Expr::Lambda { + params, + return_type, + body, + .. + } => { let captured = self.flatten_scopes(); let func = Function { params: params.clone(), @@ -925,7 +1049,12 @@ impl Executor { } } - fn bind_pattern(&mut self, pattern: &Pattern, value: Value, span: Span) -> Result<(), RuntimeError> { + fn bind_pattern( + &mut self, + pattern: &Pattern, + value: Value, + span: Span, + ) -> Result<(), RuntimeError> { match pattern { Pattern::Ignore => Ok(()), Pattern::Name(name) => { @@ -934,8 +1063,12 @@ impl Executor { } Pattern::Tuple(parts) => { let values = match value { - Value::Array(arr) => arr.lock().map(|v| v.clone()).unwrap_or_else(|_| Vec::new()), - Value::Tuple(tup) => tup.lock().map(|v| v.clone()).unwrap_or_else(|_| Vec::new()), + Value::Array(arr) => { + arr.lock().map(|v| v.clone()).unwrap_or_else(|_| Vec::new()) + } + Value::Tuple(tup) => { + tup.lock().map(|v| v.clone()).unwrap_or_else(|_| Vec::new()) + } other => vec![other], }; for (idx, pat) in parts.iter().enumerate() { @@ -954,19 +1087,17 @@ impl Executor { for stmt in stmts { match stmt { Stmt::Expr { expr, .. } => last = self.eval_expr(expr)?, - _ => { - match self.exec_stmt(stmt) { - Ok(ExecResult::Normal) => {} - Ok(other) => { - pending = Ok(other); - break; - } - Err(e) => { - pending = Err(e); - break; - } + _ => match self.exec_stmt(stmt) { + Ok(ExecResult::Normal) => {} + Ok(other) => { + pending = Ok(other); + break; } - } + Err(e) => { + pending = Err(e); + break; + } + }, } } match self.exit_scope(pending)? { @@ -974,11 +1105,19 @@ impl Executor { ExecResult::Return(v) => Ok(v), ExecResult::Continue => Ok(last), ExecResult::Break => Ok(last), - ExecResult::Throw(v) => Err(self.err(&format!("Uncaught throw: {}", v.as_string()), self.last_span)), + ExecResult::Throw(v) => Err(self.err( + &format!("Uncaught throw: {}", v.as_string()), + self.last_span, + )), } } - fn eval_parallel_for_expr(&mut self, pattern: &Pattern, iterable: &Expr, body: &[Stmt]) -> Result { + fn eval_parallel_for_expr( + &mut self, + pattern: &Pattern, + iterable: &Expr, + body: &[Stmt], + ) -> Result { let it = self.eval_expr(iterable)?; let items = self.iterate_value(it, iterable.span())?; if items.is_empty() { @@ -1105,7 +1244,10 @@ impl Executor { return Err(err); } - let values = ordered.into_iter().map(|v| v.unwrap_or(Value::Null)).collect(); + let values = ordered + .into_iter() + .map(|v| v.unwrap_or(Value::Null)) + .collect(); Ok(Value::array(values)) } @@ -1211,7 +1353,11 @@ impl Executor { Value::Generator(Arc::new(handle)) } - pub fn next_generator(&self, generator: &Arc, span: Span) -> Result { + pub fn next_generator( + &self, + generator: &Arc, + span: Span, + ) -> Result { match self.generator_next_step(generator, span)? { GeneratorStep::Yield(v) => Ok(generator_step_dict(false, v, Value::Null)), GeneratorStep::Done(v) => Ok(generator_step_dict(true, Value::Null, v)), @@ -1254,7 +1400,12 @@ impl Executor { } } - fn call(&mut self, callee: Value, args: Vec, span: Span) -> Result { + fn call( + &mut self, + callee: Value, + args: Vec, + span: Span, + ) -> Result { match callee { Value::NativeFunction(f) => { let positional = self.positional_only_args(args, span)?; @@ -1308,7 +1459,11 @@ impl Executor { } } - pub fn call_value(&mut self, callee: Value, args: Vec) -> Result { + pub fn call_value( + &mut self, + callee: Value, + args: Vec, + ) -> Result { self.call(callee, args, Span { line: 0, col: 0 }) } @@ -1316,7 +1471,11 @@ impl Executor { self.iterate_value(value, span) } - fn positional_only_args(&self, args: Vec, span: Span) -> Result, RuntimeError> { + fn positional_only_args( + &self, + args: Vec, + span: Span, + ) -> Result, RuntimeError> { let mut out = Vec::new(); for arg in args { match arg { @@ -1325,14 +1484,22 @@ impl Executor { out.extend(self.expand_spread_value(v, span)?); } RuntimeCallArg::Named { .. } => { - return Err(self.err("Named arguments are only supported for user-defined functions in v1", span)); + return Err(self.err( + "Named arguments are only supported for user-defined functions in v1", + span, + )); } } } Ok(out) } - fn call_user_function(&mut self, func: &Function, args: Vec, span: Span) -> Result { + fn call_user_function( + &mut self, + func: &Function, + args: Vec, + span: Span, + ) -> Result { let mut positional = Vec::new(); let mut named: HashMap = HashMap::new(); for arg in args { @@ -1343,7 +1510,10 @@ impl Executor { } RuntimeCallArg::Named { name, value } => { if named.insert(name.clone(), value).is_some() { - return Err(self.err(&format!("Duplicate named argument '{name}' for {}", func.name), span)); + return Err(self.err( + &format!("Duplicate named argument '{name}' for {}", func.name), + span, + )); } } } @@ -1356,7 +1526,10 @@ impl Executor { if let Some(vidx) = variadic_param { if vidx != func.params.len() - 1 { return Err(self.err( - &format!("Variadic parameter for {} must be the final parameter", func.name), + &format!( + "Variadic parameter for {} must be the final parameter", + func.name + ), span, )); } @@ -1385,7 +1558,10 @@ impl Executor { if param.variadic { if named.contains_key(¶m.name) { return Err(self.err( - &format!("Variadic parameter '{}' for {} cannot be passed by name", param.name, func.name), + &format!( + "Variadic parameter '{}' for {} cannot be passed by name", + param.name, func.name + ), span, )); } @@ -1419,7 +1595,10 @@ impl Executor { let value = if positional_index < positional.len() { if named.contains_key(¶m.name) { return Err(self.err( - &format!("Argument '{}' for {} was provided both positionally and by name", param.name, func.name), + &format!( + "Argument '{}' for {} was provided both positionally and by name", + param.name, func.name + ), span, )); } @@ -1432,7 +1611,10 @@ impl Executor { self.eval_expr(default_expr)? } else { return Err(self.err( - &format!("Missing required argument '{}' for {}", param.name, func.name), + &format!( + "Missing required argument '{}' for {}", + param.name, func.name + ), span, )); }; @@ -1483,7 +1665,7 @@ impl Executor { ExecResult::Continue => return Err(self.err("'continue' used outside loop", span)), ExecResult::Break => return Err(self.err("'break' used outside loop", span)), ExecResult::Throw(v) => { - return Err(self.err(&format!("Uncaught throw: {}", v.as_string()), span)) + return Err(self.err(&format!("Uncaught throw: {}", v.as_string()), span)); } }; if let Some(t) = &func.return_type { @@ -1526,7 +1708,12 @@ impl Executor { } } - fn construct_record(&mut self, record_name: &str, args: Vec, span: Span) -> Result { + fn construct_record( + &mut self, + record_name: &str, + args: Vec, + span: Span, + ) -> Result { let def = self .record_defs .get(record_name) @@ -1543,7 +1730,10 @@ impl Executor { } RuntimeCallArg::Positional(_) | RuntimeCallArg::Spread(_) => { return Err(self.err( - &format!("Record constructor {}(...) expects named arguments only", record_name), + &format!( + "Record constructor {}(...) expects named arguments only", + record_name + ), span, )); } @@ -1562,7 +1752,10 @@ impl Executor { self.eval_expr(default)? } else { pending = Err(self.err( - &format!("Record '{}' missing required field '{}'", record_name, field.name), + &format!( + "Record '{}' missing required field '{}'", + record_name, field.name + ), span, )); break; @@ -1609,7 +1802,12 @@ impl Executor { Ok(scope_out) } - fn construct_nominal(&mut self, type_name: &str, args: Vec, span: Span) -> Result { + fn construct_nominal( + &mut self, + type_name: &str, + args: Vec, + span: Span, + ) -> Result { let base = self .type_aliases .get(type_name) @@ -1618,7 +1816,10 @@ impl Executor { if args.len() != 1 { return Err(self.err( - &format!("Type constructor {}(...) expects exactly 1 positional argument", type_name), + &format!( + "Type constructor {}(...) expects exactly 1 positional argument", + type_name + ), span, )); } @@ -1626,7 +1827,10 @@ impl Executor { RuntimeCallArg::Positional(v) => v, RuntimeCallArg::Spread(_) | RuntimeCallArg::Named { .. } => { return Err(self.err( - &format!("Type constructor {}(...) expects exactly 1 positional argument", type_name), + &format!( + "Type constructor {}(...) expects exactly 1 positional argument", + type_name + ), span, )); } @@ -1654,7 +1858,12 @@ impl Executor { type_str(ty) } - fn type_matches_checked(&self, ty: &TypeExpr, value: &Value, span: Span) -> Result { + fn type_matches_checked( + &self, + ty: &TypeExpr, + value: &Value, + span: Span, + ) -> Result { type_matches_with_resolver(ty, value, &self.type_aliases, &self.record_defs) .map_err(|e| self.err(&e, span)) } @@ -1701,11 +1910,12 @@ impl Executor { fn namespace_access(&self, obj: Value, name: &str, span: Span) -> Result { match obj { - Value::Module(module) => module - .exports - .get(name) - .cloned() - .ok_or_else(|| self.err(&format!("Module '{}' has no export '{}'", module.name, name), span)), + Value::Module(module) => module.exports.get(name).cloned().ok_or_else(|| { + self.err( + &format!("Module '{}' has no export '{}'", module.name, name), + span, + ) + }), _ => Err(self.err("'::' requires module namespace", span)), } } @@ -1722,94 +1932,125 @@ impl Executor { return Ok(Value::Number(guard.len() as f64)); } } - Ok(Value::Ufcs { name: name.to_string(), receiver: Box::new(Value::Dict(map)) }) - } - Value::Array(arr) => { - match name { - "add" => { - let arr_ref = arr.clone(); - let func = move |args: Vec| { - let mut list = arr_ref.lock().map_err(|_| "Array lock poisoned".to_string())?; - if let Some(v) = args.get(0) { - list.push(v.clone()); - Ok(Value::Null) - } else { - Err("add expects 1 argument".to_string()) - } - }; - Ok(Value::NativeFunction(Arc::new(func))) - } - "count" => Ok(Value::Number(arr.lock().map(|v| v.len() as f64).unwrap_or(0.0))), - _ => Ok(Value::Ufcs { name: name.to_string(), receiver: Box::new(Value::Array(arr)) }), - } + Ok(Value::Ufcs { + name: name.to_string(), + receiver: Box::new(Value::Dict(map)), + }) } + Value::Array(arr) => match name { + "add" => { + let arr_ref = arr.clone(); + let func = move |args: Vec| { + let mut list = arr_ref + .lock() + .map_err(|_| "Array lock poisoned".to_string())?; + if let Some(v) = args.get(0) { + list.push(v.clone()); + Ok(Value::Null) + } else { + Err("add expects 1 argument".to_string()) + } + }; + Ok(Value::NativeFunction(Arc::new(func))) + } + "count" => Ok(Value::Number( + arr.lock().map(|v| v.len() as f64).unwrap_or(0.0), + )), + _ => Ok(Value::Ufcs { + name: name.to_string(), + receiver: Box::new(Value::Array(arr)), + }), + }, Value::Tuple(tup) => match name { - "count" => Ok(Value::Number(tup.lock().map(|v| v.len() as f64).unwrap_or(0.0))), - _ => Ok(Value::Ufcs { name: name.to_string(), receiver: Box::new(Value::Tuple(tup)) }), + "count" => Ok(Value::Number( + tup.lock().map(|v| v.len() as f64).unwrap_or(0.0), + )), + _ => Ok(Value::Ufcs { + name: name.to_string(), + receiver: Box::new(Value::Tuple(tup)), + }), }, Value::String(s) => { if let Some(method) = strings::get_method(name, &s) { return Ok(method); } - Ok(Value::Ufcs { name: name.to_string(), receiver: Box::new(Value::String(s)) }) + Ok(Value::Ufcs { + name: name.to_string(), + receiver: Box::new(Value::String(s)), + }) } Value::Task(task) => match name { "join" => { let task_ref = task.clone(); - Ok(Value::NativeFunctionExec(Arc::new(move |exec, _args, span| { - exec.task_join(&task_ref, span) - }))) + Ok(Value::NativeFunctionExec(Arc::new( + move |exec, _args, span| exec.task_join(&task_ref, span), + ))) } "done" => { let task_ref = task.clone(); - Ok(Value::NativeFunctionExec(Arc::new(move |exec, _args, span| { - let done = task_ref - .state - .lock() - .map_err(|_| exec.err("Task state lock poisoned", span))? - .status - != TaskStatus::Running; - Ok(Value::Bool(done)) - }))) + Ok(Value::NativeFunctionExec(Arc::new( + move |exec, _args, span| { + let done = task_ref + .state + .lock() + .map_err(|_| exec.err("Task state lock poisoned", span))? + .status + != TaskStatus::Running; + Ok(Value::Bool(done)) + }, + ))) } "cancel" => { let task_ref = task.clone(); - Ok(Value::NativeFunctionExec(Arc::new(move |exec, _args, span| { - let mut state = task_ref - .state - .lock() - .map_err(|_| exec.err("Task state lock poisoned", span))?; - if state.status == TaskStatus::Running { - state.status = TaskStatus::Cancelled; - state.error = Some(exec.err("Task cancelled", span)); - return Ok(Value::Bool(true)); - } - Ok(Value::Bool(false)) - }))) + Ok(Value::NativeFunctionExec(Arc::new( + move |exec, _args, span| { + let mut state = task_ref + .state + .lock() + .map_err(|_| exec.err("Task state lock poisoned", span))?; + if state.status == TaskStatus::Running { + state.status = TaskStatus::Cancelled; + state.error = Some(exec.err("Task cancelled", span)); + return Ok(Value::Bool(true)); + } + Ok(Value::Bool(false)) + }, + ))) } "error" => { let task_ref = task.clone(); - Ok(Value::NativeFunctionExec(Arc::new(move |exec, _args, span| { - let state = task_ref - .state - .lock() - .map_err(|_| exec.err("Task state lock poisoned", span))?; - if let Some(err) = &state.error { - Ok(Value::String(err.message.clone())) - } else { - Ok(Value::Null) - } - }))) + Ok(Value::NativeFunctionExec(Arc::new( + move |exec, _args, span| { + let state = task_ref + .state + .lock() + .map_err(|_| exec.err("Task state lock poisoned", span))?; + if let Some(err) = &state.error { + Ok(Value::String(err.message.clone())) + } else { + Ok(Value::Null) + } + }, + ))) } - _ => Ok(Value::Ufcs { name: name.to_string(), receiver: Box::new(Value::Task(task)) }), + _ => Ok(Value::Ufcs { + name: name.to_string(), + receiver: Box::new(Value::Task(task)), + }), }, Value::Number(n) => { if let Some(method) = numbers::get_method(name, n) { return Ok(method); } - Ok(Value::Ufcs { name: name.to_string(), receiver: Box::new(Value::Number(n)) }) + Ok(Value::Ufcs { + name: name.to_string(), + receiver: Box::new(Value::Number(n)), + }) } - _ => Ok(Value::Ufcs { name: name.to_string(), receiver: Box::new(obj) }), + _ => Ok(Value::Ufcs { + name: name.to_string(), + receiver: Box::new(obj), + }), } } @@ -1817,15 +2058,24 @@ impl Executor { match obj { Value::Array(arr) => { let i = to_i64(idx)? as usize; - arr.lock().ok().and_then(|v| v.get(i).cloned()).ok_or_else(|| self.err("Index out of range", span)) + arr.lock() + .ok() + .and_then(|v| v.get(i).cloned()) + .ok_or_else(|| self.err("Index out of range", span)) } Value::Tuple(tup) => { let i = to_i64(idx)? as usize; - tup.lock().ok().and_then(|v| v.get(i).cloned()).ok_or_else(|| self.err("Index out of range", span)) + tup.lock() + .ok() + .and_then(|v| v.get(i).cloned()) + .ok_or_else(|| self.err("Index out of range", span)) } Value::Dict(map) => { let key = idx.as_string(); - map.lock().ok().and_then(|v| v.get(&key).cloned()).ok_or_else(|| self.err("Missing key", span)) + map.lock() + .ok() + .and_then(|v| v.get(&key).cloned()) + .ok_or_else(|| self.err("Missing key", span)) } _ => Err(self.err("Indexing not supported", span)), } @@ -1836,16 +2086,25 @@ impl Executor { Value::Array(arr) => { let i = to_i64(idx)? as usize; { - let mut list = arr.lock().map_err(|_| self.err("Array lock poisoned", Span { line: 0, col: 0 }))?; - if i >= list.len() { return Err(self.err("Index out of range", Span { line: 0, col: 0 })); } + let mut list = arr + .lock() + .map_err(|_| self.err("Array lock poisoned", Span { line: 0, col: 0 }))?; + if i >= list.len() { + return Err(self.err("Index out of range", Span { line: 0, col: 0 })); + } list[i] = val; } Ok(Value::Array(arr)) } - Value::Tuple(_) => Err(self.err("Cannot assign to tuple index (tuples are immutable)", Span { line: 0, col: 0 })), + Value::Tuple(_) => Err(self.err( + "Cannot assign to tuple index (tuples are immutable)", + Span { line: 0, col: 0 }, + )), Value::Dict(map) => { let key = idx.as_string(); - map.lock().map_err(|_| self.err("Dict lock poisoned", Span { line: 0, col: 0 }))?.insert(key, val); + map.lock() + .map_err(|_| self.err("Dict lock poisoned", Span { line: 0, col: 0 }))? + .insert(key, val); Ok(Value::Dict(map)) } _ => Err(self.err("Index assignment not supported", Span { line: 0, col: 0 })), @@ -1859,9 +2118,13 @@ impl Executor { Value::Range(a, b) => { let mut out = Vec::new(); if a <= b { - for i in a..=b { out.push(Value::Number(i as f64)); } + for i in a..=b { + out.push(Value::Number(i as f64)); + } } else { - for i in (b..=a).rev() { out.push(Value::Number(i as f64)); } + for i in (b..=a).rev() { + out.push(Value::Number(i as f64)); + } } Ok(out) } @@ -1888,7 +2151,12 @@ impl Executor { } } - fn exec_use(&mut self, module: &str, alias: Option<&str>, span: Span) -> Result<(), RuntimeError> { + fn exec_use( + &mut self, + module: &str, + alias: Option<&str>, + span: Span, + ) -> Result<(), RuntimeError> { let module_value = self.load_builtin_module(module, span)?; if let Some(alias_name) = alias { self.ensure_name_free(alias_name, span)?; @@ -1904,7 +2172,12 @@ impl Executor { if let Some(existing) = self.lookup(module) { match existing { Value::Module(existing_module) if Arc::ptr_eq(&existing_module, &module_value) => {} - _ => return Err(self.err(&format!("Name conflict for import/use binding '{}'", module), span)), + _ => { + return Err(self.err( + &format!("Name conflict for import/use binding '{}'", module), + span, + )); + } } } @@ -1927,17 +2200,23 @@ impl Executor { for item in items { let bind_name = item.alias.as_deref().unwrap_or(&item.name); self.ensure_name_free(bind_name, span)?; - let value = module - .exports - .get(&item.name) - .cloned() - .ok_or_else(|| self.err(&format!("Module '{}' has no export '{}'", module.name, item.name), span))?; + let value = module.exports.get(&item.name).cloned().ok_or_else(|| { + self.err( + &format!("Module '{}' has no export '{}'", module.name, item.name), + span, + ) + })?; self.define(bind_name, value); } Ok(()) } - fn exec_import_namespace(&mut self, alias: &str, source: &ImportSource, span: Span) -> Result<(), RuntimeError> { + fn exec_import_namespace( + &mut self, + alias: &str, + source: &ImportSource, + span: Span, + ) -> Result<(), RuntimeError> { self.ensure_name_free(alias, span)?; let module = self.load_module_from_source(source, span)?; self.define(alias, Value::Module(module)); @@ -1946,26 +2225,41 @@ impl Executor { fn ensure_name_free(&self, name: &str, span: Span) -> Result<(), RuntimeError> { if self.lookup(name).is_some() { - return Err(self.err(&format!("Name conflict for import/use binding '{}'", name), span)); + return Err(self.err( + &format!("Name conflict for import/use binding '{}'", name), + span, + )); } Ok(()) } - fn load_module_from_source(&mut self, source: &ImportSource, span: Span) -> Result, RuntimeError> { + fn load_module_from_source( + &mut self, + source: &ImportSource, + span: Span, + ) -> Result, RuntimeError> { match source { ImportSource::Builtin(name) => self.load_builtin_module(name, span), ImportSource::Path(path) => self.load_script_module(path, span), } } - fn load_builtin_module(&self, name: &str, span: Span) -> Result, RuntimeError> { + fn load_builtin_module( + &self, + name: &str, + span: Span, + ) -> Result, RuntimeError> { self.builtin_modules .get(name) .cloned() .ok_or_else(|| self.err(&format!("Unknown builtin module '{name}'"), span)) } - fn load_script_module(&mut self, raw_path: &str, span: Span) -> Result, RuntimeError> { + fn load_script_module( + &mut self, + raw_path: &str, + span: Span, + ) -> Result, RuntimeError> { let full = self.resolve_script_path(raw_path, span)?; let key = format!("script:{}", full.display()); @@ -2006,16 +2300,18 @@ impl Executor { self.loading_stack.push(key.clone()); let result = (|| -> Result, RuntimeError> { - let source = fs::read_to_string(&full) - .map_err(|e| self.err(&format!("Failed to read module {}: {e}", full.display()), span))?; + let source = fs::read_to_string(&full).map_err(|e| { + self.err( + &format!("Failed to read module {}: {e}", full.display()), + span, + ) + })?; let mut scanner = Scanner::new(&source); let tokens = scanner .scan_tokens() .map_err(|e| self.err(&format!("{}: {e}", full.display()), span))?; let mut parser = Parser::new(tokens, &source, full.to_string_lossy().to_string()); - let program = parser - .parse_program() - .map_err(|e| self.err(&e, span))?; + let program = parser.parse_program().map_err(|e| self.err(&e, span))?; let mut child = Executor { filename: full.to_string_lossy().to_string(), @@ -2070,7 +2366,8 @@ impl Executor { path.to_path_buf() } else { let base = if self.filename == "" || self.filename == "" { - std::env::current_dir().map_err(|e| self.err(&format!("Failed to resolve cwd: {e}"), span))? + std::env::current_dir() + .map_err(|e| self.err(&format!("Failed to resolve cwd: {e}"), span))? } else { Path::new(&self.filename) .parent() @@ -2081,7 +2378,10 @@ impl Executor { }; std::fs::canonicalize(&resolved).map_err(|e| { self.err( - &format!("Failed to resolve import path '{}': {e}", resolved.display()), + &format!( + "Failed to resolve import path '{}': {e}", + resolved.display() + ), span, ) }) @@ -2130,7 +2430,9 @@ impl Executor { fn lookup(&self, name: &str) -> Option { for scope in self.scopes.iter().rev() { - if let Some(v) = scope.get(name) { return Some(v.clone()); } + if let Some(v) = scope.get(name) { + return Some(v.clone()); + } } None } @@ -2145,7 +2447,10 @@ impl Executor { self.deferred_scopes.push(Vec::new()); } - fn exit_scope(&mut self, mut pending: Result) -> Result { + fn exit_scope( + &mut self, + mut pending: Result, + ) -> Result { let deferred_blocks = self.deferred_scopes.pop().unwrap_or_default(); for block in deferred_blocks.into_iter().rev() { match self.exec_block(&block) { @@ -2171,7 +2476,11 @@ impl Executor { } fn push_frame(&mut self, name: String, span: Span) { - self.stack.push(Frame { name, line: span.line, col: span.col }); + self.stack.push(Frame { + name, + line: span.line, + col: span.col, + }); } fn pop_frame(&mut self) { @@ -2189,7 +2498,14 @@ impl Executor { fn err(&self, message: &str, span: Span) -> RuntimeError { let span = if span.line == 0 { self.last_span } else { span }; let snippet = self.render_snippet(span); - RuntimeError::new(message.to_string(), self.filename.clone(), span.line, span.col, snippet, self.stack.clone()) + RuntimeError::new( + message.to_string(), + self.filename.clone(), + span.line, + span.col, + snippet, + self.stack.clone(), + ) } pub fn make_error(&self, message: &str, span: Span) -> RuntimeError { @@ -2198,7 +2514,9 @@ impl Executor { fn render_snippet(&self, span: Span) -> Option { let lines: Vec<&str> = self.source.lines().collect(); - if span.line == 0 || span.line > lines.len() { return None; } + if span.line == 0 || span.line > lines.len() { + return None; + } let line = lines[span.line - 1]; let col = if span.col == 0 { 1 } else { span.col }; let mut caret = String::new(); @@ -2236,7 +2554,12 @@ impl Executor { self.err(&msg, span) } - fn unknown_method_or_function_error(&self, name: &str, receiver: &Value, span: Span) -> RuntimeError { + fn unknown_method_or_function_error( + &self, + name: &str, + receiver: &Value, + span: Span, + ) -> RuntimeError { let ty = value_type_str(receiver); let mut candidates = self.known_method_names_for(receiver); candidates.extend(self.known_global_function_names()); @@ -2255,7 +2578,9 @@ impl Executor { let mut names: Vec = self.scopes[0] .iter() .filter_map(|(k, v)| match v { - Value::Function(_) | Value::NativeFunction(_) | Value::NativeFunctionExec(_) => Some(k.clone()), + Value::Function(_) | Value::NativeFunction(_) | Value::NativeFunctionExec(_) => { + Some(k.clone()) + } _ => None, }) .collect(); @@ -2270,7 +2595,10 @@ impl Executor { .into_iter() .map(str::to_string) .collect(), - Value::Array(_) => vec!["add", "count"].into_iter().map(str::to_string).collect(), + Value::Array(_) => vec!["add", "count"] + .into_iter() + .map(str::to_string) + .collect(), Value::Tuple(_) => vec!["count"].into_iter().map(str::to_string).collect(), Value::Dict(_) => vec!["count"].into_iter().map(str::to_string).collect(), Value::String(_) => vec![ @@ -2297,8 +2625,8 @@ impl Executor { .map(str::to_string) .collect(), Value::Number(_) => vec![ - "abs", "floor", "ceil", "round", "min", "max", "clamp", "pow", "sqrt", "toInt", "toFloat", - "isInt", "toString", + "abs", "floor", "ceil", "round", "min", "max", "clamp", "pow", "sqrt", "toInt", + "toFloat", "isInt", "toString", ] .into_iter() .map(str::to_string) @@ -2324,20 +2652,36 @@ enum GeneratorStep { fn to_f64(v: Value) -> Result { match v { Value::Number(n) => Ok(n), - _ => Err(RuntimeError::new("Expected number".to_string(), "".to_string(), 0, 0, None, Vec::new())), + _ => Err(RuntimeError::new( + "Expected number".to_string(), + "".to_string(), + 0, + 0, + None, + Vec::new(), + )), } } fn to_i64(v: Value) -> Result { match v { Value::Number(n) => Ok(n as i64), - _ => Err(RuntimeError::new("Expected number".to_string(), "".to_string(), 0, 0, None, Vec::new())), + _ => Err(RuntimeError::new( + "Expected number".to_string(), + "".to_string(), + 0, + 0, + None, + Vec::new(), + )), } } fn now_hms() -> String { use std::time::{SystemTime, UNIX_EPOCH}; - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); let secs = now.as_secs() % 86400; let h = secs / 3600; let m = (secs % 3600) / 60; @@ -2361,7 +2705,9 @@ fn parallelism_cap() -> usize { } } } - std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) } fn type_str(ty: &crate::parser::ast::TypeExpr) -> String { @@ -2401,7 +2747,10 @@ fn type_matches_with_resolver( "range" => Ok(matches!(value, Value::Range(_, _))), "task" => Ok(matches!(value, Value::Task(_))), "generator" => Ok(matches!(value, Value::Generator(_))), - "fn" => Ok(matches!(value, Value::Function(_) | Value::NativeFunction(_) | Value::NativeFunctionExec(_))), + "fn" => Ok(matches!( + value, + Value::Function(_) | Value::NativeFunction(_) | Value::NativeFunctionExec(_) + )), other => { if record_defs.contains_key(other) { return Ok(record_type_name(value).as_deref() == Some(other)); @@ -2444,7 +2793,10 @@ fn type_matches_with_resolver( }, T::Map(k, v) => match value { Value::Dict(map) => { - let items = map.lock().map(|m| m.clone()).unwrap_or_else(|_| HashMap::new()); + let items = map + .lock() + .map(|m| m.clone()) + .unwrap_or_else(|_| HashMap::new()); for (key, val) in items { let key_val = Value::String(key); if !type_matches_with_resolver(k, &key_val, type_aliases, record_defs)? { @@ -2459,10 +2811,15 @@ fn type_matches_with_resolver( _ => Ok(false), }, T::Range(inner) => match value { - Value::Range(_, _) => type_matches_with_resolver(inner, &Value::Number(0.0), type_aliases, record_defs), + Value::Range(_, _) => { + type_matches_with_resolver(inner, &Value::Number(0.0), type_aliases, record_defs) + } _ => Ok(false), }, - T::Fn(_) => Ok(matches!(value, Value::Function(_) | Value::NativeFunction(_) | Value::NativeFunctionExec(_))), + T::Fn(_) => Ok(matches!( + value, + Value::Function(_) | Value::NativeFunction(_) | Value::NativeFunctionExec(_) + )), T::Union(items) => { for ty in items { if type_matches_with_resolver(ty, value, type_aliases, record_defs)? { @@ -2552,10 +2909,11 @@ fn stmt_contains_yield(stmt: &Stmt) -> bool { | Stmt::Throw { value: expr, .. } | Stmt::Invoke { expr, .. } => expr_contains_yield(expr), Stmt::IndexAssign { - target, index, expr, .. - } => { - expr_contains_yield(target) || expr_contains_yield(index) || expr_contains_yield(expr) - } + target, + index, + expr, + .. + } => expr_contains_yield(target) || expr_contains_yield(index) || expr_contains_yield(expr), Stmt::Return { value: None, .. } | Stmt::Continue { .. } | Stmt::Break { .. } @@ -2579,8 +2937,12 @@ fn expr_contains_yield(expr: &Expr) -> bool { CallArg::Named { value, .. } => expr_contains_yield(value), }) } - Expr::Member { object, .. } | Expr::NamespaceMember { object, .. } => expr_contains_yield(object), - Expr::Index { object, index, .. } => expr_contains_yield(object) || expr_contains_yield(index), + Expr::Member { object, .. } | Expr::NamespaceMember { object, .. } => { + expr_contains_yield(object) + } + Expr::Index { object, index, .. } => { + expr_contains_yield(object) || expr_contains_yield(index) + } Expr::Array { items, .. } | Expr::Tuple { items, .. } => { items.iter().any(expr_contains_yield) } @@ -2606,11 +2968,17 @@ fn expr_contains_yield(expr: &Expr) -> bool { then_branch, else_branch, .. - } => expr_contains_yield(cond) || block_contains_yield(then_branch) || block_contains_yield(else_branch), + } => { + expr_contains_yield(cond) + || block_contains_yield(then_branch) + || block_contains_yield(else_branch) + } Expr::Match { subject, arms, .. } => { expr_contains_yield(subject) || arms.iter().any(|arm| match &arm.kind { - MatchArmKind::Value(e) => expr_contains_yield(e) || block_contains_yield(&arm.body), + MatchArmKind::Value(e) => { + expr_contains_yield(e) || block_contains_yield(&arm.body) + } MatchArmKind::Compare { rhs, .. } => { expr_contains_yield(rhs) || block_contains_yield(&arm.body) } @@ -2635,7 +3003,9 @@ fn expr_contains_yield(expr: &Expr) -> bool { InterpPart::Expr(e) => expr_contains_yield(e), }), Expr::Sh { command, .. } => expr_contains_yield(command), - Expr::Ssh { host, command, .. } => expr_contains_yield(host) || expr_contains_yield(command), + Expr::Ssh { host, command, .. } => { + expr_contains_yield(host) || expr_contains_yield(command) + } Expr::Literal { .. } | Expr::Var { .. } => false, } } @@ -2654,7 +3024,11 @@ fn suggest_names(target: &str, candidates: &[String]) -> Vec { 2 } else { let d = levenshtein(&target_l, &c_l); - if d <= 2 { 3 + d } else { return None; } + if d <= 2 { + 3 + d + } else { + return None; + } }; Some((score, c.clone())) }) diff --git a/src/runtime/shell/result.rs b/src/runtime/shell/result.rs index 48c792d..d0bd17a 100644 --- a/src/runtime/shell/result.rs +++ b/src/runtime/shell/result.rs @@ -16,8 +16,14 @@ impl ShellResult { let mut map = HashMap::new(); map.insert("stdout".to_string(), Value::String(self.stdout.clone())); map.insert("stderr".to_string(), Value::String(self.stderr.clone())); - map.insert("exit_code".to_string(), Value::Number(self.exit_code as f64)); - map.insert("duration_ms".to_string(), Value::Number(self.duration.as_millis() as f64)); + map.insert( + "exit_code".to_string(), + Value::Number(self.exit_code as f64), + ); + map.insert( + "duration_ms".to_string(), + Value::Number(self.duration.as_millis() as f64), + ); Value::Dict(Arc::new(Mutex::new(map))) } } diff --git a/src/runtime/shell/sh.rs b/src/runtime/shell/sh.rs index 1db4657..cc9d583 100644 --- a/src/runtime/shell/sh.rs +++ b/src/runtime/shell/sh.rs @@ -12,9 +12,18 @@ pub fn run_sh(command: &str) -> Result { .map_err(|e| format!("Failed to run shell: {e}"))?; let duration = start.elapsed(); - let stdout = String::from_utf8_lossy(&output.stdout).trim_end().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim_end().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout) + .trim_end() + .to_string(); + let stderr = String::from_utf8_lossy(&output.stderr) + .trim_end() + .to_string(); let exit_code = output.status.code().unwrap_or(-1); - Ok(ShellResult { stdout, stderr, exit_code, duration }) + Ok(ShellResult { + stdout, + stderr, + exit_code, + duration, + }) } diff --git a/src/runtime/shell/ssh.rs b/src/runtime/shell/ssh.rs index 0d1ed3d..2f2d423 100644 --- a/src/runtime/shell/ssh.rs +++ b/src/runtime/shell/ssh.rs @@ -12,9 +12,18 @@ pub fn run_ssh(host: &str, command: &str) -> Result { .map_err(|e| format!("Failed to run ssh: {e}"))?; let duration = start.elapsed(); - let stdout = String::from_utf8_lossy(&output.stdout).trim_end().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim_end().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout) + .trim_end() + .to_string(); + let stderr = String::from_utf8_lossy(&output.stderr) + .trim_end() + .to_string(); let exit_code = output.status.code().unwrap_or(-1); - Ok(ShellResult { stdout, stderr, exit_code, duration }) + Ok(ShellResult { + stdout, + stderr, + exit_code, + duration, + }) } diff --git a/src/runtime/value.rs b/src/runtime/value.rs index 612690f..c47c66b 100644 --- a/src/runtime/value.rs +++ b/src/runtime/value.rs @@ -29,14 +29,30 @@ pub enum Value { Dict(Arc>>), Range(i64, i64), Duration(Duration), - Nominal { name: String, inner: Box }, + Nominal { + name: String, + inner: Box, + }, Task(Arc), Generator(Arc), Function(Arc), Module(Arc), NativeFunction(Arc) -> Result + Send + Sync>), - NativeFunctionExec(Arc, crate::parser::ast::Span) -> Result + Send + Sync>), - Ufcs { name: String, receiver: Box }, + NativeFunctionExec( + Arc< + dyn Fn( + &mut crate::runtime::executor::Executor, + Vec, + crate::parser::ast::Span, + ) -> Result + + Send + + Sync, + >, + ), + Ufcs { + name: String, + receiver: Box, + }, } impl Value { @@ -49,12 +65,18 @@ impl Value { pub fn parse_duration(text: &str) -> Result { let mut idx = 0; for c in text.chars() { - if c.is_ascii_digit() { idx += 1; } else { break; } + if c.is_ascii_digit() { + idx += 1; + } else { + break; + } } if idx == 0 || idx >= text.len() { return Err("Invalid duration literal".to_string()); } - let value: u64 = text[..idx].parse().map_err(|_| "Invalid duration value".to_string())?; + let value: u64 = text[..idx] + .parse() + .map_err(|_| "Invalid duration value".to_string())?; let unit = &text[idx..]; let dur = match unit { "ms" => Duration::from_millis(value), @@ -81,7 +103,11 @@ impl Value { Value::Nominal { inner, .. } => inner.is_truthy(), Value::Task(_) => true, Value::Generator(_) => true, - Value::Function(_) | Value::Module(_) | Value::NativeFunction(_) | Value::NativeFunctionExec(_) | Value::Ufcs { .. } => true, + Value::Function(_) + | Value::Module(_) + | Value::NativeFunction(_) + | Value::NativeFunctionExec(_) + | Value::Ufcs { .. } => true, } } @@ -90,15 +116,25 @@ impl Value { Value::Null => "null".to_string(), Value::Bool(b) => b.to_string(), Value::Number(n) => { - if n.fract() == 0.0 { format!("{}", *n as i64) } else { n.to_string() } + if n.fract() == 0.0 { + format!("{}", *n as i64) + } else { + n.to_string() + } } Value::String(s) => s.clone(), Value::Array(a) => { - let items: Vec = a.lock().map(|v| v.iter().map(|x| x.as_string()).collect()).unwrap_or_else(|_| Vec::new()); + let items: Vec = a + .lock() + .map(|v| v.iter().map(|x| x.as_string()).collect()) + .unwrap_or_else(|_| Vec::new()); format!("[{}]", items.join(", ")) } Value::Tuple(t) => { - let items: Vec = t.lock().map(|v| v.iter().map(|x| x.as_string()).collect()).unwrap_or_else(|_| Vec::new()); + let items: Vec = t + .lock() + .map(|v| v.iter().map(|x| x.as_string()).collect()) + .unwrap_or_else(|_| Vec::new()); format!("({})", items.join(", ")) } Value::Dict(d) => { @@ -134,3 +170,60 @@ impl fmt::Debug for Value { write!(f, "{}", self.as_string()) } } + +#[cfg(test)] +mod tests { + use super::Value; + + #[test] + fn value_is_truthy() { + assert!(!Value::Null.is_truthy()); + assert!(!Value::Bool(false).is_truthy()); + assert!(Value::Bool(true).is_truthy()); + assert!(!Value::Number(0.0).is_truthy()); + assert!(Value::Number(1.0).is_truthy()); + assert!(!Value::String(String::new()).is_truthy()); + assert!(Value::String("x".to_string()).is_truthy()); + } + + #[test] + fn value_as_string() { + assert_eq!(Value::Null.as_string(), "null"); + assert_eq!(Value::Bool(true).as_string(), "true"); + assert_eq!(Value::Number(42.0).as_string(), "42"); + assert_eq!(Value::Number(3.14).as_string(), "3.14"); + assert_eq!(Value::String("hi".to_string()).as_string(), "hi"); + assert_eq!(Value::Range(1, 5).as_string(), "1..5"); + } + + #[test] + fn value_parse_duration() { + use std::time::Duration; + assert_eq!( + Value::parse_duration("100ms").unwrap(), + Duration::from_millis(100) + ); + assert_eq!(Value::parse_duration("2s").unwrap(), Duration::from_secs(2)); + assert!(Value::parse_duration("x").is_err()); + } + + #[test] + fn value_array_as_string() { + let arr = Value::array(vec![Value::Number(1.0), Value::Number(2.0)]); + assert_eq!(arr.as_string(), "[1, 2]"); + } + + #[test] + fn value_tuple_as_string() { + let tup = Value::tuple(vec![Value::Bool(true), Value::String("x".to_string())]); + assert_eq!(tup.as_string(), "(true, x)"); + } + + #[test] + fn value_empty_array_truthy() { + let empty = Value::array(vec![]); + assert!(!empty.is_truthy()); + let non_empty = Value::array(vec![Value::Null]); + assert!(non_empty.is_truthy()); + } +} diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..823c389 --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,72 @@ +//! End-to-end tests: run full scripts and assert on captured output. + +use v2r::run_script_capture; + +fn assert_output_contains(captured: &[String], needle: &str) { + let found = captured.iter().any(|line| line.contains(needle)); + assert!( + found, + "Expected output to contain {:?}, got: {:?}", + needle, captured + ); +} + +#[test] +fn e2e_log_literal() { + let out = run_script_capture("log 42").unwrap(); + assert_output_contains(&out, "42"); +} + +#[test] +fn e2e_log_string() { + let out = run_script_capture(r#"log "hello""#).unwrap(); + assert_output_contains(&out, "hello"); +} + +#[test] +fn e2e_arithmetic() { + let out = run_script_capture("log 1 + 2 * 3").unwrap(); + assert_output_contains(&out, "7"); // 1 + 6 +} + +#[test] +fn e2e_sort_builtin() { + let out = run_script_capture("log sort([3, 1, 2])").unwrap(); + assert_output_contains(&out, "[1, 2, 3]"); +} + +#[test] +fn e2e_filter_find() { + let out = run_script_capture("let nums = [5, 1, 2, 4, 3]; log find(nums, n -> n > 3)").unwrap(); + assert_output_contains(&out, "5"); +} + +#[test] +fn e2e_function_and_named_args() { + let src = r#" +fn greet(name, suffix = "!") { return "hello, #{name}#{suffix}" } +log greet("world") +log greet(name = "team", suffix = " :)") +"#; + let out = run_script_capture(src).unwrap(); + assert_output_contains(&out, "hello, world!"); + assert_output_contains(&out, "hello, team :)"); +} + +#[test] +fn e2e_interpolated_string() { + let out = run_script_capture(r#"log "result: #{1 + 2}""#).unwrap(); + assert_output_contains(&out, "result: 3"); +} + +#[test] +fn e2e_parse_error() { + let err = run_script_capture("let x = ").unwrap_err(); + assert!(err.contains("Expected") || err.to_lowercase().contains("parse") || err.len() > 0); +} + +#[test] +fn e2e_runtime_error_undefined_var() { + let err = run_script_capture("log nonexistent").unwrap_err(); + assert!(!err.is_empty()); +}