diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 5fb60da..3142ae7 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 # For VitePress lastUpdated feature @@ -57,14 +57,14 @@ jobs: cp -r "${RUST_DOC_SRC}/." "${RUST_DOC_OUTPUT}/" - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" cache-dependency-path: hypnoscript-docs/package-lock.json - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@v6 - name: Install Dependencies working-directory: hypnoscript-docs @@ -80,7 +80,7 @@ jobs: cp -r "${RUST_DOC_OUTPUT}/." hypnoscript-docs/docs/.vitepress/dist/rust-api/ - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: hypnoscript-docs/docs/.vitepress/dist @@ -94,7 +94,7 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 # Optional: Build and test on PR test-build: @@ -106,7 +106,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -127,7 +127,7 @@ jobs: cp -r "${RUST_DOC_SRC}/." "${RUST_DOC_OUTPUT}/" - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml index 4677434..a87851d 100644 --- a/.github/workflows/greetings.yml +++ b/.github/workflows/greetings.yml @@ -1,16 +1,49 @@ +# Welcomes first-time contributors with useful onboarding information. +# +# Docs: https://github.com/actions/first-interaction name: Greetings -on: [pull_request_target, issues] +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +permissions: + issues: write + pull-requests: write jobs: greeting: runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write steps: - - uses: actions/first-interaction@v1 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - issue-message: "Message that will be displayed on users' first issue" - pr-message: "Message that will be displayed on users' first pull request" + - uses: actions/first-interaction@v3 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + issue_message: | + πŸ‘‹ Thanks for opening your first issue in **HypnoScript**! + + To help us triage it quickly, please make sure your report includes: + + - **What you expected** to happen and **what actually** happened + - A **minimal `.hyp` snippet** or the CLI command that reproduces the problem + (e.g. `hypnoscript check file.hyp` / `hypnoscript exec file.hyp`) + - The output of `hypnoscript version` and your OS + + A maintainer will take a look as soon as possible. If you don't get a + response within a couple of weeks, feel free to ping the thread β€” + inactive issues are marked as stale automatically. + pr_message: | + πŸ‘‹ Thanks for your first pull request to **HypnoScript** β€” welcome aboard! + + Before a review, please check that: + + - [ ] `cargo fmt --all -- --check` passes + - [ ] `cargo clippy --all-targets --all-features -- -D warnings` is clean + - [ ] `cargo test --workspace` succeeds + - [ ] New language features come with tests (see `hypnoscript-tests/`) + - [ ] The PR targets the `dev` branch (unless it's a hotfix/release) + + CI will run the full build-and-test matrix automatically, and labels + are applied based on the paths you touched. A maintainer will review + your changes soon β€” thanks for contributing! πŸŽ‰ diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index 3e9fc61..db019f0 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -1,20 +1,30 @@ -# This workflow will triage pull requests and apply a label based on the -# paths that are modified in the pull request. +# Triages pull requests and applies labels based on the paths that are +# modified, the base branch, and the head branch naming convention. # -# To use this workflow, you will need to set up a .github/labeler.yml -# file with configuration. For more information, see: -# https://github.com/actions/labeler - +# Configuration lives in .github/labeler.yml +# Docs: https://github.com/actions/labeler name: Labeler -on: [pull_request_target] + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: labeler-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write jobs: label: runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: write - steps: - uses: actions/labeler@v6 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + # Remove path-based labels again when the matching changes are + # reverted / no longer part of the PR. + sync-labels: true diff --git a/.github/workflows/rust-build-and-release.yml b/.github/workflows/rust-build-and-release.yml index 0c2983f..13aa35e 100644 --- a/.github/workflows/rust-build-and-release.yml +++ b/.github/workflows/rust-build-and-release.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -86,7 +86,7 @@ jobs: fi - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.asset_name }} path: | @@ -97,7 +97,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -131,7 +131,7 @@ jobs: run: cargo deb --package hypnoscript-cli - name: Upload .deb artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: hypnoscript-deb path: target/debian/*.deb @@ -144,7 +144,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Stage installer script run: | @@ -153,7 +153,7 @@ jobs: chmod +x artifacts/installer/install.sh - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: artifacts @@ -161,7 +161,7 @@ jobs: run: ls -R artifacts - name: Create Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v3 with: files: | artifacts/**/* @@ -247,7 +247,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -265,14 +265,14 @@ jobs: cp -r "${RUST_DOC_SRC}/." "${RUST_DOC_OUTPUT}/" - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" cache-dependency-path: hypnoscript-docs/package-lock.json - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@v6 - name: Install docs dependencies working-directory: hypnoscript-docs @@ -288,13 +288,13 @@ jobs: cp -r "${RUST_DOC_OUTPUT}/." "${VITEPRESS_DIST}/${RUST_API_SUBDIR}/" - name: Upload documentation artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: ${{ env.VITEPRESS_DIST }} - name: Deploy documentation id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 publish-crates: needs: create-release @@ -303,7 +303,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 diff --git a/.github/workflows/rust-build-and-test.yml b/.github/workflows/rust-build-and-test.yml index 1a742f9..e85ca06 100644 --- a/.github/workflows/rust-build-and-test.yml +++ b/.github/workflows/rust-build-and-test.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -26,7 +26,7 @@ jobs: components: rustfmt, clippy - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.cargo/registry key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} @@ -34,7 +34,7 @@ jobs: ${{ runner.os }}-cargo-registry- - name: Cache cargo index - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.cargo/git key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} @@ -42,7 +42,7 @@ jobs: ${{ runner.os }}-cargo-git- - name: Cache cargo build - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: target key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} @@ -91,7 +91,7 @@ jobs: .\target\release\hypnoscript.exe exec hypnoscript-tests\test_rust_demo.hyp - name: Upload test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: test-results-${{ matrix.os }} @@ -100,7 +100,7 @@ jobs: **/test-results.xml - name: Upload CLI binary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: hypnoscript-cli-${{ matrix.os }} path: | @@ -119,7 +119,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -161,7 +161,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -181,7 +181,7 @@ jobs: time ./target/release/hypnoscript exec hypnoscript-tests/test_rust_demo.hyp - name: Upload performance results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: performance-results path: | @@ -195,7 +195,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -211,7 +211,7 @@ jobs: - name: Upload coverage to Codecov if: env.CODECOV_TOKEN != '' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: files: lcov.info fail_ci_if_error: true @@ -229,7 +229,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -250,7 +250,7 @@ jobs: tar -czf hypnoscript-rust-release.tar.gz -C release . - name: Upload release artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: rust-release-package path: hypnoscript-rust-release.tar.gz diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a27534f..43ebf00 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,27 +1,67 @@ -# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. +# Warns and then closes issues and PRs that have had no activity for a +# specified amount of time. # -# You can adjust the behavior by modifying this file. -# For more information, see: -# https://github.com/actions/stale +# Docs: https://github.com/actions/stale name: Mark stale issues and pull requests on: schedule: - - cron: '30 14 * * *' + - cron: '30 14 * * *' + workflow_dispatch: # allow manual runs + +concurrency: + group: stale + cancel-in-progress: true + +permissions: + issues: write + pull-requests: write jobs: stale: - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - steps: - - uses: actions/stale@v5 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: 'Stale issue message' - stale-pr-message: 'Stale pull request message' - stale-issue-label: 'no-issue-activity' - stale-pr-label: 'no-pr-activity' + - uses: actions/stale@v10 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # --- Issues --- + days-before-issue-stale: 60 + days-before-issue-close: 14 + stale-issue-label: 'status:stale' + close-issue-label: 'status:auto-closed' + exempt-issue-labels: 'pinned,security,type:bug,help wanted,good first issue,status:blocked' + stale-issue-message: > + This issue has been automatically marked as stale because it has had + no activity in the last 60 days. It will be closed in 14 days if no + further activity occurs. If this issue is still relevant, please + leave a comment or remove the `status:stale` label. Thank you for + your contributions! + close-issue-message: > + This issue was closed automatically because it has been stale for + 14 days with no activity. Feel free to reopen it or create a new + issue with up-to-date context if the problem persists. + + # --- Pull requests --- + days-before-pr-stale: 30 + days-before-pr-close: 14 + stale-pr-label: 'status:stale' + close-pr-label: 'status:auto-closed' + exempt-pr-labels: 'pinned,security,status:blocked,type:release' + exempt-draft-pr: true + delete-branch: false + stale-pr-message: > + This pull request has been automatically marked as stale because it + has had no activity in the last 30 days. It will be closed in + 14 days if no further activity occurs. Please rebase, push an + update, or leave a comment if you intend to continue working on it. + close-pr-message: > + This pull request was closed automatically because it has been + stale for 14 days with no activity. Feel free to reopen it once you + are ready to continue. + + # --- General behaviour --- + exempt-all-milestones: true + remove-stale-when-updated: true + operations-per-run: 200 + ascending: true # process oldest first so nothing starves diff --git a/.github/workflows/summary.yml b/.github/workflows/summary.yml deleted file mode 100644 index 9b07bb8..0000000 --- a/.github/workflows/summary.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Summarize new issues - -on: - issues: - types: [opened] - -jobs: - summary: - runs-on: ubuntu-latest - permissions: - issues: write - models: read - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Run AI inference - id: inference - uses: actions/ai-inference@v1 - with: - prompt: | - Summarize the following GitHub issue in one paragraph: - Title: ${{ github.event.issue.title }} - Body: ${{ github.event.issue.body }} - - - name: Comment with AI summary - run: | - gh issue comment $ISSUE_NUMBER --body '${{ steps.inference.outputs.response }}' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - RESPONSE: ${{ steps.inference.outputs.response }} diff --git a/.gitignore b/.gitignore index 813cac6..c07420e 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ hypnoscript-docs/static/install.sh *.dylib *.wasm *.wat +hypnoscript-compiler/hypnoscript_output* diff --git a/CHANGELOG.md b/CHANGELOG.md index d857ea0..6d2718b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,115 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/). +## [1.3.0] - 2026-07-06 + +### Added + +- **Unicode string escapes**: `\uXXXX` (4 hex digits) and `\xNN` (2 hex digits) escape + sequences in string literals, with clear errors for invalid digits and invalid Unicode + scalar values. + +- **Closures with lexical scoping**: functions declared inside other functions now + capture the enclosing local variables (by-value snapshot at declaration time) and can + recurse. Variable lookups no longer leak across function-call boundaries (previously + the interpreter used dynamic scoping, so a callee could accidentally read the caller's + locals); accessing a caller-local from a callee is now an `UndefinedVariable` error. +- **Recursion depth limit**: deeply recursive programs abort with a graceful + `RecursionLimitExceeded` error instead of crashing the host process with a stack + overflow. Default limit is 1,000 calls; configurable via the `HYPNO_MAX_CALL_DEPTH` + environment variable, the `--max-call-depth` CLI flag on `exec`, or + `Interpreter::set_max_call_depth`. The interpreter additionally grows the native stack + on demand (`stacker`), so the limit is reliable in debug and release builds alike. +- **Filesystem sandbox for file builtins**: setting the `HYPNO_SANDBOX` environment + variable (or passing `--sandbox ` to `exec`, or calling + `hypnoscript_runtime::set_sandbox_root`) confines `ReadFile`, `WriteFile`, + `DeleteFile`, `ListDirectory` and all other file builtins to that directory. Escapes + via `..`, absolute paths or symlinks are rejected with `PermissionDenied`; relative + paths resolve against the sandbox root. Without a configured root, behaviour is + unchanged. +- **Working promise builtins**: `delayedValue(ms, value)` returns a real promise that + resolves when awaited (delay honours `HYPNO_TIME_SCALE`), plus `instantPromise(value)`, + `promiseAll(array)` (waits for the longest delay, simulating concurrency), + `promiseRace(array)` (waits for the shortest) and `isPromiseResolved(p)`. `await` now + resolves pending promises deterministically instead of sleeping a hard-coded 10 ms. +- **Central builtin registry** (`hypnoscript_compiler::builtin_registry`): all 142 + builtin signatures are declared once in a table that feeds both the type checker + registrations and the CLI `builtins` command (which previously printed a hardcoded, + outdated list and now renders every builtin with its full signature). +- **Value representation optimized**: arrays and records are shared via `Rc` (safe since + both are immutable value types in the language), and function bodies are shared via + `Rc` as well β€” cloning a value or looking up a function no longer deep-copies element + vectors or the body AST. +- **`AsyncBuiltins` placeholders removed**: `delayed_value` (returned a fake string + "promise"), `promise_all` and `promise_race` (returned wrong results) were replaced by + the real interpreter builtins above. +- **`null` literal**: `null` is now a first-class literal (previously the nullish + operators `lucidFallback`/`dreamReach` existed but `null` itself could not be written). + It works in expressions, record fields and as an `entrain` pattern (`when null => ...`). +- **Nullable types**: `number?` (or the hypnotic spelling `lucid number`) declares a type + that admits `null`. The type checker enforces this in both directions: assigning `null` + to a non-nullable type is an error, as is assigning a nullable value to a non-nullable + target. `lucidFallback` strips nullability from the result type. +- **Array type annotations**: `string[]`, `number[][]` and combinations like `number[]?` + are accepted everywhere a type annotation is allowed, and are checked against array + literal element types. +- **Labeled loops with labeled break/continue**: `outer: loop (...) { ... snap outer; }` + breaks out of the named loop from any nesting depth; `sink outer;` continues its next + iteration. Also supports the `label name:` keyword form. Unknown labels are runtime + errors. +- **`drift(ms);` / `pauseReality(ms);` statements**: pause execution, with static type + checking of the duration expression. +- **Standalone `deepFocus (cond) { ... }` statement**: the conditional block form that was + already in the AST and interpreter is now parseable. +- **`imperative suggestion` two-word form** for function declarations (top-level and in + sessions), alongside the existing one-word `imperativeSuggestion`. +- **`sharedTrance` shorthand**: `sharedTrance total: number = 0;` now works without an + explicit `induce`/`implant`/`embed`/`freeze` keyword. +- **Source encoding tolerance**: `.hyp` files with UTF-8 BOMs or in UTF-16 (LE/BE, with or + without BOM) are decoded transparently by the CLI and test harness + (`hypnoscript_lexer_parser::decode_source`). +- **`HYPNO_TIME_SCALE` environment variable**: scales every themed pause (`drift`, + `DeepTrance`, `HypnoticCountdown`, ...). `0` skips pauses entirely (used by the test + harness), `0.5` halves them, unset means real time. +- **String Interpolation**: `"Hello, ${name}!"` embeds arbitrary expressions in string + literals. Interpolations are desugared by the lexer into string concatenation, so they + work uniformly across the interpreter, type checker and all compile targets. + `\${` escapes a literal `${`; nested braces and strings inside `${...}` are supported. +- **Readable numeric literals**: digit separators (`1_000_000`) and exponent notation + (`2.5e3`, `7e-2`) in number literals. +- **End-to-end sample-program harness** (`hypnoscript-compiler/tests/hyp_programs.rs`): + every `.hyp` file in `hypnoscript-tests/` must be categorized as runnable or + known-unsupported (with a reason); runnable programs are executed on every + `cargo test` run instead of only one file in CI. With the language gaps above closed, + **all 27 sample programs now run** (previously 16; legacy files with genuine bugs β€” + a duplicated `Focus {`, a missing semicolon, `deeplyLess` used where `<` was meant β€” + were fixed). +- **`check` command now exits non-zero when type errors are found**, so it can gate CI. + +### Changed + +- **Structured syntax errors**: the lexer and parser now return `SyntaxError` values with + line/column information instead of plain strings. Parser errors additionally report the + offending token (e.g. `Expected ';' after expression, found 'observe' at line 4, column 5`). +- **Interpreter modularized**: `hypnoscript-compiler/src/interpreter.rs` (3,800 lines) was + split into focused submodules `error`, `value`, `session` and `builtins`; the public API + (`Interpreter`, `InterpreterError`, `Value`) is unchanged. +- **Keyword table deduplicated**: keyword definitions in `token.rs` live in a single + declarative table instead of ~570 lines of repetitive map insertions. +- **Lexer cleanup**: operator lexing extracted into a table-driven helper; comment skipping + unified; multi-line strings now report the token's start line. + +### Fixed + +- `a..b` no longer silently swallows one dot (it is now a clear syntax error suggesting + `.` or `...`). +- Number literals no longer accept multiple decimal points (`1.2.3` previously lexed as a + single invalid number; `1.2.member` now lexes correctly as member access). +- Unterminated block comments are reported as errors instead of being silently accepted. +- Unterminated strings report the string's start position instead of the end of file. +- `PadLeft` / `PadRight` now count characters instead of bytes, so padding widths are + correct for non-ASCII strings (e.g. `cafΓ©`, emoji). + ## [1.2.0] - 2026-01-22 ### Added @@ -85,3 +194,4 @@ All notable changes to this project will be documented in this file. The format [1.0.0]: https://github.com/Kink-Development-Group/hyp-runtime/releases/tag/1.0.0 [1.2.0]: https://github.com/Kink-Development-Group/hyp-runtime/releases/tag/1.2.0 +[1.3.0]: https://github.com/Kink-Development-Group/hyp-runtime/releases/tag/v1.3.0 diff --git a/Cargo.toml b/Cargo.toml index c28071e..7c732e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "1.2.0" +version = "1.3.0" edition = "2024" authors = ["Kink Development Group"] license = "MIT" @@ -20,8 +20,8 @@ repository = "https://github.com/Kink-Development-Group/hyp-runtime" anyhow = "1.0" thiserror = "2.0.18" serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.149" -reqwest = { version = "0.13.1", default-features = false, features = ["json", "blocking", "query", "rustls"] } +serde_json = "1.0" +reqwest = { version = "0.13", default-features = false, features = ["json", "blocking", "query", "rustls"] } csv = "1.4.0" [profile.release] diff --git a/PACKAGE_MANAGER.md b/PACKAGE_MANAGER.md index 7d3c170..e6cbb95 100644 --- a/PACKAGE_MANAGER.md +++ b/PACKAGE_MANAGER.md @@ -141,10 +141,10 @@ The lock file ensures reproducible builds by capturing exact dependency versions ```json { - "lockVersion": "1.2.0", + "lockVersion": "1.3.0", "lockedAnchors": { "hypnoscript-runtime": { - "version": "^1.2.0", + "version": "^1.3.0", "source": "registry", "integrity": null, "dependencies": {} diff --git a/README.md b/README.md index 183005b..bf8a9b7 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,16 @@ The complete runtime environment, compiler, and command-line tools are exclusive - πŸ’Ž **Nullish Operators** – `lucidFallback` (`??`) and `dreamReach` (`?.`) for safe null handling - πŸ›οΈ **OOP Support** – Sessions with `constructor`, `expose`/`conceal`, `dominant` (static) - πŸ–₯️ **Extended CLI** – `run`, `lex`, `parse`, `check`, `compile-wasm`, `compile-native`, `optimize`, `builtins`, `version` -- βœ… **Comprehensive Tests** – 185+ tests across all compiler modules +- 🧡 **String Interpolation** – `"Hello, ${name}!"` with arbitrary expressions inside `${...}` +- πŸ”’ **Readable Numbers** – digit separators (`1_000_000`) and exponent notation (`2.5e3`) +- 🌫️ **Null Safety** – `null` literal, nullable types (`number?` / `lucid number`), enforced by the type checker +- πŸͺ€ **Closures** – nested suggestions capture their lexical environment and can recurse +- πŸ›‘οΈ **Sandboxing** – confine all file builtins to a directory via `--sandbox` / `HYPNO_SANDBOX` +- πŸŒ€ **Safe Recursion** – graceful `RecursionLimitExceeded` errors instead of stack-overflow crashes (`--max-call-depth` / `HYPNO_MAX_CALL_DEPTH`) +- ⏳ **Promises** – `delayedValue`, `instantPromise`, `promiseAll`, `promiseRace`, `isPromiseResolved` with deterministic `await` +- πŸ” **Labeled Loops** – `outer: loop (...) { snap outer; }` for breaking/continuing outer loops +- ⏸️ **Timed Pauses** – `drift(ms);` statement; scale or skip all pauses via `HYPNO_TIME_SCALE` +- βœ… **Comprehensive Tests** – 300+ tests across all compiler modules, plus an end-to-end harness over the sample programs - πŸ“š **Documentation** – VitePress + extensive architecture docs + complete Rustdoc - πŸš€ **Performance** – Zero-cost abstractions, no garbage collector, optimized native code @@ -32,21 +41,23 @@ The complete runtime environment, compiler, and command-line tools are exclusive ```text hyp-runtime/ β”œβ”€β”€ Cargo.toml # Workspace configuration -β”œβ”€β”€ COMPILER_ARCHITECTURE.md # Detailed compiler documentation β”œβ”€β”€ hypnoscript-core/ # Type system & symbols (100%) β”œβ”€β”€ hypnoscript-lexer-parser/ # Tokens, Lexer, AST, Parser (100%) +β”‚ β”œβ”€β”€ lexer.rs # βœ… Tokenizer incl. string interpolation +β”‚ β”œβ”€β”€ parser.rs # βœ… Recursive-descent parser +β”‚ └── error.rs # βœ… Syntax errors with line/column info β”œβ”€β”€ hypnoscript-compiler/ # Compiler backend (100%) -β”‚ β”œβ”€β”€ interpreter.rs # βœ… Tree-walking interpreter +β”‚ β”œβ”€β”€ interpreter/ # βœ… Tree-walking interpreter (error/value/session/builtins) β”‚ β”œβ”€β”€ type_checker.rs # βœ… Static type checking β”‚ β”œβ”€β”€ wasm_codegen.rs # βœ… WASM Text Format (.wat) β”‚ β”œβ”€β”€ wasm_binary.rs # βœ… WASM Binary Format (.wasm) β”‚ β”œβ”€β”€ optimizer.rs # βœ… Code optimizations -β”‚ └── native_codegen.rs # 🚧 Native compilation (LLVM) +β”‚ └── native_codegen.rs # 🚧 Native compilation (planned) β”œβ”€β”€ hypnoscript-runtime/ # 180+ builtin functions (100%) └── hypnoscript-cli/ # Command-line interface (100%) ``` -Documentation is available in `hypnoscript-docs/` (Docusaurus). +Documentation is available in `hypnoscript-docs/` (VitePress). --- @@ -109,6 +120,9 @@ Focus { observe message; observe x; + // String interpolation + observe "The answer is ${x}, doubled it is ${x * 2}."; + // Hypnotic operator synonym if (x yourEyesAreGettingHeavy 40) deepFocus { observe "X is greater than 40"; @@ -247,15 +261,15 @@ Example `trance.json`: ```json { - β€œritualName”: β€œmy-hypno-app”, - β€œmantra”: β€œ1.0.0”, - β€œintent”: β€œcli”, - β€œsuggestions”: { - β€œfocus”: β€œhypnoscript exec src/main.hyp”, - β€œtest”: β€œhypnoscript exec tests/test.hyp” + "ritualName": "my-hypno-app", + "mantra": "1.0.0", + "intent": "cli", + "suggestions": { + "focus": "hypnoscript exec src/main.hyp", + "test": "hypnoscript exec tests/test.hyp" }, - β€œanchors”: { - β€œhypnoscript-runtime”: β€œ^1.0.0” + "anchors": { + "hypnoscript-runtime": "^1.0.0" } } ``` @@ -274,19 +288,16 @@ cargo test --all **Test Coverage**: -- βœ… Lexer: 15+ tests -- βœ… Parser: 20+ tests -- βœ… Type Checker: 10+ tests -- βœ… Interpreter: 12+ tests -- βœ… WASM Generator: 4+ tests -- βœ… Optimizer: 6+ tests -- βœ… Native Generator: 5+ tests -- βœ… Runtime Builtins: 30+ tests -- βœ… Pattern Matching: Full coverage -- βœ… Triggers: Full coverage -- βœ… Nullish Operators: Full coverage +- βœ… Lexer: tokenization, escapes, interpolation, numeric literals +- βœ… Parser: all statements/expressions, positions in error messages +- βœ… Type Checker, Interpreter, Optimizer, WASM & Native Generators +- βœ… Runtime Builtins (math, string, array, collection, dict, file, ...) +- βœ… Pattern Matching, Triggers, Nullish Operators: full coverage +- βœ… End-to-end harness: every sample program in `hypnoscript-tests/` is + categorized and the supported ones are executed on every test run + (`hypnoscript-compiler/tests/hyp_programs.rs`) -### Total: 185+ tests (all passing) +### Total: 300+ tests (all passing) ### Compiler Tests @@ -622,7 +633,8 @@ mod tests { - [x] Complete program execution - [x] CLI integration (10 commands) - [x] CI/CD pipelines -- [x] Comprehensive tests (100+ tests) +- [x] Comprehensive tests (300+ tests) +- [x] String interpolation (`"${...}"`) - [x] Multilingual documentation ### In Development 🚧 @@ -662,18 +674,16 @@ mod tests { - βœ… C# codebase removed (all former `.csproj` projects deleted) - βœ… Rust workspace production-ready - βœ… Complete port of core functionality -- βœ… All 48 tests passing +- βœ… All tests passing (300+) - πŸ”„ Optional extensions (e.g., Network/ML builtins) possible as roadmap items -Migration details: see `IMPLEMENTATION_SUMMARY.md`. - --- ## πŸ”— Links & Resources - πŸ“˜ [Rust Book](https://doc.rust-lang.org/book/) - πŸ“¦ [Cargo Documentation](https://doc.rust-lang.org/cargo/) -- 🧾 Project Docs: `HypnoScript.Dokumentation/` +- 🧾 Project Docs: `hypnoscript-docs/` (VitePress) - 🐞 Issues & Discussions: --- diff --git a/examples/trance.json b/examples/trance.json index 27c679e..f111aad 100644 --- a/examples/trance.json +++ b/examples/trance.json @@ -29,8 +29,8 @@ "test": "hypnoscript exec tests/smoke.hyp" }, "anchors": { - "hypnoscript-runtime": "^1.2.0", - "hypnoscript-cli": "^1.2.0" + "hypnoscript-runtime": "^1.3.0", + "hypnoscript-cli": "^1.3.0" }, "deepAnchors": { "@hypno/testing-lab": "^0.3.0" diff --git a/hypnoscript-cli/src/main.rs b/hypnoscript-cli/src/main.rs index 08aa950..a6cf496 100644 --- a/hypnoscript-cli/src/main.rs +++ b/hypnoscript-cli/src/main.rs @@ -25,7 +25,7 @@ const GITHUB_OWNER: &str = "Kink-Development-Group"; const GITHUB_REPO: &str = "hyp-runtime"; const GITHUB_API: &str = "https://api.github.com"; const DEFAULT_TIMEOUT_SECS: u64 = 20; -const DEFAULT_PACKAGE_VERSION: &str = "^1.2.0"; +const DEFAULT_PACKAGE_VERSION: &str = "^1.3.0"; #[cfg(not(target_os = "windows"))] const INSTALLER_FALLBACK_URL: &str = "https://kink-development-group.github.io/hyp-runtime/install.sh"; @@ -40,6 +40,13 @@ fn into_anyhow(error: E) -> anyhow::Error { anyhow::Error::msg(error.to_string()) } +/// Read a HypnoScript source file, tolerating UTF-8/UTF-16 BOMs and UTF-16 +/// encodings (common for files created on Windows). +fn read_source(path: &str) -> Result { + let bytes = fs::read(path)?; + hypnoscript_lexer_parser::decode_source(&bytes).map_err(|e| anyhow!("{}: {}", path, e)) +} + #[derive(Parser)] #[command(name = "hypnoscript")] #[command(about = "HypnoScript - The Hypnotic Programming Language", long_about = None)] @@ -75,6 +82,16 @@ enum Commands { /// Output trace information to file #[arg(long)] trace_file: Option, + + /// Restrict file builtins to this directory (filesystem sandbox). + /// Can also be set via the HYPNO_SANDBOX environment variable. + #[arg(long)] + sandbox: Option, + + /// Maximum function call depth before aborting with an error. + /// Can also be set via the HYPNO_MAX_CALL_DEPTH environment variable. + #[arg(long)] + max_call_depth: Option, }, /// Lex a HypnoScript file (tokenize) @@ -229,12 +246,22 @@ fn main() -> Result<()> { breakpoints, watch, trace_file, + sandbox, + max_call_depth, } => { if verbose { println!("Running file: {}", file); } - let source = fs::read_to_string(&file)?; + if let Some(sandbox_dir) = sandbox { + hypnoscript_runtime::set_sandbox_root(&sandbox_dir) + .map_err(|e| anyhow!("Invalid sandbox directory '{}': {}", sandbox_dir, e))?; + if verbose { + println!("Filesystem sandbox: {}", sandbox_dir); + } + } + + let source = read_source(&file)?; // If debug mode is enabled, start interactive debug session if debug { @@ -285,6 +312,9 @@ fn main() -> Result<()> { // Execute let mut interpreter = Interpreter::new(); + if let Some(depth) = max_call_depth { + interpreter.set_max_call_depth(depth); + } interpreter.execute_program(ast).map_err(into_anyhow)?; if verbose { @@ -293,7 +323,7 @@ fn main() -> Result<()> { } Commands::Lex { file } => { - let source = fs::read_to_string(&file)?; + let source = read_source(&file)?; let mut lexer = Lexer::new(&source); let tokens = lexer.lex().map_err(into_anyhow)?; @@ -305,7 +335,7 @@ fn main() -> Result<()> { } Commands::Parse { file } => { - let source = fs::read_to_string(&file)?; + let source = read_source(&file)?; let mut lexer = Lexer::new(&source); let tokens = lexer.lex().map_err(into_anyhow)?; let mut parser = HypnoParser::new(tokens); @@ -316,7 +346,7 @@ fn main() -> Result<()> { } Commands::Check { file } => { - let source = fs::read_to_string(&file)?; + let source = read_source(&file)?; let mut lexer = Lexer::new(&source); let tokens = lexer.lex().map_err(into_anyhow)?; let mut parser = HypnoParser::new(tokens); @@ -329,9 +359,13 @@ fn main() -> Result<()> { println!("βœ… No type errors found!"); } else { println!("❌ Type errors found:"); - for error in errors { + for error in &errors { println!(" - {}", error); } + return Err(anyhow!( + "type checking failed with {} error(s)", + errors.len() + )); } } @@ -340,7 +374,7 @@ fn main() -> Result<()> { output, binary, } => { - let source = fs::read_to_string(&input)?; + let source = read_source(&input)?; let mut lexer = Lexer::new(&source); let tokens = lexer.lex().map_err(into_anyhow)?; let mut parser = HypnoParser::new(tokens); @@ -371,7 +405,7 @@ fn main() -> Result<()> { target, opt_level, } => { - let source = fs::read_to_string(&input)?; + let source = read_source(&input)?; let mut lexer = Lexer::new(&source); let tokens = lexer.lex().map_err(into_anyhow)?; let mut parser = HypnoParser::new(tokens); @@ -433,7 +467,7 @@ fn main() -> Result<()> { output, stats, } => { - let source = fs::read_to_string(&input)?; + let source = read_source(&input)?; let mut lexer = Lexer::new(&source); let tokens = lexer.lex().map_err(into_anyhow)?; let mut parser = HypnoParser::new(tokens); @@ -579,39 +613,19 @@ fn main() -> Result<()> { } Commands::Builtins => { - println!("=== HypnoScript Builtin Functions ===\n"); - - println!("πŸ“Š Math Builtins:"); - println!(" - Sin, Cos, Tan, Sqrt, Pow, Log, Log10"); - println!(" - Abs, Floor, Ceil, Round, Min, Max"); - println!(" - Factorial, Gcd, Lcm, IsPrime, Fibonacci"); - println!(" - Clamp"); - - println!("\nπŸ“ String Builtins:"); - println!(" - Length, ToUpper, ToLower, Trim"); - println!(" - IndexOf, Replace, Reverse, Capitalize"); - println!(" - StartsWith, EndsWith, Contains"); - println!(" - Split, Substring, Repeat"); - println!(" - PadLeft, PadRight"); - - println!("\nπŸ“¦ Array Builtins:"); - println!(" - Length, IsEmpty, Get, IndexOf, Contains"); - println!(" - Reverse, Sum, Average, Min, Max, Sort"); - println!(" - First, Last, Take, Skip, Slice"); - println!(" - Join, Count, Distinct"); - - println!("\n✨ Hypnotic Builtins:"); - println!(" - observe (output)"); - println!(" - drift (sleep)"); - println!(" - DeepTrance"); - println!(" - HypnoticCountdown"); - println!(" - TranceInduction"); - println!(" - HypnoticVisualization"); - - println!("\nπŸ”„ Conversion Functions:"); - println!(" - ToInt, ToDouble, ToString, ToBoolean"); - - println!("\nTotal: 50+ builtin functions implemented"); + use hypnoscript_compiler::builtin_registry; + + println!("=== HypnoScript Builtin Functions ==="); + for category in builtin_registry::categories() { + println!("\n{}:", category); + for sig in builtin_registry::by_category(category) { + println!(" {}", sig.render()); + } + } + println!( + "\nTotal: {} builtin functions", + builtin_registry::BUILTINS.len() + ); } } diff --git a/hypnoscript-cli/src/package.rs b/hypnoscript-cli/src/package.rs index 696ed97..fbf82d8 100644 --- a/hypnoscript-cli/src/package.rs +++ b/hypnoscript-cli/src/package.rs @@ -232,7 +232,7 @@ impl PackageManager { if !path.exists() { // Return empty lock file if it doesn't exist return Ok(TranceLock { - lock_version: "1.2.0".to_string(), + lock_version: "1.3.0".to_string(), locked_anchors: HashMap::new(), }); } @@ -450,7 +450,7 @@ impl PackageManager { // For now, we'll create a lock file with the dependencies // In a full implementation, this would fetch packages from a registry let mut lock = TranceLock { - lock_version: "1.2.0".to_string(), + lock_version: "1.3.0".to_string(), locked_anchors: HashMap::new(), }; @@ -586,7 +586,7 @@ mod tests { pm.init("test-package".to_string(), None)?; pm.add_dependency( "hypnoscript-runtime".to_string(), - "^1.2.0".to_string(), + "^1.3.0".to_string(), false, )?; diff --git a/hypnoscript-compiler/Cargo.toml b/hypnoscript-compiler/Cargo.toml index 3f49e8c..6eca6f7 100644 --- a/hypnoscript-compiler/Cargo.toml +++ b/hypnoscript-compiler/Cargo.toml @@ -26,3 +26,4 @@ cranelift-jit = "0.128.0" cranelift-object = "0.128.0" cranelift-native = "0.128.0" target-lexicon = "0.13.4" +stacker = "0.1.24" diff --git a/hypnoscript-compiler/hypnoscript_output b/hypnoscript-compiler/hypnoscript_output deleted file mode 100755 index a4b5010..0000000 Binary files a/hypnoscript-compiler/hypnoscript_output and /dev/null differ diff --git a/hypnoscript-compiler/src/async_builtins.rs b/hypnoscript-compiler/src/async_builtins.rs index bbed361..7bf47ec 100644 --- a/hypnoscript-compiler/src/async_builtins.rs +++ b/hypnoscript-compiler/src/async_builtins.rs @@ -24,22 +24,6 @@ impl AsyncBuiltins { Value::Null } - /// Create a promise that resolves after a delay - /// - /// # Example (HypnoScript) - /// ```hypnoscript - /// induce promise = delayedValue(1000, 42); - /// induce result = await promise; // Returns 42 after 1 second - /// ``` - /// - /// Note: Due to Value containing Rc (not Send), this returns a placeholder. - /// For true async promises with arbitrary values, Value needs to use Arc. - pub fn delayed_value(milliseconds: f64, _value: Value) -> Value { - // Cannot use promise_delay with Value due to Send requirement - // This would require refactoring Value to use Arc instead of Rc - Value::String(format!("", milliseconds)) - } - /// Execute function with timeout /// /// # Example (HypnoScript) @@ -75,34 +59,6 @@ impl AsyncBuiltins { }) } - /// Wait for multiple promises to complete (Promise.all) - /// - /// # Example (HypnoScript) - /// ```hypnoscript - /// induce results = await promiseAll([promise1, promise2, promise3]); - /// ``` - pub async fn promise_all(promises: Vec) -> Result { - // In real implementation, would convert Value::Promise to AsyncPromise - // and use crate::async_promise::promise_all - - Ok(Value::Array(promises)) - } - - /// Race multiple promises (first to complete wins) - /// - /// # Example (HypnoScript) - /// ```hypnoscript - /// induce fastest = await promiseRace([promise1, promise2]); - /// ``` - pub async fn promise_race(promises: Vec) -> Result { - if promises.is_empty() { - return Err("No promises provided".to_string()); - } - - // Return first promise for now - Ok(promises[0].clone()) - } - /// Create MPSC channel /// /// # Example (HypnoScript) diff --git a/hypnoscript-compiler/src/builtin_registry.rs b/hypnoscript-compiler/src/builtin_registry.rs new file mode 100644 index 0000000..82fd103 --- /dev/null +++ b/hypnoscript-compiler/src/builtin_registry.rs @@ -0,0 +1,315 @@ +//! Single source of truth for builtin function signatures. +//! +//! Every builtin the interpreter can dispatch (see +//! `interpreter/builtins.rs`) is described here exactly once. Consumers: +//! +//! - [`crate::TypeChecker`] registers all signatures for static checking +//! - the CLI `builtins` command renders an always-up-to-date listing +//! +//! **Adding a builtin?** Add its implementation to the matching +//! `call_*_builtin` dispatcher in `interpreter/builtins.rs` *and* one entry +//! to [`BUILTINS`]. The type checker and CLI pick it up automatically. + +use hypnoscript_core::HypnoType; + +/// Compact, `const`-friendly builtin parameter/return type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ty { + Number, + String, + Boolean, + /// `number[]` + NumberArray, + /// `string[]` + StringArray, + /// `unknown[]` β€” array of arbitrary values + AnyArray, + /// Arbitrary value (typed as `unknown`) + Any, + /// No meaningful return value (typed as `unknown`) + Void, +} + +impl Ty { + /// Converts the compact type into the type checker's [`HypnoType`]. + pub fn to_hypno_type(self) -> HypnoType { + match self { + Ty::Number => HypnoType::number(), + Ty::String => HypnoType::string(), + Ty::Boolean => HypnoType::boolean(), + Ty::NumberArray => HypnoType::create_array(HypnoType::number()), + Ty::StringArray => HypnoType::create_array(HypnoType::string()), + Ty::AnyArray => HypnoType::create_array(HypnoType::unknown()), + Ty::Any | Ty::Void => HypnoType::unknown(), + } + } + + /// Human-readable type name for documentation output. + pub fn display_name(self) -> &'static str { + match self { + Ty::Number => "number", + Ty::String => "string", + Ty::Boolean => "boolean", + Ty::NumberArray => "number[]", + Ty::StringArray => "string[]", + Ty::AnyArray => "unknown[]", + Ty::Any => "unknown", + Ty::Void => "void", + } + } +} + +/// Declarative description of a single builtin function. +#[derive(Debug, Clone, Copy)] +pub struct BuiltinSignature { + pub name: &'static str, + pub category: &'static str, + pub params: &'static [Ty], + pub returns: Ty, +} + +impl BuiltinSignature { + /// Renders `Name(number, string) -> boolean` style documentation. + pub fn render(&self) -> String { + let params: Vec<&str> = self.params.iter().map(|t| t.display_name()).collect(); + format!( + "{}({}) -> {}", + self.name, + params.join(", "), + self.returns.display_name() + ) + } +} + +/// Shorthand macro keeping the table below readable. +macro_rules! builtins { + ($($category:literal { $($name:literal ($($param:ident),*) -> $ret:ident;)+ })+) => { + &[ + $($(BuiltinSignature { + name: $name, + category: $category, + params: &[$(Ty::$param),*], + returns: Ty::$ret, + },)+)+ + ] + }; +} + +/// All builtin functions known to the HypnoScript toolchain. +pub const BUILTINS: &[BuiltinSignature] = builtins! { + "Math" { + "Sin"(Number) -> Number; + "Cos"(Number) -> Number; + "Tan"(Number) -> Number; + "Sqrt"(Number) -> Number; + "Log"(Number) -> Number; + "Log10"(Number) -> Number; + "Abs"(Number) -> Number; + "Floor"(Number) -> Number; + "Ceil"(Number) -> Number; + "Round"(Number) -> Number; + "Min"(Number, Number) -> Number; + "Max"(Number, Number) -> Number; + "Pow"(Number, Number) -> Number; + "Factorial"(Number) -> Number; + "Gcd"(Number, Number) -> Number; + "Lcm"(Number, Number) -> Number; + "Fibonacci"(Number) -> Number; + "IsPrime"(Number) -> Boolean; + "Clamp"(Number, Number, Number) -> Number; + } + "String" { + "Length"(String) -> Number; + "ToUpper"(String) -> String; + "ToLower"(String) -> String; + "Trim"(String) -> String; + "Reverse"(String) -> String; + "Capitalize"(String) -> String; + "RemoveDuplicates"(String) -> String; + "UniqueCharacters"(String) -> String; + "ReverseWords"(String) -> String; + "TitleCase"(String) -> String; + "IndexOf"(String, String) -> Number; + "Replace"(String, String, String) -> String; + "StartsWith"(String, String) -> Boolean; + "EndsWith"(String, String) -> Boolean; + "Contains"(String, String) -> Boolean; + "Split"(String, String) -> StringArray; + "Substring"(String, Number, Number) -> String; + "Repeat"(String, Number) -> String; + "PadLeft"(String, Number, String) -> String; + "PadRight"(String, Number, String) -> String; + "IsEmpty"(String) -> Boolean; + "IsWhitespace"(String) -> Boolean; + } + "Array" { + "ArrayLength"(AnyArray) -> Number; + "ArrayIsEmpty"(AnyArray) -> Boolean; + "ArrayGet"(AnyArray, Number) -> Any; + "ArrayIndexOf"(AnyArray, Any) -> Number; + "ArrayContains"(AnyArray, Any) -> Boolean; + "ArrayReverse"(AnyArray) -> AnyArray; + "ArraySum"(NumberArray) -> Number; + "ArrayAverage"(NumberArray) -> Number; + "ArrayMin"(NumberArray) -> Number; + "ArrayMax"(NumberArray) -> Number; + "ArraySort"(NumberArray) -> NumberArray; + "ArrayFirst"(AnyArray) -> Any; + "ArrayLast"(AnyArray) -> Any; + "ArrayTake"(AnyArray, Number) -> AnyArray; + "ArraySkip"(AnyArray, Number) -> AnyArray; + "ArraySlice"(AnyArray, Number, Number) -> AnyArray; + "ArrayJoin"(AnyArray, String) -> String; + "ArrayCount"(AnyArray, Any) -> Number; + "ArrayDistinct"(AnyArray) -> AnyArray; + } + "Core / Hypnotic" { + "Observe"(Any) -> Void; + "Drift"(Number) -> Void; + "DeepTrance"(Number) -> Void; + "HypnoticCountdown"(Number) -> Void; + "TranceInduction"(String) -> Void; + "HypnoticVisualization"(String) -> Void; + "ToInt"(Number) -> Number; + "ToDouble"(String) -> Number; + "ToString"(Any) -> String; + "ToBoolean"(String) -> Boolean; + } + "File / IO" { + "ReadFile"(String) -> String; + "WriteFile"(String, String) -> Void; + "AppendFile"(String, String) -> Void; + "DeleteFile"(String) -> Void; + "CreateDirectory"(String) -> Void; + "FileExists"(String) -> Boolean; + "IsFile"(String) -> Boolean; + "IsDirectory"(String) -> Boolean; + "ListDirectory"(String) -> StringArray; + "GetFileSize"(String) -> Number; + "CopyFile"(String, String) -> Number; + "RenameFile"(String, String) -> Void; + "GetFileExtension"(String) -> String; + "GetFileName"(String) -> String; + "GetParentDirectory"(String) -> String; + } + "Hashing / Utility" { + "HashString"(String) -> Number; + "HashNumber"(Number) -> Number; + "SimpleRandom"(Number) -> Number; + "AreAnagrams"(String, String) -> Boolean; + "IsPalindrome"(String) -> Boolean; + "CountOccurrences"(String, String) -> Number; + } + "Statistics" { + "Mean"(NumberArray) -> Number; + "Median"(NumberArray) -> Number; + "Mode"(NumberArray) -> Number; + "StandardDeviation"(NumberArray) -> Number; + "Variance"(NumberArray) -> Number; + "Range"(NumberArray) -> Number; + "Percentile"(NumberArray, Number) -> Number; + "Correlation"(NumberArray, NumberArray) -> Number; + "LinearRegression"(NumberArray, NumberArray) -> NumberArray; + } + "System" { + "GetCurrentDirectory"() -> String; + "GetEnv"(String) -> String; + "SetEnv"(String, String) -> Void; + "GetOperatingSystem"() -> String; + "GetArchitecture"() -> String; + "GetCpuCount"() -> Number; + "GetHostname"() -> String; + "GetUsername"() -> String; + "GetHomeDirectory"() -> String; + "GetTempDirectory"() -> String; + "GetArgs"() -> StringArray; + "Exit"(Number) -> Void; + } + "Time / Date" { + "CurrentTimestamp"() -> Number; + "CurrentDate"() -> String; + "CurrentTime"() -> String; + "CurrentDateTime"() -> String; + "FormatDateTime"(String) -> String; + "DayOfWeek"() -> Number; + "DayOfYear"() -> Number; + "IsLeapYear"(Number) -> Boolean; + "DaysInMonth"(Number, Number) -> Number; + "CurrentYear"() -> Number; + "CurrentMonth"() -> Number; + "CurrentDay"() -> Number; + "CurrentHour"() -> Number; + "CurrentMinute"() -> Number; + "CurrentSecond"() -> Number; + } + "Validation" { + "IsValidEmail"(String) -> Boolean; + "IsValidUrl"(String) -> Boolean; + "IsValidPhoneNumber"(String) -> Boolean; + "IsAlphanumeric"(String) -> Boolean; + "IsAlphabetic"(String) -> Boolean; + "IsNumeric"(String) -> Boolean; + "IsLowercase"(String) -> Boolean; + "IsUppercase"(String) -> Boolean; + "IsInRange"(Number, Number, Number) -> Boolean; + "MatchesPattern"(String, String) -> Boolean; + } + "Async / Promises" { + "delayedValue"(Number, Any) -> Any; + "instantPromise"(Any) -> Any; + "promiseAll"(AnyArray) -> AnyArray; + "promiseRace"(AnyArray) -> Any; + "isPromiseResolved"(Any) -> Boolean; + } +}; + +/// Looks up a builtin signature by name. +pub fn find(name: &str) -> Option<&'static BuiltinSignature> { + BUILTINS.iter().find(|sig| sig.name == name) +} + +/// Returns all categories in declaration order (deduplicated). +pub fn categories() -> Vec<&'static str> { + let mut seen = Vec::new(); + for sig in BUILTINS { + if !seen.contains(&sig.category) { + seen.push(sig.category); + } + } + seen +} + +/// Returns all builtins belonging to `category`. +pub fn by_category(category: &str) -> impl Iterator { + BUILTINS.iter().filter(move |sig| sig.category == category) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_has_no_duplicate_names() { + let mut names: Vec<&str> = BUILTINS.iter().map(|s| s.name).collect(); + names.sort_unstable(); + let before = names.len(); + names.dedup(); + assert_eq!(before, names.len(), "duplicate builtin names in registry"); + } + + #[test] + fn find_locates_builtins() { + let sin = find("Sin").expect("Sin must be registered"); + assert_eq!(sin.params, &[Ty::Number]); + assert_eq!(sin.returns, Ty::Number); + assert!(find("DefinitelyNotABuiltin").is_none()); + } + + #[test] + fn render_produces_readable_signature() { + assert_eq!( + find("Clamp").unwrap().render(), + "Clamp(number, number, number) -> number" + ); + } +} diff --git a/hypnoscript-compiler/src/interpreter/builtins.rs b/hypnoscript-compiler/src/interpreter/builtins.rs new file mode 100644 index 0000000..616caff --- /dev/null +++ b/hypnoscript-compiler/src/interpreter/builtins.rs @@ -0,0 +1,955 @@ +//! Builtin function dispatch: bridges interpreter values to the +//! `hypnoscript-runtime` builtin modules (math, string, array, file, ...). + +use super::Interpreter; +use super::error::InterpreterError; +use super::value::Value; +use hypnoscript_runtime::{ + ArrayBuiltins, CoreBuiltins, FileBuiltins, HashingBuiltins, MathBuiltins, StatisticsBuiltins, + StringBuiltins, SystemBuiltins, TimeBuiltins, ValidationBuiltins, +}; + +impl Interpreter { + pub(crate) fn call_builtin( + &mut self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + if let Some(result) = self.call_math_builtin(name, args)? { + return Ok(Some(result)); + } + + if let Some(result) = self.call_string_builtin(name, args)? { + return Ok(Some(result)); + } + + if let Some(result) = self.call_array_builtin(name, args)? { + return Ok(Some(result)); + } + + if let Some(result) = self.call_core_builtin(name, args)? { + return Ok(Some(result)); + } + + if let Some(result) = self.call_file_builtin(name, args)? { + return Ok(Some(result)); + } + + if let Some(result) = self.call_hashing_builtin(name, args)? { + return Ok(Some(result)); + } + + if let Some(result) = self.call_statistics_builtin(name, args)? { + return Ok(Some(result)); + } + + if let Some(result) = self.call_system_builtin(name, args)? { + return Ok(Some(result)); + } + + if let Some(result) = self.call_time_builtin(name, args)? { + return Ok(Some(result)); + } + + if let Some(result) = self.call_validation_builtin(name, args)? { + return Ok(Some(result)); + } + + if let Some(result) = self.call_async_builtin(name, args)? { + return Ok(Some(result)); + } + + Ok(None) + } + + /// Simulated-async builtins operating on [`Value::Promise`]. + /// + /// The interpreter is single-threaded; promises carry a simulated delay + /// that elapses when they are awaited (scaled via `HYPNO_TIME_SCALE`, + /// exactly like `drift`). + pub(crate) fn call_async_builtin( + &self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + use super::value::Promise; + use std::cell::RefCell; + use std::rc::Rc; + + let result = match name { + // delayedValue(ms, value) -> promise resolving to `value` after `ms` + "delayedValue" => { + let delay = self.number_arg(args, 0, name)?.max(0.0) as u64; + let value = self.arg(args, 1, name)?.clone(); + Some(Value::Promise(Rc::new(RefCell::new(Promise::delayed( + delay, value, + ))))) + } + // instantPromise(value) -> already resolved promise + "instantPromise" => { + let value = self.arg(args, 0, name)?.clone(); + Some(Value::Promise(Rc::new(RefCell::new(Promise::resolve( + value, + ))))) + } + // promiseAll([p1, p2, ...]) -> waits for the *longest* delay + // (simulated concurrency) and returns the array of results. + "promiseAll" => { + let promises = self.array_arg(args, 0, name)?; + let max_delay = promises + .iter() + .filter_map(|p| match p { + Value::Promise(p) => p.borrow().pending_delay_ms(), + _ => None, + }) + .max() + .unwrap_or(0); + CoreBuiltins::drift(max_delay); + let results = promises + .iter() + .map(|p| match p { + Value::Promise(p) => p.borrow_mut().mark_resolved(), + other => other.clone(), + }) + .collect(); + Some(Value::array(results)) + } + // promiseRace([p1, p2, ...]) -> waits for the *shortest* delay and + // returns that promise's value. + "promiseRace" => { + let promises = self.array_arg(args, 0, name)?; + if promises.is_empty() { + return Err(InterpreterError::Runtime(format!( + "Builtin '{}' requires a non-empty array", + name + ))); + } + let winner = promises + .iter() + .min_by_key(|p| match p { + Value::Promise(p) => p.borrow().pending_delay_ms().unwrap_or(0), + _ => 0, + }) + .expect("non-empty array always has a minimum"); + let value = match winner { + Value::Promise(p) => { + CoreBuiltins::drift(p.borrow().pending_delay_ms().unwrap_or(0)); + p.borrow_mut().mark_resolved() + } + other => other.clone(), + }; + Some(value) + } + // isPromiseResolved(p) -> whether awaiting would complete instantly + "isPromiseResolved" => match self.arg(args, 0, name)? { + Value::Promise(p) => Some(Value::Boolean(p.borrow().is_resolved())), + _ => Some(Value::Boolean(true)), + }, + _ => None, + }; + + Ok(result) + } + + pub(crate) fn call_math_builtin( + &self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + let result = match name { + "Sin" => Some(Value::Number(MathBuiltins::sin( + self.number_arg(args, 0, name)?, + ))), + "Cos" => Some(Value::Number(MathBuiltins::cos( + self.number_arg(args, 0, name)?, + ))), + "Tan" => Some(Value::Number(MathBuiltins::tan( + self.number_arg(args, 0, name)?, + ))), + "Sqrt" => Some(Value::Number(MathBuiltins::sqrt( + self.number_arg(args, 0, name)?, + ))), + "Log" => Some(Value::Number(MathBuiltins::log( + self.number_arg(args, 0, name)?, + ))), + "Log10" => Some(Value::Number(MathBuiltins::log10( + self.number_arg(args, 0, name)?, + ))), + "Abs" => Some(Value::Number(MathBuiltins::abs( + self.number_arg(args, 0, name)?, + ))), + "Floor" => Some(Value::Number(MathBuiltins::floor( + self.number_arg(args, 0, name)?, + ))), + "Ceil" => Some(Value::Number(MathBuiltins::ceil( + self.number_arg(args, 0, name)?, + ))), + "Round" => Some(Value::Number(MathBuiltins::round( + self.number_arg(args, 0, name)?, + ))), + "Min" => Some(Value::Number(MathBuiltins::min( + self.number_arg(args, 0, name)?, + self.number_arg(args, 1, name)?, + ))), + "Max" => Some(Value::Number(MathBuiltins::max( + self.number_arg(args, 0, name)?, + self.number_arg(args, 1, name)?, + ))), + "Pow" => Some(Value::Number(MathBuiltins::pow( + self.number_arg(args, 0, name)?, + self.number_arg(args, 1, name)?, + ))), + "Factorial" => Some(Value::Number(MathBuiltins::factorial( + self.integer_arg(args, 0, name)?, + ) as f64)), + "Gcd" => Some(Value::Number(MathBuiltins::gcd( + self.integer_arg(args, 0, name)?, + self.integer_arg(args, 1, name)?, + ) as f64)), + "Lcm" => Some(Value::Number(MathBuiltins::lcm( + self.integer_arg(args, 0, name)?, + self.integer_arg(args, 1, name)?, + ) as f64)), + "IsPrime" => Some(Value::Boolean(MathBuiltins::is_prime( + self.integer_arg(args, 0, name)?, + ))), + "Fibonacci" => Some(Value::Number(MathBuiltins::fibonacci( + self.integer_arg(args, 0, name)?, + ) as f64)), + "Clamp" => Some(Value::Number(MathBuiltins::clamp( + self.number_arg(args, 0, name)?, + self.number_arg(args, 1, name)?, + self.number_arg(args, 2, name)?, + ))), + _ => None, + }; + + Ok(result) + } + + pub(crate) fn call_string_builtin( + &self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + let result = match name { + "Length" => { + // Length works for both strings and arrays + if args.is_empty() { + return Err(InterpreterError::Runtime(format!( + "Function '{}' requires at least 1 argument", + name + ))); + } + match &args[0] { + Value::String(s) => Some(Value::Number(s.len() as f64)), + Value::Array(arr) => Some(Value::Number(arr.len() as f64)), + _ => { + return Err(InterpreterError::TypeError(format!( + "Function 'Length' expects string or array argument, got {}", + args[0] + ))); + } + } + } + "ToUpper" => Some(Value::String(StringBuiltins::to_upper( + &self.string_arg(args, 0, name)?, + ))), + "ToLower" => Some(Value::String(StringBuiltins::to_lower( + &self.string_arg(args, 0, name)?, + ))), + "Trim" => Some(Value::String(StringBuiltins::trim( + &self.string_arg(args, 0, name)?, + ))), + "IndexOf" => Some(Value::Number(StringBuiltins::index_of( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ) as f64)), + "Replace" => Some(Value::String(StringBuiltins::replace( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + &self.string_arg(args, 2, name)?, + ))), + "Reverse" => Some(Value::String(StringBuiltins::reverse( + &self.string_arg(args, 0, name)?, + ))), + "Capitalize" => Some(Value::String(StringBuiltins::capitalize( + &self.string_arg(args, 0, name)?, + ))), + "StartsWith" => Some(Value::Boolean(StringBuiltins::starts_with( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ))), + "EndsWith" => Some(Value::Boolean(StringBuiltins::ends_with( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ))), + "Contains" => Some(Value::Boolean(StringBuiltins::contains( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ))), + "Split" => { + let items = StringBuiltins::split( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ) + .into_iter() + .map(Value::String) + .collect(); + Some(Value::array(items)) + } + "Substring" => Some(Value::String(StringBuiltins::substring( + &self.string_arg(args, 0, name)?, + self.usize_arg(args, 1, name)?, + self.usize_arg(args, 2, name)?, + ))), + "Repeat" => Some(Value::String(StringBuiltins::repeat( + &self.string_arg(args, 0, name)?, + self.usize_arg(args, 1, name)?, + ))), + "PadLeft" => Some(Value::String(StringBuiltins::pad_left( + &self.string_arg(args, 0, name)?, + self.usize_arg(args, 1, name)?, + self.char_arg(args, 2, name)?, + ))), + "PadRight" => Some(Value::String(StringBuiltins::pad_right( + &self.string_arg(args, 0, name)?, + self.usize_arg(args, 1, name)?, + self.char_arg(args, 2, name)?, + ))), + "IsEmpty" => Some(Value::Boolean(StringBuiltins::is_empty( + &self.string_arg(args, 0, name)?, + ))), + "IsWhitespace" => Some(Value::Boolean(StringBuiltins::is_whitespace( + &self.string_arg(args, 0, name)?, + ))), + _ => None, + }; + + Ok(result) + } + + pub(crate) fn call_array_builtin( + &self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + let result = match name { + "ArrayLength" => { + let array = self.array_arg(args, 0, name)?; + Some(Value::Number(ArrayBuiltins::length(&array) as f64)) + } + "ArrayIsEmpty" => { + let array = self.array_arg(args, 0, name)?; + Some(Value::Boolean(ArrayBuiltins::is_empty(&array))) + } + "ArrayGet" => { + let array = self.array_arg(args, 0, name)?; + let index = self.usize_arg(args, 1, name)?; + let value = ArrayBuiltins::get(&array, index).unwrap_or(Value::Null); + Some(value) + } + "ArrayIndexOf" => { + let array = self.array_arg(args, 0, name)?; + let target = self.arg(args, 1, name)?.clone(); + Some(Value::Number( + ArrayBuiltins::index_of(&array, &target) as f64 + )) + } + "ArrayContains" => { + let array = self.array_arg(args, 0, name)?; + let target = self.arg(args, 1, name)?.clone(); + Some(Value::Boolean(ArrayBuiltins::contains(&array, &target))) + } + "ArrayReverse" => { + let array = self.array_arg(args, 0, name)?; + Some(Value::array(ArrayBuiltins::reverse(&array))) + } + "ArraySum" => { + let array = self.array_arg(args, 0, name)?; + let numbers = self.values_to_numbers(&array, name)?; + Some(Value::Number(ArrayBuiltins::sum(&numbers))) + } + "ArrayAverage" => { + let array = self.array_arg(args, 0, name)?; + let numbers = self.values_to_numbers(&array, name)?; + Some(Value::Number(ArrayBuiltins::average(&numbers))) + } + "ArrayMin" => { + let array = self.array_arg(args, 0, name)?; + let numbers = self.values_to_numbers(&array, name)?; + Some(Value::Number(ArrayBuiltins::min(&numbers))) + } + "ArrayMax" => { + let array = self.array_arg(args, 0, name)?; + let numbers = self.values_to_numbers(&array, name)?; + Some(Value::Number(ArrayBuiltins::max(&numbers))) + } + "ArraySort" => { + let array = self.array_arg(args, 0, name)?; + let numbers = self.values_to_numbers(&array, name)?; + let sorted = ArrayBuiltins::sort(&numbers) + .into_iter() + .map(Value::Number) + .collect(); + Some(Value::array(sorted)) + } + "ArrayFirst" => { + let array = self.array_arg(args, 0, name)?; + Some(ArrayBuiltins::first(&array).unwrap_or(Value::Null)) + } + "ArrayLast" => { + let array = self.array_arg(args, 0, name)?; + Some(ArrayBuiltins::last(&array).unwrap_or(Value::Null)) + } + "ArrayTake" => { + let array = self.array_arg(args, 0, name)?; + let count = self.usize_arg(args, 1, name)?; + Some(Value::array(ArrayBuiltins::take(&array, count))) + } + "ArraySkip" => { + let array = self.array_arg(args, 0, name)?; + let count = self.usize_arg(args, 1, name)?; + Some(Value::array(ArrayBuiltins::skip(&array, count))) + } + "ArraySlice" => { + let array = self.array_arg(args, 0, name)?; + let start = self.usize_arg(args, 1, name)?; + let end = self.usize_arg(args, 2, name)?; + Some(Value::array(ArrayBuiltins::slice(&array, start, end))) + } + "ArrayJoin" => { + let array = self.array_arg(args, 0, name)?; + let separator = self.string_arg(args, 1, name)?; + Some(Value::String(ArrayBuiltins::join(&array, &separator))) + } + "ArrayCount" => { + let array = self.array_arg(args, 0, name)?; + let target = self.arg(args, 1, name)?.clone(); + Some(Value::Number(ArrayBuiltins::count(&array, &target) as f64)) + } + "ArrayDistinct" => { + let array = self.array_arg(args, 0, name)?; + Some(Value::array(ArrayBuiltins::distinct(&array))) + } + _ => None, + }; + + Ok(result) + } + + pub(crate) fn call_core_builtin( + &self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + let result = match name { + "Observe" => { + let message = self.arg(args, 0, name)?.to_string(); + CoreBuiltins::observe(&message); + Some(Value::Null) + } + "Drift" => { + let duration = self.number_arg(args, 0, name)?; + CoreBuiltins::drift(duration.max(0.0) as u64); + Some(Value::Null) + } + "DeepTrance" => { + let duration = self.number_arg(args, 0, name)?; + CoreBuiltins::deep_trance(duration.max(0.0) as u64); + Some(Value::Null) + } + "HypnoticCountdown" => { + CoreBuiltins::hypnotic_countdown(self.integer_arg(args, 0, name)?); + Some(Value::Null) + } + "TranceInduction" => { + CoreBuiltins::trance_induction(&self.string_arg(args, 0, name)?); + Some(Value::Null) + } + "HypnoticVisualization" => { + CoreBuiltins::hypnotic_visualization(&self.string_arg(args, 0, name)?); + Some(Value::Null) + } + "ToInt" => Some(Value::Number( + CoreBuiltins::to_int(self.number_arg(args, 0, name)?) as f64, + )), + "ToDouble" => Some(Value::Number( + CoreBuiltins::to_double(&self.string_arg(args, 0, name)?) + .map_err(InterpreterError::Runtime)?, + )), + "ToString" => Some(Value::String( + args.first() + .map(|v| v.to_string()) + .unwrap_or_else(|| "null".to_string()), + )), + "ToBoolean" => Some(Value::Boolean(CoreBuiltins::to_boolean( + &self.string_arg(args, 0, name)?, + ))), + _ => None, + }; + + Ok(result) + } + + pub(crate) fn call_file_builtin( + &self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + let result = match name { + "ReadFile" => Some(Value::String( + FileBuiltins::read_file(&self.string_arg(args, 0, name)?) + .map_err(|e| InterpreterError::Runtime(e.to_string()))?, + )), + "WriteFile" => { + FileBuiltins::write_file( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ) + .map_err(|e| InterpreterError::Runtime(e.to_string()))?; + Some(Value::Null) + } + "AppendFile" => { + FileBuiltins::append_file( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ) + .map_err(|e| InterpreterError::Runtime(e.to_string()))?; + Some(Value::Null) + } + "FileExists" => Some(Value::Boolean(FileBuiltins::file_exists( + &self.string_arg(args, 0, name)?, + ))), + "IsFile" => Some(Value::Boolean(FileBuiltins::is_file( + &self.string_arg(args, 0, name)?, + ))), + "IsDirectory" => Some(Value::Boolean(FileBuiltins::is_directory( + &self.string_arg(args, 0, name)?, + ))), + "DeleteFile" => { + FileBuiltins::delete_file(&self.string_arg(args, 0, name)?) + .map_err(|e| InterpreterError::Runtime(e.to_string()))?; + Some(Value::Null) + } + "CreateDirectory" => { + FileBuiltins::create_directory(&self.string_arg(args, 0, name)?) + .map_err(|e| InterpreterError::Runtime(e.to_string()))?; + Some(Value::Null) + } + "ListDirectory" => { + let files = FileBuiltins::list_directory(&self.string_arg(args, 0, name)?) + .map_err(|e| InterpreterError::Runtime(e.to_string()))? + .into_iter() + .map(Value::String) + .collect(); + Some(Value::array(files)) + } + "GetFileSize" => Some(Value::Number( + FileBuiltins::get_file_size(&self.string_arg(args, 0, name)?) + .map_err(|e| InterpreterError::Runtime(e.to_string()))? as f64, + )), + "CopyFile" => Some(Value::Number( + FileBuiltins::copy_file( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ) + .map_err(|e| InterpreterError::Runtime(e.to_string()))? as f64, + )), + "RenameFile" => { + FileBuiltins::rename_file( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ) + .map_err(|e| InterpreterError::Runtime(e.to_string()))?; + Some(Value::Null) + } + "GetFileExtension" => Some(self.option_string_to_value( + FileBuiltins::get_file_extension(&self.string_arg(args, 0, name)?), + )), + "GetFileName" => Some(self.option_string_to_value(FileBuiltins::get_file_name( + &self.string_arg(args, 0, name)?, + ))), + "GetParentDirectory" => Some(self.option_string_to_value( + FileBuiltins::get_parent_directory(&self.string_arg(args, 0, name)?), + )), + _ => None, + }; + + Ok(result) + } + + pub(crate) fn call_hashing_builtin( + &self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + let result = match name { + "HashString" => Some(Value::Number(HashingBuiltins::hash_string( + &self.string_arg(args, 0, name)?, + ) as f64)), + "HashNumber" => Some(Value::Number(HashingBuiltins::hash_number( + self.number_arg(args, 0, name)?, + ) as f64)), + "SimpleRandom" => Some(Value::Number(HashingBuiltins::simple_random( + self.u64_arg(args, 0, name)?, + ) as f64)), + "AreAnagrams" => Some(Value::Boolean(HashingBuiltins::are_anagrams( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ))), + "IsPalindrome" => Some(Value::Boolean(HashingBuiltins::is_palindrome( + &self.string_arg(args, 0, name)?, + ))), + "CountOccurrences" => Some(Value::Number(HashingBuiltins::count_occurrences( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ) as f64)), + "RemoveDuplicates" => Some(Value::String(HashingBuiltins::remove_duplicates( + &self.string_arg(args, 0, name)?, + ))), + "UniqueCharacters" => Some(Value::String(HashingBuiltins::unique_characters( + &self.string_arg(args, 0, name)?, + ))), + "ReverseWords" => Some(Value::String(HashingBuiltins::reverse_words( + &self.string_arg(args, 0, name)?, + ))), + "TitleCase" => Some(Value::String(HashingBuiltins::title_case( + &self.string_arg(args, 0, name)?, + ))), + _ => None, + }; + + Ok(result) + } + + pub(crate) fn call_statistics_builtin( + &self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + let numbers_primary = |this: &Self| -> Result, InterpreterError> { + let array = this.array_arg(args, 0, name)?; + this.values_to_numbers(&array, name) + }; + + let result = match name { + "Mean" => Some(Value::Number(StatisticsBuiltins::calculate_mean( + &numbers_primary(self)?, + ))), + "Median" => Some(Value::Number(StatisticsBuiltins::calculate_median( + &numbers_primary(self)?, + ))), + "Mode" => Some(Value::Number(StatisticsBuiltins::calculate_mode( + &numbers_primary(self)?, + ))), + "StandardDeviation" => Some(Value::Number( + StatisticsBuiltins::calculate_standard_deviation(&numbers_primary(self)?), + )), + "Variance" => Some(Value::Number(StatisticsBuiltins::calculate_variance( + &numbers_primary(self)?, + ))), + "Range" => Some(Value::Number(StatisticsBuiltins::calculate_range( + &numbers_primary(self)?, + ))), + "Percentile" => Some(Value::Number(StatisticsBuiltins::calculate_percentile( + &numbers_primary(self)?, + self.number_arg(args, 1, name)?, + ))), + "Correlation" => { + let x = self.values_to_numbers(&self.array_arg(args, 0, name)?, name)?; + let y = self.values_to_numbers(&self.array_arg(args, 1, name)?, name)?; + Some(Value::Number(StatisticsBuiltins::calculate_correlation( + &x, &y, + ))) + } + "LinearRegression" => { + let x = self.values_to_numbers(&self.array_arg(args, 0, name)?, name)?; + let y = self.values_to_numbers(&self.array_arg(args, 1, name)?, name)?; + let (slope, intercept) = StatisticsBuiltins::linear_regression(&x, &y); + Some(Value::array(vec![ + Value::Number(slope), + Value::Number(intercept), + ])) + } + _ => None, + }; + + Ok(result) + } + + pub(crate) fn call_system_builtin( + &self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + let result = match name { + "GetCurrentDirectory" => Some(Value::String(SystemBuiltins::get_current_directory())), + "GetEnv" => Some(self.option_string_to_value(SystemBuiltins::get_env_var( + &self.string_arg(args, 0, name)?, + ))), + "SetEnv" => { + SystemBuiltins::set_env_var( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ) + .map_err(InterpreterError::Runtime)?; + Some(Value::Null) + } + "GetOperatingSystem" => Some(Value::String(SystemBuiltins::get_operating_system())), + "GetArchitecture" => Some(Value::String(SystemBuiltins::get_architecture())), + "GetCpuCount" => Some(Value::Number(SystemBuiltins::get_cpu_count() as f64)), + "GetHostname" => Some(Value::String(SystemBuiltins::get_hostname())), + "GetUsername" => Some(Value::String(SystemBuiltins::get_username())), + "GetHomeDirectory" => Some(Value::String(SystemBuiltins::get_home_directory())), + "GetTempDirectory" => Some(Value::String(SystemBuiltins::get_temp_directory())), + "GetArgs" => Some(Value::array( + SystemBuiltins::get_args() + .into_iter() + .map(Value::String) + .collect(), + )), + "Exit" => { + // Exit mirrors the legacy runtime behavior by terminating the host process immediately. + SystemBuiltins::exit(self.integer_arg(args, 0, name)? as i32); + } + _ => None, + }; + + Ok(result) + } + + pub(crate) fn call_time_builtin( + &self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + let result = match name { + "CurrentTimestamp" => Some(Value::Number(TimeBuiltins::get_current_time() as f64)), + "CurrentDate" => Some(Value::String(TimeBuiltins::get_current_date())), + "CurrentTime" => Some(Value::String(TimeBuiltins::get_current_time_string())), + "CurrentDateTime" => Some(Value::String(TimeBuiltins::get_current_date_time())), + "FormatDateTime" => Some(Value::String(TimeBuiltins::format_date_time( + &self.string_arg(args, 0, name)?, + ))), + "DayOfWeek" => Some(Value::Number(TimeBuiltins::get_day_of_week() as f64)), + "DayOfYear" => Some(Value::Number(TimeBuiltins::get_day_of_year() as f64)), + "IsLeapYear" => Some(Value::Boolean(TimeBuiltins::is_leap_year( + self.integer_arg(args, 0, name)? as i32, + ))), + "DaysInMonth" => Some(self.option_u32_to_value(TimeBuiltins::get_days_in_month( + self.integer_arg(args, 0, name)? as i32, + self.usize_arg(args, 1, name)? as u32, + ))), + "CurrentYear" => Some(Value::Number(TimeBuiltins::get_year() as f64)), + "CurrentMonth" => Some(Value::Number(TimeBuiltins::get_month() as f64)), + "CurrentDay" => Some(Value::Number(TimeBuiltins::get_day() as f64)), + "CurrentHour" => Some(Value::Number(TimeBuiltins::get_hour() as f64)), + "CurrentMinute" => Some(Value::Number(TimeBuiltins::get_minute() as f64)), + "CurrentSecond" => Some(Value::Number(TimeBuiltins::get_second() as f64)), + _ => None, + }; + + Ok(result) + } + + pub(crate) fn call_validation_builtin( + &self, + name: &str, + args: &[Value], + ) -> Result, InterpreterError> { + let result = match name { + "IsValidEmail" => Some(Value::Boolean(ValidationBuiltins::is_valid_email( + &self.string_arg(args, 0, name)?, + ))), + "IsValidUrl" => Some(Value::Boolean(ValidationBuiltins::is_valid_url( + &self.string_arg(args, 0, name)?, + ))), + "IsValidPhoneNumber" => Some(Value::Boolean( + ValidationBuiltins::is_valid_phone_number(&self.string_arg(args, 0, name)?), + )), + "IsAlphanumeric" => Some(Value::Boolean(ValidationBuiltins::is_alphanumeric( + &self.string_arg(args, 0, name)?, + ))), + "IsAlphabetic" => Some(Value::Boolean(ValidationBuiltins::is_alphabetic( + &self.string_arg(args, 0, name)?, + ))), + "IsNumeric" => Some(Value::Boolean(ValidationBuiltins::is_numeric( + &self.string_arg(args, 0, name)?, + ))), + "IsLowercase" => Some(Value::Boolean(ValidationBuiltins::is_lowercase( + &self.string_arg(args, 0, name)?, + ))), + "IsUppercase" => Some(Value::Boolean(ValidationBuiltins::is_uppercase( + &self.string_arg(args, 0, name)?, + ))), + "IsInRange" => Some(Value::Boolean(ValidationBuiltins::is_in_range( + self.number_arg(args, 0, name)?, + self.number_arg(args, 1, name)?, + self.number_arg(args, 2, name)?, + ))), + "MatchesPattern" => Some(Value::Boolean(ValidationBuiltins::matches_pattern( + &self.string_arg(args, 0, name)?, + &self.string_arg(args, 1, name)?, + ))), + _ => None, + }; + + Ok(result) + } + + pub(crate) fn arg<'a>( + &self, + args: &'a [Value], + index: usize, + name: &str, + ) -> Result<&'a Value, InterpreterError> { + args.get(index).ok_or_else(|| { + InterpreterError::Runtime(format!( + "Builtin '{}' expected argument at position {}", + name, + index + 1 + )) + }) + } + + pub(crate) fn number_arg( + &self, + args: &[Value], + index: usize, + name: &str, + ) -> Result { + self.arg(args, index, name)?.to_number().map_err(|_| { + InterpreterError::TypeError(format!( + "Builtin '{}' expected numeric argument at position {}", + name, + index + 1 + )) + }) + } + + pub(crate) fn integer_arg( + &self, + args: &[Value], + index: usize, + name: &str, + ) -> Result { + let value = self.number_arg(args, index, name)?; + Ok(value.round() as i64) + } + + pub(crate) fn u64_arg( + &self, + args: &[Value], + index: usize, + name: &str, + ) -> Result { + let value = self.number_arg(args, index, name)?; + if value < 0.0 { + return Err(InterpreterError::TypeError(format!( + "Builtin '{}' expected non-negative number at position {}", + name, + index + 1 + ))); + } + Ok(value.round() as u64) + } + + pub(crate) fn usize_arg( + &self, + args: &[Value], + index: usize, + name: &str, + ) -> Result { + let value = self.number_arg(args, index, name)?; + if value < 0.0 { + return Err(InterpreterError::TypeError(format!( + "Builtin '{}' expected non-negative number at position {}", + name, + index + 1 + ))); + } + Ok(value.round() as usize) + } + + pub(crate) fn string_arg( + &self, + args: &[Value], + index: usize, + name: &str, + ) -> Result { + match self.arg(args, index, name)? { + Value::String(s) => Ok(s.clone()), + other => Err(InterpreterError::TypeError(format!( + "Builtin '{}' expected string argument at position {}, got {:?}", + name, + index + 1, + other + ))), + } + } + + pub(crate) fn char_arg( + &self, + args: &[Value], + index: usize, + name: &str, + ) -> Result { + let text = self.string_arg(args, index, name)?; + text.chars().next().ok_or_else(|| { + InterpreterError::TypeError(format!( + "Builtin '{}' expected non-empty string to derive character at position {}", + name, + index + 1 + )) + }) + } + + /// Returns the array argument as a shared handle (O(1), no deep copy). + pub(crate) fn array_arg( + &self, + args: &[Value], + index: usize, + name: &str, + ) -> Result>, InterpreterError> { + match self.arg(args, index, name)? { + Value::Array(items) => Ok(std::rc::Rc::clone(items)), + other => Err(InterpreterError::TypeError(format!( + "Builtin '{}' expected array argument at position {}, got {:?}", + name, + index + 1, + other + ))), + } + } + + pub(crate) fn option_string_to_value(&self, input: Option) -> Value { + input.map(Value::String).unwrap_or(Value::Null) + } + + pub(crate) fn option_u32_to_value(&self, input: Option) -> Value { + input + .map(|v| Value::Number(v as f64)) + .unwrap_or(Value::Null) + } + + pub(crate) fn values_to_numbers( + &self, + values: &[Value], + name: &str, + ) -> Result, InterpreterError> { + values + .iter() + .enumerate() + .map(|(i, value)| { + value.to_number().map_err(|_| { + InterpreterError::TypeError(format!( + "Builtin '{}' expected numeric array element at position {}", + name, + i + 1 + )) + }) + }) + .collect() + } +} diff --git a/hypnoscript-compiler/src/interpreter/error.rs b/hypnoscript-compiler/src/interpreter/error.rs new file mode 100644 index 0000000..7672f05 --- /dev/null +++ b/hypnoscript-compiler/src/interpreter/error.rs @@ -0,0 +1,50 @@ +//! Runtime error types for the HypnoScript interpreter. + +use super::value::Value; +use thiserror::Error; + +/// Interpreter errors that can occur during program execution. +/// +/// These errors represent runtime failures in HypnoScript programs, +/// including type mismatches, undefined variables, and control flow errors. +#[derive(Error, Debug)] +pub enum InterpreterError { + #[error("Runtime error: {0}")] + Runtime(String), + + #[error("Break statement outside of loop")] + BreakOutsideLoop, + + #[error("Continue statement outside of loop")] + ContinueOutsideLoop, + + #[error("'snap {0}' does not match any enclosing label")] + LabeledBreak(String), + + #[error("'sink {0}' does not match any enclosing label")] + LabeledContinue(String), + + #[error("Return from function: {0:?}")] + Return(Value), + + #[error("Variable '{0}' not found")] + UndefinedVariable(String), + + #[error("Type error: {0}")] + TypeError(String), + + #[error("Maximum call depth of {0} exceeded (possible infinite recursion)")] + RecursionLimitExceeded(usize), +} + +/// Provide a simple locale-aware message while we prepare full i18n plumbing. +/// +/// When both variants are identical (the common case today) the message is +/// emitted once instead of being duplicated as `msg (DE: msg)`. +pub(crate) fn localized(en: &str, de: &str) -> String { + if en == de { + en.to_string() + } else { + format!("{} (DE: {})", en, de) + } +} diff --git a/hypnoscript-compiler/src/interpreter.rs b/hypnoscript-compiler/src/interpreter/mod.rs similarity index 57% rename from hypnoscript-compiler/src/interpreter.rs rename to hypnoscript-compiler/src/interpreter/mod.rs index 3b0c7cf..e12a1b5 100644 --- a/hypnoscript-compiler/src/interpreter.rs +++ b/hypnoscript-compiler/src/interpreter/mod.rs @@ -1,45 +1,32 @@ +//! The HypnoScript tree-walking interpreter. +//! +//! Split into focused submodules: +//! - [`error`]: runtime error types +//! - [`value`]: runtime values (numbers, strings, functions, promises, ...) +//! - [`session`]: session (class) definitions and instances +//! - [`builtins`]: dispatch into the `hypnoscript-runtime` builtin modules + +mod builtins; +mod error; +mod session; +mod value; + +pub use error::InterpreterError; +pub use value::{FunctionValue, Promise, RecordValue, Value}; + +pub use session::{SessionDefinition, SessionInstance}; + +use error::localized; +use session::{SessionFieldDefinition, SessionMethodDefinition}; + use hypnoscript_lexer_parser::ast::{ AstNode, Pattern, SessionField, SessionMember, SessionMethod, SessionVisibility, VariableStorage, }; -use hypnoscript_runtime::{ - ArrayBuiltins, CoreBuiltins, FileBuiltins, HashingBuiltins, MathBuiltins, StatisticsBuiltins, - StringBuiltins, SystemBuiltins, TimeBuiltins, ValidationBuiltins, -}; +use hypnoscript_runtime::CoreBuiltins; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::rc::Rc; -use thiserror::Error; - -/// Interpreter errors that can occur during program execution. -/// -/// These errors represent runtime failures in HypnoScript programs, -/// including type mismatches, undefined variables, and control flow errors. -#[derive(Error, Debug)] -pub enum InterpreterError { - #[error("Runtime error: {0}")] - Runtime(String), - - #[error("Break statement outside of loop")] - BreakOutsideLoop, - - #[error("Continue statement outside of loop")] - ContinueOutsideLoop, - - #[error("Return from function: {0:?}")] - Return(Value), - - #[error("Variable '{0}' not found")] - UndefinedVariable(String), - - #[error("Type error: {0}")] - TypeError(String), -} - -/// Provide a simple locale-aware message while we prepare full i18n plumbing. -fn localized(en: &str, de: &str) -> String { - format!("{} (DE: {})", en, de) -} /// Type alias for debug pause callback to reduce type complexity. type DebugPauseCallback = @@ -52,994 +39,364 @@ enum ScopeLayer { Shared, } -/// Represents a callable suggestion (function) within the interpreter. -/// -/// HypnoScript functions can be: -/// - Global suggestions (top-level functions) -/// - Session methods (instance methods) -/// - Static session methods (`dominant` keyword) -/// - Constructors (special session methods) -/// - Triggers (event-driven callbacks) -/// -/// # Examples -/// -/// ```hyp -/// // Global suggestion -/// suggestion greet(name: string) { -/// awaken "Hello, " + name; -/// } -/// -/// // Session method -/// session Calculator { -/// suggestion add(a: number, b: number) { -/// awaken a + b; -/// } -/// } -/// ``` #[derive(Debug, Clone)] -pub struct FunctionValue { - name: String, - parameters: Vec, - body: Vec, - this_binding: Option>>, +struct ExecutionContextFrame { session_name: Option, - is_static: bool, - is_constructor: bool, -} - -impl FunctionValue { - fn new_global(name: String, parameters: Vec, body: Vec) -> Self { - Self { - name, - parameters, - body, - this_binding: None, - session_name: None, - is_static: false, - is_constructor: false, - } - } - - fn new_session_member( - session_name: String, - method: &SessionMethodDefinition, - this_binding: Option>>, - ) -> Self { - Self { - name: format!("{}::{}", session_name, method.name), - parameters: method.parameters.clone(), - body: method.body.clone(), - this_binding, - session_name: Some(session_name), - is_static: method.is_static, - is_constructor: method.is_constructor, - } - } - - fn this_binding(&self) -> Option>> { - self.this_binding.as_ref().map(Rc::clone) - } - - fn session_name(&self) -> Option<&str> { - self.session_name.as_deref() - } -} - -impl PartialEq for FunctionValue { - fn eq(&self, other: &Self) -> bool { - self.name == other.name - && self.parameters == other.parameters - && self.body == other.body - && self.session_name == other.session_name - && self.is_static == other.is_static - && self.is_constructor == other.is_constructor - } } -impl Eq for FunctionValue {} - -/// Definition of a session field (instance scope). -/// -/// Session fields represent instance-level variables in HypnoScript sessions (classes). -/// They can have visibility modifiers (`expose`/`conceal`) and optional type annotations. +/// The HypnoScript interpreter. /// -/// # Examples +/// Executes HypnoScript AST nodes using a tree-walking interpretation strategy. +/// Supports: +/// - Variable scopes (global, shared, local) +/// - Functions and triggers +/// - Sessions (OOP) +/// - Pattern matching (`entrain`/`when`) +/// - Async execution (`mesmerize`/`await`) +/// - Channels for inter-task communication +/// - 180+ builtin functions /// -/// ```hyp -/// session Person { -/// expose name: string = "Unknown"; -/// conceal age: number = 0; -/// } -/// ``` -#[derive(Debug, Clone)] -struct SessionFieldDefinition { - name: String, - #[allow(dead_code)] - type_annotation: Option, - visibility: SessionVisibility, - initializer: Option, -} - -/// Definition of a session method. +/// # Architecture /// -/// Session methods represent callable functions within HypnoScript sessions. -/// They can be: -/// - Instance methods (default) -/// - Static methods (`dominant` keyword) -/// - Constructors (special methods with `constructor` keyword) +/// The interpreter maintains: +/// - `globals`: Top-level variables in `Focus { ... } Relax` scope +/// - `shared`: Variables declared with `sharedTrance` (module-level) +/// - `locals`: Stack of local scopes (function calls, loops, blocks) +/// - `const_globals`/`const_locals`: Tracks immutable variables (`freeze`) +/// - `execution_context`: Call stack for session method dispatch +/// - `tranceify_types`: Record type definitions +/// - `async_runtime`: Optional async task executor +/// - `channel_registry`: Optional channel system for message passing /// /// # Examples /// -/// ```hyp -/// session Calculator { -/// // Constructor -/// constructor(initial: number) { -/// induce this.value = initial; -/// } +/// ```rust +/// use hypnoscript_compiler::Interpreter; +/// use hypnoscript_lexer_parser::Parser; +/// use hypnoscript_lexer_parser::Lexer; /// -/// // Instance method -/// expose suggestion add(n: number) { -/// induce this.value = this.value + n; -/// } +/// let source = r#" +/// Focus { +/// entrance { +/// observe "Hello, World!"; +/// } +/// } Relax; +/// "#; /// -/// // Static method -/// dominant suggestion createDefault() { -/// awaken Calculator(0); -/// } -/// } +/// let mut lexer = Lexer::new(source); +/// let tokens = lexer.lex().unwrap(); +/// let mut parser = Parser::new(tokens); +/// let ast = parser.parse_program().unwrap(); +/// let mut interpreter = Interpreter::new(); +/// interpreter.execute_program(ast).unwrap(); /// ``` -#[derive(Debug, Clone)] -struct SessionMethodDefinition { - name: String, - parameters: Vec, - body: Vec, - visibility: SessionVisibility, - is_static: bool, - is_constructor: bool, -} +pub struct Interpreter { + globals: HashMap, + shared: HashMap, + const_globals: HashSet, + locals: Vec>, + const_locals: Vec>, + execution_context: Vec, + /// Tranceify type definitions (field names for each type) + tranceify_types: HashMap>, + /// Label declared immediately before a loop statement; the loop claims it + /// on entry so `snap label;` / `sink label;` can target that loop. + pending_loop_label: Option, + /// Current function call nesting depth (guards against stack overflow). + call_depth: usize, + /// Maximum allowed function call nesting depth. + max_call_depth: usize, + /// Index into `locals` marking the start of the current function + /// activation. Variable lookups do not cross this barrier, giving + /// lexical (instead of dynamic) scoping; closures re-introduce outer + /// variables via their captured environment. + scope_barriers: Vec, -/// Runtime data for a static field, including its initializer AST. -/// -/// Static fields are initialized once and shared across all session instances. -/// They are declared with the `dominant` keyword in HypnoScript. -/// -/// # Examples -/// -/// ```hyp -/// session Counter { -/// dominant instanceCount: number = 0; -/// -/// constructor() { -/// induce Counter.instanceCount = Counter.instanceCount + 1; -/// } -/// } -/// ``` -#[derive(Debug, Clone)] -struct SessionStaticField { - definition: SessionFieldDefinition, - initializer: Option, - value: Value, + /// Optional async runtime for true async execution + pub async_runtime: Option>, + + /// Optional channel registry for inter-task communication + pub channel_registry: Option>, + + /// Optional debug state for step-through debugging + pub debug_state: Option, + + /// Callback invoked when debugger pauses (for REPL integration) + #[allow(dead_code)] + debug_pause_callback: Option, } -/// Stores metadata and static members for a session (class-like construct). -/// -/// Sessions are HypnoScript's OOP construct, similar to classes in other languages. -/// They support: -/// - Instance and static fields -/// - Instance and static methods -/// - Constructors -/// - Visibility modifiers (`expose`/`conceal`) -/// -/// # Examples -/// -/// ```hyp -/// session BankAccount { -/// conceal balance: number = 0; -/// dominant totalAccounts: number = 0; -/// -/// constructor(initialBalance: number) { -/// induce this.balance = initialBalance; -/// induce BankAccount.totalAccounts = BankAccount.totalAccounts + 1; -/// } -/// -/// expose suggestion deposit(amount: number) { -/// induce this.balance = this.balance + amount; -/// } -/// -/// expose suggestion getBalance() { -/// awaken this.balance; -/// } -/// -/// dominant suggestion getTotalAccounts() { -/// awaken BankAccount.totalAccounts; -/// } -/// } -/// ``` -#[derive(Debug)] -pub struct SessionDefinition { - name: String, - fields: HashMap, - field_order: Vec, - methods: HashMap, - static_methods: HashMap, - static_fields: RefCell>, - static_field_order: Vec, - constructor: Option, +impl Default for Interpreter { + fn default() -> Self { + Self::new() + } } -impl SessionDefinition { - fn new(name: String) -> Self { +impl Interpreter { + pub fn new() -> Self { Self { - name, - fields: HashMap::new(), - field_order: Vec::new(), - methods: HashMap::new(), - static_methods: HashMap::new(), - static_fields: RefCell::new(HashMap::new()), - static_field_order: Vec::new(), - constructor: None, + globals: HashMap::new(), + shared: HashMap::new(), + const_globals: HashSet::new(), + locals: Vec::new(), + const_locals: Vec::new(), + execution_context: Vec::new(), + tranceify_types: HashMap::new(), + pending_loop_label: None, + call_depth: 0, + max_call_depth: Self::default_max_call_depth(), + scope_barriers: Vec::new(), + async_runtime: None, + channel_registry: None, + debug_state: None, + debug_pause_callback: None, } } - fn name(&self) -> &str { - &self.name + /// Default maximum call depth. Can be overridden via the + /// `HYPNO_MAX_CALL_DEPTH` environment variable or [`Self::set_max_call_depth`]. + fn default_max_call_depth() -> usize { + std::env::var("HYPNO_MAX_CALL_DEPTH") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0) + .unwrap_or(1_000) } - fn push_field(&mut self, field: SessionFieldDefinition) -> Result<(), InterpreterError> { - if self.fields.contains_key(&field.name) - || self.static_fields.borrow().contains_key(&field.name) - { - return Err(InterpreterError::Runtime(localized( - &format!( - "Duplicate session field '{}' in session '{}'", - field.name, self.name - ), - &format!( - "Duplicate field '{}' in session '{}'", - field.name, self.name - ), - ))); - } - self.field_order.push(field.name.clone()); - self.fields.insert(field.name.clone(), field); - Ok(()) + /// Overrides the maximum function call nesting depth. + /// + /// Deeply recursive HypnoScript programs abort with + /// [`InterpreterError::RecursionLimitExceeded`] instead of crashing the + /// host process with a stack overflow. + pub fn set_max_call_depth(&mut self, depth: usize) { + self.max_call_depth = depth.max(1); } - fn push_static_field( - &mut self, - field: SessionFieldDefinition, - initializer: Option, - ) -> Result<(), InterpreterError> { - if self.fields.contains_key(&field.name) - || self.static_fields.borrow().contains_key(&field.name) - { - return Err(InterpreterError::Runtime(localized( - &format!( - "Duplicate session field '{}' in session '{}'", - field.name, self.name - ), - &format!( - "Duplicate field '{}' in session '{}'", - field.name, self.name - ), - ))); - } - self.static_field_order.push(field.name.clone()); - self.static_fields.borrow_mut().insert( - field.name.clone(), - SessionStaticField { - definition: field, - initializer, - value: Value::Null, - }, - ); - Ok(()) + /// Create interpreter with async runtime support + pub fn with_async_runtime() -> Result { + let mut interpreter = Self::new(); + interpreter.enable_async_runtime()?; + Ok(interpreter) } - fn push_method(&mut self, method: SessionMethodDefinition) -> Result<(), InterpreterError> { - if method.is_constructor { - if self.constructor.is_some() { - return Err(InterpreterError::Runtime(localized( - &format!("Multiple constructors declared in session '{}'", self.name), - &format!("Multiple constructors declared in session '{}'", self.name), - ))); - } - self.constructor = Some(method); - return Ok(()); - } + /// Enable async runtime for existing interpreter + pub fn enable_async_runtime(&mut self) -> Result<(), InterpreterError> { + if self.async_runtime.is_none() { + let runtime = crate::async_runtime::AsyncRuntime::new().map_err(|e| { + InterpreterError::Runtime(format!("Failed to create async runtime: {}", e)) + })?; + let registry = crate::channel_system::ChannelRegistry::new(); - if method.is_static { - if self.static_methods.contains_key(&method.name) { - return Err(InterpreterError::Runtime(localized( - &format!( - "Duplicate static method '{}' in session '{}'", - method.name, self.name - ), - &format!( - "Duplicate static method '{}' in session '{}'", - method.name, self.name - ), - ))); - } - self.static_methods.insert(method.name.clone(), method); - } else { - if self.methods.contains_key(&method.name) { - return Err(InterpreterError::Runtime(localized( - &format!( - "Duplicate method '{}' in session '{}'", - method.name, self.name - ), - &format!( - "Duplicate method '{}' in session '{}'", - method.name, self.name - ), - ))); - } - self.methods.insert(method.name.clone(), method); + self.async_runtime = Some(std::sync::Arc::new(runtime)); + self.channel_registry = Some(std::sync::Arc::new(registry)); } Ok(()) } - fn get_field_definition(&self, name: &str) -> Option<&SessionFieldDefinition> { - self.fields.get(name) + // === Debug Mode Methods === + + /// Enables debug mode with the given source code. + /// + /// When debug mode is enabled, the interpreter will check for breakpoints + /// and step conditions before each statement execution. + /// + /// # Arguments + /// * `source` - The source code (for display in debugger) + pub fn enable_debug_mode(&mut self, source: &str) { + self.debug_state = Some(crate::debug::DebugState::with_source(source)); } - fn get_method_definition(&self, name: &str) -> Option<&SessionMethodDefinition> { - self.methods.get(name) + /// Enables debug mode without source code. + pub fn enable_debug_mode_no_source(&mut self) { + self.debug_state = Some(crate::debug::DebugState::new()); } - fn get_static_method_definition(&self, name: &str) -> Option<&SessionMethodDefinition> { - self.static_methods.get(name) + /// Disables debug mode. + pub fn disable_debug_mode(&mut self) { + self.debug_state = None; } - fn get_static_field_snapshot(&self, name: &str) -> Option { - self.static_fields.borrow().get(name).cloned() + /// Returns whether debug mode is enabled. + pub fn is_debug_mode(&self) -> bool { + self.debug_state.is_some() } - fn set_static_field_value(&self, name: &str, value: Value) -> Result<(), InterpreterError> { - let mut fields = self.static_fields.borrow_mut(); - match fields.get_mut(name) { - Some(field) => { - field.value = value; - Ok(()) - } - None => Err(InterpreterError::Runtime(localized( - &format!( - "Static field '{}' not found on session '{}'", - name, self.name - ), - &format!( - "Static field '{}' not found on session '{}'", - name, self.name - ), - ))), + /// Sets a breakpoint at the given line number. + /// + /// # Arguments + /// * `line` - Line number (1-indexed) + /// + /// # Returns + /// `true` if breakpoint was newly added, `false` if already existed or debug mode not enabled + pub fn set_breakpoint(&mut self, line: usize) -> bool { + if let Some(ref mut state) = self.debug_state { + state.set_breakpoint(line) + } else { + false } } - fn take_static_field_initializer(&self, name: &str) -> Option { - self.static_fields - .borrow() - .get(name) - .and_then(|field| field.initializer.clone()) + /// Removes a breakpoint at the given line number. + pub fn remove_breakpoint(&mut self, line: usize) -> bool { + if let Some(ref mut state) = self.debug_state { + state.remove_breakpoint(line) + } else { + false + } } - fn field_order(&self) -> &[String] { - &self.field_order + /// Checks if a breakpoint exists at the given line. + pub fn has_breakpoint(&self, line: usize) -> bool { + self.debug_state + .as_ref() + .map(|s| s.has_breakpoint(line)) + .unwrap_or(false) } - fn static_field_order(&self) -> &[String] { - &self.static_field_order + /// Returns all breakpoints. + pub fn breakpoints(&self) -> Vec { + self.debug_state + .as_ref() + .map(|s| s.breakpoints().iter().copied().collect()) + .unwrap_or_default() } - fn constructor(&self) -> Option<&SessionMethodDefinition> { - self.constructor.as_ref() + /// Clears all breakpoints. + pub fn clear_breakpoints(&mut self) { + if let Some(ref mut state) = self.debug_state { + state.clear_breakpoints(); + } } -} - -/// Runtime representation of a session instance. -/// -/// Each instantiated session creates a `SessionInstance` that holds: -/// - A reference to the session definition (metadata) -/// - Instance-specific field values -/// -/// # Examples -/// -/// ```hyp -/// session Person { -/// expose name: string = "Unknown"; -/// expose age: number = 0; -/// -/// constructor(n: string, a: number) { -/// induce this.name = n; -/// induce this.age = a; -/// } -/// } -/// -/// // Creates a SessionInstance -/// induce person = Person("Alice", 30); -/// ``` -#[derive(Debug)] -pub struct SessionInstance { - definition: Rc, - field_values: HashMap, -} -impl SessionInstance { - fn new(definition: Rc) -> Self { - let mut field_values = HashMap::new(); - for name in definition.field_order() { - field_values.insert(name.clone(), Value::Null); - } - Self { - definition, - field_values, + /// Sets the step mode for debugging. + pub fn set_step_mode(&mut self, mode: crate::debug::StepMode) { + if let Some(ref mut state) = self.debug_state { + state.set_step_mode(mode); } } - fn definition(&self) -> Rc { - Rc::clone(&self.definition) + /// Gets the current step mode. + pub fn step_mode(&self) -> crate::debug::StepMode { + self.debug_state + .as_ref() + .map(|s| s.step_mode()) + .unwrap_or(crate::debug::StepMode::None) } - fn definition_name(&self) -> &str { - self.definition.name() + /// Adds a watch expression. + /// + /// # Returns + /// The ID of the new watch expression, or None if debug mode not enabled + pub fn add_watch(&mut self, expression: String) -> Option { + self.debug_state.as_mut().map(|s| s.add_watch(expression)) } - fn get_field(&self, name: &str) -> Option { - self.field_values.get(name).cloned() + /// Removes a watch expression by ID. + pub fn remove_watch(&mut self, id: usize) -> bool { + self.debug_state + .as_mut() + .map(|s| s.remove_watch(id)) + .unwrap_or(false) } - fn set_field(&mut self, name: &str, value: Value) { - self.field_values.insert(name.to_string(), value); + /// Returns the current call stack for debugging. + pub fn debug_call_stack(&self) -> Vec { + self.debug_state + .as_ref() + .map(|s| s.call_stack().to_vec()) + .unwrap_or_default() } -} - -#[derive(Debug, Clone)] -struct ExecutionContextFrame { - session_name: Option, -} - -/// Simple Promise/Future wrapper for async operations. -/// -/// Promises represent asynchronous computations in HypnoScript. -/// They are created by `mesmerize` suggestions and resolved with `await` or `surrenderTo`. -/// -/// # Examples -/// -/// ```hyp -/// // Async suggestion returns a Promise -/// mesmerize suggestion fetchData() { -/// induce data = "some data"; -/// awaken data; -/// } -/// -/// entrance { -/// induce result = await fetchData(); -/// observe result; -/// } -/// ``` -#[derive(Debug, Clone)] -pub struct Promise { - /// The resolved value (if completed) - value: Option, - /// Whether the promise is resolved - resolved: bool, -} -impl Promise { - #[allow(dead_code)] - fn new() -> Self { - Self { - value: None, - resolved: false, + /// Returns local variables in the current scope. + pub fn debug_locals(&self) -> HashMap { + if let Some(scope) = self.locals.last() { + scope.clone() + } else { + HashMap::new() } } - #[allow(dead_code)] - fn resolve(value: Value) -> Self { - Self { - value: Some(value), - resolved: true, - } + /// Returns global variables. + pub fn debug_globals(&self) -> HashMap { + self.globals.clone() } - fn is_resolved(&self) -> bool { - self.resolved + /// Returns all visible variables (locals + globals). + pub fn debug_all_variables(&self) -> HashMap { + let mut vars = self.globals.clone(); + vars.extend(self.shared.clone()); + for scope in &self.locals { + vars.extend(scope.clone()); + } + vars } - fn get_value(&self) -> Option { - self.value.clone() + /// Returns the current debug state summary. + pub fn debug_summary(&self) -> String { + self.debug_state + .as_ref() + .map(|s| s.summary()) + .unwrap_or_else(|| "Debug mode not enabled".to_string()) } -} - -/// Runtime value in HypnoScript. -/// -/// Represents all possible runtime values in the HypnoScript interpreter. -/// This includes primitives, collections, functions, sessions, and async values. -/// -/// # Variants -/// -/// - `Number(f64)` - Numeric values (e.g., `42`, `3.14`) -/// - `String(String)` - Text values (e.g., `"Hello"`) -/// - `Boolean(bool)` - Boolean values (`true`/`false`) -/// - `Array(Vec)` - Arrays (e.g., `[1, 2, 3]`) -/// - `Function(FunctionValue)` - Callable suggestions -/// - `Session(Rc)` - Session type (class constructor) -/// - `Instance(Rc>)` - Session instance -/// - `Promise(Rc>)` - Async promise from `mesmerize` -/// - `Record(RecordValue)` - Record/struct from `tranceify` -/// - `Null` - Null value -/// -/// # Examples -/// -/// ```hyp -/// induce num: number = 42; // Value::Number -/// induce text: string = "Hello"; // Value::String -/// induce flag: boolean = true; // Value::Boolean -/// induce list: number[] = [1, 2, 3]; // Value::Array -/// induce account = BankAccount(100); // Value::Instance -/// induce promise = mesmerize getData(); // Value::Promise -/// induce nothing: null = null; // Value::Null -/// ``` -#[derive(Debug, Clone)] -pub enum Value { - Number(f64), - String(String), - Boolean(bool), - Array(Vec), - Function(FunctionValue), - Session(Rc), - Instance(Rc>), - Promise(Rc>), - Record(RecordValue), - Null, -} - -/// A record instance (from tranceify declarations). -/// -/// Records are user-defined structured data types in HypnoScript, -/// similar to structs in other languages. -/// -/// # Examples -/// -/// ```hyp -/// tranceify Point { -/// x: number, -/// y: number -/// } -/// -/// entrance { -/// induce p = Point { x: 10, y: 20 }; -/// observe p.x; // 10 -/// } -/// ``` -#[derive(Debug, Clone)] -pub struct RecordValue { - pub type_name: String, - pub fields: HashMap, -} -impl PartialEq for RecordValue { - fn eq(&self, other: &Self) -> bool { - self.type_name == other.type_name && self.fields == other.fields + /// Formats the source context around the current line. + pub fn debug_source_context(&self, context_lines: usize) -> String { + self.debug_state + .as_ref() + .map(|s| s.format_source_context(context_lines)) + .unwrap_or_else(|| "(no source available)".to_string()) } -} - -impl Eq for RecordValue {} -impl PartialEq for Value { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Value::Number(a), Value::Number(b)) => (a - b).abs() < f64::EPSILON, - (Value::String(a), Value::String(b)) => a == b, - (Value::Boolean(a), Value::Boolean(b)) => a == b, - (Value::Null, Value::Null) => true, - (Value::Array(a), Value::Array(b)) => a == b, - (Value::Function(fa), Value::Function(fb)) => fa == fb, - (Value::Session(sa), Value::Session(sb)) => Rc::ptr_eq(sa, sb), - (Value::Instance(ia), Value::Instance(ib)) => Rc::ptr_eq(ia, ib), - (Value::Promise(pa), Value::Promise(pb)) => Rc::ptr_eq(pa, pb), - (Value::Record(ra), Value::Record(rb)) => ra == rb, - _ => false, + /// Checks if the debugger should pause before executing a statement. + /// Returns the pause reason if should pause, None otherwise. + #[allow(dead_code)] + fn check_debug_pause(&mut self, line: usize) -> Option { + if let Some(ref mut state) = self.debug_state { + state.should_pause(line) + } else { + None } } -} -impl Eq for Value {} - -impl Value { - pub fn is_truthy(&self) -> bool { - match self { - Value::Boolean(b) => *b, - Value::Null => false, - Value::Number(n) => *n != 0.0, - Value::String(s) => !s.is_empty(), - Value::Array(a) => !a.is_empty(), - Value::Function(_) - | Value::Session(_) - | Value::Instance(_) - | Value::Promise(_) - | Value::Record(_) => true, + /// Handles a debug pause event. + /// This is called when a breakpoint is hit or step completes. + #[allow(dead_code)] + fn handle_debug_pause(&mut self, reason: crate::debug::PauseReason) { + if let Some(ref mut state) = self.debug_state { + state.pause(reason); } } - pub fn to_number(&self) -> Result { - match self { - Value::Number(n) => Ok(*n), - Value::String(s) => s.parse::().map_err(|_| { - InterpreterError::TypeError(format!("Cannot convert '{}' to number", s)) - }), - Value::Boolean(b) => Ok(if *b { 1.0 } else { 0.0 }), - _ => Err(InterpreterError::TypeError( - "Cannot convert to number".to_string(), - )), + /// Resumes execution after a pause. + pub fn debug_resume(&mut self) { + if let Some(ref mut state) = self.debug_state { + state.resume(); } } -} -impl std::fmt::Display for Value { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Value::Number(n) => write!(f, "{}", n), - Value::String(s) => write!(f, "{}", s), - Value::Boolean(b) => write!(f, "{}", b), - Value::Null => write!(f, "null"), - Value::Array(arr) => { - let elements: Vec = arr.iter().map(|v| v.to_string()).collect(); - write!(f, "[{}]", elements.join(", ")) - } - Value::Function(func) => write!(f, "", func.name), - Value::Session(session) => write!(f, "", session.name()), - Value::Instance(instance) => { - let name = instance.borrow().definition_name().to_string(); - write!(f, "", name) - } - Value::Promise(promise) => { - if promise.borrow().is_resolved() { - write!(f, "") - } else { - write!(f, "") - } - } - Value::Record(record) => { - write!(f, "", record.type_name) - } + /// Pushes a debug call frame when entering a function. + #[allow(dead_code)] + fn debug_push_frame(&mut self, function_name: &str, line: usize) { + if let Some(ref mut state) = self.debug_state { + state.push_frame(crate::debug::CallFrame::new( + function_name.to_string(), + line, + )); } } -} - -/// The HypnoScript interpreter. -/// -/// Executes HypnoScript AST nodes using a tree-walking interpretation strategy. -/// Supports: -/// - Variable scopes (global, shared, local) -/// - Functions and triggers -/// - Sessions (OOP) -/// - Pattern matching (`entrain`/`when`) -/// - Async execution (`mesmerize`/`await`) -/// - Channels for inter-task communication -/// - 180+ builtin functions -/// -/// # Architecture -/// -/// The interpreter maintains: -/// - `globals`: Top-level variables in `Focus { ... } Relax` scope -/// - `shared`: Variables declared with `sharedTrance` (module-level) -/// - `locals`: Stack of local scopes (function calls, loops, blocks) -/// - `const_globals`/`const_locals`: Tracks immutable variables (`freeze`) -/// - `execution_context`: Call stack for session method dispatch -/// - `tranceify_types`: Record type definitions -/// - `async_runtime`: Optional async task executor -/// - `channel_registry`: Optional channel system for message passing -/// -/// # Examples -/// -/// ```rust -/// use hypnoscript_compiler::Interpreter; -/// use hypnoscript_lexer_parser::Parser; -/// use hypnoscript_lexer_parser::Lexer; -/// -/// let source = r#" -/// Focus { -/// entrance { -/// observe "Hello, World!"; -/// } -/// } Relax; -/// "#; -/// -/// let mut lexer = Lexer::new(source); -/// let tokens = lexer.lex().unwrap(); -/// let mut parser = Parser::new(tokens); -/// let ast = parser.parse_program().unwrap(); -/// let mut interpreter = Interpreter::new(); -/// interpreter.execute_program(ast).unwrap(); -/// ``` -pub struct Interpreter { - globals: HashMap, - shared: HashMap, - const_globals: HashSet, - locals: Vec>, - const_locals: Vec>, - execution_context: Vec, - /// Tranceify type definitions (field names for each type) - tranceify_types: HashMap>, - - /// Optional async runtime for true async execution - pub async_runtime: Option>, - - /// Optional channel registry for inter-task communication - pub channel_registry: Option>, - - /// Optional debug state for step-through debugging - pub debug_state: Option, - /// Callback invoked when debugger pauses (for REPL integration) + /// Pops a debug call frame when exiting a function. #[allow(dead_code)] - debug_pause_callback: Option, -} - -impl Default for Interpreter { - fn default() -> Self { - Self::new() - } -} - -impl Interpreter { - pub fn new() -> Self { - Self { - globals: HashMap::new(), - shared: HashMap::new(), - const_globals: HashSet::new(), - locals: Vec::new(), - const_locals: Vec::new(), - execution_context: Vec::new(), - tranceify_types: HashMap::new(), - async_runtime: None, - channel_registry: None, - debug_state: None, - debug_pause_callback: None, + fn debug_pop_frame(&mut self) { + if let Some(ref mut state) = self.debug_state { + state.pop_frame(); } } - /// Create interpreter with async runtime support - pub fn with_async_runtime() -> Result { - let runtime = crate::async_runtime::AsyncRuntime::new().map_err(|e| { - InterpreterError::Runtime(format!("Failed to create async runtime: {}", e)) - })?; - let registry = crate::channel_system::ChannelRegistry::new(); - - Ok(Self { - globals: HashMap::new(), - shared: HashMap::new(), - const_globals: HashSet::new(), - locals: Vec::new(), - const_locals: Vec::new(), - execution_context: Vec::new(), - tranceify_types: HashMap::new(), - async_runtime: Some(std::sync::Arc::new(runtime)), - channel_registry: Some(std::sync::Arc::new(registry)), - debug_state: None, - debug_pause_callback: None, - }) - } - - /// Enable async runtime for existing interpreter - pub fn enable_async_runtime(&mut self) -> Result<(), InterpreterError> { - if self.async_runtime.is_none() { - let runtime = crate::async_runtime::AsyncRuntime::new().map_err(|e| { - InterpreterError::Runtime(format!("Failed to create async runtime: {}", e)) - })?; - let registry = crate::channel_system::ChannelRegistry::new(); - - self.async_runtime = Some(std::sync::Arc::new(runtime)); - self.channel_registry = Some(std::sync::Arc::new(registry)); - } - Ok(()) - } - - // === Debug Mode Methods === - - /// Enables debug mode with the given source code. - /// - /// When debug mode is enabled, the interpreter will check for breakpoints - /// and step conditions before each statement execution. - /// - /// # Arguments - /// * `source` - The source code (for display in debugger) - pub fn enable_debug_mode(&mut self, source: &str) { - self.debug_state = Some(crate::debug::DebugState::with_source(source)); - } - - /// Enables debug mode without source code. - pub fn enable_debug_mode_no_source(&mut self) { - self.debug_state = Some(crate::debug::DebugState::new()); - } - - /// Disables debug mode. - pub fn disable_debug_mode(&mut self) { - self.debug_state = None; - } - - /// Returns whether debug mode is enabled. - pub fn is_debug_mode(&self) -> bool { - self.debug_state.is_some() - } - - /// Sets a breakpoint at the given line number. - /// - /// # Arguments - /// * `line` - Line number (1-indexed) - /// - /// # Returns - /// `true` if breakpoint was newly added, `false` if already existed or debug mode not enabled - pub fn set_breakpoint(&mut self, line: usize) -> bool { - if let Some(ref mut state) = self.debug_state { - state.set_breakpoint(line) - } else { - false - } - } - - /// Removes a breakpoint at the given line number. - pub fn remove_breakpoint(&mut self, line: usize) -> bool { - if let Some(ref mut state) = self.debug_state { - state.remove_breakpoint(line) - } else { - false - } - } - - /// Checks if a breakpoint exists at the given line. - pub fn has_breakpoint(&self, line: usize) -> bool { - self.debug_state - .as_ref() - .map(|s| s.has_breakpoint(line)) - .unwrap_or(false) - } - - /// Returns all breakpoints. - pub fn breakpoints(&self) -> Vec { - self.debug_state - .as_ref() - .map(|s| s.breakpoints().iter().copied().collect()) - .unwrap_or_default() - } - - /// Clears all breakpoints. - pub fn clear_breakpoints(&mut self) { - if let Some(ref mut state) = self.debug_state { - state.clear_breakpoints(); - } - } - - /// Sets the step mode for debugging. - pub fn set_step_mode(&mut self, mode: crate::debug::StepMode) { - if let Some(ref mut state) = self.debug_state { - state.set_step_mode(mode); - } - } - - /// Gets the current step mode. - pub fn step_mode(&self) -> crate::debug::StepMode { - self.debug_state - .as_ref() - .map(|s| s.step_mode()) - .unwrap_or(crate::debug::StepMode::None) - } - - /// Adds a watch expression. - /// - /// # Returns - /// The ID of the new watch expression, or None if debug mode not enabled - pub fn add_watch(&mut self, expression: String) -> Option { - self.debug_state.as_mut().map(|s| s.add_watch(expression)) - } - - /// Removes a watch expression by ID. - pub fn remove_watch(&mut self, id: usize) -> bool { - self.debug_state - .as_mut() - .map(|s| s.remove_watch(id)) - .unwrap_or(false) - } - - /// Returns the current call stack for debugging. - pub fn debug_call_stack(&self) -> Vec { - self.debug_state - .as_ref() - .map(|s| s.call_stack().to_vec()) - .unwrap_or_default() - } - - /// Returns local variables in the current scope. - pub fn debug_locals(&self) -> HashMap { - if let Some(scope) = self.locals.last() { - scope.clone() - } else { - HashMap::new() - } - } - - /// Returns global variables. - pub fn debug_globals(&self) -> HashMap { - self.globals.clone() - } - - /// Returns all visible variables (locals + globals). - pub fn debug_all_variables(&self) -> HashMap { - let mut vars = self.globals.clone(); - vars.extend(self.shared.clone()); - for scope in &self.locals { - vars.extend(scope.clone()); - } - vars - } - - /// Returns the current debug state summary. - pub fn debug_summary(&self) -> String { - self.debug_state - .as_ref() - .map(|s| s.summary()) - .unwrap_or_else(|| "Debug mode not enabled".to_string()) - } - - /// Formats the source context around the current line. - pub fn debug_source_context(&self, context_lines: usize) -> String { - self.debug_state - .as_ref() - .map(|s| s.format_source_context(context_lines)) - .unwrap_or_else(|| "(no source available)".to_string()) - } - - /// Checks if the debugger should pause before executing a statement. - /// Returns the pause reason if should pause, None otherwise. - #[allow(dead_code)] - fn check_debug_pause(&mut self, line: usize) -> Option { - if let Some(ref mut state) = self.debug_state { - state.should_pause(line) - } else { - None - } - } - - /// Handles a debug pause event. - /// This is called when a breakpoint is hit or step completes. - #[allow(dead_code)] - fn handle_debug_pause(&mut self, reason: crate::debug::PauseReason) { - if let Some(ref mut state) = self.debug_state { - state.pause(reason); - } - } - - /// Resumes execution after a pause. - pub fn debug_resume(&mut self) { - if let Some(ref mut state) = self.debug_state { - state.resume(); - } - } - - /// Pushes a debug call frame when entering a function. - #[allow(dead_code)] - fn debug_push_frame(&mut self, function_name: &str, line: usize) { - if let Some(ref mut state) = self.debug_state { - state.push_frame(crate::debug::CallFrame::new( - function_name.to_string(), - line, - )); - } - } - - /// Pops a debug call frame when exiting a function. - #[allow(dead_code)] - fn debug_pop_frame(&mut self) { - if let Some(ref mut state) = self.debug_state { - state.pop_frame(); - } - } - - /// Triggers a programmatic breakpoint from code. - pub fn trigger_breakpoint(&mut self) -> Option { - self.debug_state.as_mut().map(|s| s.trigger_breakpoint()) - } + /// Triggers a programmatic breakpoint from code. + pub fn trigger_breakpoint(&mut self) -> Option { + self.debug_state.as_mut().map(|s| s.trigger_breakpoint()) + } pub fn execute_program(&mut self, program: AstNode) -> Result<(), InterpreterError> { if let AstNode::Program(statements) = program { @@ -1085,27 +442,21 @@ impl Interpreter { parameters, return_type: _, body, - } => { - let param_names: Vec = parameters.iter().map(|p| p.name.clone()).collect(); - let func = FunctionValue::new_global(name.clone(), param_names, body.clone()); - self.define_variable( - VariableStorage::Local, - name.clone(), - Value::Function(func), - false, - ); - Ok(()) } - - AstNode::TriggerDeclaration { + // Triggers are handled like functions + | AstNode::TriggerDeclaration { name, parameters, return_type: _, body, } => { - // Triggers are handled like functions let param_names: Vec = parameters.iter().map(|p| p.name.clone()).collect(); - let func = FunctionValue::new_global(name.clone(), param_names, body.clone()); + let func = FunctionValue::new_closure( + name.clone(), + param_names, + body.clone(), + self.capture_lexical_environment(), + ); self.define_variable( VariableStorage::Local, name.clone(), @@ -1220,6 +571,7 @@ impl Interpreter { } AstNode::WhileStatement { condition, body } => { + let my_label = self.pending_loop_label.take(); loop { let cond_value = self.evaluate_expression(condition)?; if !cond_value.is_truthy() { @@ -1229,6 +581,14 @@ impl Interpreter { match self.execute_block(body) { Err(InterpreterError::BreakOutsideLoop) => break, Err(InterpreterError::ContinueOutsideLoop) => continue, + Err(InterpreterError::LabeledBreak(l)) if Some(&l) == my_label.as_ref() => { + break; + } + Err(InterpreterError::LabeledContinue(l)) + if Some(&l) == my_label.as_ref() => + { + continue; + } Err(e) => return Err(e), Ok(()) => {} } @@ -1242,6 +602,7 @@ impl Interpreter { update, body, } => { + let my_label = self.pending_loop_label.take(); if let Some(init_stmt) = init.as_ref() { self.execute_statement(init_stmt)?; } @@ -1254,14 +615,15 @@ impl Interpreter { } } + // 'continue' skips the rest of the body but not the update. match self.execute_loop_body(body) { Err(InterpreterError::BreakOutsideLoop) => break, - Err(InterpreterError::ContinueOutsideLoop) => { - if let Some(update_stmt) = update.as_ref() { - self.execute_statement(update_stmt)?; - } - continue; + Err(InterpreterError::LabeledBreak(l)) if Some(&l) == my_label.as_ref() => { + break; } + Err(InterpreterError::ContinueOutsideLoop) => {} + Err(InterpreterError::LabeledContinue(l)) + if Some(&l) == my_label.as_ref() => {} Err(e) => return Err(e), Ok(()) => {} } @@ -1273,6 +635,18 @@ impl Interpreter { Ok(()) } + AstNode::LabeledStatement { label, body } => { + self.pending_loop_label = Some(label.clone()); + let result = self.execute_statement(body); + self.pending_loop_label = None; + match result { + // Fallback for labels on non-loop statements: a matching + // labeled break simply exits the labeled statement. + Err(InterpreterError::LabeledBreak(l)) if l == *label => Ok(()), + other => other, + } + } + AstNode::SuspendStatement => { // Suspend is an infinite pause - in practice, this should wait for external input // For now, we'll just log a warning @@ -1281,6 +655,18 @@ impl Interpreter { Ok(()) } + AstNode::DriftStatement { duration } => { + let value = self.evaluate_expression(duration)?; + let milliseconds = value.to_number().map_err(|_| { + InterpreterError::TypeError(localized( + "drift duration must be a number of milliseconds", + "drift-Dauer muss eine Zahl in Millisekunden sein", + )) + })?; + CoreBuiltins::drift(milliseconds.max(0.0) as u64); + Ok(()) + } + AstNode::ReturnStatement(value) => { let ret_value = if let Some(expr) = value { self.evaluate_expression(expr)? @@ -1290,9 +676,15 @@ impl Interpreter { Err(InterpreterError::Return(ret_value)) } - AstNode::BreakStatement => Err(InterpreterError::BreakOutsideLoop), + AstNode::BreakStatement { label } => match label { + Some(label) => Err(InterpreterError::LabeledBreak(label.clone())), + None => Err(InterpreterError::BreakOutsideLoop), + }, - AstNode::ContinueStatement => Err(InterpreterError::ContinueOutsideLoop), + AstNode::ContinueStatement { label } => match label { + Some(label) => Err(InterpreterError::LabeledContinue(label.clone())), + None => Err(InterpreterError::ContinueOutsideLoop), + }, AstNode::ExpressionStatement(expr) => { self.evaluate_expression(expr)?; @@ -1334,6 +726,7 @@ impl Interpreter { AstNode::StringLiteral(s) => Ok(Value::String(s.clone())), AstNode::BooleanLiteral(b) => Ok(Value::Boolean(*b)), + AstNode::NullLiteral => Ok(Value::Null), AstNode::Identifier(name) => self.get_variable(name), @@ -1342,7 +735,7 @@ impl Interpreter { for elem in elements { values.push(self.evaluate_expression(elem)?); } - Ok(Value::Array(values)) + Ok(Value::array(values)) } AstNode::BinaryExpression { @@ -1412,28 +805,10 @@ impl Interpreter { AstNode::AwaitExpression { expression } => { // Evaluate the expression - it might return a Promise let value = self.evaluate_expression(expression)?; - - // If it's a Promise, await it (resolve it) - if let Value::Promise(promise_ref) = value { - let promise = promise_ref.borrow(); - if promise.is_resolved() { - // Promise is already resolved, return its value - Ok(promise.get_value().unwrap_or(Value::Null)) - } else { - // Promise not yet resolved - in a real async system, we'd wait - // For now, return null (could simulate delay here) - drop(promise); // Release borrow before potentially waiting - - // Simulate async operation with small delay - std::thread::sleep(std::time::Duration::from_millis(10)); - - // Re-check if resolved after wait - let promise = promise_ref.borrow(); - Ok(promise.get_value().unwrap_or(Value::Null)) - } - } else { + match value { + Value::Promise(promise_ref) => Ok(Self::await_promise(&promise_ref)), // Not a promise, just return the value - Ok(value) + other => Ok(other), } } @@ -1542,7 +917,7 @@ impl Interpreter { field_values.insert(field_init.name.clone(), value); } - Ok(Value::Record(RecordValue { + Ok(Value::record(RecordValue { type_name: type_name.clone(), fields: field_values, })) @@ -1624,7 +999,7 @@ impl Interpreter { if let Some(rest_name) = rest { let rest_elements: Vec = arr.iter().skip(elements.len()).cloned().collect(); - bindings.insert(rest_name.clone(), Value::Array(rest_elements)); + bindings.insert(rest_name.clone(), Value::array(rest_elements)); } else if arr.len() > elements.len() { return Ok(None); // Too many elements and no rest pattern } @@ -1784,6 +1159,16 @@ impl Interpreter { self.invoke_callable(&callee_value, &args) } + /// Resolves a promise: any remaining simulated delay elapses via + /// [`CoreBuiltins::drift`] (honouring `HYPNO_TIME_SCALE`), after which + /// the promise is marked resolved and its value returned. + fn await_promise(promise: &Rc>) -> Value { + if let Some(delay) = promise.borrow().pending_delay_ms() { + CoreBuiltins::drift(delay); + } + promise.borrow_mut().mark_resolved() + } + fn invoke_callable( &mut self, callee: &Value, @@ -1823,6 +1208,32 @@ impl Interpreter { ))); } + if self.call_depth >= self.max_call_depth { + return Err(InterpreterError::RecursionLimitExceeded( + self.max_call_depth, + )); + } + self.call_depth += 1; + + // Grow the native stack on demand so that deeply recursive scripts hit + // the graceful `RecursionLimitExceeded` error above instead of + // overflowing the host stack (tree-walking frames are large, + // especially in debug builds). + let result = stacker::maybe_grow(128 * 1024, 2 * 1024 * 1024, || { + self.call_function_frame(function, args) + }); + self.call_depth -= 1; + result + } + + /// Executes a single function activation. Only called via + /// [`Self::call_function`], which enforces the depth limit and keeps + /// enough native stack available. + fn call_function_frame( + &mut self, + function: &FunctionValue, + args: &[Value], + ) -> Result { let session_name = function.session_name().map(|name| name.to_string()); if session_name.is_some() { self.execution_context.push(ExecutionContextFrame { @@ -1831,7 +1242,27 @@ impl Interpreter { } self.push_scope(); + // Lexical scoping: lookups inside this activation must not see the + // caller's locals. Closures bring outer variables along explicitly. + self.scope_barriers.push(self.locals.len() - 1); + + // 1. Captured lexical environment (outermost precedence layer) + for (name, value) in function.captured.iter() { + self.define_variable(VariableStorage::Local, name.clone(), value.clone(), false); + } + + // 2. Self-reference so closures can recurse even though their own + // name was not yet visible when the capture snapshot was taken. + if session_name.is_none() { + self.define_variable( + VariableStorage::Local, + function.name.clone(), + Value::Function(function.clone()), + false, + ); + } + // 3. `this` binding and parameters shadow captures. if let Some(instance) = function.this_binding() { self.define_variable( VariableStorage::Local, @@ -1846,12 +1277,13 @@ impl Interpreter { } let result = (|| { - for stmt in &function.body { + for stmt in function.body.iter() { self.execute_statement(stmt)?; } Ok(Value::Null) })(); + self.scope_barriers.pop(); self.pop_scope(); if session_name.is_some() { @@ -1929,7 +1361,7 @@ impl Interpreter { let method_def = SessionMethodDefinition { name: method.name.clone(), parameters, - body: method.body.clone(), + body: Rc::new(method.body.clone()), visibility: method.visibility, is_static: method.is_static, is_constructor: method.is_constructor, @@ -2238,925 +1670,85 @@ impl Interpreter { Err(InterpreterError::Runtime(localized( &format!( - "Session instance of '{}' has no field '{}'", - definition.name(), - property - ), - &format!( - "Session instance of '{}' has no field '{}'", - definition.name(), - property - ), - ))) - } - Value::Session(session_rc) => { - if let Some(static_field) = session_rc.get_static_field_snapshot(property) { - self.ensure_visibility( - static_field.definition.visibility, - session_rc.name(), - "field", - property, - )?; - session_rc.set_static_field_value(property, value)?; - return Ok(()); - } - - if session_rc.get_static_method_definition(property).is_some() { - return Err(InterpreterError::Runtime(localized( - &format!("Cannot assign to static method '{}'", property), - &format!("Cannot assign to static method '{}'", property), - ))); - } - - Err(InterpreterError::Runtime(localized( - &format!( - "Session '{}' has no static field '{}'", - session_rc.name(), - property - ), - &format!( - "Session '{}' has no static field '{}'", - session_rc.name(), - property - ), - ))) - } - _ => Err(InterpreterError::Runtime(localized( - "Assignment target is not a session member", - "Assignment target is not a session member", - ))), - } - } - - fn ensure_visibility( - &self, - visibility: SessionVisibility, - session_name: &str, - member_kind: &str, - member_name: &str, - ) -> Result<(), InterpreterError> { - if visibility == SessionVisibility::Private && !self.is_access_allowed(session_name) { - return Err(InterpreterError::Runtime(localized( - &format!( - "Access denied to private {} '{}' of session '{}'", - member_kind, member_name, session_name - ), - &format!( - "Access denied to private {} '{}' of session '{}'", - member_kind, member_name, session_name - ), - ))); - } - Ok(()) - } - - fn is_access_allowed(&self, session_name: &str) -> bool { - self.execution_context - .iter() - .rev() - .find_map(|frame| frame.session_name.as_deref()) - == Some(session_name) - } - - fn call_builtin( - &mut self, - name: &str, - args: &[Value], - ) -> Result, InterpreterError> { - if let Some(result) = self.call_math_builtin(name, args)? { - return Ok(Some(result)); - } - - if let Some(result) = self.call_string_builtin(name, args)? { - return Ok(Some(result)); - } - - if let Some(result) = self.call_array_builtin(name, args)? { - return Ok(Some(result)); - } - - if let Some(result) = self.call_core_builtin(name, args)? { - return Ok(Some(result)); - } - - if let Some(result) = self.call_file_builtin(name, args)? { - return Ok(Some(result)); - } - - if let Some(result) = self.call_hashing_builtin(name, args)? { - return Ok(Some(result)); - } - - if let Some(result) = self.call_statistics_builtin(name, args)? { - return Ok(Some(result)); - } - - if let Some(result) = self.call_system_builtin(name, args)? { - return Ok(Some(result)); - } - - if let Some(result) = self.call_time_builtin(name, args)? { - return Ok(Some(result)); - } - - if let Some(result) = self.call_validation_builtin(name, args)? { - return Ok(Some(result)); - } - - Ok(None) - } - - fn call_math_builtin( - &self, - name: &str, - args: &[Value], - ) -> Result, InterpreterError> { - let result = match name { - "Sin" => Some(Value::Number(MathBuiltins::sin( - self.number_arg(args, 0, name)?, - ))), - "Cos" => Some(Value::Number(MathBuiltins::cos( - self.number_arg(args, 0, name)?, - ))), - "Tan" => Some(Value::Number(MathBuiltins::tan( - self.number_arg(args, 0, name)?, - ))), - "Sqrt" => Some(Value::Number(MathBuiltins::sqrt( - self.number_arg(args, 0, name)?, - ))), - "Log" => Some(Value::Number(MathBuiltins::log( - self.number_arg(args, 0, name)?, - ))), - "Log10" => Some(Value::Number(MathBuiltins::log10( - self.number_arg(args, 0, name)?, - ))), - "Abs" => Some(Value::Number(MathBuiltins::abs( - self.number_arg(args, 0, name)?, - ))), - "Floor" => Some(Value::Number(MathBuiltins::floor( - self.number_arg(args, 0, name)?, - ))), - "Ceil" => Some(Value::Number(MathBuiltins::ceil( - self.number_arg(args, 0, name)?, - ))), - "Round" => Some(Value::Number(MathBuiltins::round( - self.number_arg(args, 0, name)?, - ))), - "Min" => Some(Value::Number(MathBuiltins::min( - self.number_arg(args, 0, name)?, - self.number_arg(args, 1, name)?, - ))), - "Max" => Some(Value::Number(MathBuiltins::max( - self.number_arg(args, 0, name)?, - self.number_arg(args, 1, name)?, - ))), - "Pow" => Some(Value::Number(MathBuiltins::pow( - self.number_arg(args, 0, name)?, - self.number_arg(args, 1, name)?, - ))), - "Factorial" => Some(Value::Number(MathBuiltins::factorial( - self.integer_arg(args, 0, name)?, - ) as f64)), - "Gcd" => Some(Value::Number(MathBuiltins::gcd( - self.integer_arg(args, 0, name)?, - self.integer_arg(args, 1, name)?, - ) as f64)), - "Lcm" => Some(Value::Number(MathBuiltins::lcm( - self.integer_arg(args, 0, name)?, - self.integer_arg(args, 1, name)?, - ) as f64)), - "IsPrime" => Some(Value::Boolean(MathBuiltins::is_prime( - self.integer_arg(args, 0, name)?, - ))), - "Fibonacci" => Some(Value::Number(MathBuiltins::fibonacci( - self.integer_arg(args, 0, name)?, - ) as f64)), - "Clamp" => Some(Value::Number(MathBuiltins::clamp( - self.number_arg(args, 0, name)?, - self.number_arg(args, 1, name)?, - self.number_arg(args, 2, name)?, - ))), - _ => None, - }; - - Ok(result) - } - - fn call_string_builtin( - &self, - name: &str, - args: &[Value], - ) -> Result, InterpreterError> { - let result = match name { - "Length" => { - // Length works for both strings and arrays - if args.is_empty() { - return Err(InterpreterError::Runtime(format!( - "Function '{}' requires at least 1 argument", - name - ))); - } - match &args[0] { - Value::String(s) => Some(Value::Number(s.len() as f64)), - Value::Array(arr) => Some(Value::Number(arr.len() as f64)), - _ => { - return Err(InterpreterError::TypeError(format!( - "Function 'Length' expects string or array argument, got {}", - args[0] - ))); - } - } - } - "ToUpper" => Some(Value::String(StringBuiltins::to_upper( - &self.string_arg(args, 0, name)?, - ))), - "ToLower" => Some(Value::String(StringBuiltins::to_lower( - &self.string_arg(args, 0, name)?, - ))), - "Trim" => Some(Value::String(StringBuiltins::trim( - &self.string_arg(args, 0, name)?, - ))), - "IndexOf" => Some(Value::Number(StringBuiltins::index_of( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ) as f64)), - "Replace" => Some(Value::String(StringBuiltins::replace( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - &self.string_arg(args, 2, name)?, - ))), - "Reverse" => Some(Value::String(StringBuiltins::reverse( - &self.string_arg(args, 0, name)?, - ))), - "Capitalize" => Some(Value::String(StringBuiltins::capitalize( - &self.string_arg(args, 0, name)?, - ))), - "StartsWith" => Some(Value::Boolean(StringBuiltins::starts_with( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ))), - "EndsWith" => Some(Value::Boolean(StringBuiltins::ends_with( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ))), - "Contains" => Some(Value::Boolean(StringBuiltins::contains( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ))), - "Split" => { - let items = StringBuiltins::split( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ) - .into_iter() - .map(Value::String) - .collect(); - Some(Value::Array(items)) - } - "Substring" => Some(Value::String(StringBuiltins::substring( - &self.string_arg(args, 0, name)?, - self.usize_arg(args, 1, name)?, - self.usize_arg(args, 2, name)?, - ))), - "Repeat" => Some(Value::String(StringBuiltins::repeat( - &self.string_arg(args, 0, name)?, - self.usize_arg(args, 1, name)?, - ))), - "PadLeft" => Some(Value::String(StringBuiltins::pad_left( - &self.string_arg(args, 0, name)?, - self.usize_arg(args, 1, name)?, - self.char_arg(args, 2, name)?, - ))), - "PadRight" => Some(Value::String(StringBuiltins::pad_right( - &self.string_arg(args, 0, name)?, - self.usize_arg(args, 1, name)?, - self.char_arg(args, 2, name)?, - ))), - "IsEmpty" => Some(Value::Boolean(StringBuiltins::is_empty( - &self.string_arg(args, 0, name)?, - ))), - "IsWhitespace" => Some(Value::Boolean(StringBuiltins::is_whitespace( - &self.string_arg(args, 0, name)?, - ))), - _ => None, - }; - - Ok(result) - } - - fn call_array_builtin( - &self, - name: &str, - args: &[Value], - ) -> Result, InterpreterError> { - let result = match name { - "ArrayLength" => { - let array = self.array_arg(args, 0, name)?; - Some(Value::Number(ArrayBuiltins::length(&array) as f64)) - } - "ArrayIsEmpty" => { - let array = self.array_arg(args, 0, name)?; - Some(Value::Boolean(ArrayBuiltins::is_empty(&array))) - } - "ArrayGet" => { - let array = self.array_arg(args, 0, name)?; - let index = self.usize_arg(args, 1, name)?; - let value = ArrayBuiltins::get(&array, index).unwrap_or(Value::Null); - Some(value) - } - "ArrayIndexOf" => { - let array = self.array_arg(args, 0, name)?; - let target = self.arg(args, 1, name)?.clone(); - Some(Value::Number( - ArrayBuiltins::index_of(&array, &target) as f64 - )) - } - "ArrayContains" => { - let array = self.array_arg(args, 0, name)?; - let target = self.arg(args, 1, name)?.clone(); - Some(Value::Boolean(ArrayBuiltins::contains(&array, &target))) - } - "ArrayReverse" => { - let array = self.array_arg(args, 0, name)?; - Some(Value::Array(ArrayBuiltins::reverse(&array))) - } - "ArraySum" => { - let array = self.array_arg(args, 0, name)?; - let numbers = self.values_to_numbers(&array, name)?; - Some(Value::Number(ArrayBuiltins::sum(&numbers))) - } - "ArrayAverage" => { - let array = self.array_arg(args, 0, name)?; - let numbers = self.values_to_numbers(&array, name)?; - Some(Value::Number(ArrayBuiltins::average(&numbers))) - } - "ArrayMin" => { - let array = self.array_arg(args, 0, name)?; - let numbers = self.values_to_numbers(&array, name)?; - Some(Value::Number(ArrayBuiltins::min(&numbers))) - } - "ArrayMax" => { - let array = self.array_arg(args, 0, name)?; - let numbers = self.values_to_numbers(&array, name)?; - Some(Value::Number(ArrayBuiltins::max(&numbers))) - } - "ArraySort" => { - let array = self.array_arg(args, 0, name)?; - let numbers = self.values_to_numbers(&array, name)?; - let sorted = ArrayBuiltins::sort(&numbers) - .into_iter() - .map(Value::Number) - .collect(); - Some(Value::Array(sorted)) - } - "ArrayFirst" => { - let array = self.array_arg(args, 0, name)?; - Some(ArrayBuiltins::first(&array).unwrap_or(Value::Null)) - } - "ArrayLast" => { - let array = self.array_arg(args, 0, name)?; - Some(ArrayBuiltins::last(&array).unwrap_or(Value::Null)) - } - "ArrayTake" => { - let array = self.array_arg(args, 0, name)?; - let count = self.usize_arg(args, 1, name)?; - Some(Value::Array(ArrayBuiltins::take(&array, count))) - } - "ArraySkip" => { - let array = self.array_arg(args, 0, name)?; - let count = self.usize_arg(args, 1, name)?; - Some(Value::Array(ArrayBuiltins::skip(&array, count))) - } - "ArraySlice" => { - let array = self.array_arg(args, 0, name)?; - let start = self.usize_arg(args, 1, name)?; - let end = self.usize_arg(args, 2, name)?; - Some(Value::Array(ArrayBuiltins::slice(&array, start, end))) - } - "ArrayJoin" => { - let array = self.array_arg(args, 0, name)?; - let separator = self.string_arg(args, 1, name)?; - Some(Value::String(ArrayBuiltins::join(&array, &separator))) - } - "ArrayCount" => { - let array = self.array_arg(args, 0, name)?; - let target = self.arg(args, 1, name)?.clone(); - Some(Value::Number(ArrayBuiltins::count(&array, &target) as f64)) - } - "ArrayDistinct" => { - let array = self.array_arg(args, 0, name)?; - Some(Value::Array(ArrayBuiltins::distinct(&array))) - } - _ => None, - }; - - Ok(result) - } - - fn call_core_builtin( - &self, - name: &str, - args: &[Value], - ) -> Result, InterpreterError> { - let result = match name { - "Observe" => { - let message = self.arg(args, 0, name)?.to_string(); - CoreBuiltins::observe(&message); - Some(Value::Null) - } - "Drift" => { - let duration = self.number_arg(args, 0, name)?; - CoreBuiltins::drift(duration.max(0.0) as u64); - Some(Value::Null) - } - "DeepTrance" => { - let duration = self.number_arg(args, 0, name)?; - CoreBuiltins::deep_trance(duration.max(0.0) as u64); - Some(Value::Null) - } - "HypnoticCountdown" => { - CoreBuiltins::hypnotic_countdown(self.integer_arg(args, 0, name)?); - Some(Value::Null) - } - "TranceInduction" => { - CoreBuiltins::trance_induction(&self.string_arg(args, 0, name)?); - Some(Value::Null) - } - "HypnoticVisualization" => { - CoreBuiltins::hypnotic_visualization(&self.string_arg(args, 0, name)?); - Some(Value::Null) - } - "ToInt" => Some(Value::Number( - CoreBuiltins::to_int(self.number_arg(args, 0, name)?) as f64, - )), - "ToDouble" => Some(Value::Number( - CoreBuiltins::to_double(&self.string_arg(args, 0, name)?) - .map_err(InterpreterError::Runtime)?, - )), - "ToString" => Some(Value::String( - args.first() - .map(|v| v.to_string()) - .unwrap_or_else(|| "null".to_string()), - )), - "ToBoolean" => Some(Value::Boolean(CoreBuiltins::to_boolean( - &self.string_arg(args, 0, name)?, - ))), - _ => None, - }; - - Ok(result) - } - - fn call_file_builtin( - &self, - name: &str, - args: &[Value], - ) -> Result, InterpreterError> { - let result = match name { - "ReadFile" => Some(Value::String( - FileBuiltins::read_file(&self.string_arg(args, 0, name)?) - .map_err(|e| InterpreterError::Runtime(e.to_string()))?, - )), - "WriteFile" => { - FileBuiltins::write_file( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ) - .map_err(|e| InterpreterError::Runtime(e.to_string()))?; - Some(Value::Null) - } - "AppendFile" => { - FileBuiltins::append_file( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ) - .map_err(|e| InterpreterError::Runtime(e.to_string()))?; - Some(Value::Null) - } - "FileExists" => Some(Value::Boolean(FileBuiltins::file_exists( - &self.string_arg(args, 0, name)?, - ))), - "IsFile" => Some(Value::Boolean(FileBuiltins::is_file( - &self.string_arg(args, 0, name)?, - ))), - "IsDirectory" => Some(Value::Boolean(FileBuiltins::is_directory( - &self.string_arg(args, 0, name)?, - ))), - "DeleteFile" => { - FileBuiltins::delete_file(&self.string_arg(args, 0, name)?) - .map_err(|e| InterpreterError::Runtime(e.to_string()))?; - Some(Value::Null) - } - "CreateDirectory" => { - FileBuiltins::create_directory(&self.string_arg(args, 0, name)?) - .map_err(|e| InterpreterError::Runtime(e.to_string()))?; - Some(Value::Null) - } - "ListDirectory" => { - let files = FileBuiltins::list_directory(&self.string_arg(args, 0, name)?) - .map_err(|e| InterpreterError::Runtime(e.to_string()))? - .into_iter() - .map(Value::String) - .collect(); - Some(Value::Array(files)) - } - "GetFileSize" => Some(Value::Number( - FileBuiltins::get_file_size(&self.string_arg(args, 0, name)?) - .map_err(|e| InterpreterError::Runtime(e.to_string()))? as f64, - )), - "CopyFile" => Some(Value::Number( - FileBuiltins::copy_file( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ) - .map_err(|e| InterpreterError::Runtime(e.to_string()))? as f64, - )), - "RenameFile" => { - FileBuiltins::rename_file( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ) - .map_err(|e| InterpreterError::Runtime(e.to_string()))?; - Some(Value::Null) - } - "GetFileExtension" => Some(self.option_string_to_value( - FileBuiltins::get_file_extension(&self.string_arg(args, 0, name)?), - )), - "GetFileName" => Some(self.option_string_to_value(FileBuiltins::get_file_name( - &self.string_arg(args, 0, name)?, - ))), - "GetParentDirectory" => Some(self.option_string_to_value( - FileBuiltins::get_parent_directory(&self.string_arg(args, 0, name)?), - )), - _ => None, - }; - - Ok(result) - } - - fn call_hashing_builtin( - &self, - name: &str, - args: &[Value], - ) -> Result, InterpreterError> { - let result = match name { - "HashString" => Some(Value::Number(HashingBuiltins::hash_string( - &self.string_arg(args, 0, name)?, - ) as f64)), - "HashNumber" => Some(Value::Number(HashingBuiltins::hash_number( - self.number_arg(args, 0, name)?, - ) as f64)), - "SimpleRandom" => Some(Value::Number(HashingBuiltins::simple_random( - self.u64_arg(args, 0, name)?, - ) as f64)), - "AreAnagrams" => Some(Value::Boolean(HashingBuiltins::are_anagrams( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ))), - "IsPalindrome" => Some(Value::Boolean(HashingBuiltins::is_palindrome( - &self.string_arg(args, 0, name)?, - ))), - "CountOccurrences" => Some(Value::Number(HashingBuiltins::count_occurrences( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ) as f64)), - "RemoveDuplicates" => Some(Value::String(HashingBuiltins::remove_duplicates( - &self.string_arg(args, 0, name)?, - ))), - "UniqueCharacters" => Some(Value::String(HashingBuiltins::unique_characters( - &self.string_arg(args, 0, name)?, - ))), - "ReverseWords" => Some(Value::String(HashingBuiltins::reverse_words( - &self.string_arg(args, 0, name)?, - ))), - "TitleCase" => Some(Value::String(HashingBuiltins::title_case( - &self.string_arg(args, 0, name)?, - ))), - _ => None, - }; - - Ok(result) - } - - fn call_statistics_builtin( - &self, - name: &str, - args: &[Value], - ) -> Result, InterpreterError> { - let numbers_primary = |this: &Self| -> Result, InterpreterError> { - let array = this.array_arg(args, 0, name)?; - this.values_to_numbers(&array, name) - }; - - let result = match name { - "Mean" => Some(Value::Number(StatisticsBuiltins::calculate_mean( - &numbers_primary(self)?, - ))), - "Median" => Some(Value::Number(StatisticsBuiltins::calculate_median( - &numbers_primary(self)?, - ))), - "Mode" => Some(Value::Number(StatisticsBuiltins::calculate_mode( - &numbers_primary(self)?, - ))), - "StandardDeviation" => Some(Value::Number( - StatisticsBuiltins::calculate_standard_deviation(&numbers_primary(self)?), - )), - "Variance" => Some(Value::Number(StatisticsBuiltins::calculate_variance( - &numbers_primary(self)?, - ))), - "Range" => Some(Value::Number(StatisticsBuiltins::calculate_range( - &numbers_primary(self)?, - ))), - "Percentile" => Some(Value::Number(StatisticsBuiltins::calculate_percentile( - &numbers_primary(self)?, - self.number_arg(args, 1, name)?, - ))), - "Correlation" => { - let x = self.values_to_numbers(&self.array_arg(args, 0, name)?, name)?; - let y = self.values_to_numbers(&self.array_arg(args, 1, name)?, name)?; - Some(Value::Number(StatisticsBuiltins::calculate_correlation( - &x, &y, - ))) - } - "LinearRegression" => { - let x = self.values_to_numbers(&self.array_arg(args, 0, name)?, name)?; - let y = self.values_to_numbers(&self.array_arg(args, 1, name)?, name)?; - let (slope, intercept) = StatisticsBuiltins::linear_regression(&x, &y); - Some(Value::Array(vec![ - Value::Number(slope), - Value::Number(intercept), - ])) - } - _ => None, - }; - - Ok(result) - } - - fn call_system_builtin( - &self, - name: &str, - args: &[Value], - ) -> Result, InterpreterError> { - let result = match name { - "GetCurrentDirectory" => Some(Value::String(SystemBuiltins::get_current_directory())), - "GetEnv" => Some(self.option_string_to_value(SystemBuiltins::get_env_var( - &self.string_arg(args, 0, name)?, - ))), - "SetEnv" => { - SystemBuiltins::set_env_var( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ) - .map_err(InterpreterError::Runtime)?; - Some(Value::Null) - } - "GetOperatingSystem" => Some(Value::String(SystemBuiltins::get_operating_system())), - "GetArchitecture" => Some(Value::String(SystemBuiltins::get_architecture())), - "GetCpuCount" => Some(Value::Number(SystemBuiltins::get_cpu_count() as f64)), - "GetHostname" => Some(Value::String(SystemBuiltins::get_hostname())), - "GetUsername" => Some(Value::String(SystemBuiltins::get_username())), - "GetHomeDirectory" => Some(Value::String(SystemBuiltins::get_home_directory())), - "GetTempDirectory" => Some(Value::String(SystemBuiltins::get_temp_directory())), - "GetArgs" => Some(Value::Array( - SystemBuiltins::get_args() - .into_iter() - .map(Value::String) - .collect(), - )), - "Exit" => { - // Exit mirrors the legacy runtime behavior by terminating the host process immediately. - SystemBuiltins::exit(self.integer_arg(args, 0, name)? as i32); - } - _ => None, - }; - - Ok(result) - } - - fn call_time_builtin( - &self, - name: &str, - args: &[Value], - ) -> Result, InterpreterError> { - let result = match name { - "CurrentTimestamp" => Some(Value::Number(TimeBuiltins::get_current_time() as f64)), - "CurrentDate" => Some(Value::String(TimeBuiltins::get_current_date())), - "CurrentTime" => Some(Value::String(TimeBuiltins::get_current_time_string())), - "CurrentDateTime" => Some(Value::String(TimeBuiltins::get_current_date_time())), - "FormatDateTime" => Some(Value::String(TimeBuiltins::format_date_time( - &self.string_arg(args, 0, name)?, - ))), - "DayOfWeek" => Some(Value::Number(TimeBuiltins::get_day_of_week() as f64)), - "DayOfYear" => Some(Value::Number(TimeBuiltins::get_day_of_year() as f64)), - "IsLeapYear" => Some(Value::Boolean(TimeBuiltins::is_leap_year( - self.integer_arg(args, 0, name)? as i32, - ))), - "DaysInMonth" => Some(self.option_u32_to_value(TimeBuiltins::get_days_in_month( - self.integer_arg(args, 0, name)? as i32, - self.usize_arg(args, 1, name)? as u32, - ))), - "CurrentYear" => Some(Value::Number(TimeBuiltins::get_year() as f64)), - "CurrentMonth" => Some(Value::Number(TimeBuiltins::get_month() as f64)), - "CurrentDay" => Some(Value::Number(TimeBuiltins::get_day() as f64)), - "CurrentHour" => Some(Value::Number(TimeBuiltins::get_hour() as f64)), - "CurrentMinute" => Some(Value::Number(TimeBuiltins::get_minute() as f64)), - "CurrentSecond" => Some(Value::Number(TimeBuiltins::get_second() as f64)), - _ => None, - }; - - Ok(result) - } - - fn call_validation_builtin( - &self, - name: &str, - args: &[Value], - ) -> Result, InterpreterError> { - let result = match name { - "IsValidEmail" => Some(Value::Boolean(ValidationBuiltins::is_valid_email( - &self.string_arg(args, 0, name)?, - ))), - "IsValidUrl" => Some(Value::Boolean(ValidationBuiltins::is_valid_url( - &self.string_arg(args, 0, name)?, - ))), - "IsValidPhoneNumber" => Some(Value::Boolean( - ValidationBuiltins::is_valid_phone_number(&self.string_arg(args, 0, name)?), - )), - "IsAlphanumeric" => Some(Value::Boolean(ValidationBuiltins::is_alphanumeric( - &self.string_arg(args, 0, name)?, - ))), - "IsAlphabetic" => Some(Value::Boolean(ValidationBuiltins::is_alphabetic( - &self.string_arg(args, 0, name)?, - ))), - "IsNumeric" => Some(Value::Boolean(ValidationBuiltins::is_numeric( - &self.string_arg(args, 0, name)?, - ))), - "IsLowercase" => Some(Value::Boolean(ValidationBuiltins::is_lowercase( - &self.string_arg(args, 0, name)?, - ))), - "IsUppercase" => Some(Value::Boolean(ValidationBuiltins::is_uppercase( - &self.string_arg(args, 0, name)?, - ))), - "IsInRange" => Some(Value::Boolean(ValidationBuiltins::is_in_range( - self.number_arg(args, 0, name)?, - self.number_arg(args, 1, name)?, - self.number_arg(args, 2, name)?, - ))), - "MatchesPattern" => Some(Value::Boolean(ValidationBuiltins::matches_pattern( - &self.string_arg(args, 0, name)?, - &self.string_arg(args, 1, name)?, - ))), - _ => None, - }; - - Ok(result) - } - - fn arg<'a>( - &self, - args: &'a [Value], - index: usize, - name: &str, - ) -> Result<&'a Value, InterpreterError> { - args.get(index).ok_or_else(|| { - InterpreterError::Runtime(format!( - "Builtin '{}' expected argument at position {}", - name, - index + 1 - )) - }) - } - - fn number_arg( - &self, - args: &[Value], - index: usize, - name: &str, - ) -> Result { - self.arg(args, index, name)?.to_number().map_err(|_| { - InterpreterError::TypeError(format!( - "Builtin '{}' expected numeric argument at position {}", - name, - index + 1 - )) - }) - } - - fn integer_arg( - &self, - args: &[Value], - index: usize, - name: &str, - ) -> Result { - let value = self.number_arg(args, index, name)?; - Ok(value.round() as i64) - } - - fn u64_arg(&self, args: &[Value], index: usize, name: &str) -> Result { - let value = self.number_arg(args, index, name)?; - if value < 0.0 { - return Err(InterpreterError::TypeError(format!( - "Builtin '{}' expected non-negative number at position {}", - name, - index + 1 - ))); - } - Ok(value.round() as u64) - } + "Session instance of '{}' has no field '{}'", + definition.name(), + property + ), + &format!( + "Session instance of '{}' has no field '{}'", + definition.name(), + property + ), + ))) + } + Value::Session(session_rc) => { + if let Some(static_field) = session_rc.get_static_field_snapshot(property) { + self.ensure_visibility( + static_field.definition.visibility, + session_rc.name(), + "field", + property, + )?; + session_rc.set_static_field_value(property, value)?; + return Ok(()); + } - fn usize_arg( - &self, - args: &[Value], - index: usize, - name: &str, - ) -> Result { - let value = self.number_arg(args, index, name)?; - if value < 0.0 { - return Err(InterpreterError::TypeError(format!( - "Builtin '{}' expected non-negative number at position {}", - name, - index + 1 - ))); - } - Ok(value.round() as usize) - } + if session_rc.get_static_method_definition(property).is_some() { + return Err(InterpreterError::Runtime(localized( + &format!("Cannot assign to static method '{}'", property), + &format!("Cannot assign to static method '{}'", property), + ))); + } - fn string_arg( - &self, - args: &[Value], - index: usize, - name: &str, - ) -> Result { - match self.arg(args, index, name)? { - Value::String(s) => Ok(s.clone()), - other => Err(InterpreterError::TypeError(format!( - "Builtin '{}' expected string argument at position {}, got {:?}", - name, - index + 1, - other + Err(InterpreterError::Runtime(localized( + &format!( + "Session '{}' has no static field '{}'", + session_rc.name(), + property + ), + &format!( + "Session '{}' has no static field '{}'", + session_rc.name(), + property + ), + ))) + } + _ => Err(InterpreterError::Runtime(localized( + "Assignment target is not a session member", + "Assignment target is not a session member", ))), } } - fn char_arg(&self, args: &[Value], index: usize, name: &str) -> Result { - let text = self.string_arg(args, index, name)?; - text.chars().next().ok_or_else(|| { - InterpreterError::TypeError(format!( - "Builtin '{}' expected non-empty string to derive character at position {}", - name, - index + 1 - )) - }) - } - - fn array_arg( + fn ensure_visibility( &self, - args: &[Value], - index: usize, - name: &str, - ) -> Result, InterpreterError> { - match self.arg(args, index, name)? { - Value::Array(items) => Ok(items.clone()), - other => Err(InterpreterError::TypeError(format!( - "Builtin '{}' expected array argument at position {}, got {:?}", - name, - index + 1, - other - ))), + visibility: SessionVisibility, + session_name: &str, + member_kind: &str, + member_name: &str, + ) -> Result<(), InterpreterError> { + if visibility == SessionVisibility::Private && !self.is_access_allowed(session_name) { + return Err(InterpreterError::Runtime(localized( + &format!( + "Access denied to private {} '{}' of session '{}'", + member_kind, member_name, session_name + ), + &format!( + "Access denied to private {} '{}' of session '{}'", + member_kind, member_name, session_name + ), + ))); } + Ok(()) } - fn option_string_to_value(&self, input: Option) -> Value { - input.map(Value::String).unwrap_or(Value::Null) - } - - fn option_u32_to_value(&self, input: Option) -> Value { - input - .map(|v| Value::Number(v as f64)) - .unwrap_or(Value::Null) - } - - fn values_to_numbers( - &self, - values: &[Value], - name: &str, - ) -> Result, InterpreterError> { - values + fn is_access_allowed(&self, session_name: &str) -> bool { + self.execution_context .iter() - .enumerate() - .map(|(i, value)| { - value.to_number().map_err(|_| { - InterpreterError::TypeError(format!( - "Builtin '{}' expected numeric array element at position {}", - name, - i + 1 - )) - }) - }) - .collect() + .rev() + .find_map(|frame| frame.session_name.as_deref()) + == Some(session_name) } - fn push_scope(&mut self) { self.locals.push(HashMap::new()); self.const_locals.push(HashSet::new()); @@ -3247,7 +1839,7 @@ impl Interpreter { } } - for idx in (0..self.locals.len()).rev() { + for idx in (self.current_barrier()..self.locals.len()).rev() { if self.locals[idx].contains_key(&name) { let is_const = self .const_locals @@ -3282,8 +1874,7 @@ impl Interpreter { } fn resolve_assignment_scope(&self, name: &str) -> ScopeLayer { - if self - .locals + if self.locals[self.current_barrier()..] .iter() .rev() .any(|scope| scope.contains_key(name)) @@ -3298,9 +1889,28 @@ impl Interpreter { } } + /// Index of the first local scope belonging to the current function + /// activation. Lookups never cross this barrier (lexical scoping). + fn current_barrier(&self) -> usize { + self.scope_barriers.last().copied().unwrap_or(0) + } + + /// Snapshot of all variables visible in the current activation, + /// innermost bindings shadowing outer ones. Used to capture the lexical + /// environment of nested function declarations (closures). + fn capture_lexical_environment(&self) -> HashMap { + let mut captured = HashMap::new(); + for scope in &self.locals[self.current_barrier()..] { + for (name, value) in scope { + captured.insert(name.clone(), value.clone()); + } + } + captured + } + fn get_variable(&self, name: &str) -> Result { - // Search in local scopes (from innermost to outermost) - for scope in self.locals.iter().rev() { + // Search visible local scopes (from innermost down to the barrier) + for scope in self.locals[self.current_barrier()..].iter().rev() { if let Some(value) = scope.get(name) { return Ok(value.clone()); } @@ -3323,6 +1933,182 @@ mod tests { use super::*; use hypnoscript_lexer_parser::{Lexer, Parser}; + /// Lexes and parses `source`, panicking with a helpful message on failure. + fn parse(source: &str) -> AstNode { + let tokens = Lexer::new(source).lex().expect("lexing failed"); + Parser::new(tokens).parse_program().expect("parsing failed") + } + + #[test] + fn test_delayed_value_promise_resolves_on_await() { + // HYPNO_TIME_SCALE=0 keeps the test instant. + unsafe { std::env::set_var("HYPNO_TIME_SCALE", "0") }; + let source = r#" +Focus { + entrance { + induce p = delayedValue(50, 42); + induce pending = isPromiseResolved(p); + induce result = await p; + induce resolved = isPromiseResolved(p); + induce all = promiseAll([delayedValue(10, 1), instantPromise(2), 3]); + induce fastest = promiseRace([delayedValue(500, "slow"), instantPromise("fast")]); + } +} Relax +"#; + let ast = parse(source); + let mut interpreter = Interpreter::new(); + interpreter.execute_program(ast).expect("program failed"); + assert_eq!( + interpreter.get_variable("pending").unwrap(), + Value::Boolean(false) + ); + assert_eq!( + interpreter.get_variable("result").unwrap(), + Value::Number(42.0) + ); + assert_eq!( + interpreter.get_variable("resolved").unwrap(), + Value::Boolean(true) + ); + assert_eq!( + interpreter.get_variable("all").unwrap(), + Value::array(vec![ + Value::Number(1.0), + Value::Number(2.0), + Value::Number(3.0) + ]) + ); + assert_eq!( + interpreter.get_variable("fastest").unwrap(), + Value::String("fast".to_string()) + ); + } + + #[test] + fn test_closure_captures_outer_variable() { + let source = r#" +Focus { + suggestion makeGreeting(name: string): string { + induce prefix: string = "Hello, "; + suggestion greet(): string { + awaken prefix + name; + } + awaken greet(); + } + entrance { + induce message: string = makeGreeting("Trance"); + } +} Relax +"#; + let ast = parse(source); + let mut interpreter = Interpreter::new(); + interpreter.execute_program(ast).expect("program failed"); + assert_eq!( + interpreter.get_variable("message").unwrap(), + Value::String("Hello, Trance".to_string()) + ); + } + + #[test] + fn test_nested_function_can_recurse() { + let source = r#" +Focus { + suggestion outer(): number { + induce base: number = 3; + suggestion sumTo(n: number): number { + if (n <= 0) deepFocus { + awaken base; + } + awaken n + sumTo(n - 1); + } + awaken sumTo(4); + } + entrance { + induce total: number = outer(); + } +} Relax +"#; + let ast = parse(source); + let mut interpreter = Interpreter::new(); + interpreter.execute_program(ast).expect("program failed"); + // 4 + 3 + 2 + 1 + base(3) = 13 + assert_eq!( + interpreter.get_variable("total").unwrap(), + Value::Number(13.0) + ); + } + + #[test] + fn test_lexical_scoping_hides_caller_locals() { + // `helper` must NOT see the caller's local `secret` (lexical, not + // dynamic scoping). + let source = r#" +Focus { + suggestion helper(): number { + awaken secret; + } + suggestion caller(): number { + induce secret: number = 99; + awaken helper(); + } + entrance { + induce x: number = caller(); + } +} Relax +"#; + let ast = parse(source); + let mut interpreter = Interpreter::new(); + match interpreter.execute_program(ast) { + Err(InterpreterError::UndefinedVariable(name)) => assert_eq!(name, "secret"), + other => panic!( + "expected UndefinedVariable('secret'), got {:?}", + other.err() + ), + } + } + + #[test] + fn test_recursion_limit_reports_error_instead_of_crashing() { + let source = r#" +Focus { + suggestion infinite(n: number): number { + awaken infinite(n + 1); + } + entrance { + infinite(0); + } +} Relax +"#; + let ast = parse(source); + let mut interpreter = Interpreter::new(); + interpreter.set_max_call_depth(64); + match interpreter.execute_program(ast) { + Err(InterpreterError::RecursionLimitExceeded(64)) => {} + other => panic!("expected RecursionLimitExceeded, got {:?}", other.err()), + } + } + + #[test] + fn test_recursion_within_limit_succeeds() { + let source = r#" +Focus { + suggestion countdown(n: number): number { + if (n <= 0) deepFocus { + awaken 0; + } + awaken countdown(n - 1); + } + entrance { + induce result: number = countdown(50); + } +} Relax +"#; + let ast = parse(source); + let mut interpreter = Interpreter::new(); + interpreter.set_max_call_depth(64); + assert!(interpreter.execute_program(ast).is_ok()); + } + #[test] fn test_simple_program() { let source = r#" @@ -3585,6 +2371,129 @@ Focus { assert_eq!(status, Value::String("Luna".to_string())); } + /// Run a program and return the interpreter for state inspection. + fn run_program(source: &str) -> Interpreter { + let mut lexer = Lexer::new(source); + let tokens = lexer.lex().unwrap(); + let mut parser = Parser::new(tokens); + let ast = parser.parse_program().unwrap(); + let mut interpreter = Interpreter::new(); + let result = interpreter.execute_program(ast); + assert!(result.is_ok(), "execution failed: {:?}", result.err()); + interpreter + } + + #[test] + fn test_labeled_break_exits_outer_loop() { + let interpreter = run_program( + r#" +Focus { + induce hits: number = 0; + entrance { + outer: loop (induce i: number = 0; i < 5; i = i + 1) { + loop (induce j: number = 0; j < 5; j = j + 1) { + hits = hits + 1; + if (hits == 3) { + snap outer; + } + } + } + } +} Relax +"#, + ); + assert_eq!( + interpreter.debug_globals().get("hits"), + Some(&Value::Number(3.0)), + "labeled break must exit both loops" + ); + } + + #[test] + fn test_labeled_continue_advances_outer_loop() { + let interpreter = run_program( + r#" +Focus { + induce outerRuns: number = 0; + induce innerRuns: number = 0; + entrance { + outer: loop (induce i: number = 0; i < 3; i = i + 1) { + outerRuns = outerRuns + 1; + loop (induce j: number = 0; j < 3; j = j + 1) { + innerRuns = innerRuns + 1; + sink outer; + } + outerRuns = outerRuns + 100; + } + } +} Relax +"#, + ); + let globals = interpreter.debug_globals(); + assert_eq!( + globals.get("outerRuns"), + Some(&Value::Number(3.0)), + "outer body after inner loop must be skipped" + ); + assert_eq!( + globals.get("innerRuns"), + Some(&Value::Number(3.0)), + "inner loop must run once per outer iteration" + ); + } + + #[test] + fn test_labeled_break_with_unknown_label_errors() { + let source = r#" +Focus { + entrance { + loop (induce i: number = 0; i < 3; i = i + 1) { + snap nowhere; + } + } +} Relax +"#; + let mut lexer = Lexer::new(source); + let tokens = lexer.lex().unwrap(); + let mut parser = Parser::new(tokens); + let ast = parser.parse_program().unwrap(); + let mut interpreter = Interpreter::new(); + let result = interpreter.execute_program(ast); + assert!(result.is_err(), "unknown label must be a runtime error"); + assert!(result.unwrap_err().to_string().contains("nowhere")); + } + + #[test] + fn test_null_literal_and_nullish_operators() { + let interpreter = run_program( + r#" +Focus { + induce value: number = 0; + induce state: string = ""; + entrance { + induce maybe: number? = null; + value = maybe lucidFallback 42; + state = entrain maybe { + when null => "empty"; + otherwise => "filled"; + }; + } +} Relax +"#, + ); + let globals = interpreter.debug_globals(); + assert_eq!( + globals.get("value"), + Some(&Value::Number(42.0)), + "lucidFallback must supply the default" + ); + assert_eq!( + globals.get("state"), + Some(&Value::String("empty".to_string())), + "null pattern must match" + ); + } + #[test] fn test_entrain_record_pattern_default_scope_cleanup() { let source = r#" diff --git a/hypnoscript-compiler/src/interpreter/session.rs b/hypnoscript-compiler/src/interpreter/session.rs new file mode 100644 index 0000000..d7d2eb6 --- /dev/null +++ b/hypnoscript-compiler/src/interpreter/session.rs @@ -0,0 +1,378 @@ +//! Session (class) definitions and instances: HypnoScript's OOP layer. + +use super::error::{InterpreterError, localized}; +use super::value::Value; +use hypnoscript_lexer_parser::ast::{AstNode, SessionVisibility}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +/// Definition of a session field (instance scope). +/// +/// Session fields represent instance-level variables in HypnoScript sessions (classes). +/// They can have visibility modifiers (`expose`/`conceal`) and optional type annotations. +/// +/// # Examples +/// +/// ```hyp +/// session Person { +/// expose name: string = "Unknown"; +/// conceal age: number = 0; +/// } +/// ``` +#[derive(Debug, Clone)] +pub(crate) struct SessionFieldDefinition { + pub(crate) name: String, + #[allow(dead_code)] + pub(crate) type_annotation: Option, + pub(crate) visibility: SessionVisibility, + pub(crate) initializer: Option, +} + +/// Definition of a session method. +/// +/// Session methods represent callable functions within HypnoScript sessions. +/// They can be: +/// - Instance methods (default) +/// - Static methods (`dominant` keyword) +/// - Constructors (special methods with `constructor` keyword) +/// +/// # Examples +/// +/// ```hyp +/// session Calculator { +/// // Constructor +/// constructor(initial: number) { +/// induce this.value = initial; +/// } +/// +/// // Instance method +/// expose suggestion add(n: number) { +/// induce this.value = this.value + n; +/// } +/// +/// // Static method +/// dominant suggestion createDefault() { +/// awaken Calculator(0); +/// } +/// } +/// ``` +#[derive(Debug, Clone)] +pub(crate) struct SessionMethodDefinition { + pub(crate) name: String, + pub(crate) parameters: Vec, + /// Shared so that binding a method to an instance is O(1) instead of + /// deep-cloning the method body AST. + pub(crate) body: Rc>, + pub(crate) visibility: SessionVisibility, + pub(crate) is_static: bool, + pub(crate) is_constructor: bool, +} + +/// Runtime data for a static field, including its initializer AST. +/// +/// Static fields are initialized once and shared across all session instances. +/// They are declared with the `dominant` keyword in HypnoScript. +/// +/// # Examples +/// +/// ```hyp +/// session Counter { +/// dominant instanceCount: number = 0; +/// +/// constructor() { +/// induce Counter.instanceCount = Counter.instanceCount + 1; +/// } +/// } +/// ``` +#[derive(Debug, Clone)] +pub(crate) struct SessionStaticField { + pub(crate) definition: SessionFieldDefinition, + pub(crate) initializer: Option, + pub(crate) value: Value, +} + +/// Stores metadata and static members for a session (class-like construct). +/// +/// Sessions are HypnoScript's OOP construct, similar to classes in other languages. +/// They support: +/// - Instance and static fields +/// - Instance and static methods +/// - Constructors +/// - Visibility modifiers (`expose`/`conceal`) +/// +/// # Examples +/// +/// ```hyp +/// session BankAccount { +/// conceal balance: number = 0; +/// dominant totalAccounts: number = 0; +/// +/// constructor(initialBalance: number) { +/// induce this.balance = initialBalance; +/// induce BankAccount.totalAccounts = BankAccount.totalAccounts + 1; +/// } +/// +/// expose suggestion deposit(amount: number) { +/// induce this.balance = this.balance + amount; +/// } +/// +/// expose suggestion getBalance() { +/// awaken this.balance; +/// } +/// +/// dominant suggestion getTotalAccounts() { +/// awaken BankAccount.totalAccounts; +/// } +/// } +/// ``` +#[derive(Debug)] +pub struct SessionDefinition { + pub(crate) name: String, + fields: HashMap, + field_order: Vec, + methods: HashMap, + static_methods: HashMap, + static_fields: RefCell>, + static_field_order: Vec, + constructor: Option, +} + +impl SessionDefinition { + pub(crate) fn new(name: String) -> Self { + Self { + name, + fields: HashMap::new(), + field_order: Vec::new(), + methods: HashMap::new(), + static_methods: HashMap::new(), + static_fields: RefCell::new(HashMap::new()), + static_field_order: Vec::new(), + constructor: None, + } + } + + pub(crate) fn name(&self) -> &str { + &self.name + } + + pub(crate) fn push_field( + &mut self, + field: SessionFieldDefinition, + ) -> Result<(), InterpreterError> { + if self.fields.contains_key(&field.name) + || self.static_fields.borrow().contains_key(&field.name) + { + return Err(InterpreterError::Runtime(localized( + &format!( + "Duplicate session field '{}' in session '{}'", + field.name, self.name + ), + &format!( + "Duplicate field '{}' in session '{}'", + field.name, self.name + ), + ))); + } + self.field_order.push(field.name.clone()); + self.fields.insert(field.name.clone(), field); + Ok(()) + } + + pub(crate) fn push_static_field( + &mut self, + field: SessionFieldDefinition, + initializer: Option, + ) -> Result<(), InterpreterError> { + if self.fields.contains_key(&field.name) + || self.static_fields.borrow().contains_key(&field.name) + { + return Err(InterpreterError::Runtime(localized( + &format!( + "Duplicate session field '{}' in session '{}'", + field.name, self.name + ), + &format!( + "Duplicate field '{}' in session '{}'", + field.name, self.name + ), + ))); + } + self.static_field_order.push(field.name.clone()); + self.static_fields.borrow_mut().insert( + field.name.clone(), + SessionStaticField { + definition: field, + initializer, + value: Value::Null, + }, + ); + Ok(()) + } + + pub(crate) fn push_method( + &mut self, + method: SessionMethodDefinition, + ) -> Result<(), InterpreterError> { + if method.is_constructor { + if self.constructor.is_some() { + return Err(InterpreterError::Runtime(localized( + &format!("Multiple constructors declared in session '{}'", self.name), + &format!("Multiple constructors declared in session '{}'", self.name), + ))); + } + self.constructor = Some(method); + return Ok(()); + } + + if method.is_static { + if self.static_methods.contains_key(&method.name) { + return Err(InterpreterError::Runtime(localized( + &format!( + "Duplicate static method '{}' in session '{}'", + method.name, self.name + ), + &format!( + "Duplicate static method '{}' in session '{}'", + method.name, self.name + ), + ))); + } + self.static_methods.insert(method.name.clone(), method); + } else { + if self.methods.contains_key(&method.name) { + return Err(InterpreterError::Runtime(localized( + &format!( + "Duplicate method '{}' in session '{}'", + method.name, self.name + ), + &format!( + "Duplicate method '{}' in session '{}'", + method.name, self.name + ), + ))); + } + self.methods.insert(method.name.clone(), method); + } + Ok(()) + } + + pub(crate) fn get_field_definition(&self, name: &str) -> Option<&SessionFieldDefinition> { + self.fields.get(name) + } + + pub(crate) fn get_method_definition(&self, name: &str) -> Option<&SessionMethodDefinition> { + self.methods.get(name) + } + + pub(crate) fn get_static_method_definition( + &self, + name: &str, + ) -> Option<&SessionMethodDefinition> { + self.static_methods.get(name) + } + + pub(crate) fn get_static_field_snapshot(&self, name: &str) -> Option { + self.static_fields.borrow().get(name).cloned() + } + + pub(crate) fn set_static_field_value( + &self, + name: &str, + value: Value, + ) -> Result<(), InterpreterError> { + let mut fields = self.static_fields.borrow_mut(); + match fields.get_mut(name) { + Some(field) => { + field.value = value; + Ok(()) + } + None => Err(InterpreterError::Runtime(localized( + &format!( + "Static field '{}' not found on session '{}'", + name, self.name + ), + &format!( + "Static field '{}' not found on session '{}'", + name, self.name + ), + ))), + } + } + + pub(crate) fn take_static_field_initializer(&self, name: &str) -> Option { + self.static_fields + .borrow() + .get(name) + .and_then(|field| field.initializer.clone()) + } + + pub(crate) fn field_order(&self) -> &[String] { + &self.field_order + } + + pub(crate) fn static_field_order(&self) -> &[String] { + &self.static_field_order + } + + pub(crate) fn constructor(&self) -> Option<&SessionMethodDefinition> { + self.constructor.as_ref() + } +} + +/// Runtime representation of a session instance. +/// +/// Each instantiated session creates a `SessionInstance` that holds: +/// - A reference to the session definition (metadata) +/// - Instance-specific field values +/// +/// # Examples +/// +/// ```hyp +/// session Person { +/// expose name: string = "Unknown"; +/// expose age: number = 0; +/// +/// constructor(n: string, a: number) { +/// induce this.name = n; +/// induce this.age = a; +/// } +/// } +/// +/// // Creates a SessionInstance +/// induce person = Person("Alice", 30); +/// ``` +#[derive(Debug)] +pub struct SessionInstance { + definition: Rc, + field_values: HashMap, +} + +impl SessionInstance { + pub(crate) fn new(definition: Rc) -> Self { + let mut field_values = HashMap::new(); + for name in definition.field_order() { + field_values.insert(name.clone(), Value::Null); + } + Self { + definition, + field_values, + } + } + + pub(crate) fn definition(&self) -> Rc { + Rc::clone(&self.definition) + } + + pub(crate) fn definition_name(&self) -> &str { + self.definition.name() + } + + pub(crate) fn get_field(&self, name: &str) -> Option { + self.field_values.get(name).cloned() + } + + pub(crate) fn set_field(&mut self, name: &str, value: Value) { + self.field_values.insert(name.to_string(), value); + } +} diff --git a/hypnoscript-compiler/src/interpreter/value.rs b/hypnoscript-compiler/src/interpreter/value.rs new file mode 100644 index 0000000..0843108 --- /dev/null +++ b/hypnoscript-compiler/src/interpreter/value.rs @@ -0,0 +1,358 @@ +//! Runtime values: primitives, functions, promises and records. + +use super::error::InterpreterError; +use super::session::{SessionDefinition, SessionInstance, SessionMethodDefinition}; +use hypnoscript_lexer_parser::ast::AstNode; +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +/// Represents a callable suggestion (function) within the interpreter. +/// +/// HypnoScript functions can be: +/// - Global suggestions (top-level functions) +/// - Session methods (instance methods) +/// - Static session methods (`dominant` keyword) +/// - Constructors (special session methods) +/// - Triggers (event-driven callbacks) +/// +/// # Examples +/// +/// ```hyp +/// // Global suggestion +/// suggestion greet(name: string) { +/// awaken "Hello, " + name; +/// } +/// +/// // Session method +/// session Calculator { +/// suggestion add(a: number, b: number) { +/// awaken a + b; +/// } +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct FunctionValue { + pub(crate) name: String, + pub(crate) parameters: Vec, + /// Shared so that cloning a function value (variable lookups, argument + /// passing) never deep-copies the body AST. + pub(crate) body: Rc>, + pub(crate) this_binding: Option>>, + pub(crate) session_name: Option, + pub(crate) is_static: bool, + pub(crate) is_constructor: bool, + /// Lexical environment captured when the function was declared inside + /// another function (closure by-value snapshot). Installed into the + /// activation scope on every call; parameters shadow captures. + pub(crate) captured: Rc>, +} + +impl FunctionValue { + /// Creates a function that captures the given lexical environment. + /// Top-level functions simply capture an empty environment. + pub(crate) fn new_closure( + name: String, + parameters: Vec, + body: Vec, + captured: HashMap, + ) -> Self { + Self { + name, + parameters, + body: Rc::new(body), + this_binding: None, + session_name: None, + is_static: false, + is_constructor: false, + captured: Rc::new(captured), + } + } + + pub(crate) fn new_session_member( + session_name: String, + method: &SessionMethodDefinition, + this_binding: Option>>, + ) -> Self { + Self { + name: format!("{}::{}", session_name, method.name), + parameters: method.parameters.clone(), + body: Rc::clone(&method.body), + this_binding, + session_name: Some(session_name), + is_static: method.is_static, + is_constructor: method.is_constructor, + captured: Rc::new(HashMap::new()), + } + } + + pub(crate) fn this_binding(&self) -> Option>> { + self.this_binding.as_ref().map(Rc::clone) + } + + pub(crate) fn session_name(&self) -> Option<&str> { + self.session_name.as_deref() + } +} + +impl PartialEq for FunctionValue { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.parameters == other.parameters + && self.body == other.body + && self.session_name == other.session_name + && self.is_static == other.is_static + && self.is_constructor == other.is_constructor + } +} + +impl Eq for FunctionValue {} + +/// Simple Promise/Future wrapper for async operations. +/// +/// Promises represent asynchronous computations in HypnoScript. +/// They are created by `mesmerize` suggestions and resolved with `await` or `surrenderTo`. +/// +/// # Examples +/// +/// ```hyp +/// // Async suggestion returns a Promise +/// mesmerize suggestion fetchData() { +/// induce data = "some data"; +/// awaken data; +/// } +/// +/// entrance { +/// induce result = await fetchData(); +/// observe result; +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct Promise { + /// The value this promise resolves to. + value: Option, + /// Whether the promise is resolved + resolved: bool, + /// Remaining simulated delay (in milliseconds) before the promise + /// resolves. `await` waits this long (honouring `HYPNO_TIME_SCALE`) + /// and then resolves the promise with `value`. + delay_ms: Option, +} + +impl Promise { + #[allow(dead_code)] + pub(crate) fn new() -> Self { + Self { + value: None, + resolved: false, + delay_ms: None, + } + } + + /// Creates an already-resolved promise. + pub(crate) fn resolve(value: Value) -> Self { + Self { + value: Some(value), + resolved: true, + delay_ms: None, + } + } + + /// Creates a promise that resolves to `value` after `delay_ms` + /// simulated milliseconds (elapsed when the promise is awaited). + pub(crate) fn delayed(delay_ms: u64, value: Value) -> Self { + Self { + value: Some(value), + resolved: false, + delay_ms: Some(delay_ms), + } + } + + pub(crate) fn is_resolved(&self) -> bool { + self.resolved + } + + /// Remaining simulated delay before this promise resolves. + pub(crate) fn pending_delay_ms(&self) -> Option { + if self.resolved { None } else { self.delay_ms } + } + + /// Marks the promise as resolved (its delay has elapsed) and returns + /// the resolved value. + pub(crate) fn mark_resolved(&mut self) -> Value { + self.resolved = true; + self.delay_ms = None; + self.value.clone().unwrap_or(Value::Null) + } +} + +/// Runtime value in HypnoScript. +/// +/// Represents all possible runtime values in the HypnoScript interpreter. +/// This includes primitives, collections, functions, sessions, and async values. +/// +/// # Variants +/// +/// - `Number(f64)` - Numeric values (e.g., `42`, `3.14`) +/// - `String(String)` - Text values (e.g., `"Hello"`) +/// - `Boolean(bool)` - Boolean values (`true`/`false`) +/// - `Array(Vec)` - Arrays (e.g., `[1, 2, 3]`) +/// - `Function(FunctionValue)` - Callable suggestions +/// - `Session(Rc)` - Session type (class constructor) +/// - `Instance(Rc>)` - Session instance +/// - `Promise(Rc>)` - Async promise from `mesmerize` +/// - `Record(RecordValue)` - Record/struct from `tranceify` +/// - `Null` - Null value +/// +/// # Examples +/// +/// ```hyp +/// induce num: number = 42; // Value::Number +/// induce text: string = "Hello"; // Value::String +/// induce flag: boolean = true; // Value::Boolean +/// induce list: number[] = [1, 2, 3]; // Value::Array +/// induce account = BankAccount(100); // Value::Instance +/// induce promise = mesmerize getData(); // Value::Promise +/// induce nothing: null = null; // Value::Null +/// ``` +#[derive(Debug, Clone)] +pub enum Value { + Number(f64), + String(String), + Boolean(bool), + /// Arrays are immutable values; sharing them via `Rc` makes cloning + /// (assignments, function arguments, ...) O(1) instead of deep copies. + Array(Rc>), + Function(FunctionValue), + Session(Rc), + Instance(Rc>), + Promise(Rc>), + /// Records are immutable values; shared via `Rc` like arrays. + Record(Rc), + Null, +} + +impl Value { + /// Creates an array value (shared, cheap to clone). + pub fn array(values: Vec) -> Self { + Value::Array(Rc::new(values)) + } + + /// Creates a record value (shared, cheap to clone). + pub fn record(record: RecordValue) -> Self { + Value::Record(Rc::new(record)) + } +} + +/// A record instance (from tranceify declarations). +/// +/// Records are user-defined structured data types in HypnoScript, +/// similar to structs in other languages. +/// +/// # Examples +/// +/// ```hyp +/// tranceify Point { +/// x: number, +/// y: number +/// } +/// +/// entrance { +/// induce p = Point { x: 10, y: 20 }; +/// observe p.x; // 10 +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct RecordValue { + pub type_name: String, + pub fields: HashMap, +} + +impl PartialEq for RecordValue { + fn eq(&self, other: &Self) -> bool { + self.type_name == other.type_name && self.fields == other.fields + } +} + +impl Eq for RecordValue {} + +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Value::Number(a), Value::Number(b)) => (a - b).abs() < f64::EPSILON, + (Value::String(a), Value::String(b)) => a == b, + (Value::Boolean(a), Value::Boolean(b)) => a == b, + (Value::Null, Value::Null) => true, + (Value::Array(a), Value::Array(b)) => a == b, + (Value::Function(fa), Value::Function(fb)) => fa == fb, + (Value::Session(sa), Value::Session(sb)) => Rc::ptr_eq(sa, sb), + (Value::Instance(ia), Value::Instance(ib)) => Rc::ptr_eq(ia, ib), + (Value::Promise(pa), Value::Promise(pb)) => Rc::ptr_eq(pa, pb), + (Value::Record(ra), Value::Record(rb)) => ra == rb, + _ => false, + } + } +} + +impl Eq for Value {} + +impl Value { + pub fn is_truthy(&self) -> bool { + match self { + Value::Boolean(b) => *b, + Value::Null => false, + Value::Number(n) => *n != 0.0, + Value::String(s) => !s.is_empty(), + Value::Array(a) => !a.is_empty(), + Value::Function(_) + | Value::Session(_) + | Value::Instance(_) + | Value::Promise(_) + | Value::Record(_) => true, + } + } + + pub fn to_number(&self) -> Result { + match self { + Value::Number(n) => Ok(*n), + Value::String(s) => s.parse::().map_err(|_| { + InterpreterError::TypeError(format!("Cannot convert '{}' to number", s)) + }), + Value::Boolean(b) => Ok(if *b { 1.0 } else { 0.0 }), + _ => Err(InterpreterError::TypeError( + "Cannot convert to number".to_string(), + )), + } + } +} + +impl std::fmt::Display for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Value::Number(n) => write!(f, "{}", n), + Value::String(s) => write!(f, "{}", s), + Value::Boolean(b) => write!(f, "{}", b), + Value::Null => write!(f, "null"), + Value::Array(arr) => { + let elements: Vec = arr.iter().map(|v| v.to_string()).collect(); + write!(f, "[{}]", elements.join(", ")) + } + Value::Function(func) => write!(f, "", func.name), + Value::Session(session) => write!(f, "", session.name()), + Value::Instance(instance) => { + let name = instance.borrow().definition_name().to_string(); + write!(f, "", name) + } + Value::Promise(promise) => { + if promise.borrow().is_resolved() { + write!(f, "") + } else { + write!(f, "") + } + } + Value::Record(record) => { + write!(f, "", record.type_name) + } + } + } +} diff --git a/hypnoscript-compiler/src/lib.rs b/hypnoscript-compiler/src/lib.rs index 921c4de..c926b81 100644 --- a/hypnoscript-compiler/src/lib.rs +++ b/hypnoscript-compiler/src/lib.rs @@ -136,6 +136,7 @@ pub mod async_builtins; pub mod async_promise; pub mod async_runtime; +pub mod builtin_registry; pub mod channel_system; pub mod debug; pub mod interpreter; diff --git a/hypnoscript-compiler/src/type_checker.rs b/hypnoscript-compiler/src/type_checker.rs index 1a1e957..77e9194 100644 --- a/hypnoscript-compiler/src/type_checker.rs +++ b/hypnoscript-compiler/src/type_checker.rs @@ -170,346 +170,18 @@ impl TypeChecker { checker } - /// Register builtin function signatures + /// Register builtin function signatures from the central registry. + /// + /// The signatures live in [`crate::builtin_registry::BUILTINS`], the + /// single source of truth shared with the CLI documentation output. fn register_builtins(&mut self) { - // Math - for name in [ - "Sin", "Cos", "Tan", "Sqrt", "Log", "Log10", "Abs", "Floor", "Ceil", "Round", - ] { - self.register_builtin(name, vec![HypnoType::number()], HypnoType::number()); - } - for name in ["Min", "Max", "Pow"] { - self.register_builtin( - name, - vec![HypnoType::number(), HypnoType::number()], - HypnoType::number(), - ); - } - for name in ["Factorial", "Gcd", "Lcm", "Fibonacci"] { - self.register_builtin(name, vec![HypnoType::number()], HypnoType::number()); - } - self.register_builtin("IsPrime", vec![HypnoType::number()], HypnoType::boolean()); - self.register_builtin( - "Clamp", - vec![ - HypnoType::number(), - HypnoType::number(), - HypnoType::number(), - ], - HypnoType::number(), - ); - - // Strings - self.register_builtin("Length", vec![HypnoType::string()], HypnoType::number()); - for name in [ - "ToUpper", - "ToLower", - "Trim", - "Reverse", - "Capitalize", - "RemoveDuplicates", - "UniqueCharacters", - "ReverseWords", - "TitleCase", - ] { - self.register_builtin(name, vec![HypnoType::string()], HypnoType::string()); - } - self.register_builtin( - "IndexOf", - vec![HypnoType::string(), HypnoType::string()], - HypnoType::number(), - ); - self.register_builtin( - "Replace", - vec![ - HypnoType::string(), - HypnoType::string(), - HypnoType::string(), - ], - HypnoType::string(), - ); - self.register_builtin( - "StartsWith", - vec![HypnoType::string(), HypnoType::string()], - HypnoType::boolean(), - ); - self.register_builtin( - "EndsWith", - vec![HypnoType::string(), HypnoType::string()], - HypnoType::boolean(), - ); - self.register_builtin( - "Contains", - vec![HypnoType::string(), HypnoType::string()], - HypnoType::boolean(), - ); - self.register_builtin( - "Split", - vec![HypnoType::string(), HypnoType::string()], - HypnoType::create_array(HypnoType::string()), - ); - self.register_builtin( - "Substring", - vec![ - HypnoType::string(), - HypnoType::number(), - HypnoType::number(), - ], - HypnoType::string(), - ); - self.register_builtin( - "Repeat", - vec![HypnoType::string(), HypnoType::number()], - HypnoType::string(), - ); - for name in ["PadLeft", "PadRight"] { + for sig in crate::builtin_registry::BUILTINS { self.register_builtin( - name, - vec![ - HypnoType::string(), - HypnoType::number(), - HypnoType::string(), - ], - HypnoType::string(), + sig.name, + sig.params.iter().map(|ty| ty.to_hypno_type()).collect(), + sig.returns.to_hypno_type(), ); } - self.register_builtin("IsEmpty", vec![HypnoType::string()], HypnoType::boolean()); - self.register_builtin( - "IsWhitespace", - vec![HypnoType::string()], - HypnoType::boolean(), - ); - - // Arrays - let any_array = || HypnoType::create_array(HypnoType::unknown()); - let number_array = || HypnoType::create_array(HypnoType::number()); - let string_array = || HypnoType::create_array(HypnoType::string()); - - self.register_builtin("ArrayLength", vec![any_array()], HypnoType::number()); - self.register_builtin("ArrayIsEmpty", vec![any_array()], HypnoType::boolean()); - self.register_builtin( - "ArrayGet", - vec![any_array(), HypnoType::number()], - HypnoType::unknown(), - ); - self.register_builtin( - "ArrayIndexOf", - vec![any_array(), HypnoType::unknown()], - HypnoType::number(), - ); - self.register_builtin( - "ArrayContains", - vec![any_array(), HypnoType::unknown()], - HypnoType::boolean(), - ); - self.register_builtin("ArrayReverse", vec![any_array()], any_array()); - for name in ["ArraySum", "ArrayAverage", "ArrayMin", "ArrayMax"] { - self.register_builtin(name, vec![number_array()], HypnoType::number()); - } - self.register_builtin("ArraySort", vec![number_array()], number_array()); - for name in ["ArrayFirst", "ArrayLast"] { - self.register_builtin(name, vec![any_array()], HypnoType::unknown()); - } - for name in ["ArrayTake", "ArraySkip"] { - self.register_builtin(name, vec![any_array(), HypnoType::number()], any_array()); - } - self.register_builtin( - "ArraySlice", - vec![any_array(), HypnoType::number(), HypnoType::number()], - any_array(), - ); - self.register_builtin( - "ArrayJoin", - vec![any_array(), HypnoType::string()], - HypnoType::string(), - ); - self.register_builtin( - "ArrayCount", - vec![any_array(), HypnoType::unknown()], - HypnoType::number(), - ); - self.register_builtin("ArrayDistinct", vec![any_array()], any_array()); - - // Core / Hypnotic - self.register_builtin("Observe", vec![HypnoType::unknown()], HypnoType::unknown()); - for name in ["Drift", "DeepTrance", "HypnoticCountdown"] { - self.register_builtin(name, vec![HypnoType::number()], HypnoType::unknown()); - } - self.register_builtin( - "TranceInduction", - vec![HypnoType::string()], - HypnoType::unknown(), - ); - self.register_builtin( - "HypnoticVisualization", - vec![HypnoType::string()], - HypnoType::unknown(), - ); - self.register_builtin("ToInt", vec![HypnoType::number()], HypnoType::number()); - self.register_builtin("ToDouble", vec![HypnoType::string()], HypnoType::number()); - self.register_builtin("ToString", vec![HypnoType::unknown()], HypnoType::string()); - self.register_builtin("ToBoolean", vec![HypnoType::string()], HypnoType::boolean()); - - // File / IO - self.register_builtin("ReadFile", vec![HypnoType::string()], HypnoType::string()); - for name in ["WriteFile", "AppendFile"] { - self.register_builtin( - name, - vec![HypnoType::string(), HypnoType::string()], - HypnoType::unknown(), - ); - } - for name in ["DeleteFile", "CreateDirectory"] { - self.register_builtin(name, vec![HypnoType::string()], HypnoType::unknown()); - } - for name in ["FileExists", "IsFile", "IsDirectory"] { - self.register_builtin(name, vec![HypnoType::string()], HypnoType::boolean()); - } - self.register_builtin("ListDirectory", vec![HypnoType::string()], string_array()); - self.register_builtin( - "GetFileSize", - vec![HypnoType::string()], - HypnoType::number(), - ); - self.register_builtin( - "CopyFile", - vec![HypnoType::string(), HypnoType::string()], - HypnoType::number(), - ); - self.register_builtin( - "RenameFile", - vec![HypnoType::string(), HypnoType::string()], - HypnoType::unknown(), - ); - for name in ["GetFileExtension", "GetFileName", "GetParentDirectory"] { - self.register_builtin(name, vec![HypnoType::string()], HypnoType::string()); - } - - // Hashing / Utility - self.register_builtin("HashString", vec![HypnoType::string()], HypnoType::number()); - self.register_builtin("HashNumber", vec![HypnoType::number()], HypnoType::number()); - self.register_builtin( - "SimpleRandom", - vec![HypnoType::number()], - HypnoType::number(), - ); - self.register_builtin( - "AreAnagrams", - vec![HypnoType::string(), HypnoType::string()], - HypnoType::boolean(), - ); - self.register_builtin( - "IsPalindrome", - vec![HypnoType::string()], - HypnoType::boolean(), - ); - self.register_builtin( - "CountOccurrences", - vec![HypnoType::string(), HypnoType::string()], - HypnoType::number(), - ); - - // Statistics - for name in [ - "Mean", - "Median", - "Mode", - "StandardDeviation", - "Variance", - "Range", - ] { - self.register_builtin(name, vec![number_array()], HypnoType::number()); - } - self.register_builtin( - "Percentile", - vec![number_array(), HypnoType::number()], - HypnoType::number(), - ); - self.register_builtin( - "Correlation", - vec![number_array(), number_array()], - HypnoType::number(), - ); - self.register_builtin( - "LinearRegression", - vec![number_array(), number_array()], - number_array(), - ); - - // System - self.register_builtin("GetCurrentDirectory", vec![], HypnoType::string()); - self.register_builtin("GetEnv", vec![HypnoType::string()], HypnoType::string()); - self.register_builtin( - "SetEnv", - vec![HypnoType::string(), HypnoType::string()], - HypnoType::unknown(), - ); - self.register_builtin("GetOperatingSystem", vec![], HypnoType::string()); - self.register_builtin("GetArchitecture", vec![], HypnoType::string()); - self.register_builtin("GetCpuCount", vec![], HypnoType::number()); - self.register_builtin("GetHostname", vec![], HypnoType::string()); - self.register_builtin("GetUsername", vec![], HypnoType::string()); - self.register_builtin("GetHomeDirectory", vec![], HypnoType::string()); - self.register_builtin("GetTempDirectory", vec![], HypnoType::string()); - self.register_builtin("GetArgs", vec![], string_array()); - self.register_builtin("Exit", vec![HypnoType::number()], HypnoType::unknown()); - - // Time / Date - self.register_builtin("CurrentTimestamp", vec![], HypnoType::number()); - self.register_builtin("CurrentDate", vec![], HypnoType::string()); - self.register_builtin("CurrentTime", vec![], HypnoType::string()); - self.register_builtin("CurrentDateTime", vec![], HypnoType::string()); - self.register_builtin( - "FormatDateTime", - vec![HypnoType::string()], - HypnoType::string(), - ); - self.register_builtin("DayOfWeek", vec![], HypnoType::number()); - self.register_builtin("DayOfYear", vec![], HypnoType::number()); - self.register_builtin( - "IsLeapYear", - vec![HypnoType::number()], - HypnoType::boolean(), - ); - self.register_builtin( - "DaysInMonth", - vec![HypnoType::number(), HypnoType::number()], - HypnoType::number(), - ); - self.register_builtin("CurrentYear", vec![], HypnoType::number()); - self.register_builtin("CurrentMonth", vec![], HypnoType::number()); - self.register_builtin("CurrentDay", vec![], HypnoType::number()); - self.register_builtin("CurrentHour", vec![], HypnoType::number()); - self.register_builtin("CurrentMinute", vec![], HypnoType::number()); - self.register_builtin("CurrentSecond", vec![], HypnoType::number()); - - // Validation - for name in [ - "IsValidEmail", - "IsValidUrl", - "IsValidPhoneNumber", - "IsAlphanumeric", - "IsAlphabetic", - "IsNumeric", - "IsLowercase", - "IsUppercase", - ] { - self.register_builtin(name, vec![HypnoType::string()], HypnoType::boolean()); - } - self.register_builtin( - "IsInRange", - vec![ - HypnoType::number(), - HypnoType::number(), - HypnoType::number(), - ], - HypnoType::boolean(), - ); - self.register_builtin( - "MatchesPattern", - vec![HypnoType::string(), HypnoType::string()], - HypnoType::boolean(), - ); } fn register_builtin( @@ -522,13 +194,29 @@ impl TypeChecker { .insert(name.to_string(), (parameter_types, return_type)); } - /// Parse type annotation string to HypnoType + /// Parse type annotation string to HypnoType. + /// + /// Handles the suffix modifiers produced by the parser: `?` marks a + /// nullable type (`number?` / `lucid number`) and `[]` an array type + /// (`string[]`, `number[]?`, ...). fn parse_type_annotation(&self, type_str: Option<&str>) -> HypnoType { + let Some(type_str) = type_str else { + return HypnoType::unknown(); + }; + + if let Some(inner) = type_str.strip_suffix('?') { + return self.parse_type_annotation(Some(inner)).into_nullable(); + } + + if let Some(inner) = type_str.strip_suffix("[]") { + return HypnoType::create_array(self.parse_type_annotation(Some(inner))); + } + match type_str { - Some("number") => HypnoType::number(), - Some("string") => HypnoType::string(), - Some("boolean") => HypnoType::boolean(), - Some("trance") => HypnoType::new(HypnoBaseType::Trance, None), + "number" => HypnoType::number(), + "string" => HypnoType::string(), + "boolean" => HypnoType::boolean(), + "trance" => HypnoType::new(HypnoBaseType::Trance, None), _ => HypnoType::unknown(), } } @@ -1289,6 +977,18 @@ impl TypeChecker { } } + AstNode::DriftStatement { duration } => { + let duration_type = self.infer_type(duration); + if duration_type.base_type != HypnoBaseType::Number + && duration_type.base_type != HypnoBaseType::Unknown + { + self.errors.push(format!( + "drift duration must be a number of milliseconds, got {}", + duration_type + )); + } + } + AstNode::IfStatement { condition, then_branch, @@ -1431,6 +1131,7 @@ impl TypeChecker { AstNode::NumberLiteral(_) => HypnoType::number(), AstNode::StringLiteral(_) => HypnoType::string(), AstNode::BooleanLiteral(_) => HypnoType::boolean(), + AstNode::NullLiteral => HypnoType::null(), AstNode::Identifier(name) => { if name == "this" && self.in_static_context { @@ -1700,10 +1401,14 @@ impl TypeChecker { AstNode::NullishCoalescing { left, right } => { let left_type = self.infer_type(left); - let _right_type = self.infer_type(right); - // Nullish coalescing returns the type of the right side if left is null - // For simplicity, we return the left type (as it's usually the expected type) - left_type + let right_type = self.infer_type(right); + // 'left lucidFallback right' only yields the right side when the + // left side is null, so the result is never null itself. + if left_type.base_type == HypnoBaseType::Null { + right_type + } else { + left_type.into_non_nullable() + } } AstNode::OptionalChaining { object, property } => { @@ -1995,4 +1700,122 @@ Focus { assert!(!errors.is_empty()); assert!(errors[0].contains("Type mismatch")); } + + fn check_source(source: &str) -> Vec { + let mut lexer = Lexer::new(source); + let tokens = lexer.lex().unwrap(); + let mut parser = Parser::new(tokens); + let ast = parser.parse_program().unwrap(); + let mut checker = TypeChecker::new(); + checker.check_program(&ast) + } + + #[test] + fn test_null_requires_nullable_type() { + let errors = check_source( + r#" +Focus { + induce x: number = null; +} Relax +"#, + ); + assert!( + errors + .iter() + .any(|e| e.contains("expected Number, got null")), + "expected null assignment error, got {:?}", + errors + ); + } + + #[test] + fn test_nullable_annotation_accepts_null() { + let errors = check_source( + r#" +Focus { + induce x: number? = null; + induce y: lucid string = null; + induce z: number? = 42; +} Relax +"#, + ); + assert!(errors.is_empty(), "unexpected errors: {:?}", errors); + } + + #[test] + fn test_nullable_value_needs_nullable_target() { + let errors = check_source( + r#" +Focus { + induce maybe: number? = null; + induce strict: number = maybe; +} Relax +"#, + ); + assert!( + errors + .iter() + .any(|e| e.contains("expected Number, got Number?")), + "expected nullable-to-strict error, got {:?}", + errors + ); + } + + #[test] + fn test_lucid_fallback_strips_nullability() { + let errors = check_source( + r#" +Focus { + induce maybe: number? = null; + induce strict: number = maybe lucidFallback 5; +} Relax +"#, + ); + assert!(errors.is_empty(), "unexpected errors: {:?}", errors); + } + + #[test] + fn test_array_annotation_matches_array_literal() { + let errors = check_source( + r#" +Focus { + induce names: string[] = ["a", "b"]; + induce wrong: number[] = ["a"]; +} Relax +"#, + ); + assert_eq!(errors.len(), 1, "expected exactly one error: {:?}", errors); + assert!(errors[0].contains("wrong")); + } + + #[test] + fn test_drift_duration_must_be_numeric() { + let errors = check_source( + r#" +Focus { + entrance { + drift("soon"); + } +} Relax +"#, + ); + assert!( + errors + .iter() + .any(|e| e.contains("drift duration must be a number")), + "expected drift type error, got {:?}", + errors + ); + + let ok = check_source( + r#" +Focus { + entrance { + drift(100); + } +} Relax +"#, + ); + assert!(ok.is_empty(), "unexpected errors: {:?}", ok); + } } diff --git a/hypnoscript-compiler/src/wasm_codegen.rs b/hypnoscript-compiler/src/wasm_codegen.rs index b37a961..399d51f 100644 --- a/hypnoscript-compiler/src/wasm_codegen.rs +++ b/hypnoscript-compiler/src/wasm_codegen.rs @@ -311,7 +311,10 @@ impl WasmCodeGenerator { self.break_labels.pop(); } - AstNode::BreakStatement => { + AstNode::BreakStatement { label: loop_label } => { + if loop_label.is_some() { + self.emit_line(";; warning: labeled break is not supported in WASM yet"); + } self.emit_line(";; break"); if let Some(label) = self.break_labels.last() { self.emit_line(&format!("br {}", label)); @@ -320,7 +323,10 @@ impl WasmCodeGenerator { } } - AstNode::ContinueStatement => { + AstNode::ContinueStatement { label: loop_label } => { + if loop_label.is_some() { + self.emit_line(";; warning: labeled continue is not supported in WASM yet"); + } self.emit_line(";; continue"); if let Some(label) = self.continue_labels.last() { self.emit_line(&format!("br {}", label)); diff --git a/hypnoscript-compiler/tests/hyp_programs.rs b/hypnoscript-compiler/tests/hyp_programs.rs new file mode 100644 index 0000000..1358e00 --- /dev/null +++ b/hypnoscript-compiler/tests/hyp_programs.rs @@ -0,0 +1,152 @@ +//! End-to-end integration tests over the sample programs in +//! `hypnoscript-tests/`. +//! +//! Every `.hyp` file in that directory must be listed either in +//! [`EXPECTED_PASS`] (it is lexed, parsed and interpreted and must succeed) +//! or in [`KNOWN_UNSUPPORTED`] (it exercises syntax or behavior the toolchain +//! does not support yet, with a reason). A new sample program that is not +//! categorized fails the sweep test, so coverage cannot silently rot. + +use hypnoscript_compiler::Interpreter; +use hypnoscript_lexer_parser::{Lexer, Parser, decode_source}; +use std::path::PathBuf; + +/// Sample programs that must run successfully end-to-end. +const EXPECTED_PASS: &[&str] = &[ + "medium_test.hyp", + "simple_test.hyp", // UTF-16 LE encoded β€” also exercises source decoding + "test.hyp", + "test_advanced.hyp", + "test_all_new_features.hyp", + "test_assertions.hyp", + "test_async.hyp", + "test_async_system.hyp", + "test_basic.hyp", + "test_channels.hyp", + "test_closures_promises.hyp", + "test_compiler.hyp", + "test_comprehensive.hyp", + "test_enterprise_features.hyp", + "test_enterprise_v3.hyp", + "test_extended_builtins.hyp", + "test_extended_features.hyp", + "test_new_features.hyp", + "test_new_language_features.hyp", + "test_parallel_execution.hyp", + "test_pattern_matching.hyp", + "test_pendulum_debug.hyp", + "test_rust_demo.hyp", + "test_scoping.hyp", + "test_simple.hyp", + "test_simple_features.hyp", + "test_simple_new_features.hyp", + "test_tranceify.hyp", +]; + +/// Sample programs that are known not to run, with the reason. These document +/// gaps rather than hiding them; when a gap is closed, move the file to +/// [`EXPECTED_PASS`]. Currently every sample program runs. +const KNOWN_UNSUPPORTED: &[(&str, &str)] = &[]; + +fn samples_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("hypnoscript-tests") +} + +fn run_program(source: &str) -> Result<(), String> { + let mut lexer = Lexer::new(source); + let tokens = lexer.lex().map_err(|e| format!("lex error: {}", e))?; + let mut parser = Parser::new(tokens); + let ast = parser + .parse_program() + .map_err(|e| format!("parse error: {}", e))?; + let mut interpreter = Interpreter::new(); + interpreter + .execute_program(ast) + .map_err(|e| format!("runtime error: {}", e)) +} + +#[test] +fn expected_pass_programs_run_successfully() { + // Skip all themed pauses (drift, HypnoticCountdown, ...) so the sample + // sweep runs in seconds instead of minutes. See CoreBuiltins::drift. + // SAFETY: tests in this binary run in-process; nothing else reads this + // variable concurrently at this point. + unsafe { std::env::set_var("HYPNO_TIME_SCALE", "0") }; + + let dir = samples_dir(); + let mut failures = Vec::new(); + + for name in EXPECTED_PASS { + let path = dir.join(name); + let source = match std::fs::read(&path) { + Ok(bytes) => match decode_source(&bytes) { + Ok(source) => source, + Err(e) => { + failures.push(format!("{}: cannot decode file: {}", name, e)); + continue; + } + }, + Err(e) => { + failures.push(format!("{}: cannot read file: {}", name, e)); + continue; + } + }; + if let Err(e) = run_program(&source) { + failures.push(format!("{}: {}", name, e)); + } + } + + assert!( + failures.is_empty(), + "sample programs failed:\n{}", + failures.join("\n") + ); +} + +#[test] +fn every_sample_program_is_categorized() { + let dir = samples_dir(); + let mut uncategorized = Vec::new(); + let mut seen = Vec::new(); + + for entry in std::fs::read_dir(&dir).expect("hypnoscript-tests directory must exist") { + let entry = entry.expect("readable directory entry"); + let file_name = entry.file_name().to_string_lossy().into_owned(); + if !file_name.ends_with(".hyp") { + continue; + } + seen.push(file_name.clone()); + + let known = EXPECTED_PASS.contains(&file_name.as_str()) + || KNOWN_UNSUPPORTED.iter().any(|(name, _)| *name == file_name); + if !known { + uncategorized.push(file_name); + } + } + + assert!( + !seen.is_empty(), + "no .hyp files found in {} β€” wrong directory?", + dir.display() + ); + assert!( + uncategorized.is_empty(), + "new sample programs must be added to EXPECTED_PASS or KNOWN_UNSUPPORTED: {:?}", + uncategorized + ); + + // Listed files must actually exist, so stale entries are caught too. + for name in EXPECTED_PASS + .iter() + .chain(KNOWN_UNSUPPORTED.iter().map(|(name, _)| name)) + { + assert!( + seen.iter().any(|f| f == name), + "listed sample program '{}' does not exist in {}", + name, + dir.display() + ); + } +} diff --git a/hypnoscript-core/src/types.rs b/hypnoscript-core/src/types.rs index 0740c65..e558992 100644 --- a/hypnoscript-core/src/types.rs +++ b/hypnoscript-core/src/types.rs @@ -14,6 +14,8 @@ pub enum HypnoBaseType { Function, Session, Record, + /// The type of the `null` literal. Only compatible with nullable types. + Null, Unknown, } @@ -26,6 +28,10 @@ pub struct HypnoType { pub fields: Option>, pub parameter_types: Option>, pub return_type: Option>, + /// Whether the type admits `null` (declared with a `?` suffix or the + /// `lucid` modifier, e.g. `number?` / `lucid number`). + #[serde(default)] + pub nullable: bool, } impl HypnoType { @@ -38,42 +44,32 @@ impl HypnoType { fields: None, parameter_types: None, return_type: None, + nullable: false, } } /// Create an array type pub fn create_array(element_type: HypnoType) -> Self { Self { - base_type: HypnoBaseType::Array, - name: None, element_type: Some(Box::new(element_type)), - fields: None, - parameter_types: None, - return_type: None, + ..Self::new(HypnoBaseType::Array, None) } } /// Create a record type pub fn create_record(name: String, fields: HashMap) -> Self { Self { - base_type: HypnoBaseType::Record, - name: Some(name), - element_type: None, fields: Some(fields), - parameter_types: None, - return_type: None, + ..Self::new(HypnoBaseType::Record, Some(name)) } } /// Create a function type pub fn create_function(parameter_types: Vec, return_type: HypnoType) -> Self { Self { - base_type: HypnoBaseType::Function, - name: None, - element_type: None, - fields: None, parameter_types: Some(parameter_types), return_type: Some(Box::new(return_type)), + ..Self::new(HypnoBaseType::Function, None) } } @@ -94,6 +90,31 @@ impl HypnoType { Self::new(HypnoBaseType::Unknown, None) } + /// The type of the `null` literal. + pub fn null() -> Self { + Self { + nullable: true, + ..Self::new(HypnoBaseType::Null, None) + } + } + + /// Return this type marked as nullable (`T?` / `lucid T`). + pub fn into_nullable(mut self) -> Self { + self.nullable = true; + self + } + + /// Return this type with the nullable marker removed. Used when an + /// operation (e.g. `lucidFallback`) guarantees a non-null result. + pub fn into_non_nullable(mut self) -> Self { + self.nullable = false; + self + } + + pub fn is_nullable(&self) -> bool { + self.nullable || self.base_type == HypnoBaseType::Null + } + /// Type checking predicates pub fn is_array(&self) -> bool { self.base_type == HypnoBaseType::Array @@ -114,8 +135,20 @@ impl HypnoType { ) } - /// Check if this type is compatible with another type + /// Check if this type is compatible with another type. + /// + /// `self` is the expected type, `other` the actual one. A `null` value + /// only fits nullable types; a nullable actual value only fits nullable + /// expected types (it might be null at runtime). pub fn is_compatible_with(&self, other: &HypnoType) -> bool { + if other.base_type == HypnoBaseType::Null { + return self.is_nullable(); + } + + if other.nullable && !self.nullable { + return false; + } + if self.base_type != other.base_type { return false; } @@ -172,7 +205,18 @@ impl HypnoType { impl fmt::Display for HypnoType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.fmt_base(f)?; + if self.nullable && self.base_type != HypnoBaseType::Null { + write!(f, "?")?; + } + Ok(()) + } +} + +impl HypnoType { + fn fmt_base(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self.base_type { + HypnoBaseType::Null => write!(f, "null"), HypnoBaseType::Array => { if let Some(ref elem) = self.element_type { write!(f, "[{}]", elem) @@ -226,3 +270,52 @@ impl std::hash::Hash for HypnoType { } impl Eq for HypnoType {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_null_only_fits_nullable_types() { + let null_type = HypnoType::null(); + assert!(!HypnoType::number().is_compatible_with(&null_type)); + assert!( + HypnoType::number() + .into_nullable() + .is_compatible_with(&null_type) + ); + } + + #[test] + fn test_nullable_actual_needs_nullable_expected() { + let nullable_number = HypnoType::number().into_nullable(); + assert!(!HypnoType::number().is_compatible_with(&nullable_number)); + assert!( + HypnoType::number() + .into_nullable() + .is_compatible_with(&nullable_number) + ); + // Non-nullable values still fit nullable slots. + assert!( + HypnoType::number() + .into_nullable() + .is_compatible_with(&HypnoType::number()) + ); + } + + #[test] + fn test_nullable_display() { + assert_eq!(HypnoType::number().into_nullable().to_string(), "Number?"); + assert_eq!(HypnoType::null().to_string(), "null"); + assert_eq!( + HypnoType::create_array(HypnoType::string()).to_string(), + "[String]" + ); + } + + #[test] + fn test_non_nullable_strips_marker() { + let t = HypnoType::string().into_nullable().into_non_nullable(); + assert!(!t.is_nullable()); + } +} diff --git a/hypnoscript-docs/docs/builtins/_complete-reference.md b/hypnoscript-docs/docs/builtins/_complete-reference.md index f1359a5..ad16d8c 100644 --- a/hypnoscript-docs/docs/builtins/_complete-reference.md +++ b/hypnoscript-docs/docs/builtins/_complete-reference.md @@ -379,6 +379,16 @@ All array functions use the `Array` prefix to distinguish from string functions | `MeasureTranceDepth` | `(action: () -> void) -> number` | Measure execution time | | `Memoize` | `(f: A -> R) -> (A -> R)` | Function with caching | +## Async / Promise Builtins + +| Function | Signature | Description | +| ------------------- | ------------------------------------ | ------------------------------------------ | +| `delayedValue` | `(delayMs: number, value: any) -> promise` | Promise resolved after delay (on await) | +| `instantPromise` | `(value: any) -> promise` | Already-resolved promise | +| `promiseAll` | `(promises: any[]) -> any[]` | Wait for all promises (longest delay) | +| `promiseRace` | `(promises: any[]) -> any` | Value of the fastest promise | +| `isPromiseResolved` | `(promise: any) -> boolean` | Check promise state without waiting | + ## Usage Notes ### Naming Conventions diff --git a/hypnoscript-docs/docs/builtins/deepmind-functions.md b/hypnoscript-docs/docs/builtins/deepmind-functions.md index c934066..062a73b 100644 --- a/hypnoscript-docs/docs/builtins/deepmind-functions.md +++ b/hypnoscript-docs/docs/builtins/deepmind-functions.md @@ -23,6 +23,11 @@ to be expressed declaratively. | `EnsureAwakening` | `void` | Guarantee cleanup execution | | `MeasureTranceDepth` | `number` | Measure runtime in milliseconds | | `Memoize` | `suggestion` | Cache function results | +| `delayedValue` | `promise` | Promise resolved after a delay | +| `instantPromise` | `promise` | Already-resolved promise | +| `promiseAll` | `array` | Wait for all promises | +| `promiseRace` | `any` | Value of the fastest promise | +| `isPromiseResolved` | `boolean` | Check promise state without waiting | :::tip Naming conventions All DeepMind builtins use PascalCase (`RepeatAction`) and accept `suggestion()` blocks as parameters. @@ -202,6 +207,65 @@ observe memoSquare(4); // 16 observe memoSquare(4); // 16 (future calls from cache) ``` +## Promises & await + +Promise builtins create real pending values that resolve deterministically when awaited β€” like a post-hypnotic suggestion that fires exactly on cue. Unlike the other DeepMind builtins, they use camelCase names (`delayedValue`), matching the async style of the language. + +### delayedValue(delayMs, value) + +- **Signature:** `(delayMs: number, value: any) -> promise` +- **Description:** Returns a pending promise that resolves to `value` after `delayMs` milliseconds. The delay honours `HYPNO_TIME_SCALE` (`0` resolves immediately β€” useful for tests). + +```hyp +induce slow = delayedValue(20, "slow"); +induce value = await delayedValue(5, 42); +observe value; // 42 +``` + +### instantPromise(value) + +- **Signature:** `(value: any) -> promise` +- **Description:** Returns an already-resolved promise wrapping `value`. + +```hyp +induce fast = instantPromise("fast"); +observe await fast; // "fast" +``` + +### promiseAll(promises) + +- **Signature:** `(promises: any[]) -> any[]` +- **Description:** Waits for **all** promises in the array (i.e., for the longest delay) and returns their values in order. + +```hyp +induce all = promiseAll([delayedValue(5, 1), instantPromise(2)]); +observe ArrayLength(all); // 2 +``` + +### promiseRace(promises) + +- **Signature:** `(promises: any[]) -> any` +- **Description:** Waits only for the **fastest** promise (the shortest delay) and returns its value. + +```hyp +induce winner = promiseRace([delayedValue(20, "slow"), instantPromise("fast")]); +observe winner; // "fast" +``` + +### isPromiseResolved(promise) + +- **Signature:** `(promise: any) -> boolean` +- **Description:** Checks whether a promise has already resolved, without waiting. + +```hyp +induce slow = delayedValue(20, "slow"); +observe isPromiseResolved(slow); // false β€” still pending +``` + +:::tip await +`await` resolves pending promises deterministically: `await delayedValue(5, 42)` always yields `42` once the delay has elapsed. Awaiting a non-promise value simply returns the value itself. +::: + ## Usage Tips - `RepeatAction`, `RepeatUntil`, and `RepeatWhile` block synchronously; use `DelayedSuggestion` for simple diff --git a/hypnoscript-docs/docs/builtins/file-functions.md b/hypnoscript-docs/docs/builtins/file-functions.md index 44c2e41..5aa47da 100644 --- a/hypnoscript-docs/docs/builtins/file-functions.md +++ b/hypnoscript-docs/docs/builtins/file-functions.md @@ -4,4 +4,78 @@ title: File Functions # File Functions -This page will document file-related built-in functions. Content coming soon. +File builtins let a HypnoScript program read, write, and inspect the filesystem β€” anchoring trance state to disk. + +## Overview + +| Function | Signature | Brief Description | +| -------------------- | ---------------------------------- | ---------------------------------------- | +| `ReadFile` | `(path: string) -> string` | Read entire file as string | +| `WriteFile` | `(path: string, content: string) -> void` | Write string to file (creates parents) | +| `AppendFile` | `(path: string, content: string) -> void` | Append string to file | +| `DeleteFile` | `(path: string) -> void` | Delete a file | +| `CreateDirectory` | `(path: string) -> void` | Create a directory (recursively) | +| `FileExists` | `(path: string) -> boolean` | Check whether a path exists | +| `IsFile` | `(path: string) -> boolean` | Check whether a path is a file | +| `IsDirectory` | `(path: string) -> boolean` | Check whether a path is a directory | +| `ListDirectory` | `(path: string) -> string[]` | List directory entries | +| `GetFileSize` | `(path: string) -> number` | File size in bytes | +| `CopyFile` | `(from: string, to: string) -> number` | Copy a file, returns bytes copied | +| `RenameFile` | `(from: string, to: string) -> void` | Rename / move a file | +| `GetFileExtension` | `(path: string) -> string` | Extract file extension (pure) | +| `GetFileName` | `(path: string) -> string` | Extract file name (pure) | +| `GetParentDirectory` | `(path: string) -> string` | Extract parent directory (pure) | + +## Example + +```hyp +Focus { + entrance { + WriteFile("notes.txt", "You are getting sleepy..."); + AppendFile("notes.txt", "\nDeeper and deeper."); + + if (FileExists("notes.txt")) { + induce content: string = ReadFile("notes.txt"); + observe content; + observe "Size: " + GetFileSize("notes.txt") + " bytes"; + } + + DeleteFile("notes.txt"); + } +} Relax +``` + +## Filesystem Sandbox + +All file builtins can be confined to a single directory β€” a protective circle around the session. Activate the sandbox with the `HYPNO_SANDBOX` environment variable or the `--sandbox ` flag on `exec`: + +```bash +hypnoscript exec script.hyp --sandbox ./workspace +# or +HYPNO_SANDBOX=./workspace hypnoscript exec script.hyp +``` + +With a sandbox root configured: + +- **Relative paths** (`"notes.txt"`, `"data/log.txt"`) resolve against the sandbox root β€” not the process working directory. +- **Escape attempts** are rejected with a `PermissionDenied` error: `..` traversal, absolute paths outside the root, and symlinks that point outside the root. +- **Pure path-string helpers** (`GetFileExtension`, `GetFileName`, `GetParentDirectory`) never touch the filesystem and are unaffected. + +Without a sandbox root, behavior is unchanged β€” file builtins access the filesystem freely. + +```hyp +Focus { + entrance { + // With --sandbox ./workspace: + WriteFile("safe.txt", "ok"); // -> ./workspace/safe.txt + ReadFile("../outside.txt"); // -> PermissionDenied error + ReadFile("/etc/passwd"); // -> PermissionDenied error + } +} Relax +``` + +## See also + +- [Builtin Overview](./overview) +- [CLI Commands – exec](../cli/commands#exec---execute-a-program) +- [CLI Configuration – Environment Variables](../cli/configuration#environment-variables) diff --git a/hypnoscript-docs/docs/builtins/overview.md b/hypnoscript-docs/docs/builtins/overview.md index f4773d6..ab02631 100644 --- a/hypnoscript-docs/docs/builtins/overview.md +++ b/hypnoscript-docs/docs/builtins/overview.md @@ -296,6 +296,7 @@ Advanced functional programming and control flow. | ------------------ | ---------------------------------------------------------------- | | **Loops** | `RepeatAction`, `RepeatUntil`, `RepeatWhile` | | **Delay** | `DelayedSuggestion` | +| **Promises** | `delayedValue`, `instantPromise`, `promiseAll`, `promiseRace`, `isPromiseResolved` | | **Composition** | `Compose`, `Pipe` | | **Error Handling** | `TryOrAwaken`, `EnsureAwakening` | | **More** | `IfTranced`, `SequentialTrance`, `MeasureTranceDepth`, `Memoize` | diff --git a/hypnoscript-docs/docs/builtins/string-functions.md b/hypnoscript-docs/docs/builtins/string-functions.md index 49ef240..608c021 100644 --- a/hypnoscript-docs/docs/builtins/string-functions.md +++ b/hypnoscript-docs/docs/builtins/string-functions.md @@ -255,6 +255,10 @@ induce padded = PadRight(text, 10, "*"); observe padded; // "Hello*****" ``` +:::info Unicode-aware width +Since 1.3.0, `PadLeft` and `PadRight` measure the target width in **characters**, not bytes. Multi-byte characters like `Γ©` or `🎯` count as one, so `PadLeft("cafΓ©", 6, " ")` yields `" cafΓ©"` β€” exactly two spaces of padding. +::: + ### FormatString(template, ...args) Formats a string with placeholders. diff --git a/hypnoscript-docs/docs/cli/commands.md b/hypnoscript-docs/docs/cli/commands.md index 72a7c34..464b3f7 100644 --- a/hypnoscript-docs/docs/cli/commands.md +++ b/hypnoscript-docs/docs/cli/commands.md @@ -48,6 +48,8 @@ hypnoscript exec [OPTIONS] | `--breakpoints ` | | Set initial breakpoints (comma-separated lines) | | `--watch ` | | Watch variables during execution (comma-separated) | | `--trace-file ` | | Write debug trace to file | +| `--sandbox ` | | Confine all file builtins to this directory | +| `--max-call-depth ` | | Maximum function call depth (default: 1000) | ### Behavior @@ -81,6 +83,36 @@ hypnoscript exec script.hyp --debug --watch counter,result,total # Full debug session with trace file hypnoscript exec script.hyp --debug --breakpoints 10,20 --watch x,y --trace-file debug.log + +# Confine file builtins to a working directory +hypnoscript exec script.hyp --sandbox ./workspace + +# Allow deeper recursion +hypnoscript exec recursive.hyp --max-call-depth 10000 +``` + +### Filesystem Sandbox + +`--sandbox ` restricts every file builtin (`ReadFile`, `WriteFile`, `AppendFile`, `DeleteFile`, `ListDirectory`, `CreateDirectory`, ...) to the given directory: + +- Relative paths in the script resolve **against the sandbox root**, not the process working directory. +- Escape attempts via `..`, absolute paths outside the root, or symlinks pointing outside are rejected with a `PermissionDenied` error. +- Pure path-string helpers (`GetFileExtension`, `GetFileName`, ...) are unaffected. +- Without `--sandbox` (or the `HYPNO_SANDBOX` environment variable), file access behaves as before β€” unrestricted. + +```bash +# Equivalent via environment variable +HYPNO_SANDBOX=./workspace hypnoscript exec script.hyp +``` + +See [File Functions](../builtins/file-functions) for the affected builtins. + +### Recursion Depth Limit + +`--max-call-depth ` sets the maximum function call depth. When exceeded, the program aborts with a graceful `RecursionLimitExceeded` error instead of a stack overflow. The default is **1,000**; the `HYPNO_MAX_CALL_DEPTH` environment variable works as an alternative: + +```bash +HYPNO_MAX_CALL_DEPTH=5000 hypnoscript exec deep_trance.hyp ``` ### Debug Mode @@ -90,7 +122,7 @@ When `--debug` is specified, an interactive debugging session starts: ``` $ hypnoscript exec script.hyp --debug -HypnoScript Debugger v1.2.0 +HypnoScript Debugger v1.3.0 Type 'help' for available commands. (hypno-debug) b 10 @@ -420,7 +452,7 @@ hypnoscript version ### Output ``` -HypnoScript v1.2.0 +HypnoScript v1.3.0 The Hypnotic Programming Language Migrated from C# to Rust for improved performance diff --git a/hypnoscript-docs/docs/cli/configuration.md b/hypnoscript-docs/docs/cli/configuration.md index ad3843c..de9f48d 100644 --- a/hypnoscript-docs/docs/cli/configuration.md +++ b/hypnoscript-docs/docs/cli/configuration.md @@ -8,14 +8,15 @@ The Rust-based HypnoScript CLI deliberately avoids global configuration files. I ## CLI Runtime Flags -| Subcommand | Options | Effect | -| ----------------------------------- | ---------------------- | ------------------------------------------------------------------------- | -| `run ` | `--debug`, `--verbose` | Debug shows tokens, AST, and type checks; verbose outputs status messages | -| `compile-wasm` | `--output ` | Selects the name of the `.wat` file (default: `.wat`) | -| `version` | _(none)_ | Outputs toolchain information | -| `lex`, `parse`, `check`, `builtins` | _(none)_ | Use no additional options | +| Subcommand | Options | Effect | +| ----------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- | +| `run ` | `--debug`, `--verbose` | Debug shows tokens, AST, and type checks; verbose outputs status messages | +| `exec ` | `--sandbox `, `--max-call-depth ` | Confines file builtins to a directory; caps recursion depth (default 1000) | +| `compile-wasm` | `--output ` | Selects the name of the `.wat` file (default: `.wat`) | +| `version` | _(none)_ | Outputs toolchain information | +| `lex`, `parse`, `check`, `builtins` | _(none)_ | Use no additional options | -More flags currently don't exist. This makes the CLI simple but also very predictable – especially for scripts and CI. +This keeps the CLI simple but also very predictable – especially for scripts and CI. See [CLI Commands](./commands#exec---execute-a-program) for the full `exec` reference. ## Creating Custom Wrappers @@ -75,7 +76,23 @@ This documents how the project should be built or checked – without custom CLI ## Environment Variables -The CLI currently does not read any special `HYPNOSCRIPT_*` variables. You can still use environment variables to control file paths or flags: +The runtime reads a small set of `HYPNO_*` variables: + +| Variable | Effect | +| ---------------------- | -------------------------------------------------------------------------------------------------- | +| `HYPNO_SANDBOX` | Confines all file builtins to the given directory (same as `exec --sandbox`) | +| `HYPNO_MAX_CALL_DEPTH` | Maximum function call depth before `RecursionLimitExceeded` (default: `1000`) | +| `HYPNO_TIME_SCALE` | Scales all themed pauses and promise delays (`0` skips them entirely – useful for tests) | + +```bash +# Run a script fully sandboxed, with deeper recursion and no pauses +HYPNO_SANDBOX=./workspace \ +HYPNO_MAX_CALL_DEPTH=5000 \ +HYPNO_TIME_SCALE=0 \ +hypnoscript exec script.hyp +``` + +Beyond these, you can of course use your own environment variables to control file paths or flags: ```bash export HYPNO_DEFAULT=examples/intro.hyp diff --git a/hypnoscript-docs/docs/debugging/debug-mode.md b/hypnoscript-docs/docs/debugging/debug-mode.md index 5791612..00b449c 100644 --- a/hypnoscript-docs/docs/debugging/debug-mode.md +++ b/hypnoscript-docs/docs/debugging/debug-mode.md @@ -81,7 +81,7 @@ In interactive debug mode, the following commands are available: ```bash $ hypnoscript exec --debug examples/calculator.hyp -HypnoScript Debugger v1.2.0 +HypnoScript Debugger v1.3.0 Type 'help' for available commands. (hypno-debug) b 10 diff --git a/hypnoscript-docs/docs/debugging/overview.md b/hypnoscript-docs/docs/debugging/overview.md index 393fc01..39e5ec1 100644 --- a/hypnoscript-docs/docs/debugging/overview.md +++ b/hypnoscript-docs/docs/debugging/overview.md @@ -134,7 +134,7 @@ HypnoScript provides detailed error reports with: ```hyp $ hypnoscript exec calculator.hyp --debug --breakpoints 10 -HypnoScript Debugger v1.2.0 +HypnoScript Debugger v1.3.0 Type 'help' for available commands. (hypno-debug) run diff --git a/hypnoscript-docs/docs/development/debugging.md b/hypnoscript-docs/docs/development/debugging.md index 6add24f..398a12d 100644 --- a/hypnoscript-docs/docs/development/debugging.md +++ b/hypnoscript-docs/docs/development/debugging.md @@ -305,7 +305,7 @@ Unhandled runtime exception occurs. Environment: - OS: Windows 10 -- HypnoScript version: 1.2.0 +- HypnoScript version: 1.3.0 ``` ## Conclusion diff --git a/hypnoscript-docs/docs/getting-started/installation.md b/hypnoscript-docs/docs/getting-started/installation.md index ec0f186..6ed9fc0 100644 --- a/hypnoscript-docs/docs/getting-started/installation.md +++ b/hypnoscript-docs/docs/getting-started/installation.md @@ -110,7 +110,7 @@ hypnoscript exec test.hyp Expected output (abbreviated): ```text -HypnoScript v1.2.0 +HypnoScript v1.3.0 Installation successful! ``` diff --git a/hypnoscript-docs/docs/language-reference/_keywords-reference.md b/hypnoscript-docs/docs/language-reference/_keywords-reference.md index 74fe758..377a524 100644 --- a/hypnoscript-docs/docs/language-reference/_keywords-reference.md +++ b/hypnoscript-docs/docs/language-reference/_keywords-reference.md @@ -10,7 +10,7 @@ Complete reference of all keywords in HypnoScript based on the Rust implementati | `Relax` | Program end (required) | `Focus { ... } Relax` | | `entrance`| Initialization block (optional) | `entrance { observe "Start"; }` | | `finale` | Cleanup/destructor block (optional) | `finale { observe "End"; }` | -| `deepFocus`| Extended if block with deeper scope | `if (x > 5) deepFocus { ... }` | +| `deepFocus`| Conditional block, standalone or after `if` | `deepFocus (x > 5) { ... }` | ## Variable Declarations diff --git a/hypnoscript-docs/docs/language-reference/functions.md b/hypnoscript-docs/docs/language-reference/functions.md index d13feb9..ddaca67 100644 --- a/hypnoscript-docs/docs/language-reference/functions.md +++ b/hypnoscript-docs/docs/language-reference/functions.md @@ -137,6 +137,82 @@ Focus { } Relax; ``` +## Closures and Lexical Scoping + +Suggestions can be declared inside other suggestions. An inner suggestion is a **closure**: it captures the local variables of its enclosing function **by value, at the moment it is declared** β€” a hypnotic snapshot of the surrounding trance. + +```hyp +Focus { + suggestion makeAdder(offset: number): number { + induce bonus: number = 10; + + // 'add' captures 'offset' and 'bonus' from the enclosing scope + suggestion add(n: number): number { + awaken n + offset + bonus; + } + + awaken add(5); + } + + entrance { + induce result: number = makeAdder(100); + observe result; // 115 + } +} Relax; +``` + +Nested suggestions can also recurse β€” the closure sees its own name: + +```hyp +Focus { + suggestion sumBelow(limit: number): number { + suggestion helper(n: number): number { + if (n <= 0) { + awaken 0; + } + awaken n + helper(n - 1); + } + awaken helper(limit); + } + + entrance { + observe sumBelow(4); // 10 + } +} Relax; +``` + +**Scoping rules (since 1.3.0):** + +- Captures are **by-value snapshots**: mutating the outer variable after the inner suggestion is declared does not change what the closure sees. +- HypnoScript uses strict **lexical scoping**. Earlier versions allowed a called function to read the *caller's* locals (dynamic scoping); this is gone. A function that references a variable which is neither a parameter, a local, a captured value, nor a global now fails with an `UndefinedVariable` error. + +## Recursion Depth Limit + +Deep recursion no longer crashes the interpreter with a stack overflow. When the call depth exceeds the limit (**1,000 calls** by default), execution aborts gracefully with a `RecursionLimitExceeded` error. + +The limit is configurable: + +| Mechanism | Example | +| -------------------------------- | ---------------------------------------------- | +| Environment variable | `HYPNO_MAX_CALL_DEPTH=5000 hypnoscript exec f.hyp` | +| CLI flag on `exec` | `hypnoscript exec f.hyp --max-call-depth 5000` | +| Rust API (embedding) | `Interpreter::set_max_call_depth(5000)` | + +```hyp +Focus { + suggestion sinkForever(n: number): number { + awaken sinkForever(n + 1); // no base case! + } + + entrance { + sinkForever(0); + // => Error: RecursionLimitExceeded (after 1000 calls) + } +} Relax; +``` + +See [CLI Commands](../cli/commands#exec---execute-a-program) for the `exec` options. + ## Functions with Arrays ```hyp diff --git a/hypnoscript-docs/docs/language-reference/sessions.md b/hypnoscript-docs/docs/language-reference/sessions.md index 1ab1011..4e77c39 100644 --- a/hypnoscript-docs/docs/language-reference/sessions.md +++ b/hypnoscript-docs/docs/language-reference/sessions.md @@ -32,7 +32,7 @@ Key points: - `session Name { ... }` declares the type. - Fields require an explicit visibility keyword (`expose` for public, `conceal` for private). Initialisers are optional. -- Methods use `suggestion`, `imperativeSuggestion`, or `dominant suggestion` depending on the style you prefer. The parser treats `imperativeSuggestion` as an instance method and `dominant suggestion` as static. +- Methods use `suggestion`, `imperativeSuggestion` (or the two-word form `imperative suggestion`), or `dominant suggestion` depending on the style you prefer. The parser treats `imperativeSuggestion` as an instance method and `dominant suggestion` as static. - The optional `constructor` keyword after `suggestion` marks a constructor. Constructors cannot be static and always return an instance of the surrounding session. The type checker enforces those rules. ## Field visibility diff --git a/hypnoscript-docs/docs/language-reference/syntax.md b/hypnoscript-docs/docs/language-reference/syntax.md index da5adc1..eb02be0 100644 --- a/hypnoscript-docs/docs/language-reference/syntax.md +++ b/hypnoscript-docs/docs/language-reference/syntax.md @@ -48,6 +48,14 @@ Focus { } Relax ``` +### Source File Encoding + +`.hyp` files are usually plain UTF-8, but the CLI and test harness decode +other common encodings transparently: UTF-8 with a byte-order mark (BOM), +and UTF-16 (little- or big-endian, with or without BOM). Files saved by +editors that default to UTF-16 β€” such as some Windows tools β€” simply work, +no re-encoding required. + ## Variables and Assignments ### Induce (Variable Declaration) @@ -68,6 +76,36 @@ Focus { } Relax ``` +### SharedTrance (Global Variables) + +`sharedTrance` declares a module-level variable that every suggestion shares β€” +one trance, one mind. The declaration keyword (`induce`, `implant`, `embed`, +`freeze`) is optional; the shorthand `sharedTrance name: type = value;` works +on its own: + +```hyp +Focus { + // Shorthand β€” no induce needed + sharedTrance total: number = 0; + + // Classic long form is still valid + sharedTrance induce sessionName: string = "Deep Dive"; + + suggestion addToTotal(n: number) { + total = total + n; + } + + entrance { + addToTotal(5); + addToTotal(7); + observe "Total: " + total; // 12 + } +} Relax +``` + +`sharedTrance` must be followed by a variable declaration; anything else is a +parse error. + ### Data Types HypnoScript supports various data types: @@ -81,6 +119,8 @@ Focus { // Numbers (only number type) induce integer: number = 42; induce decimal: number = 3.14159; + induce large: number = 1_000_000; // digit separators + induce scientific: number = 2.5e3; // exponent notation // Boolean induce flag: boolean = true; @@ -95,6 +135,103 @@ Focus { } Relax ``` +### String Escape Sequences + +String literals support the usual escapes plus hexadecimal Unicode escapes: + +| Escape | Meaning | +| -------- | ---------------------------------------------- | +| `\n` | Newline | +| `\t` | Tab | +| `\r` | Carriage return | +| `\\` | Backslash | +| `\"` | Double quote | +| `\$` | Literal `$` (suppresses interpolation) | +| `\uXXXX` | Unicode code point (exactly 4 hex digits) | +| `\xNN` | Unicode code point (exactly 2 hex digits) | + +```hyp +Focus { + entrance { + observe "\u0048\u0079\u0070\u006E\u006F"; // => Hypno + observe "Hello\x20World\x21"; // => Hello World! + observe "Spiral: \u25CC"; // => Spiral: β—Œ + } +} Relax +``` + +Both forms consume a fixed number of hex digits. A truncated escape, a +non-hex digit, or a value that is not a valid Unicode scalar (such as a +surrogate code point `\uD800`–`\uDFFF`) is a **lexer error** β€” the program +does not even reach the parser. + +### String Interpolation + +Embed arbitrary expressions in string literals with `${...}`: + +```hyp +Focus { + entrance { + induce name: string = "Luna"; + induce depth: number = 6; + + observe "Guest ${name} sinks to depth ${depth + 1}."; + // => Guest Luna sinks to depth 7. + + // Escape with \${ to output a literal ${ + observe "costs \${price}"; // => costs ${price} + } +} Relax +``` + +Interpolations may contain any expression β€” variables, arithmetic, function +calls, even nested strings. The result is converted to a string automatically. + +### Null and Nullable Types + +`null` is a first-class literal. A type only admits `null` when it is declared +nullable β€” with a `?` suffix or the hypnotic `lucid` modifier: + +```hyp +Focus { + entrance { + induce maybe: number? = null; // nullable via '?' + induce dreamy: lucid string = null; // nullable via 'lucid' + + // lucidFallback (??) supplies a default; the result is non-nullable + induce certain: number = maybe lucidFallback 42; + + // null works as an entrain pattern + induce state: string = entrain maybe { + when null => "empty"; + otherwise => "filled"; + }; + observe state; + } +} Relax +``` + +The type checker enforces null safety in both directions: assigning `null` (or +a nullable value) to a non-nullable type is a type error. + +### Array Type Annotations + +Use the `[]` suffix for typed arrays, including nested and nullable forms: + +```hyp +Focus { + tranceify Plan { + title: string; + tags: string[]; + } + entrance { + induce names: string[] = ["Alice", "Bob"]; + induce matrix: number[][] = [[1, 2], [3, 4]]; + induce optionalTags: string[]? = null; + } +} Relax +``` + ## Output ### Observe (Output) @@ -143,6 +280,40 @@ Focus { } Relax ``` +### DeepFocus (Conditional Block) + +`deepFocus (condition) { ... }` is a standalone conditional statement β€” an +`if` with hypnotic emphasis, marking a block the program sinks into only when +the condition holds. There is no `else` branch: + +```hyp +Focus { + entrance { + induce depth: number = 7; + + deepFocus (depth > 5) { + observe "You are in deep trance now..."; + } + } +} Relax +``` + +`deepFocus` may also follow an `if` condition as a block marker: + +```hyp +Focus { + entrance { + induce n: number = 0; + if (n <= 0) deepFocus { + observe "Fully grounded."; + } + } +} Relax +``` + +The condition must be a boolean β€” the type checker reports +`DeepFocus condition must be boolean` otherwise. + ### While Loop ```hyp @@ -184,6 +355,48 @@ Focus { } Relax ``` +### Labeled Loops + +Label a loop to `snap` (break) or `sink` (continue) it from inside nested +loops: + +```hyp +Focus { + entrance { + outer: loop (induce i: number = 0; i < 3; i = i + 1) { + loop (induce j: number = 0; j < 3; j = j + 1) { + if (i + j == 3) { + snap outer; // break out of BOTH loops + } + if (j == 1) { + sink outer; // continue the OUTER loop + } + } + } + } +} Relax +``` + +The keyword form `label outer: loop (...) { ... }` is also accepted. + +### Timed Pauses (drift) + +`drift(ms);` (synonym: `pauseReality(ms);`) pauses execution for the given +number of milliseconds: + +```hyp +Focus { + entrance { + observe "Sinking deeper..."; + drift(500); + observe "...and deeper."; + } +} Relax +``` + +The `HYPNO_TIME_SCALE` environment variable scales every themed pause: +`HYPNO_TIME_SCALE=0` skips all pauses (useful for tests), `0.5` halves them. + ## Functions ### Suggestion (Function Definition) @@ -220,6 +433,38 @@ Focus { } Relax ``` +### Imperative Suggestions + +`imperativeSuggestion` declares a function in commanding style. Since 1.3.0 +the two-word form `imperative suggestion` is accepted as well β€” both are +interchangeable, at the top level and inside sessions: + +```hyp +Focus { + // One-word form + imperativeSuggestion obey(command: string) { + observe "You will " + command + "."; + } + + // Two-word form β€” identical meaning + imperative suggestion comply(command: string) { + observe "You must " + command + "."; + } + + session Hypnotist { + // Works as an instance method too + expose imperative suggestion command(target: string) { + observe "Sleep now, " + target + "!"; + } + } + + entrance { + obey("relax"); + comply("focus"); + } +} Relax +``` + ### Functions with Return Values ```hyp diff --git a/hypnoscript-docs/package-lock.json b/hypnoscript-docs/package-lock.json index 5aad940..023f799 100644 --- a/hypnoscript-docs/package-lock.json +++ b/hypnoscript-docs/package-lock.json @@ -1,12 +1,12 @@ { "name": "hypnoscript-documentation", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hypnoscript-documentation", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "vue": "^3.4.21" diff --git a/hypnoscript-docs/package.json b/hypnoscript-docs/package.json index 882a274..87c2477 100644 --- a/hypnoscript-docs/package.json +++ b/hypnoscript-docs/package.json @@ -1,6 +1,6 @@ { "name": "hypnoscript-documentation", - "version": "1.2.0", + "version": "1.3.0", "private": true, "type": "module", "scripts": { diff --git a/hypnoscript-lexer-parser/src/ast.rs b/hypnoscript-lexer-parser/src/ast.rs index 33e0b97..612a8e5 100644 --- a/hypnoscript-lexer-parser/src/ast.rs +++ b/hypnoscript-lexer-parser/src/ast.rs @@ -114,9 +114,32 @@ pub enum AstNode { /// suspend: Pause without fixed end (infinite loop or wait) SuspendStatement, + /// drift/pauseReality: Pause execution for the given number of milliseconds + /// Example: drift(500); + DriftStatement { + duration: Box, + }, + ReturnStatement(Option>), - BreakStatement, - ContinueStatement, + + /// snap: Break out of a loop, optionally naming an enclosing label + /// Example: snap; / snap outerLoop; + BreakStatement { + label: Option, + }, + + /// sink: Continue with the next loop iteration, optionally naming a label + /// Example: sink; / sink outerLoop; + ContinueStatement { + label: Option, + }, + + /// A labeled statement, used as a target for `snap`/`sink` + /// Example: outerLoop: loop (...) { ... } + LabeledStatement { + label: String, + body: Box, + }, /// oscillate: Toggle a boolean variable /// Example: oscillate myFlag; @@ -136,6 +159,7 @@ pub enum AstNode { NumberLiteral(f64), StringLiteral(String), BooleanLiteral(bool), + NullLiteral, Identifier(String), BinaryExpression { @@ -239,6 +263,7 @@ impl AstNode { AstNode::NumberLiteral(_) | AstNode::StringLiteral(_) | AstNode::BooleanLiteral(_) + | AstNode::NullLiteral | AstNode::Identifier(_) | AstNode::BinaryExpression { .. } | AstNode::UnaryExpression { .. } @@ -270,9 +295,11 @@ impl AstNode { | AstNode::WhileStatement { .. } | AstNode::LoopStatement { .. } | AstNode::SuspendStatement + | AstNode::DriftStatement { .. } | AstNode::ReturnStatement(_) - | AstNode::BreakStatement - | AstNode::ContinueStatement + | AstNode::BreakStatement { .. } + | AstNode::ContinueStatement { .. } + | AstNode::LabeledStatement { .. } | AstNode::OscillateStatement { .. } ) } diff --git a/hypnoscript-lexer-parser/src/error.rs b/hypnoscript-lexer-parser/src/error.rs new file mode 100644 index 0000000..2d20ee6 --- /dev/null +++ b/hypnoscript-lexer-parser/src/error.rs @@ -0,0 +1,49 @@ +//! Structured syntax errors with source positions. + +use std::fmt; + +/// A lexing or parsing error, carrying the source position where it occurred. +/// +/// The [`fmt::Display`] implementation renders the message together with the +/// line and column, so callers that only need a human-readable string can use +/// `error.to_string()`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyntaxError { + pub message: String, + pub line: usize, + pub column: usize, +} + +impl SyntaxError { + /// Create a new syntax error at the given position (1-based line/column). + pub fn new(message: impl Into, line: usize, column: usize) -> Self { + Self { + message: message.into(), + line, + column, + } + } +} + +impl fmt::Display for SyntaxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} at line {}, column {}", + self.message, self.line, self.column + ) + } +} + +impl std::error::Error for SyntaxError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_includes_position() { + let error = SyntaxError::new("Unexpected token", 3, 14); + assert_eq!(error.to_string(), "Unexpected token at line 3, column 14"); + } +} diff --git a/hypnoscript-lexer-parser/src/lexer.rs b/hypnoscript-lexer-parser/src/lexer.rs index f157872..00f7d93 100644 --- a/hypnoscript-lexer-parser/src/lexer.rs +++ b/hypnoscript-lexer-parser/src/lexer.rs @@ -1,6 +1,21 @@ +use crate::error::SyntaxError; use crate::token::{Token, TokenType}; -/// Lexer for the HypnoScript language +/// Result alias for lexing operations. +pub type LexResult = Result; + +/// Lexer for the HypnoScript language. +/// +/// Converts source text into a stream of [`Token`]s. Supports: +/// +/// - All HypnoScript keywords and their plain-language aliases +/// - Numeric literals with decimal points, exponents (`1.5e3`) and digit +/// separators (`1_000_000`) +/// - String literals with escape sequences (`\n`, `\t`, `\uXXXX`, `\xXX`, ...) +/// - String interpolation: `"Hello, ${name}!"` is desugared into a +/// parenthesized string concatenation, so every later pipeline stage sees +/// ordinary expressions +/// - Line (`//`) and block (`/* ... */`) comments pub struct Lexer { source: Vec, pos: usize, @@ -8,302 +23,67 @@ pub struct Lexer { column: usize, } +/// A piece of a string literal: either literal text or an interpolated +/// expression that was lexed into its own token stream. +enum StringPart { + Text(String), + Expression(Vec), +} + impl Lexer { /// Create a new lexer pub fn new(source: &str) -> Self { + Self::new_at(source, 1, 1) + } + + /// Create a lexer whose position reporting starts at the given + /// line/column. Used to lex interpolated expressions inside string + /// literals while keeping positions relative to the whole file. + fn new_at(source: &str, line: usize, column: usize) -> Self { Self { source: source.chars().collect(), pos: 0, - line: 1, - column: 1, + line, + column, } } /// Tokenize the source code - pub fn lex(&mut self) -> Result, String> { + pub fn lex(&mut self) -> LexResult> { let mut tokens = Vec::new(); - while !self.is_at_end() { - self.skip_whitespace(); + loop { + self.skip_whitespace_and_comments()?; if self.is_at_end() { break; } + let start_line = self.line; let start_column = self.column; let c = self.advance(); if c.is_alphabetic() || c == '_' { let ident = self.read_identifier(c); let (token_type, lexeme) = self.keyword_or_identifier(&ident); - tokens.push(Token::new(token_type, lexeme, self.line, start_column)); - } else if c.is_numeric() { - let number = self.read_number(c); + tokens.push(Token::new(token_type, lexeme, start_line, start_column)); + } else if c.is_ascii_digit() { + let number = self.read_number(c)?; tokens.push(Token::new( TokenType::NumberLiteral, number, - self.line, + start_line, start_column, )); + } else if c == '"' { + self.read_string_tokens(start_line, start_column, &mut tokens)?; } else { - match c { - '=' => { - if self.match_char('=') { - tokens.push(Token::new( - TokenType::DoubleEquals, - "==".to_string(), - self.line, - start_column, - )); - } else if self.match_char('>') { - tokens.push(Token::new( - TokenType::Arrow, - "=>".to_string(), - self.line, - start_column, - )); - } else { - tokens.push(Token::new( - TokenType::Equals, - "=".to_string(), - self.line, - start_column, - )); - } - } - '+' => tokens.push(Token::new( - TokenType::Plus, - "+".to_string(), - self.line, - start_column, - )), - '-' => tokens.push(Token::new( - TokenType::Minus, - "-".to_string(), - self.line, - start_column, - )), - '*' => tokens.push(Token::new( - TokenType::Asterisk, - "*".to_string(), - self.line, - start_column, - )), - '/' => { - if self.match_char('/') { - self.skip_line_comment(); - } else if self.match_char('*') { - self.skip_block_comment(); - } else { - tokens.push(Token::new( - TokenType::Slash, - "/".to_string(), - self.line, - start_column, - )); - } - } - '%' => tokens.push(Token::new( - TokenType::Percent, - "%".to_string(), - self.line, - start_column, - )), - '>' => { - if self.match_char('=') { - tokens.push(Token::new( - TokenType::GreaterEqual, - ">=".to_string(), - self.line, - start_column, - )); - } else { - tokens.push(Token::new( - TokenType::Greater, - ">".to_string(), - self.line, - start_column, - )); - } - } - '<' => { - if self.match_char('=') { - tokens.push(Token::new( - TokenType::LessEqual, - "<=".to_string(), - self.line, - start_column, - )); - } else { - tokens.push(Token::new( - TokenType::Less, - "<".to_string(), - self.line, - start_column, - )); - } - } - '!' => { - if self.match_char('=') { - tokens.push(Token::new( - TokenType::NotEquals, - "!=".to_string(), - self.line, - start_column, - )); - } else { - tokens.push(Token::new( - TokenType::Bang, - "!".to_string(), - self.line, - start_column, - )); - } - } - '&' => { - if self.match_char('&') { - tokens.push(Token::new( - TokenType::AmpAmp, - "&&".to_string(), - self.line, - start_column, - )); - } else { - tokens.push(Token::new( - TokenType::Ampersand, - "&".to_string(), - self.line, - start_column, - )); - } - } - '|' => { - if self.match_char('|') { - tokens.push(Token::new( - TokenType::PipePipe, - "||".to_string(), - self.line, - start_column, - )); - } else { - tokens.push(Token::new( - TokenType::Pipe, - "|".to_string(), - self.line, - start_column, - )); - } - } - '?' => { - if self.match_char('?') { - tokens.push(Token::new( - TokenType::QuestionQuestion, - "??".to_string(), - self.line, - start_column, - )); - } else if self.match_char('.') { - tokens.push(Token::new( - TokenType::QuestionDot, - "?.".to_string(), - self.line, - start_column, - )); - } else { - tokens.push(Token::new( - TokenType::QuestionMark, - "?".to_string(), - self.line, - start_column, - )); - } - } - ';' => tokens.push(Token::new( - TokenType::Semicolon, - ";".to_string(), - self.line, - start_column, - )), - ',' => tokens.push(Token::new( - TokenType::Comma, - ",".to_string(), - self.line, - start_column, - )), - '(' => tokens.push(Token::new( - TokenType::LParen, - "(".to_string(), - self.line, - start_column, - )), - ')' => tokens.push(Token::new( - TokenType::RParen, - ")".to_string(), - self.line, - start_column, - )), - '{' => tokens.push(Token::new( - TokenType::LBrace, - "{".to_string(), - self.line, - start_column, - )), - '}' => tokens.push(Token::new( - TokenType::RBrace, - "}".to_string(), - self.line, - start_column, - )), - '[' => tokens.push(Token::new( - TokenType::LBracket, - "[".to_string(), - self.line, - start_column, - )), - ']' => tokens.push(Token::new( - TokenType::RBracket, - "]".to_string(), - self.line, - start_column, - )), - ':' => tokens.push(Token::new( - TokenType::Colon, - ":".to_string(), - self.line, - start_column, - )), - '.' => { - if self.match_char('.') && self.match_char('.') { - tokens.push(Token::new( - TokenType::DotDotDot, - "...".to_string(), - self.line, - start_column, - )); - } else { - tokens.push(Token::new( - TokenType::Dot, - ".".to_string(), - self.line, - start_column, - )); - } - } - '"' => { - let string_val = self.read_string()?; - tokens.push(Token::new( - TokenType::StringLiteral, - string_val, - self.line, - start_column, - )); - } - _ => { - return Err(format!( - "Unexpected character '{}' at line {}, column {}", - c, self.line, self.column - )); - } - } + let (token_type, lexeme) = self.read_operator(c, start_line, start_column)?; + tokens.push(Token::new( + token_type, + lexeme.to_string(), + start_line, + start_column, + )); } } @@ -316,6 +96,91 @@ impl Lexer { Ok(tokens) } + /// Lex a single- or multi-character operator/delimiter starting at `c`. + fn read_operator( + &mut self, + c: char, + line: usize, + column: usize, + ) -> LexResult<(TokenType, &'static str)> { + use TokenType::*; + + let token = match c { + '=' => { + if self.match_char('=') { + (DoubleEquals, "==") + } else if self.match_char('>') { + (Arrow, "=>") + } else { + (Equals, "=") + } + } + '+' => (Plus, "+"), + '-' => (Minus, "-"), + '*' => (Asterisk, "*"), + '/' => (Slash, "/"), + '%' => (Percent, "%"), + '>' => self.one_or_two('=', (GreaterEqual, ">="), (Greater, ">")), + '<' => self.one_or_two('=', (LessEqual, "<="), (Less, "<")), + '!' => self.one_or_two('=', (NotEquals, "!="), (Bang, "!")), + '&' => self.one_or_two('&', (AmpAmp, "&&"), (Ampersand, "&")), + '|' => self.one_or_two('|', (PipePipe, "||"), (Pipe, "|")), + '?' => { + if self.match_char('?') { + (QuestionQuestion, "??") + } else if self.match_char('.') { + (QuestionDot, "?.") + } else { + (QuestionMark, "?") + } + } + ';' => (Semicolon, ";"), + ',' => (Comma, ","), + '(' => (LParen, "("), + ')' => (RParen, ")"), + '{' => (LBrace, "{"), + '}' => (RBrace, "}"), + '[' => (LBracket, "["), + ']' => (RBracket, "]"), + ':' => (Colon, ":"), + '.' => { + if self.match_char('.') { + if self.match_char('.') { + (DotDotDot, "...") + } else { + return Err(SyntaxError::new( + "Unexpected '..' (did you mean '.' or the spread operator '...'?)", + line, + column, + )); + } + } else { + (Dot, ".") + } + } + _ => { + return Err(SyntaxError::new( + format!("Unexpected character '{}'", c), + line, + column, + )); + } + }; + + Ok(token) + } + + /// If the next character matches `expected`, consume it and return `two`, + /// otherwise return `one`. + fn one_or_two( + &mut self, + expected: char, + two: (TokenType, &'static str), + one: (TokenType, &'static str), + ) -> (TokenType, &'static str) { + if self.match_char(expected) { two } else { one } + } + fn is_at_end(&self) -> bool { self.pos >= self.source.len() } @@ -323,7 +188,12 @@ impl Lexer { fn advance(&mut self) -> char { let c = self.source[self.pos]; self.pos += 1; - self.column += 1; + if c == '\n' { + self.line += 1; + self.column = 1; + } else { + self.column += 1; + } c } @@ -335,6 +205,14 @@ impl Lexer { } } + fn peek_next(&self) -> char { + if self.pos + 1 >= self.source.len() { + '\0' + } else { + self.source[self.pos + 1] + } + } + fn match_char(&mut self, expected: char) -> bool { if self.is_at_end() || self.peek() != expected { false @@ -344,42 +222,49 @@ impl Lexer { } } - fn skip_whitespace(&mut self) { - while !self.is_at_end() { - let c = self.peek(); - if c.is_whitespace() { - if c == '\n' { - self.line += 1; - self.column = 0; - } + /// Skip whitespace, line comments and block comments. Reports an error + /// for unterminated block comments instead of silently accepting them. + fn skip_whitespace_and_comments(&mut self) -> LexResult<()> { + loop { + while !self.is_at_end() && self.peek().is_whitespace() { self.advance(); - } else { - break; } - } - } - fn skip_line_comment(&mut self) { - while !self.is_at_end() && self.peek() != '\n' { - self.advance(); - } - } + if self.peek() == '/' && self.peek_next() == '/' { + while !self.is_at_end() && self.peek() != '\n' { + self.advance(); + } + continue; + } - fn skip_block_comment(&mut self) { - while !self.is_at_end() { - if self.peek() == '*' { - self.advance(); - if !self.is_at_end() && self.peek() == '/' { + if self.peek() == '/' && self.peek_next() == '*' { + let start_line = self.line; + let start_column = self.column; + self.advance(); // '/' + self.advance(); // '*' + + let mut terminated = false; + while !self.is_at_end() { + if self.peek() == '*' && self.peek_next() == '/' { + self.advance(); + self.advance(); + terminated = true; + break; + } self.advance(); - break; } - } else { - if self.peek() == '\n' { - self.line += 1; - self.column = 0; + + if !terminated { + return Err(SyntaxError::new( + "Unterminated block comment", + start_line, + start_column, + )); } - self.advance(); + continue; } + + return Ok(()); } } @@ -400,55 +285,294 @@ impl Lexer { ident } - fn read_number(&mut self, first: char) -> String { + /// Read a numeric literal. Accepts at most one decimal point (only when + /// followed by a digit, so `1.method()` lexes as member access), an + /// optional exponent (`1e5`, `2.5E-3`) and `_` digit separators, which are + /// stripped from the stored lexeme. + fn read_number(&mut self, first: char) -> LexResult { let mut number = String::new(); number.push(first); + let mut seen_dot = false; while !self.is_at_end() { let c = self.peek(); - if c.is_numeric() || c == '.' { + if c.is_ascii_digit() { + number.push(c); + self.advance(); + } else if c == '_' { + if !self.peek_next().is_ascii_digit() { + return Err(SyntaxError::new( + "Digit separator '_' must be followed by a digit", + self.line, + self.column, + )); + } + self.advance(); + } else if c == '.' && !seen_dot && self.peek_next().is_ascii_digit() { + seen_dot = true; number.push(c); self.advance(); + } else if c == 'e' || c == 'E' { + let next = self.peek_next(); + let has_signed_exponent = (next == '+' || next == '-') + && self.pos + 2 < self.source.len() + && self.source[self.pos + 2].is_ascii_digit(); + if next.is_ascii_digit() || has_signed_exponent { + number.push('e'); + self.advance(); + if has_signed_exponent { + number.push(self.advance()); + } + while !self.is_at_end() && self.peek().is_ascii_digit() { + number.push(self.advance()); + } + } + break; } else { break; } } - number + Ok(number) } - fn read_string(&mut self) -> Result { - let mut string = String::new(); + /// Read a string literal starting after the opening quote and push the + /// resulting token(s) onto `tokens`. + /// + /// Plain strings produce a single [`TokenType::StringLiteral`]. Strings + /// containing `${expr}` interpolations are desugared into a parenthesized + /// concatenation, e.g. `"a ${x} b"` becomes `("a " + (x) + " b")`. The + /// leading string segment is always emitted (even when empty) so that the + /// whole expression is string-typed and string coercion applies. + fn read_string_tokens( + &mut self, + start_line: usize, + start_column: usize, + tokens: &mut Vec, + ) -> LexResult<()> { + let parts = self.read_string_parts(start_line, start_column)?; + + // Fast path: no interpolation, emit a single string literal token. + if parts.len() == 1 + && let StringPart::Text(text) = &parts[0] + { + tokens.push(Token::new( + TokenType::StringLiteral, + text.clone(), + start_line, + start_column, + )); + return Ok(()); + } + + let synthetic = |token_type: TokenType, lexeme: &str| { + Token::new(token_type, lexeme.to_string(), start_line, start_column) + }; + + tokens.push(synthetic(TokenType::LParen, "(")); + + let mut first = true; + for part in parts { + match part { + StringPart::Text(text) => { + // The leading segment anchors the expression as a string; + // later empty segments add nothing and are skipped. + if first || !text.is_empty() { + if !first { + tokens.push(synthetic(TokenType::Plus, "+")); + } + tokens.push(synthetic(TokenType::StringLiteral, &text)); + } + } + StringPart::Expression(expr_tokens) => { + tokens.push(synthetic(TokenType::Plus, "+")); + tokens.push(synthetic(TokenType::LParen, "(")); + tokens.extend(expr_tokens); + tokens.push(synthetic(TokenType::RParen, ")")); + } + } + first = false; + } + + tokens.push(synthetic(TokenType::RParen, ")")); + Ok(()) + } + + /// Split a string literal into text segments and interpolated expressions. + /// The first part is always a text segment (possibly empty). + fn read_string_parts( + &mut self, + start_line: usize, + start_column: usize, + ) -> LexResult> { + let mut parts = Vec::new(); + let mut current = String::new(); + + loop { + if self.is_at_end() { + return Err(SyntaxError::new( + "Unterminated string", + start_line, + start_column, + )); + } - while !self.is_at_end() { let c = self.peek(); if c == '"' { self.advance(); - return Ok(string); - } else if c == '\\' { + parts.push(StringPart::Text(current)); + return Ok(parts); + } + + if c == '\\' { self.advance(); if !self.is_at_end() { let escaped = self.advance(); match escaped { - 'n' => string.push('\n'), - 't' => string.push('\t'), - 'r' => string.push('\r'), - '\\' => string.push('\\'), - '"' => string.push('"'), - _ => string.push(escaped), + 'n' => current.push('\n'), + 't' => current.push('\t'), + 'r' => current.push('\r'), + '\\' => current.push('\\'), + '"' => current.push('"'), + '$' => current.push('$'), + 'u' => current.push(self.read_hex_escape(4, "\\u")?), + 'x' => current.push(self.read_hex_escape(2, "\\x")?), + _ => current.push(escaped), } } - } else { - if c == '\n' { - self.line += 1; - self.column = 0; + continue; + } + + if c == '$' && self.peek_next() == '{' { + let expr_line = self.line; + // +2 to point at the first character inside `${`. + let expr_column = self.column + 2; + self.advance(); // '$' + self.advance(); // '{' + let expression_source = self.read_interpolation_source(expr_line, expr_column)?; + + if expression_source.trim().is_empty() { + return Err(SyntaxError::new( + "Empty interpolation '${}' in string literal", + expr_line, + expr_column, + )); } - string.push(c); - self.advance(); + + let mut sub_lexer = Lexer::new_at(&expression_source, expr_line, expr_column); + let mut expr_tokens = sub_lexer.lex()?; + expr_tokens.pop(); // drop the EOF token + + parts.push(StringPart::Text(std::mem::take(&mut current))); + parts.push(StringPart::Expression(expr_tokens)); + continue; + } + + current.push(c); + self.advance(); + } + } + + /// Consume the source text of an interpolated expression up to (and + /// including) the matching `}`. Handles nested braces and nested string + /// literals so `${entrain x { otherwise => "}" }}` stays intact. + fn read_interpolation_source(&mut self, line: usize, column: usize) -> LexResult { + let mut source = String::new(); + let mut depth = 1usize; + + while !self.is_at_end() { + let c = self.peek(); + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + self.advance(); + return Ok(source); + } + } + '"' => { + // Copy a nested string literal verbatim, respecting + // escapes so an escaped quote does not end it early. + source.push(self.advance()); + while !self.is_at_end() && self.peek() != '"' { + let inner = self.advance(); + source.push(inner); + if inner == '\\' && !self.is_at_end() { + source.push(self.advance()); + } + } + if self.is_at_end() { + break; + } + } + _ => {} } + source.push(self.advance()); } - Err(format!("Unterminated string at line {}", self.line)) + Err(SyntaxError::new( + "Unterminated interpolation '${...}' in string literal", + line, + column, + )) + } + + /// Reads a fixed-width hexadecimal escape sequence from the current position. + /// + /// `digits` controls how many hexadecimal digits are consumed after the + /// escape prefix (for example 4 for `\uXXXX` and 2 for `\xXX`). The method + /// returns the decoded Unicode scalar value or an error if the escape is + /// truncated, contains non-hex digits, or decodes to an invalid scalar such + /// as a UTF-16 surrogate. + fn read_hex_escape(&mut self, digits: usize, escape_prefix: &str) -> LexResult { + let mut hex = String::with_capacity(digits); + + for _ in 0..digits { + if self.is_at_end() { + return Err(SyntaxError::new( + format!("Unterminated {} escape", escape_prefix), + self.line, + self.column, + )); + } + + let digit = self.advance(); + if !digit.is_ascii_hexdigit() { + return Err(SyntaxError::new( + format!("Invalid {} escape digit '{}'", escape_prefix, digit), + self.line, + self.column.saturating_sub(1), + )); + } + + hex.push(digit); + } + + // Safe because each digit was already validated with `is_ascii_hexdigit`. + let value = u32::from_str_radix(&hex, 16).unwrap(); + + if (0xD800..=0xDFFF).contains(&value) { + return Err(SyntaxError::new( + format!( + "Invalid Unicode scalar value for {} escape '{}': surrogate code points (U+D800 to U+DFFF) are not valid scalar values", + escape_prefix, hex + ), + self.line, + self.column.saturating_sub(digits), + )); + } + + char::from_u32(value).ok_or_else(|| { + SyntaxError::new( + format!( + "Invalid Unicode scalar value for {} escape '{}'", + escape_prefix, hex + ), + self.line, + self.column.saturating_sub(digits), + ) + }) } fn keyword_or_identifier(&self, s: &str) -> (TokenType, String) { @@ -464,6 +588,16 @@ impl Lexer { mod tests { use super::*; + fn token_types(source: &str) -> Vec { + let mut lexer = Lexer::new(source); + lexer + .lex() + .unwrap() + .into_iter() + .map(|token| token.token_type) + .collect() + } + #[test] fn test_simple_tokens() { let mut lexer = Lexer::new("induce x: number = 42;"); @@ -480,6 +614,48 @@ mod tests { assert_eq!(tokens[0].lexeme, "Hello, World!"); } + #[test] + fn test_string_literal_unicode_escapes() { + let mut unicode_lexer = Lexer::new("\"\\u0041\\u0042\\u0043\""); + let unicode_tokens = unicode_lexer.lex().unwrap(); + assert_eq!(unicode_tokens[0].token_type, TokenType::StringLiteral); + assert_eq!(unicode_tokens[0].lexeme, "ABC"); + + let mut hex_lexer = Lexer::new(r#""Hello\x20World\x21""#); + let hex_tokens = hex_lexer.lex().unwrap(); + assert_eq!(hex_tokens[0].token_type, TokenType::StringLiteral); + assert_eq!(hex_tokens[0].lexeme, "Hello World!"); + } + + #[test] + fn test_string_literal_invalid_unicode_escape() { + let mut lexer = Lexer::new(r#""\u12G4""#); + let error = lexer.lex().unwrap_err(); + assert!(error.to_string().contains("Invalid \\u escape digit")); + } + + #[test] + fn test_string_literal_unterminated_unicode_escape() { + let mut unicode_lexer = Lexer::new("\"\\u12"); + let unicode_error = unicode_lexer.lex().unwrap_err(); + assert!( + unicode_error + .to_string() + .contains("Unterminated \\u escape") + ); + + let mut hex_lexer = Lexer::new("\"\\x4"); + let hex_error = hex_lexer.lex().unwrap_err(); + assert!(hex_error.to_string().contains("Unterminated \\x escape")); + } + + #[test] + fn test_string_literal_invalid_unicode_scalar_escape() { + let mut lexer = Lexer::new(r#""\uD800""#); + let error = lexer.lex().unwrap_err(); + assert!(error.to_string().contains("Invalid Unicode scalar value")); + } + #[test] fn test_operator_synonym_tokenization() { let mut lexer = Lexer::new("if (a youAreFeelingVerySleepy b) { }"); @@ -490,4 +666,147 @@ mod tests { .expect("synonym token not found"); assert_eq!(synonym.lexeme, "youAreFeelingVerySleepy"); } + + #[test] + fn test_unterminated_string_reports_start_position() { + let mut lexer = Lexer::new("\"never closed"); + let error = lexer.lex().unwrap_err(); + assert!(error.to_string().contains("Unterminated string")); + assert_eq!(error.line, 1); + assert_eq!(error.column, 1); + } + + #[test] + fn test_unterminated_block_comment_is_an_error() { + let mut lexer = Lexer::new("induce x = 1; /* comment"); + let error = lexer.lex().unwrap_err(); + assert!(error.to_string().contains("Unterminated block comment")); + } + + #[test] + fn test_double_dot_is_rejected_not_swallowed() { + let mut lexer = Lexer::new("a..b"); + let error = lexer.lex().unwrap_err(); + assert!(error.to_string().contains("'..'")); + } + + #[test] + fn test_number_with_digit_separators() { + let mut lexer = Lexer::new("1_000_000"); + let tokens = lexer.lex().unwrap(); + assert_eq!(tokens[0].token_type, TokenType::NumberLiteral); + assert_eq!(tokens[0].lexeme, "1000000"); + } + + #[test] + fn test_number_with_exponent() { + let mut lexer = Lexer::new("2.5e3 1E2 7e-2"); + let tokens = lexer.lex().unwrap(); + assert_eq!(tokens[0].lexeme, "2.5e3"); + assert_eq!(tokens[1].lexeme, "1e2"); + assert_eq!(tokens[2].lexeme, "7e-2"); + assert!(tokens[0].lexeme.parse::().is_ok()); + } + + #[test] + fn test_number_stops_at_second_dot() { + // `1.2.3` must not produce a single bogus number literal. + let mut lexer = Lexer::new("1.2.toFixed"); + let tokens = lexer.lex().unwrap(); + assert_eq!(tokens[0].token_type, TokenType::NumberLiteral); + assert_eq!(tokens[0].lexeme, "1.2"); + assert_eq!(tokens[1].token_type, TokenType::Dot); + assert_eq!(tokens[2].token_type, TokenType::Identifier); + } + + #[test] + fn test_invalid_digit_separator_is_rejected() { + let mut lexer = Lexer::new("1__2"); + assert!(lexer.lex().is_err()); + let mut trailing = Lexer::new("1_;"); + assert!(trailing.lex().is_err()); + } + + #[test] + fn test_multiline_string_token_reports_start_line() { + let mut lexer = Lexer::new("\"line1\nline2\" induce"); + let tokens = lexer.lex().unwrap(); + assert_eq!(tokens[0].token_type, TokenType::StringLiteral); + assert_eq!(tokens[0].line, 1); + assert_eq!(tokens[1].token_type, TokenType::Induce); + assert_eq!(tokens[1].line, 2); + } + + #[test] + fn test_string_interpolation_desugars_to_concatenation() { + // "a ${x} b" => ( "a " + ( x ) + " b" ) + let types = token_types(r#""a ${x} b""#); + assert_eq!( + types, + vec![ + TokenType::LParen, + TokenType::StringLiteral, + TokenType::Plus, + TokenType::LParen, + TokenType::Identifier, + TokenType::RParen, + TokenType::Plus, + TokenType::StringLiteral, + TokenType::RParen, + TokenType::Eof, + ] + ); + } + + #[test] + fn test_string_interpolation_expression_only() { + // "${x}" => ( "" + ( x ) ) + let mut lexer = Lexer::new(r#""${x}""#); + let tokens = lexer.lex().unwrap(); + assert_eq!(tokens[0].token_type, TokenType::LParen); + assert_eq!(tokens[1].token_type, TokenType::StringLiteral); + assert_eq!(tokens[1].lexeme, ""); + assert_eq!(tokens[2].token_type, TokenType::Plus); + assert_eq!(tokens[4].lexeme, "x"); + } + + #[test] + fn test_string_interpolation_with_nested_expression() { + // Nested braces and strings inside the interpolation stay intact. + let mut lexer = Lexer::new(r#""v: ${format("{}", value)}""#); + let tokens = lexer.lex().unwrap(); + let lexemes: Vec<&str> = tokens.iter().map(|t| t.lexeme.as_str()).collect(); + assert!(lexemes.contains(&"format")); + assert!(lexemes.contains(&"{}")); + assert!(lexemes.contains(&"value")); + } + + #[test] + fn test_string_interpolation_escape_opts_out() { + let mut lexer = Lexer::new(r#""costs \${price}""#); + let tokens = lexer.lex().unwrap(); + assert_eq!(tokens[0].token_type, TokenType::StringLiteral); + assert_eq!(tokens[0].lexeme, "costs ${price}"); + } + + #[test] + fn test_string_interpolation_empty_is_rejected() { + let mut lexer = Lexer::new(r#""${}""#); + let error = lexer.lex().unwrap_err(); + assert!(error.to_string().contains("Empty interpolation")); + } + + #[test] + fn test_string_interpolation_unterminated_is_rejected() { + let mut lexer = Lexer::new(r#""${x + 1"#); + let error = lexer.lex().unwrap_err(); + assert!(error.to_string().contains("Unterminated interpolation")); + } + + #[test] + fn test_plain_dollar_without_brace_is_literal() { + let mut lexer = Lexer::new(r#""price: $5""#); + let tokens = lexer.lex().unwrap(); + assert_eq!(tokens[0].lexeme, "price: $5"); + } } diff --git a/hypnoscript-lexer-parser/src/lib.rs b/hypnoscript-lexer-parser/src/lib.rs index e6ee590..a6321f2 100644 --- a/hypnoscript-lexer-parser/src/lib.rs +++ b/hypnoscript-lexer-parser/src/lib.rs @@ -3,11 +3,15 @@ //! This module provides the lexer and parser for the HypnoScript language. pub mod ast; +pub mod error; pub mod lexer; pub mod parser; +pub mod source; pub mod token; // Re-export commonly used types +pub use error::SyntaxError; pub use lexer::Lexer; pub use parser::Parser; +pub use source::decode_source; pub use token::{Token, TokenType}; diff --git a/hypnoscript-lexer-parser/src/parser.rs b/hypnoscript-lexer-parser/src/parser.rs index 3177167..41fb540 100644 --- a/hypnoscript-lexer-parser/src/parser.rs +++ b/hypnoscript-lexer-parser/src/parser.rs @@ -2,12 +2,18 @@ use crate::ast::{ AstNode, EntrainCase, Parameter, Pattern, RecordFieldInit, RecordFieldPattern, SessionField, SessionMember, SessionMethod, SessionVisibility, TranceifyField, VariableStorage, }; +use crate::error::SyntaxError; use crate::token::{Token, TokenType}; +/// Result alias for parsing operations. +pub type ParseResult = Result; + /// Parser for HypnoScript language. /// /// Converts a stream of tokens into an Abstract Syntax Tree (AST). /// Uses recursive descent parsing with operator precedence for expressions. +/// All errors are reported as [`SyntaxError`]s carrying the line and column +/// of the offending token. /// /// # Supported Language Constructs /// @@ -58,6 +64,14 @@ type LoopHeaderComponents = ( Option>, ); +/// Keywords that introduce a variable declaration. +const DECLARATION_KEYWORDS: [TokenType; 4] = [ + TokenType::Induce, + TokenType::Implant, + TokenType::Embed, + TokenType::Freeze, +]; + impl Parser { /// Create a new parser pub fn new(tokens: Vec) -> Self { @@ -65,16 +79,16 @@ impl Parser { } /// Parse a complete program - pub fn parse_program(&mut self) -> Result { + pub fn parse_program(&mut self) -> ParseResult { // Program must start with Focus if !self.check(&TokenType::Focus) { - return Err("Program must start with 'Focus'".to_string()); + return Err(self.error_here("Program must start with 'Focus'")); } self.advance(); // Expect opening brace if !self.match_token(&TokenType::LBrace) { - return Err("Expected '{' after 'Focus'".to_string()); + return Err(self.error_here("Expected '{' after 'Focus'")); } // Parse program body @@ -82,12 +96,12 @@ impl Parser { // Expect closing brace if !self.match_token(&TokenType::RBrace) { - return Err("Expected '}' before 'Relax'".to_string()); + return Err(self.error_here("Expected '}' before 'Relax'")); } // Program must end with Relax if !self.check(&TokenType::Relax) { - return Err("Program must end with 'Relax'".to_string()); + return Err(self.error_here("Program must end with 'Relax'")); } self.advance(); @@ -95,46 +109,28 @@ impl Parser { } /// Parse block statements - fn parse_block_statements(&mut self, context: BlockContext) -> Result, String> { + fn parse_block_statements(&mut self, context: BlockContext) -> ParseResult> { let mut statements = Vec::new(); while !self.is_at_end() && !self.check(&TokenType::RBrace) && !self.check(&TokenType::Relax) { // entrance block (constructor/setup) if self.match_token(&TokenType::Entrance) { - if context != BlockContext::Program { - return Err("'entrance' blocks are only allowed at the top level".to_string()); - } - if !self.match_token(&TokenType::LBrace) { - return Err("Expected '{' after 'entrance'".to_string()); - } - let mut entrance_statements = Vec::new(); - while !self.is_at_end() && !self.check(&TokenType::RBrace) { - entrance_statements.push(self.parse_statement(BlockContext::Regular)?); - } - if !self.match_token(&TokenType::RBrace) { - return Err("Expected '}' after entrance block".to_string()); - } - statements.push(AstNode::EntranceBlock(entrance_statements)); + statements.push(self.parse_top_level_block( + context, + "entrance", + AstNode::EntranceBlock, + )?); continue; } // finale block (destructor/cleanup) if self.match_token(&TokenType::Finale) { - if context != BlockContext::Program { - return Err("'finale' blocks are only allowed at the top level".to_string()); - } - if !self.match_token(&TokenType::LBrace) { - return Err("Expected '{' after 'finale'".to_string()); - } - let mut finale_statements = Vec::new(); - while !self.is_at_end() && !self.check(&TokenType::RBrace) { - finale_statements.push(self.parse_statement(BlockContext::Regular)?); - } - if !self.match_token(&TokenType::RBrace) { - return Err("Expected '}' after finale block".to_string()); - } - statements.push(AstNode::FinaleBlock(finale_statements)); + statements.push(self.parse_top_level_block( + context, + "finale", + AstNode::FinaleBlock, + )?); continue; } @@ -144,28 +140,49 @@ impl Parser { Ok(statements) } + /// Parse an `entrance`/`finale` block, which is only valid at the top + /// level of a program. + fn parse_top_level_block( + &mut self, + context: BlockContext, + keyword: &str, + constructor: fn(Vec) -> AstNode, + ) -> ParseResult { + if context != BlockContext::Program { + return Err(self.error_here(format!( + "'{}' blocks are only allowed at the top level", + keyword + ))); + } + if !self.match_token(&TokenType::LBrace) { + return Err(self.error_here(format!("Expected '{{' after '{}'", keyword))); + } + let mut block_statements = Vec::new(); + while !self.is_at_end() && !self.check(&TokenType::RBrace) { + block_statements.push(self.parse_statement(BlockContext::Regular)?); + } + if !self.match_token(&TokenType::RBrace) { + return Err(self.error_here(format!("Expected '}}' after {} block", keyword))); + } + Ok(constructor(block_statements)) + } + /// Parse a single statement - fn parse_statement(&mut self, context: BlockContext) -> Result { + fn parse_statement(&mut self, context: BlockContext) -> ParseResult { // Variable declaration - induce, implant, embed, freeze if self.match_token(&TokenType::SharedTrance) { - if self.match_token(&TokenType::Induce) - || self.match_token(&TokenType::Implant) - || self.match_token(&TokenType::Embed) - || self.match_token(&TokenType::Freeze) - { + // 'sharedTrance induce x = ...;' or the shorthand 'sharedTrance x = ...;' + if self.match_tokens(&DECLARATION_KEYWORDS) || self.check(&TokenType::Identifier) { return self.parse_var_declaration(VariableStorage::SharedTrance); } - return Err( - "'sharedTrance' must be followed by induce/implant/embed/freeze".to_string(), - ); + return Err(self.error_here( + "'sharedTrance' must be followed by a variable declaration \ + (induce/implant/embed/freeze or a variable name)", + )); } - if self.match_token(&TokenType::Induce) - || self.match_token(&TokenType::Implant) - || self.match_token(&TokenType::Embed) - || self.match_token(&TokenType::Freeze) - { + if self.match_tokens(&DECLARATION_KEYWORDS) { return self.parse_var_declaration(VariableStorage::Local); } @@ -179,6 +196,12 @@ impl Parser { return self.parse_if_statement(); } + // Standalone deepFocus statement: like 'if' with hypnotic emphasis + // Example: deepFocus (depth > 5) { ... } + if self.match_token(&TokenType::DeepFocus) { + return self.parse_deep_focus_statement(); + } + // While loop if self.match_token(&TokenType::While) { return self.parse_while_statement(); @@ -200,15 +223,30 @@ impl Parser { return Ok(AstNode::SuspendStatement); } - // Function declaration - if self.match_token(&TokenType::Suggestion) { + // Drift statement (sleep): drift(milliseconds); / pauseReality(milliseconds); + if self.match_tokens(&[TokenType::Drift, TokenType::PauseReality]) { + return self.parse_drift_statement(); + } + + // Function declaration ('suggestion', 'imperativeSuggestion' or + // the two-word form 'imperative suggestion') + if self.match_token(&TokenType::Suggestion) + || self.match_token(&TokenType::ImperativeSuggestion) + { + return self.parse_function_declaration(); + } + if self.match_token(&TokenType::Imperative) { + self.consume( + &TokenType::Suggestion, + "Expected 'suggestion' after 'imperative'", + )?; return self.parse_function_declaration(); } // Trigger declaration (event handler/callback) if self.match_token(&TokenType::Trigger) { if context != BlockContext::Program { - return Err("Triggers can only be declared at the top level".to_string()); + return Err(self.error_here("Triggers can only be declared at the top level")); } return self.parse_trigger_declaration(); } @@ -225,20 +263,20 @@ impl Parser { // Output statements if self.match_token(&TokenType::Observe) { - return self.parse_observe_statement(); + return self.parse_output_statement("observe", AstNode::ObserveStatement); } if self.match_token(&TokenType::Whisper) { - return self.parse_whisper_statement(); + return self.parse_output_statement("whisper", AstNode::WhisperStatement); } if self.match_token(&TokenType::Command) { - return self.parse_command_statement(); + return self.parse_output_statement("command", AstNode::CommandStatement); } // Murmur statement (quiet/debug output) if self.match_token(&TokenType::Murmur) { - return self.parse_murmur_statement(); + return self.parse_output_statement("murmur", AstNode::MurmurStatement); } // Return statement @@ -246,16 +284,18 @@ impl Parser { return self.parse_return_statement(); } - // Break + // Break: snap; / snap someLabel; if self.match_token(&TokenType::Snap) { + let label = self.parse_optional_label(); self.consume(&TokenType::Semicolon, "Expected ';' after 'snap'")?; - return Ok(AstNode::BreakStatement); + return Ok(AstNode::BreakStatement { label }); } - // Continue + // Continue: sink; / sink someLabel; if self.match_token(&TokenType::Sink) { + let label = self.parse_optional_label(); self.consume(&TokenType::Semicolon, "Expected ';' after 'sink'")?; - return Ok(AstNode::ContinueStatement); + return Ok(AstNode::ContinueStatement { label }); } // Oscillate statement (toggle boolean) @@ -263,18 +303,75 @@ impl Parser { return self.parse_oscillate_statement(); } + // Labeled statement: 'name: loop (...) { ... }' or 'label name: ...' + if self.match_token(&TokenType::Label) { + return self.parse_labeled_statement(context); + } + if self.check(&TokenType::Identifier) + && self.peek_next().map(|t| &t.token_type) == Some(&TokenType::Colon) + { + return self.parse_labeled_statement(context); + } + // Expression statement let expr = self.parse_expression()?; self.consume(&TokenType::Semicolon, "Expected ';' after expression")?; Ok(AstNode::ExpressionStatement(Box::new(expr))) } - /// Parse variable declaration (induce/implant/freeze) - /// - induce: standard variable (like let/var) - /// - implant: alternative variable declaration + /// Parse the optional label name of a `snap`/`sink` statement. + fn parse_optional_label(&mut self) -> Option { + if self.check(&TokenType::Identifier) { + Some(self.advance().lexeme.clone()) + } else { + None + } + } + + /// Parse a labeled statement: `name: `. The label usually + /// marks a loop so `snap name;` / `sink name;` can target it. + fn parse_labeled_statement(&mut self, context: BlockContext) -> ParseResult { + let label = self + .consume(&TokenType::Identifier, "Expected label name")? + .lexeme + .clone(); + self.consume(&TokenType::Colon, "Expected ':' after label name")?; + let body = Box::new(self.parse_statement(context)?); + Ok(AstNode::LabeledStatement { label, body }) + } + + /// Parse an output statement (`observe`, `whisper`, `command`, `murmur`). + fn parse_output_statement( + &mut self, + keyword: &str, + constructor: fn(Box) -> AstNode, + ) -> ParseResult { + let expr = Box::new(self.parse_expression()?); + self.consume( + &TokenType::Semicolon, + &format!("Expected ';' after {}", keyword), + )?; + Ok(constructor(expr)) + } + + /// Parse variable declaration (induce/implant/embed/freeze). + /// The declaration keyword has already been consumed. + /// - induce/implant/embed: variables (like let/var) /// - freeze: constant (like const) - fn parse_var_declaration(&mut self, storage: VariableStorage) -> Result { - // Determine if this is a constant (freeze) or variable (induce/implant) + fn parse_var_declaration(&mut self, storage: VariableStorage) -> ParseResult { + let declaration = self.parse_var_declaration_body(storage)?; + self.consume( + &TokenType::Semicolon, + "Expected ';' after variable declaration", + )?; + Ok(declaration) + } + + /// Parse the body of a variable declaration (name, optional type + /// annotation, optional initializer) without the trailing semicolon. + /// Shared between statements and loop initializers. + fn parse_var_declaration_body(&mut self, storage: VariableStorage) -> ParseResult { + // Determine if this is a constant (freeze) or variable (induce/implant/embed) let is_constant = self.previous().token_type == TokenType::Freeze; let name = self @@ -282,12 +379,7 @@ impl Parser { .lexeme .clone(); - let type_annotation = if self.match_token(&TokenType::Colon) { - let type_token = self.advance(); - Some(type_token.lexeme.clone()) - } else { - None - }; + let type_annotation = self.parse_optional_type_annotation()?; let initializer = if self.match_token(&TokenType::Equals) { Some(Box::new(self.parse_expression()?)) @@ -295,11 +387,6 @@ impl Parser { None }; - self.consume( - &TokenType::Semicolon, - "Expected ';' after variable declaration", - )?; - Ok(AstNode::VariableDeclaration { name, type_annotation, @@ -309,9 +396,29 @@ impl Parser { }) } + /// Parse drift/pauseReality statement (pause for N milliseconds) + /// Example: drift(500); + fn parse_drift_statement(&mut self) -> ParseResult { + let keyword = self.previous().lexeme.clone(); + self.consume( + &TokenType::LParen, + &format!("Expected '(' after '{}'", keyword), + )?; + let duration = Box::new(self.parse_expression()?); + self.consume( + &TokenType::RParen, + &format!("Expected ')' after '{}' duration", keyword), + )?; + self.consume( + &TokenType::Semicolon, + &format!("Expected ';' after '{}' statement", keyword), + )?; + Ok(AstNode::DriftStatement { duration }) + } + /// Parse anchor declaration (saves variable state) /// Example: anchor savedValue = currentValue; - fn parse_anchor_declaration(&mut self) -> Result { + fn parse_anchor_declaration(&mut self) -> ParseResult { let name = self .consume(&TokenType::Identifier, "Expected anchor name")? .lexeme @@ -331,7 +438,7 @@ impl Parser { /// Parse oscillate statement (toggle boolean) /// Example: oscillate myFlag; - fn parse_oscillate_statement(&mut self) -> Result { + fn parse_oscillate_statement(&mut self) -> ParseResult { let target = Box::new(self.parse_primary()?); self.consume( @@ -342,29 +449,8 @@ impl Parser { Ok(AstNode::OscillateStatement { target }) } - /// Parse whisper statement (output without newline) - fn parse_whisper_statement(&mut self) -> Result { - let expr = self.parse_expression()?; - self.consume(&TokenType::Semicolon, "Expected ';' after whisper")?; - Ok(AstNode::WhisperStatement(Box::new(expr))) - } - - /// Parse command statement (imperative output) - fn parse_command_statement(&mut self) -> Result { - let expr = self.parse_expression()?; - self.consume(&TokenType::Semicolon, "Expected ';' after command")?; - Ok(AstNode::CommandStatement(Box::new(expr))) - } - - /// Parse murmur statement (quiet/debug output) - fn parse_murmur_statement(&mut self) -> Result { - let expr = self.parse_expression()?; - self.consume(&TokenType::Semicolon, "Expected ';' after murmur")?; - Ok(AstNode::MurmurStatement(Box::new(expr))) - } - /// Parse trigger declaration (event handler/callback) - fn parse_trigger_declaration(&mut self) -> Result { + fn parse_trigger_declaration(&mut self) -> ParseResult { let name = self .consume(&TokenType::Identifier, "Expected trigger name")? .lexeme @@ -376,44 +462,19 @@ impl Parser { self.consume(&TokenType::Suggestion, "Expected 'suggestion' after '='")?; self.consume(&TokenType::LParen, "Expected '(' after 'suggestion'")?; - - // Parse parameters (inline to avoid duplication) - let mut parameters = Vec::new(); - if !self.check(&TokenType::RParen) { - loop { - let param_name = self - .consume(&TokenType::Identifier, "Expected parameter name")? - .lexeme - .clone(); - let type_annotation = if self.match_token(&TokenType::Colon) { - let type_token = self.advance(); - Some(type_token.lexeme.clone()) - } else { - None - }; - parameters.push(Parameter::new(param_name, type_annotation)); - - if !self.match_token(&TokenType::Comma) { - break; - } - } - } - + let parameters = self.parse_parameter_list()?; self.consume(&TokenType::RParen, "Expected ')' after parameters")?; - // Optional return type - let return_type = if self.match_token(&TokenType::Colon) { - let type_token = self.advance(); - Some(type_token.lexeme.clone()) - } else { - None - }; + let return_type = self.parse_optional_type_annotation()?; // Parse body self.consume(&TokenType::LBrace, "Expected '{' before trigger body")?; let body = self.parse_block_statements(BlockContext::Regular)?; self.consume(&TokenType::RBrace, "Expected '}' after trigger body")?; + // The declaration is an assignment, so a trailing ';' is customary. + self.match_token(&TokenType::Semicolon); + Ok(AstNode::TriggerDeclaration { name, parameters, @@ -423,7 +484,7 @@ impl Parser { } /// Parse if statement - fn parse_if_statement(&mut self) -> Result { + fn parse_if_statement(&mut self) -> ParseResult { self.consume(&TokenType::LParen, "Expected '(' after 'if'")?; let condition = Box::new(self.parse_expression()?); self.consume(&TokenType::RParen, "Expected ')' after if condition")?; @@ -456,8 +517,22 @@ impl Parser { }) } + /// Parse standalone deepFocus statement (conditional block with + /// hypnotic emphasis, no else branch) + fn parse_deep_focus_statement(&mut self) -> ParseResult { + self.consume(&TokenType::LParen, "Expected '(' after 'deepFocus'")?; + let condition = Box::new(self.parse_expression()?); + self.consume(&TokenType::RParen, "Expected ')' after deepFocus condition")?; + + self.consume(&TokenType::LBrace, "Expected '{' after deepFocus condition")?; + let body = self.parse_block_statements(BlockContext::Regular)?; + self.consume(&TokenType::RBrace, "Expected '}' after deepFocus block")?; + + Ok(AstNode::DeepFocusStatement { condition, body }) + } + /// Parse while statement - fn parse_while_statement(&mut self) -> Result { + fn parse_while_statement(&mut self) -> ParseResult { self.consume(&TokenType::LParen, "Expected '(' after 'while'")?; let condition = Box::new(self.parse_expression()?); self.consume(&TokenType::RParen, "Expected ')' after while condition")?; @@ -475,12 +550,12 @@ impl Parser { keyword: &str, require_header: bool, require_condition: bool, - ) -> Result { + ) -> ParseResult { let has_header = if self.match_token(&TokenType::LParen) { true } else { if require_header { - return Err(format!("Expected '(' after '{}'", keyword)); + return Err(self.error_here(format!("Expected '(' after '{}'", keyword))); } false }; @@ -513,7 +588,7 @@ impl Parser { &mut self, keyword: &str, require_condition: bool, - ) -> Result { + ) -> ParseResult { // Parse init (variable declaration or expression) let init = if self.check(&TokenType::Semicolon) { None @@ -534,7 +609,9 @@ impl Parser { }; if require_condition && condition.is_none() { - return Err(format!("{} loop requires a condition expression", keyword)); + return Err( + self.error_here(format!("{} loop requires a condition expression", keyword)) + ); } self.consume( @@ -558,38 +635,10 @@ impl Parser { Ok((init, condition, update)) } - fn parse_loop_init_statement(&mut self) -> Result>, String> { - if self.match_token(&TokenType::Induce) - || self.match_token(&TokenType::Implant) - || self.match_token(&TokenType::Embed) - || self.match_token(&TokenType::Freeze) - { - let is_constant = self.previous().token_type == TokenType::Freeze; - let name = self - .consume(&TokenType::Identifier, "Expected variable name")? - .lexeme - .clone(); - - let type_annotation = if self.match_token(&TokenType::Colon) { - let type_token = self.advance(); - Some(type_token.lexeme.clone()) - } else { - None - }; - - let initializer = if self.match_token(&TokenType::Equals) { - Some(Box::new(self.parse_expression()?)) - } else { - None - }; - - return Ok(Some(Box::new(AstNode::VariableDeclaration { - name, - type_annotation, - initializer, - is_constant, - storage: VariableStorage::Local, - }))); + fn parse_loop_init_statement(&mut self) -> ParseResult>> { + if self.match_tokens(&DECLARATION_KEYWORDS) { + let declaration = self.parse_var_declaration_body(VariableStorage::Local)?; + return Ok(Some(Box::new(declaration))); } if self.check(&TokenType::Semicolon) { @@ -601,27 +650,43 @@ impl Parser { } /// Parse function declaration - fn parse_function_declaration(&mut self) -> Result { + fn parse_function_declaration(&mut self) -> ParseResult { let name = self .consume(&TokenType::Identifier, "Expected function name")? .lexeme .clone(); self.consume(&TokenType::LParen, "Expected '(' after function name")?; + let parameters = self.parse_parameter_list()?; + self.consume(&TokenType::RParen, "Expected ')' after parameters")?; + + let return_type = self.parse_optional_type_annotation()?; + + self.consume(&TokenType::LBrace, "Expected '{' after function signature")?; + let body = self.parse_block_statements(BlockContext::Regular)?; + self.consume(&TokenType::RBrace, "Expected '}' after function body")?; + Ok(AstNode::FunctionDeclaration { + name, + parameters, + return_type, + body, + }) + } + + /// Parse a comma-separated parameter list (without the surrounding + /// parentheses). Each parameter is a name with an optional `: type` + /// annotation. + fn parse_parameter_list(&mut self) -> ParseResult> { let mut parameters = Vec::new(); + if !self.check(&TokenType::RParen) { loop { let param_name = self .consume(&TokenType::Identifier, "Expected parameter name")? .lexeme .clone(); - let type_annotation = if self.match_token(&TokenType::Colon) { - let type_token = self.advance(); - Some(type_token.lexeme.clone()) - } else { - None - }; + let type_annotation = self.parse_optional_type_annotation()?; parameters.push(Parameter::new(param_name, type_annotation)); if !self.match_token(&TokenType::Comma) { @@ -630,29 +695,23 @@ impl Parser { } } - self.consume(&TokenType::RParen, "Expected ')' after parameters")?; + Ok(parameters) + } - let return_type = if self.match_token(&TokenType::Colon) { - let type_token = self.advance(); - Some(type_token.lexeme.clone()) + /// Parse an optional `: type` annotation. Returns `None` when no colon + /// follows. Accepts identifiers as well as type keywords (`number`, + /// `string`, `boolean`, `trance`), plus the modifiers described in + /// [`Parser::parse_type_annotation`]. + fn parse_optional_type_annotation(&mut self) -> ParseResult> { + if self.match_token(&TokenType::Colon) { + Ok(Some(self.parse_type_annotation()?)) } else { - None - }; - - self.consume(&TokenType::LBrace, "Expected '{' after function signature")?; - let body = self.parse_block_statements(BlockContext::Regular)?; - self.consume(&TokenType::RBrace, "Expected '}' after function body")?; - - Ok(AstNode::FunctionDeclaration { - name, - parameters, - return_type, - body, - }) + Ok(None) + } } /// Parse session declaration - fn parse_session_declaration(&mut self) -> Result { + fn parse_session_declaration(&mut self) -> ParseResult { let name = self .consume(&TokenType::Identifier, "Expected session name")? .lexeme @@ -672,7 +731,7 @@ impl Parser { /// Parse tranceify declaration (record/struct type definition) /// Example: tranceify Person { name: string; age: number; isInTrance: boolean; } - fn parse_tranceify_declaration(&mut self) -> Result { + fn parse_tranceify_declaration(&mut self) -> ParseResult { let name = self .consume(&TokenType::Identifier, "Expected tranceify type name")? .lexeme @@ -710,7 +769,7 @@ impl Parser { /// Parse record literal (instance of a tranceify type) /// Example: Person { name: "Alice", age: 30, isInTrance: true } /// Note: The opening '{' has already been consumed - fn parse_record_literal(&mut self, type_name: String) -> Result { + fn parse_record_literal(&mut self, type_name: String) -> ParseResult { let mut fields = Vec::new(); if !self.check(&TokenType::RBrace) { @@ -747,22 +806,19 @@ impl Parser { } /// Parse an individual session member (field or method) - fn parse_session_member(&mut self) -> Result { - let mut is_static = false; - if self.match_token(&TokenType::Dominant) { - is_static = true; - } + fn parse_session_member(&mut self) -> ParseResult { + let is_static = self.match_token(&TokenType::Dominant); // Optional visibility modifiers if self.check(&TokenType::Expose) || self.check(&TokenType::Conceal) { - let visibility_token = self.advance(); - let visibility = if visibility_token.token_type == TokenType::Expose { + let visibility = if self.advance().token_type == TokenType::Expose { SessionVisibility::Public } else { SessionVisibility::Private }; if self.check(&TokenType::Suggestion) + || self.check(&TokenType::Imperative) || self.check(&TokenType::ImperativeSuggestion) || self.check(&TokenType::DominantSuggestion) { @@ -780,18 +836,13 @@ impl Parser { &mut self, is_static: bool, visibility: SessionVisibility, - ) -> Result { + ) -> ParseResult { let name = self .consume(&TokenType::Identifier, "Expected field name in session")? .lexeme .clone(); - let type_annotation = if self.match_token(&TokenType::Colon) { - let type_token = self.advance(); - Some(type_token.lexeme.clone()) - } else { - None - }; + let type_annotation = self.parse_optional_type_annotation()?; let initializer = if self.match_token(&TokenType::Equals) { Some(Box::new(self.parse_expression()?)) @@ -817,22 +868,20 @@ impl Parser { &mut self, mut is_static: bool, visibility: Option, - ) -> Result { + ) -> ParseResult { let visibility = visibility.unwrap_or(SessionVisibility::Public); - let method_token = if self.match_token(&TokenType::Suggestion) { - Some(TokenType::Suggestion) - } else if self.match_token(&TokenType::ImperativeSuggestion) { - Some(TokenType::ImperativeSuggestion) - } else if self.match_token(&TokenType::DominantSuggestion) { + if self.match_token(&TokenType::DominantSuggestion) { is_static = true; - Some(TokenType::DominantSuggestion) - } else { - None - }; - - if method_token.is_none() { - return Err("Expected 'suggestion' inside session".to_string()); + } else if self.match_token(&TokenType::Imperative) { + self.consume( + &TokenType::Suggestion, + "Expected 'suggestion' after 'imperative'", + )?; + } else if !self.match_token(&TokenType::Suggestion) + && !self.match_token(&TokenType::ImperativeSuggestion) + { + return Err(self.error_here("Expected 'suggestion' inside session")); } let mut is_constructor = false; @@ -846,36 +895,10 @@ impl Parser { }; self.consume(&TokenType::LParen, "Expected '(' after method name")?; - - let mut parameters = Vec::new(); - if !self.check(&TokenType::RParen) { - loop { - let param_name = self - .consume(&TokenType::Identifier, "Expected parameter name")? - .lexeme - .clone(); - let type_annotation = if self.match_token(&TokenType::Colon) { - let type_token = self.advance(); - Some(type_token.lexeme.clone()) - } else { - None - }; - parameters.push(Parameter::new(param_name, type_annotation)); - - if !self.match_token(&TokenType::Comma) { - break; - } - } - } - + let parameters = self.parse_parameter_list()?; self.consume(&TokenType::RParen, "Expected ')' after parameters")?; - let return_type = if self.match_token(&TokenType::Colon) { - let type_token = self.advance(); - Some(type_token.lexeme.clone()) - } else { - None - }; + let return_type = self.parse_optional_type_annotation()?; self.consume(&TokenType::LBrace, "Expected '{' after method signature")?; let body = self.parse_block_statements(BlockContext::Regular)?; @@ -892,18 +915,8 @@ impl Parser { })) } - /// Parse observe statement - fn parse_observe_statement(&mut self) -> Result { - let expr = Box::new(self.parse_expression()?); - self.consume( - &TokenType::Semicolon, - "Expected ';' after observe statement", - )?; - Ok(AstNode::ObserveStatement(expr)) - } - /// Parse return statement - fn parse_return_statement(&mut self) -> Result { + fn parse_return_statement(&mut self) -> ParseResult { let value = if !self.check(&TokenType::Semicolon) { Some(Box::new(self.parse_expression()?)) } else { @@ -914,12 +927,12 @@ impl Parser { } /// Parse expression - fn parse_expression(&mut self) -> Result { + fn parse_expression(&mut self) -> ParseResult { self.parse_assignment() } /// Parse assignment - fn parse_assignment(&mut self) -> Result { + fn parse_assignment(&mut self) -> ParseResult { let expr = self.parse_nullish_coalescing()?; if self.match_token(&TokenType::Equals) { @@ -934,7 +947,7 @@ impl Parser { } /// Parse nullish coalescing (?? or lucidFallback) - fn parse_nullish_coalescing(&mut self) -> Result { + fn parse_nullish_coalescing(&mut self) -> ParseResult { let mut left = self.parse_logical_or()?; while self.match_tokens(&[TokenType::QuestionQuestion, TokenType::LucidFallback]) { @@ -948,13 +961,18 @@ impl Parser { Ok(left) } - /// Parse logical OR - fn parse_logical_or(&mut self) -> Result { - let mut left = self.parse_logical_and()?; + /// Parse a left-associative chain of binary operators, delegating to + /// `next` for operands of the next-higher precedence level. + fn parse_binary_level( + &mut self, + operators: &[TokenType], + next: fn(&mut Self) -> ParseResult, + ) -> ParseResult { + let mut left = next(self)?; - while self.match_tokens(&[TokenType::PipePipe, TokenType::ResistanceIsFutile]) { + while self.match_tokens(operators) { let operator = self.previous().lexeme.clone(); - let right = Box::new(self.parse_logical_and()?); + let right = Box::new(next(self)?); left = AstNode::BinaryExpression { left: Box::new(left), operator, @@ -965,110 +983,70 @@ impl Parser { Ok(left) } - /// Parse logical AND - fn parse_logical_and(&mut self) -> Result { - let mut left = self.parse_equality()?; - - while self.match_tokens(&[TokenType::AmpAmp, TokenType::UnderMyControl]) { - let operator = self.previous().lexeme.clone(); - let right = Box::new(self.parse_equality()?); - left = AstNode::BinaryExpression { - left: Box::new(left), - operator, - right, - }; - } + /// Parse logical OR + fn parse_logical_or(&mut self) -> ParseResult { + self.parse_binary_level( + &[TokenType::PipePipe, TokenType::ResistanceIsFutile], + Self::parse_logical_and, + ) + } - Ok(left) + /// Parse logical AND + fn parse_logical_and(&mut self) -> ParseResult { + self.parse_binary_level( + &[TokenType::AmpAmp, TokenType::UnderMyControl], + Self::parse_equality, + ) } /// Parse equality - fn parse_equality(&mut self) -> Result { - let mut left = self.parse_comparison()?; - - while self.match_tokens(&[ - TokenType::DoubleEquals, - TokenType::NotEquals, - TokenType::YouAreFeelingVerySleepy, - TokenType::YouCannotResist, - TokenType::NotSoDeep, - ]) { - let operator = self.previous().lexeme.clone(); - let right = Box::new(self.parse_comparison()?); - left = AstNode::BinaryExpression { - left: Box::new(left), - operator, - right, - }; - } - - Ok(left) + fn parse_equality(&mut self) -> ParseResult { + self.parse_binary_level( + &[ + TokenType::DoubleEquals, + TokenType::NotEquals, + TokenType::YouAreFeelingVerySleepy, + TokenType::YouCannotResist, + TokenType::NotSoDeep, + ], + Self::parse_comparison, + ) } /// Parse comparison - fn parse_comparison(&mut self) -> Result { - let mut left = self.parse_term()?; - - while self.match_tokens(&[ - TokenType::Greater, - TokenType::GreaterEqual, - TokenType::Less, - TokenType::LessEqual, - TokenType::LookAtTheWatch, - TokenType::FallUnderMySpell, - TokenType::YourEyesAreGettingHeavy, - TokenType::GoingDeeper, - TokenType::DeeplyGreater, - TokenType::DeeplyLess, - ]) { - let operator = self.previous().lexeme.clone(); - let right = Box::new(self.parse_term()?); - left = AstNode::BinaryExpression { - left: Box::new(left), - operator, - right, - }; - } - - Ok(left) + fn parse_comparison(&mut self) -> ParseResult { + self.parse_binary_level( + &[ + TokenType::Greater, + TokenType::GreaterEqual, + TokenType::Less, + TokenType::LessEqual, + TokenType::LookAtTheWatch, + TokenType::FallUnderMySpell, + TokenType::YourEyesAreGettingHeavy, + TokenType::GoingDeeper, + TokenType::DeeplyGreater, + TokenType::DeeplyLess, + ], + Self::parse_term, + ) } /// Parse term (addition/subtraction) - fn parse_term(&mut self) -> Result { - let mut left = self.parse_factor()?; - - while self.match_tokens(&[TokenType::Plus, TokenType::Minus]) { - let operator = self.previous().lexeme.clone(); - let right = Box::new(self.parse_factor()?); - left = AstNode::BinaryExpression { - left: Box::new(left), - operator, - right, - }; - } - - Ok(left) + fn parse_term(&mut self) -> ParseResult { + self.parse_binary_level(&[TokenType::Plus, TokenType::Minus], Self::parse_factor) } /// Parse factor (multiplication/division/modulo) - fn parse_factor(&mut self) -> Result { - let mut left = self.parse_unary()?; - - while self.match_tokens(&[TokenType::Asterisk, TokenType::Slash, TokenType::Percent]) { - let operator = self.previous().lexeme.clone(); - let right = Box::new(self.parse_unary()?); - left = AstNode::BinaryExpression { - left: Box::new(left), - operator, - right, - }; - } - - Ok(left) + fn parse_factor(&mut self) -> ParseResult { + self.parse_binary_level( + &[TokenType::Asterisk, TokenType::Slash, TokenType::Percent], + Self::parse_unary, + ) } /// Parse unary - fn parse_unary(&mut self) -> Result { + fn parse_unary(&mut self) -> ParseResult { // Handle await/surrenderTo if self.match_tokens(&[TokenType::Await, TokenType::SurrenderTo]) { let expression = Box::new(self.parse_unary()?); @@ -1085,7 +1063,7 @@ impl Parser { } /// Parse call expression - fn parse_call(&mut self) -> Result { + fn parse_call(&mut self) -> ParseResult { let mut expr = self.parse_primary()?; loop { @@ -1108,7 +1086,7 @@ impl Parser { index, }; } else { - return Err("Expected property name or '[' after '?.'".to_string()); + return Err(self.error_here("Expected property name or '[' after '?.'")); } } else if self.match_token(&TokenType::Dot) { let property = self @@ -1135,7 +1113,7 @@ impl Parser { } /// Finish parsing a call expression - fn finish_call(&mut self, callee: AstNode) -> Result { + fn finish_call(&mut self, callee: AstNode) -> ParseResult { let mut arguments = Vec::new(); if !self.check(&TokenType::RParen) { @@ -1156,7 +1134,7 @@ impl Parser { } /// Parse primary expression - fn parse_primary(&mut self) -> Result { + fn parse_primary(&mut self) -> ParseResult { // Entrain (pattern matching) expression if self.check(&TokenType::Entrain) { return self.parse_entrain_expression(); @@ -1164,12 +1142,7 @@ impl Parser { // Number literal if self.check(&TokenType::NumberLiteral) { - let token = self.advance(); - let value = token - .lexeme - .parse::() - .map_err(|_| format!("Invalid number: {}", token.lexeme))?; - return Ok(AstNode::NumberLiteral(value)); + return Ok(AstNode::NumberLiteral(self.parse_number_literal()?)); } // String literal @@ -1186,10 +1159,14 @@ impl Parser { return Ok(AstNode::BooleanLiteral(false)); } + // Null literal + if self.match_token(&TokenType::Null) { + return Ok(AstNode::NullLiteral); + } + // Identifier or Record Literal if self.check(&TokenType::Identifier) { - let token = self.advance(); - let identifier = token.lexeme.clone(); + let identifier = self.advance().lexeme.clone(); // Check if this is a record literal (Type { field: value, ... }) if self.check(&TokenType::LBrace) { @@ -1229,11 +1206,23 @@ impl Parser { return Ok(expr); } - Err(format!("Unexpected token: {:?}", self.peek())) + Err(self.error_here(format!("Unexpected token '{}'", self.describe_peek()))) + } + + /// Consume a number literal token and parse its numeric value. + fn parse_number_literal(&mut self) -> ParseResult { + let (line, column) = { + let token = self.peek(); + (token.line, token.column) + }; + let lexeme = self.advance().lexeme.clone(); + lexeme + .parse::() + .map_err(|_| SyntaxError::new(format!("Invalid number: {}", lexeme), line, column)) } /// Parse entrain (pattern matching) expression - fn parse_entrain_expression(&mut self) -> Result { + fn parse_entrain_expression(&mut self) -> ParseResult { self.consume(&TokenType::Entrain, "Expected 'entrain'")?; let subject = Box::new(self.parse_expression()?); self.consume(&TokenType::LBrace, "Expected '{' after entrain subject")?; @@ -1283,14 +1272,10 @@ impl Parser { } /// Parse pattern for matching - fn parse_pattern(&mut self) -> Result { + fn parse_pattern(&mut self) -> ParseResult { // Literal patterns if self.check(&TokenType::NumberLiteral) { - let token = self.advance(); - let value = token - .lexeme - .parse::() - .map_err(|_| format!("Invalid number: {}", token.lexeme))?; + let value = self.parse_number_literal()?; return Ok(Pattern::Literal(Box::new(AstNode::NumberLiteral(value)))); } @@ -1309,6 +1294,10 @@ impl Parser { return Ok(Pattern::Literal(Box::new(AstNode::BooleanLiteral(false)))); } + if self.match_token(&TokenType::Null) { + return Ok(Pattern::Literal(Box::new(AstNode::NullLiteral))); + } + // Array pattern: [first, second, ...rest] if self.match_token(&TokenType::LBracket) { let mut elements = Vec::new(); @@ -1389,11 +1378,11 @@ impl Parser { return Ok(Pattern::Identifier(name)); } - Err(format!("Expected pattern, got {:?}", self.peek())) + Err(self.error_here(format!("Expected pattern, got '{}'", self.describe_peek()))) } /// Parse body of an entrain case (can be block or single expression) - fn parse_entrain_body(&mut self) -> Result, String> { + fn parse_entrain_body(&mut self) -> ParseResult> { if self.match_token(&TokenType::LBrace) { let mut statements = Vec::new(); while !self.check(&TokenType::RBrace) && !self.is_at_end() { @@ -1407,10 +1396,25 @@ impl Parser { } } - /// Parse type annotation (returns the type as a string) - fn parse_type_annotation(&mut self) -> Result { - // Accept identifiers and type keywords (number, string, boolean) - let type_name = match self.peek().token_type { + /// Parse type annotation (returns the type as a string). + /// + /// Supported forms: + /// - Base types: identifiers and the keywords `number`, `string`, + /// `boolean`, `trance` + /// - Nullable: `number?` or the hypnotic prefix `lucid number` + /// (both normalize to `number?`) + /// - Arrays: `string[]`, including combinations like `number[]?` + fn parse_type_annotation(&mut self) -> ParseResult { + // 'lucid T' is the hypnotic spelling of 'T?'. + if self.match_token(&TokenType::Lucid) { + let inner = self.parse_type_annotation()?; + if inner.ends_with('?') { + return Ok(inner); + } + return Ok(format!("{}?", inner)); + } + + let mut type_name = match self.peek().token_type { TokenType::Identifier => self.advance().lexeme.clone(), TokenType::Number => { self.advance(); @@ -1424,12 +1428,54 @@ impl Parser { self.advance(); "boolean".to_string() } - _ => return Err(format!("Expected type annotation, got {:?}", self.peek())), + TokenType::Trance => { + self.advance(); + "trance".to_string() + } + _ => { + return Err(self.error_here(format!( + "Expected type annotation, got '{}'", + self.describe_peek() + ))); + } }; + + // Suffix modifiers: '[]' for arrays, '?' for nullable types. + loop { + if self.check(&TokenType::LBracket) + && self.peek_next().map(|t| &t.token_type) == Some(&TokenType::RBracket) + { + self.advance(); + self.advance(); + type_name.push_str("[]"); + } else if self.match_token(&TokenType::QuestionMark) { + type_name.push('?'); + } else { + break; + } + } + Ok(type_name) } // Helper methods + + /// Build a [`SyntaxError`] pointing at the current token. + fn error_here(&self, message: impl Into) -> SyntaxError { + let token = self.peek(); + SyntaxError::new(message, token.line, token.column) + } + + /// Human-readable description of the current token for error messages. + fn describe_peek(&self) -> String { + let token = self.peek(); + if token.token_type == TokenType::Eof { + "end of input".to_string() + } else { + token.lexeme.clone() + } + } + fn match_token(&mut self, token_type: &TokenType) -> bool { if self.check(token_type) { self.advance(); @@ -1457,7 +1503,7 @@ impl Parser { } } - fn advance(&mut self) -> Token { + fn advance(&mut self) -> &Token { if !self.is_at_end() { self.current += 1; } @@ -1472,8 +1518,8 @@ impl Parser { &self.tokens[self.current] } - fn previous(&self) -> Token { - self.tokens[self.current - 1].clone() + fn previous(&self) -> &Token { + &self.tokens[self.current - 1] } fn peek_next(&self) -> Option<&Token> { @@ -1484,11 +1530,11 @@ impl Parser { } } - fn consume(&mut self, token_type: &TokenType, message: &str) -> Result { + fn consume(&mut self, token_type: &TokenType, message: &str) -> ParseResult<&Token> { if self.check(token_type) { Ok(self.advance()) } else { - Err(format!("{} at line {}", message, self.peek().line)) + Err(self.error_here(format!("{}, found '{}'", message, self.describe_peek()))) } } } @@ -1498,58 +1544,60 @@ mod tests { use super::*; use crate::lexer::Lexer; + fn parse(source: &str) -> ParseResult { + let mut lexer = Lexer::new(source); + let tokens = lexer.lex().expect("lexing failed"); + let mut parser = Parser::new(tokens); + parser.parse_program() + } + #[test] fn test_parse_simple_program() { - let source = r#" + let ast = parse( + r#" Focus { induce x: number = 42; observe x; } Relax -"#; - let mut lexer = Lexer::new(source); - let tokens = lexer.lex().unwrap(); - let mut parser = Parser::new(tokens); - let ast = parser.parse_program(); +"#, + ); assert!(ast.is_ok()); } #[test] fn test_parse_if_statement() { - let source = r#" + let ast = parse( + r#" Focus { induce x: number = 10; if (x > 5) deepFocus { observe "Greater"; } } Relax -"#; - let mut lexer = Lexer::new(source); - let tokens = lexer.lex().unwrap(); - let mut parser = Parser::new(tokens); - let ast = parser.parse_program(); +"#, + ); assert!(ast.is_ok()); } #[test] fn test_parse_hypnotic_operator_synonyms() { - let source = r#" + let ast = parse( + r#" Focus { induce x: number = 10; if (x youAreFeelingVerySleepy 10 resistanceIsFutile x youCannotResist 5) deepFocus { observe "Synonym branch"; } } Relax -"#; - let mut lexer = Lexer::new(source); - let tokens = lexer.lex().unwrap(); - let mut parser = Parser::new(tokens); - let ast = parser.parse_program(); +"#, + ); assert!(ast.is_ok()); } #[test] fn test_parse_entrain_with_record_pattern() { - let source = r#" + let ast = parse( + r#" Focus { tranceify HypnoGuest { name: string; @@ -1572,18 +1620,31 @@ Focus { observe status; } } Relax -"#; +"#, + ); + assert!(ast.is_ok(), "parse failed: {:?}", ast.err()); + } - let mut lexer = Lexer::new(source); - let tokens = lexer.lex().unwrap(); - let mut parser = Parser::new(tokens); - let ast = parser.parse_program(); + #[test] + fn test_parse_string_interpolation() { + let ast = parse( + r#" +Focus { + entrance { + induce name: string = "Luna"; + induce depth: number = 7; + observe "Guest ${name} is at depth ${depth + 1}!"; + } +} Relax +"#, + ); assert!(ast.is_ok(), "parse failed: {:?}", ast.err()); } #[test] fn test_trigger_inside_function_is_rejected() { - let source = r#" + let ast = parse( + r#" Focus { suggestion inner() { trigger localTrigger = suggestion() { @@ -1591,19 +1652,21 @@ Focus { }; } } Relax -"#; - let mut lexer = Lexer::new(source); - let tokens = lexer.lex().unwrap(); - let mut parser = Parser::new(tokens); - let ast = parser.parse_program(); +"#, + ); assert!(ast.is_err()); let error = ast.err().unwrap(); - assert!(error.contains("Triggers can only be declared at the top level")); + assert!( + error + .to_string() + .contains("Triggers can only be declared at the top level") + ); } #[test] fn test_entrance_inside_function_is_rejected() { - let source = r#" + let ast = parse( + r#" Focus { suggestion wrong() { entrance { @@ -1611,13 +1674,198 @@ Focus { } } } Relax -"#; - let mut lexer = Lexer::new(source); - let tokens = lexer.lex().unwrap(); - let mut parser = Parser::new(tokens); - let ast = parser.parse_program(); +"#, + ); assert!(ast.is_err()); let error = ast.err().unwrap(); - assert!(error.contains("'entrance' blocks are only allowed at the top level")); + assert!( + error + .to_string() + .contains("'entrance' blocks are only allowed at the top level") + ); + } + + #[test] + fn test_parser_errors_carry_positions() { + let ast = parse( + r#" +Focus { + induce x = 1 + observe x; +} Relax +"#, + ); + let error = ast.expect_err("expected a parse error"); + // The missing semicolon is discovered at 'observe' on line 4. + assert_eq!(error.line, 4); + assert!(error.message.contains("Expected ';'")); + assert!(error.message.contains("found 'observe'")); + } + + #[test] + fn test_parse_null_literal_and_pattern() { + let ast = parse( + r#" +Focus { + entrance { + induce x = null; + induce state: string = entrain x { + when null => "empty"; + otherwise => "filled"; + }; + observe state; + } +} Relax +"#, + ); + assert!(ast.is_ok(), "parse failed: {:?}", ast.err()); + } + + #[test] + fn test_parse_nullable_and_array_type_annotations() { + let ast = parse( + r#" +Focus { + tranceify Note { + title: string; + content: string?; + tags: string[]; + } + entrance { + induce maybe: number? = null; + induce clear: lucid string = null; + induce names: string[] = ["a", "b"]; + induce matrix: number[][] = [[1], [2]]; + observe maybe lucidFallback 0; + observe clear lucidFallback "d"; + observe names; + observe matrix; + } +} Relax +"#, + ); + assert!(ast.is_ok(), "parse failed: {:?}", ast.err()); + } + + #[test] + fn test_parse_drift_statement() { + let ast = parse( + r#" +Focus { + entrance { + drift(50); + pauseReality(10 + 5); + } +} Relax +"#, + ); + assert!(ast.is_ok(), "parse failed: {:?}", ast.err()); + } + + #[test] + fn test_parse_shared_trance_shorthand() { + let ast = parse( + r#" +Focus { + sharedTrance total: number = 0; + sharedTrance induce classic: number = 1; + entrance { + observe total + classic; + } +} Relax +"#, + ); + assert!(ast.is_ok(), "parse failed: {:?}", ast.err()); + } + + #[test] + fn test_parse_labeled_loop_with_labeled_break() { + let ast = parse( + r#" +Focus { + entrance { + outer: loop (induce i: number = 0; i < 3; i = i + 1) { + loop (induce j: number = 0; j < 3; j = j + 1) { + if (j == 1) { + snap outer; + } + sink outer; + } + } + } +} Relax +"#, + ); + assert!(ast.is_ok(), "parse failed: {:?}", ast.err()); + } + + #[test] + fn test_parse_deep_focus_statement() { + let ast = parse( + r#" +Focus { + entrance { + induce depth: number = 7; + deepFocus (depth > 5) { + observe "deep"; + } + } +} Relax +"#, + ); + assert!(ast.is_ok(), "parse failed: {:?}", ast.err()); + } + + #[test] + fn test_parse_imperative_suggestion_forms() { + let ast = parse( + r#" +Focus { + imperativeSuggestion announce(message: string) { + observe message; + } + imperative suggestion narrate(message: string) { + whisper message; + } + session Voice { + expose imperative suggestion speak(text: string) { + observe text; + } + } +} Relax +"#, + ); + assert!(ast.is_ok(), "parse failed: {:?}", ast.err()); + } + + #[test] + fn test_trigger_declaration_allows_trailing_semicolon() { + let ast = parse( + r#" +Focus { + trigger onDone = suggestion(name: string) { + observe name; + }; + entrance { + onDone("x"); + } +} Relax +"#, + ); + assert!(ast.is_ok(), "parse failed: {:?}", ast.err()); + } + + #[test] + fn test_loop_init_supports_declarations() { + let ast = parse( + r#" +Focus { + loop (induce i: number = 0; i < 3; i = i + 1) { + observe i; + } +} Relax +"#, + ); + assert!(ast.is_ok(), "parse failed: {:?}", ast.err()); } } diff --git a/hypnoscript-lexer-parser/src/source.rs b/hypnoscript-lexer-parser/src/source.rs new file mode 100644 index 0000000..27ace39 --- /dev/null +++ b/hypnoscript-lexer-parser/src/source.rs @@ -0,0 +1,111 @@ +//! Source file decoding: tolerant handling of BOMs and UTF-16 encodings. +//! +//! `.hyp` files written on Windows (e.g. by PowerShell or Notepad) are often +//! UTF-16 encoded or carry a UTF-8 byte-order mark. Instead of failing with +//! "stream did not contain valid UTF-8", the toolchain decodes these +//! transparently. + +/// Decode raw source bytes into a string. +/// +/// Supported encodings, detected in order: +/// - UTF-8 with BOM (`EF BB BF`, stripped) +/// - UTF-16 LE / UTF-16 BE with BOM (`FF FE` / `FE FF`) +/// - UTF-16 LE without BOM (heuristic: ASCII text has NUL high bytes) +/// - Plain UTF-8 +pub fn decode_source(bytes: &[u8]) -> Result { + if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8(rest.to_vec()) + .map_err(|e| format!("Invalid UTF-8 after byte-order mark: {}", e)); + } + + if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) { + return decode_utf16(rest, u16::from_le_bytes); + } + + if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) { + return decode_utf16(rest, u16::from_be_bytes); + } + + // BOM-less UTF-16 LE heuristic: ASCII source encoded as UTF-16 LE has a + // NUL as every second byte. Real UTF-8 source never contains NUL bytes. + if looks_like_utf16_le(bytes) { + return decode_utf16(bytes, u16::from_le_bytes); + } + + String::from_utf8(bytes.to_vec()).map_err(|e| format!("Source is not valid UTF-8: {}", e)) +} + +fn looks_like_utf16_le(bytes: &[u8]) -> bool { + if bytes.len() < 4 || !bytes.len().is_multiple_of(2) { + return false; + } + let sample = &bytes[..bytes.len().min(256)]; + let nul_high_bytes = sample + .chunks_exact(2) + .filter(|pair| pair[0] != 0 && pair[1] == 0) + .count(); + // If the clear majority of code units are ASCII-with-NUL-high-byte, + // this is almost certainly UTF-16 LE text. + nul_high_bytes * 2 > sample.len() / 2 +} + +fn decode_utf16(bytes: &[u8], read_u16: fn([u8; 2]) -> u16) -> Result { + if !bytes.len().is_multiple_of(2) { + return Err("UTF-16 source has an odd number of bytes".to_string()); + } + let units: Vec = bytes + .chunks_exact(2) + .map(|pair| read_u16([pair[0], pair[1]])) + .collect(); + String::from_utf16(&units).map_err(|e| format!("Invalid UTF-16 source: {}", e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plain_utf8() { + assert_eq!(decode_source(b"Focus {} Relax").unwrap(), "Focus {} Relax"); + } + + #[test] + fn test_utf8_with_bom() { + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice("Focus".as_bytes()); + assert_eq!(decode_source(&bytes).unwrap(), "Focus"); + } + + #[test] + fn test_utf16_le_with_bom() { + let mut bytes = vec![0xFF, 0xFE]; + for unit in "Focus ✨".encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + assert_eq!(decode_source(&bytes).unwrap(), "Focus ✨"); + } + + #[test] + fn test_utf16_be_with_bom() { + let mut bytes = vec![0xFE, 0xFF]; + for unit in "Relax".encode_utf16() { + bytes.extend_from_slice(&unit.to_be_bytes()); + } + assert_eq!(decode_source(&bytes).unwrap(), "Relax"); + } + + #[test] + fn test_utf16_le_without_bom() { + let mut bytes = Vec::new(); + for unit in "Focus { observe 1; } Relax".encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + assert_eq!(decode_source(&bytes).unwrap(), "Focus { observe 1; } Relax"); + } + + #[test] + fn test_invalid_utf8_is_rejected() { + let error = decode_source(&[0xC3, 0x28, 0x41, 0x42, 0x43]).unwrap_err(); + assert!(error.contains("not valid UTF-8")); + } +} diff --git a/hypnoscript-lexer-parser/src/token.rs b/hypnoscript-lexer-parser/src/token.rs index e1a5833..5457b58 100644 --- a/hypnoscript-lexer-parser/src/token.rs +++ b/hypnoscript-lexer-parser/src/token.rs @@ -40,7 +40,8 @@ pub enum TokenType { // Functions Suggestion, // Standard function Trigger, // Event handler/callback function - ImperativeSuggestion, // Imperative function modifier + Imperative, // Imperative modifier ('imperative suggestion ...') + ImperativeSuggestion, // Imperative function modifier (one-word form) DominantSuggestion, // Static function modifier Mesmerize, // Async function modifier Awaken, // return @@ -130,6 +131,9 @@ pub enum TokenType { True, False, + // Null literal + Null, + // Delimiters and brackets LParen, // ( RParen, // ) @@ -160,575 +164,121 @@ pub struct KeywordDefinition { pub canonical_lexeme: &'static str, } -/// All reserved words and hypnotic operator synonyms mapped by their normalized form. -static KEYWORD_DEFINITIONS: Lazy> = Lazy::new(|| { - use TokenType::*; - - let mut map = HashMap::with_capacity(64); - +/// Keyword table: `(normalized lowercase form, token type, canonical lexeme)`. +/// +/// Lookups normalize the source lexeme to ASCII lowercase, so each keyword is +/// listed once here regardless of how it is capitalized in source code. +/// Plain-language aliases (`break`, `continue`, `return`) map to the same +/// token as their hypnotic counterparts. +#[rustfmt::skip] +const KEYWORDS: &[(&str, TokenType, &str)] = &[ // Core structure keywords - map.insert( - "focus", - KeywordDefinition { - token: Focus, - canonical_lexeme: "Focus", - }, - ); - map.insert( - "relax", - KeywordDefinition { - token: Relax, - canonical_lexeme: "Relax", - }, - ); - map.insert( - "entrance", - KeywordDefinition { - token: Entrance, - canonical_lexeme: "entrance", - }, - ); - map.insert( - "finale", - KeywordDefinition { - token: Finale, - canonical_lexeme: "finale", - }, - ); - map.insert( - "deepfocus", - KeywordDefinition { - token: DeepFocus, - canonical_lexeme: "deepFocus", - }, - ); - map.insert( - "deeperstill", - KeywordDefinition { - token: DeeperStill, - canonical_lexeme: "deeperStill", - }, - ); - + ("focus", TokenType::Focus, "Focus"), + ("relax", TokenType::Relax, "Relax"), + ("entrance", TokenType::Entrance, "entrance"), + ("finale", TokenType::Finale, "finale"), + ("deepfocus", TokenType::DeepFocus, "deepFocus"), + ("deeperstill", TokenType::DeeperStill, "deeperStill"), // Variable declarations and sourcing - map.insert( - "induce", - KeywordDefinition { - token: Induce, - canonical_lexeme: "induce", - }, - ); - map.insert( - "implant", - KeywordDefinition { - token: Implant, - canonical_lexeme: "implant", - }, - ); - map.insert( - "embed", - KeywordDefinition { - token: Embed, - canonical_lexeme: "embed", - }, - ); - map.insert( - "freeze", - KeywordDefinition { - token: Freeze, - canonical_lexeme: "freeze", - }, - ); - map.insert( - "anchor", - KeywordDefinition { - token: Anchor, - canonical_lexeme: "anchor", - }, - ); - map.insert( - "from", - KeywordDefinition { - token: From, - canonical_lexeme: "from", - }, - ); - map.insert( - "external", - KeywordDefinition { - token: External, - canonical_lexeme: "external", - }, - ); - + ("induce", TokenType::Induce, "induce"), + ("implant", TokenType::Implant, "implant"), + ("embed", TokenType::Embed, "embed"), + ("freeze", TokenType::Freeze, "freeze"), + ("anchor", TokenType::Anchor, "anchor"), + ("from", TokenType::From, "from"), + ("external", TokenType::External, "external"), // Control flow constructs - map.insert( - "if", - KeywordDefinition { - token: If, - canonical_lexeme: "if", - }, - ); - map.insert( - "else", - KeywordDefinition { - token: Else, - canonical_lexeme: "else", - }, - ); - map.insert( - "when", - KeywordDefinition { - token: When, - canonical_lexeme: "when", - }, - ); - map.insert( - "otherwise", - KeywordDefinition { - token: Otherwise, - canonical_lexeme: "otherwise", - }, - ); - map.insert( - "entrain", - KeywordDefinition { - token: Entrain, - canonical_lexeme: "entrain", - }, - ); - map.insert( - "while", - KeywordDefinition { - token: While, - canonical_lexeme: "while", - }, - ); - map.insert( - "loop", - KeywordDefinition { - token: Loop, - canonical_lexeme: "loop", - }, - ); - map.insert( - "pendulum", - KeywordDefinition { - token: Pendulum, - canonical_lexeme: "pendulum", - }, - ); - map.insert( - "snap", - KeywordDefinition { - token: Snap, - canonical_lexeme: "snap", - }, - ); - map.insert( - "break", - KeywordDefinition { - token: Snap, - canonical_lexeme: "snap", - }, - ); - map.insert( - "sink", - KeywordDefinition { - token: Sink, - canonical_lexeme: "sink", - }, - ); - map.insert( - "continue", - KeywordDefinition { - token: Sink, - canonical_lexeme: "sink", - }, - ); - map.insert( - "sinkto", - KeywordDefinition { - token: SinkTo, - canonical_lexeme: "sinkTo", - }, - ); - map.insert( - "oscillate", - KeywordDefinition { - token: Oscillate, - canonical_lexeme: "oscillate", - }, - ); - map.insert( - "suspend", - KeywordDefinition { - token: Suspend, - canonical_lexeme: "suspend", - }, - ); - + ("if", TokenType::If, "if"), + ("else", TokenType::Else, "else"), + ("when", TokenType::When, "when"), + ("otherwise", TokenType::Otherwise, "otherwise"), + ("entrain", TokenType::Entrain, "entrain"), + ("while", TokenType::While, "while"), + ("loop", TokenType::Loop, "loop"), + ("pendulum", TokenType::Pendulum, "pendulum"), + ("snap", TokenType::Snap, "snap"), + ("break", TokenType::Snap, "snap"), + ("sink", TokenType::Sink, "sink"), + ("continue", TokenType::Sink, "sink"), + ("sinkto", TokenType::SinkTo, "sinkTo"), + ("oscillate", TokenType::Oscillate, "oscillate"), + ("suspend", TokenType::Suspend, "suspend"), // Functions - map.insert( - "suggestion", - KeywordDefinition { - token: Suggestion, - canonical_lexeme: "suggestion", - }, - ); - map.insert( - "trigger", - KeywordDefinition { - token: Trigger, - canonical_lexeme: "trigger", - }, - ); - map.insert( - "imperativesuggestion", - KeywordDefinition { - token: ImperativeSuggestion, - canonical_lexeme: "imperativeSuggestion", - }, - ); - map.insert( - "dominantsuggestion", - KeywordDefinition { - token: DominantSuggestion, - canonical_lexeme: "dominantSuggestion", - }, - ); - map.insert( - "mesmerize", - KeywordDefinition { - token: Mesmerize, - canonical_lexeme: "mesmerize", - }, - ); - map.insert( - "awaken", - KeywordDefinition { - token: Awaken, - canonical_lexeme: "awaken", - }, - ); - map.insert( - "await", - KeywordDefinition { - token: Await, - canonical_lexeme: "await", - }, - ); - map.insert( - "surrenderto", - KeywordDefinition { - token: SurrenderTo, - canonical_lexeme: "surrenderTo", - }, - ); - map.insert( - "return", - KeywordDefinition { - token: Awaken, - canonical_lexeme: "awaken", - }, - ); - map.insert( - "call", - KeywordDefinition { - token: Call, - canonical_lexeme: "call", - }, - ); - + ("suggestion", TokenType::Suggestion, "suggestion"), + ("trigger", TokenType::Trigger, "trigger"), + ("imperative", TokenType::Imperative, "imperative"), + ("imperativesuggestion", TokenType::ImperativeSuggestion, "imperativeSuggestion"), + ("dominantsuggestion", TokenType::DominantSuggestion, "dominantSuggestion"), + ("mesmerize", TokenType::Mesmerize, "mesmerize"), + ("awaken", TokenType::Awaken, "awaken"), + ("return", TokenType::Awaken, "awaken"), + ("await", TokenType::Await, "await"), + ("surrenderto", TokenType::SurrenderTo, "surrenderTo"), + ("call", TokenType::Call, "call"), // Sessions (classes) - map.insert( - "session", - KeywordDefinition { - token: Session, - canonical_lexeme: "session", - }, - ); - map.insert( - "constructor", - KeywordDefinition { - token: Constructor, - canonical_lexeme: "constructor", - }, - ); - map.insert( - "expose", - KeywordDefinition { - token: Expose, - canonical_lexeme: "expose", - }, - ); - map.insert( - "conceal", - KeywordDefinition { - token: Conceal, - canonical_lexeme: "conceal", - }, - ); - map.insert( - "dominant", - KeywordDefinition { - token: Dominant, - canonical_lexeme: "dominant", - }, - ); - + ("session", TokenType::Session, "session"), + ("constructor", TokenType::Constructor, "constructor"), + ("expose", TokenType::Expose, "expose"), + ("conceal", TokenType::Conceal, "conceal"), + ("dominant", TokenType::Dominant, "dominant"), // Structures and observations - map.insert( - "tranceify", - KeywordDefinition { - token: Tranceify, - canonical_lexeme: "tranceify", - }, - ); - map.insert( - "observe", - KeywordDefinition { - token: Observe, - canonical_lexeme: "observe", - }, - ); - map.insert( - "whisper", - KeywordDefinition { - token: Whisper, - canonical_lexeme: "whisper", - }, - ); - map.insert( - "command", - KeywordDefinition { - token: Command, - canonical_lexeme: "command", - }, - ); - map.insert( - "murmur", - KeywordDefinition { - token: Murmur, - canonical_lexeme: "murmur", - }, - ); - map.insert( - "drift", - KeywordDefinition { - token: Drift, - canonical_lexeme: "drift", - }, - ); - map.insert( - "pausereality", - KeywordDefinition { - token: PauseReality, - canonical_lexeme: "pauseReality", - }, - ); - map.insert( - "acceleratetime", - KeywordDefinition { - token: AccelerateTime, - canonical_lexeme: "accelerateTime", - }, - ); - map.insert( - "deceleratetime", - KeywordDefinition { - token: DecelerateTime, - canonical_lexeme: "decelerateTime", - }, - ); - map.insert( - "subconscious", - KeywordDefinition { - token: Subconscious, - canonical_lexeme: "subconscious", - }, - ); - + ("tranceify", TokenType::Tranceify, "tranceify"), + ("observe", TokenType::Observe, "observe"), + ("whisper", TokenType::Whisper, "whisper"), + ("command", TokenType::Command, "command"), + ("murmur", TokenType::Murmur, "murmur"), + ("drift", TokenType::Drift, "drift"), + ("pausereality", TokenType::PauseReality, "pauseReality"), + ("acceleratetime", TokenType::AccelerateTime, "accelerateTime"), + ("deceleratetime", TokenType::DecelerateTime, "decelerateTime"), + ("subconscious", TokenType::Subconscious, "subconscious"), // Modules and globals - map.insert( - "mindlink", - KeywordDefinition { - token: MindLink, - canonical_lexeme: "mindLink", - }, - ); - map.insert( - "sharedtrance", - KeywordDefinition { - token: SharedTrance, - canonical_lexeme: "sharedTrance", - }, - ); - map.insert( - "label", - KeywordDefinition { - token: Label, - canonical_lexeme: "label", - }, - ); - + ("mindlink", TokenType::MindLink, "mindLink"), + ("sharedtrance", TokenType::SharedTrance, "sharedTrance"), + ("label", TokenType::Label, "label"), // Operator synonyms (equality) - map.insert( - "youarefeelingverysleepy", - KeywordDefinition { - token: YouAreFeelingVerySleepy, - canonical_lexeme: "youAreFeelingVerySleepy", - }, - ); - map.insert( - "youcannotresist", - KeywordDefinition { - token: YouCannotResist, - canonical_lexeme: "youCannotResist", - }, - ); - map.insert( - "notsodeep", - KeywordDefinition { - token: NotSoDeep, - canonical_lexeme: "notSoDeep", - }, - ); - + ("youarefeelingverysleepy", TokenType::YouAreFeelingVerySleepy, "youAreFeelingVerySleepy"), + ("youcannotresist", TokenType::YouCannotResist, "youCannotResist"), + ("notsodeep", TokenType::NotSoDeep, "notSoDeep"), // Operator synonyms (comparison) - map.insert( - "lookatthewatch", - KeywordDefinition { - token: LookAtTheWatch, - canonical_lexeme: "lookAtTheWatch", - }, - ); - map.insert( - "fallundermyspell", - KeywordDefinition { - token: FallUnderMySpell, - canonical_lexeme: "fallUnderMySpell", - }, - ); - map.insert( - "youreyesaregettingheavy", - KeywordDefinition { - token: YourEyesAreGettingHeavy, - canonical_lexeme: "yourEyesAreGettingHeavy", - }, - ); - map.insert( - "goingdeeper", - KeywordDefinition { - token: GoingDeeper, - canonical_lexeme: "goingDeeper", - }, - ); - map.insert( - "deeplygreater", - KeywordDefinition { - token: DeeplyGreater, - canonical_lexeme: "deeplyGreater", - }, - ); - map.insert( - "deeplyless", - KeywordDefinition { - token: DeeplyLess, - canonical_lexeme: "deeplyLess", - }, - ); - + ("lookatthewatch", TokenType::LookAtTheWatch, "lookAtTheWatch"), + ("fallundermyspell", TokenType::FallUnderMySpell, "fallUnderMySpell"), + ("youreyesaregettingheavy", TokenType::YourEyesAreGettingHeavy, "yourEyesAreGettingHeavy"), + ("goingdeeper", TokenType::GoingDeeper, "goingDeeper"), + ("deeplygreater", TokenType::DeeplyGreater, "deeplyGreater"), + ("deeplyless", TokenType::DeeplyLess, "deeplyLess"), // Logical operator synonyms - map.insert( - "undermycontrol", - KeywordDefinition { - token: UnderMyControl, - canonical_lexeme: "underMyControl", - }, - ); - map.insert( - "resistanceisfutile", - KeywordDefinition { - token: ResistanceIsFutile, - canonical_lexeme: "resistanceIsFutile", - }, - ); - map.insert( - "lucidfallback", - KeywordDefinition { - token: LucidFallback, - canonical_lexeme: "lucidFallback", - }, - ); - map.insert( - "dreamreach", - KeywordDefinition { - token: DreamReach, - canonical_lexeme: "dreamReach", - }, - ); - + ("undermycontrol", TokenType::UnderMyControl, "underMyControl"), + ("resistanceisfutile", TokenType::ResistanceIsFutile, "resistanceIsFutile"), + ("lucidfallback", TokenType::LucidFallback, "lucidFallback"), + ("dreamreach", TokenType::DreamReach, "dreamReach"), // Primitive type aliases and literals - map.insert( - "number", - KeywordDefinition { - token: Number, - canonical_lexeme: "number", - }, - ); - map.insert( - "string", - KeywordDefinition { - token: String, - canonical_lexeme: "string", - }, - ); - map.insert( - "boolean", - KeywordDefinition { - token: Boolean, - canonical_lexeme: "boolean", - }, - ); - map.insert( - "trance", - KeywordDefinition { - token: Trance, - canonical_lexeme: "trance", - }, - ); - map.insert( - "lucid", - KeywordDefinition { - token: Lucid, - canonical_lexeme: "lucid", - }, - ); - map.insert( - "true", - KeywordDefinition { - token: True, - canonical_lexeme: "true", - }, - ); - map.insert( - "false", - KeywordDefinition { - token: False, - canonical_lexeme: "false", - }, - ); - - map.insert( - "assert", - KeywordDefinition { - token: Assert, - canonical_lexeme: "assert", - }, - ); + ("number", TokenType::Number, "number"), + ("string", TokenType::String, "string"), + ("boolean", TokenType::Boolean, "boolean"), + ("trance", TokenType::Trance, "trance"), + ("lucid", TokenType::Lucid, "lucid"), + ("true", TokenType::True, "true"), + ("false", TokenType::False, "false"), + ("null", TokenType::Null, "null"), + // Assertions + ("assert", TokenType::Assert, "assert"), +]; - map +/// All reserved words and hypnotic operator synonyms mapped by their normalized form. +static KEYWORD_DEFINITIONS: Lazy> = Lazy::new(|| { + KEYWORDS + .iter() + .map(|&(normalized, token, canonical_lexeme)| { + ( + normalized, + KeywordDefinition { + token, + canonical_lexeme, + }, + ) + }) + .collect() }); impl TokenType { @@ -764,6 +314,7 @@ impl TokenType { | TokenType::Suspend | TokenType::Suggestion | TokenType::Trigger + | TokenType::Imperative | TokenType::ImperativeSuggestion | TokenType::DominantSuggestion | TokenType::Mesmerize @@ -792,6 +343,7 @@ impl TokenType { | TokenType::Assert | TokenType::True | TokenType::False + | TokenType::Null ) } @@ -844,6 +396,7 @@ impl TokenType { | TokenType::BooleanLiteral | TokenType::True | TokenType::False + | TokenType::Null ) } diff --git a/hypnoscript-runtime/src/collection_builtins.rs b/hypnoscript-runtime/src/collection_builtins.rs index 24088d7..1463cf8 100644 --- a/hypnoscript-runtime/src/collection_builtins.rs +++ b/hypnoscript-runtime/src/collection_builtins.rs @@ -200,7 +200,7 @@ impl CollectionBuiltins { pub fn most_common(arr: &[T], n: usize) -> Vec<(T, usize)> { let freq = Self::frequency(arr); let mut freq_vec: Vec<_> = freq.into_iter().collect(); - freq_vec.sort_by(|a, b| b.1.cmp(&a.1)); + freq_vec.sort_by_key(|&(_, count)| std::cmp::Reverse(count)); freq_vec.into_iter().take(n).collect() } @@ -215,7 +215,7 @@ impl CollectionBuiltins { pub fn least_common(arr: &[T], n: usize) -> Vec<(T, usize)> { let freq = Self::frequency(arr); let mut freq_vec: Vec<_> = freq.into_iter().collect(); - freq_vec.sort_by(|a, b| a.1.cmp(&b.1)); + freq_vec.sort_by_key(|&(_, count)| count); freq_vec.into_iter().take(n).collect() } diff --git a/hypnoscript-runtime/src/core_builtins.rs b/hypnoscript-runtime/src/core_builtins.rs index 9fd7527..595c10d 100644 --- a/hypnoscript-runtime/src/core_builtins.rs +++ b/hypnoscript-runtime/src/core_builtins.rs @@ -75,9 +75,24 @@ impl CoreBuiltins { println!("{}", value.to_uppercase()); } - /// Wait for specified milliseconds (drift) + /// Wait for specified milliseconds (drift). + /// + /// All themed pauses (`drift`, `DeepTrance`, `HypnoticCountdown`, ...) + /// flow through this function. The `HYPNO_TIME_SCALE` environment + /// variable scales every pause: `0.5` halves them, `0` skips them + /// entirely (useful for tests and CI), unset/invalid means real time. pub fn drift(ms: u64) { - thread::sleep(Duration::from_millis(ms)); + let scaled = match std::env::var("HYPNO_TIME_SCALE") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|scale| scale.is_finite() && *scale >= 0.0) + { + Some(scale) => (ms as f64 * scale) as u64, + None => ms, + }; + if scaled > 0 { + thread::sleep(Duration::from_millis(scaled)); + } } /// Deep trance induction diff --git a/hypnoscript-runtime/src/file_builtins.rs b/hypnoscript-runtime/src/file_builtins.rs index 7647516..3c9f65f 100644 --- a/hypnoscript-runtime/src/file_builtins.rs +++ b/hypnoscript-runtime/src/file_builtins.rs @@ -1,5 +1,6 @@ use crate::builtin_trait::BuiltinModule; use crate::localization::LocalizedMessage; +use crate::sandbox; use std::fs; use std::io::{self, BufRead, BufReader, Write}; use std::path::Path; @@ -8,6 +9,14 @@ use std::path::Path; /// /// Provides comprehensive file system operations including reading, writing, /// directory management, and file metadata queries. +/// +/// # Sandboxing +/// +/// All functions that touch the filesystem validate their paths through the +/// [`sandbox`] module. When a sandbox root is configured (via +/// `HYPNO_SANDBOX` or [`sandbox::set_sandbox_root`]), access outside that +/// root is rejected with a `PermissionDenied` error. Pure path-string +/// helpers (`GetFileExtension`, `JoinPath`, ...) are unaffected. pub struct FileBuiltins; impl BuiltinModule for FileBuiltins { @@ -66,40 +75,40 @@ impl FileBuiltins { /// Read entire file as string pub fn read_file(path: &str) -> io::Result { - fs::read_to_string(path) + fs::read_to_string(sandbox::checked_path(path)?) } /// Write string to file pub fn write_file(path: &str, content: &str) -> io::Result<()> { - let path_ref = Path::new(path); - Self::ensure_parent_dir(path_ref)?; - fs::write(path_ref, content) + let path = sandbox::checked_path(path)?; + Self::ensure_parent_dir(&path)?; + fs::write(&path, content) } /// Append string to file pub fn append_file(path: &str, content: &str) -> io::Result<()> { - let path_ref = Path::new(path); - Self::ensure_parent_dir(path_ref)?; + let path = sandbox::checked_path(path)?; + Self::ensure_parent_dir(&path)?; let mut file = fs::OpenOptions::new() .create(true) .append(true) - .open(path_ref)?; + .open(&path)?; file.write_all(content.as_bytes()) } /// Read file contents as lines pub fn read_lines(path: &str) -> io::Result> { - let file = fs::File::open(path)?; + let file = fs::File::open(sandbox::checked_path(path)?)?; let reader = BufReader::new(file); reader.lines().collect() } /// Write lines to a file using `\n` separators pub fn write_lines(path: &str, lines: &[String]) -> io::Result<()> { - let path_ref = Path::new(path); - Self::ensure_parent_dir(path_ref)?; - let mut file = fs::File::create(path_ref)?; + let path = sandbox::checked_path(path)?; + Self::ensure_parent_dir(&path)?; + let mut file = fs::File::create(&path)?; for (index, line) in lines.iter().enumerate() { if index > 0 { file.write_all(b"\n")?; @@ -109,35 +118,35 @@ impl FileBuiltins { Ok(()) } - /// Check if file exists + /// Check if file exists (paths outside the sandbox report `false`) pub fn file_exists(path: &str) -> bool { - Path::new(path).exists() + sandbox::checked_path(path).is_ok_and(|p| p.exists()) } - /// Check if path is file + /// Check if path is file (paths outside the sandbox report `false`) pub fn is_file(path: &str) -> bool { - Path::new(path).is_file() + sandbox::checked_path(path).is_ok_and(|p| p.is_file()) } - /// Check if path is directory + /// Check if path is directory (paths outside the sandbox report `false`) pub fn is_directory(path: &str) -> bool { - Path::new(path).is_dir() + sandbox::checked_path(path).is_ok_and(|p| p.is_dir()) } /// Delete file pub fn delete_file(path: &str) -> io::Result<()> { - fs::remove_file(path) + fs::remove_file(sandbox::checked_path(path)?) } /// Create directory pub fn create_directory(path: &str) -> io::Result<()> { - fs::create_dir_all(path) + fs::create_dir_all(sandbox::checked_path(path)?) } /// List files in directory pub fn list_directory(path: &str) -> io::Result> { let mut files = Vec::new(); - for entry in fs::read_dir(path)? { + for entry in fs::read_dir(sandbox::checked_path(path)?)? { let entry = entry?; if let Some(name) = entry.file_name().to_str() { files.push(name.to_string()); @@ -148,17 +157,17 @@ impl FileBuiltins { /// Get file size in bytes pub fn get_file_size(path: &str) -> io::Result { - fs::metadata(path).map(|m| m.len()) + fs::metadata(sandbox::checked_path(path)?).map(|m| m.len()) } /// Copy file pub fn copy_file(from: &str, to: &str) -> io::Result { - fs::copy(from, to) + fs::copy(sandbox::checked_path(from)?, sandbox::checked_path(to)?) } /// Rename/move file pub fn rename_file(from: &str, to: &str) -> io::Result<()> { - fs::rename(from, to) + fs::rename(sandbox::checked_path(from)?, sandbox::checked_path(to)?) } /// Get file extension @@ -187,8 +196,8 @@ impl FileBuiltins { /// Copy a directory recursively pub fn copy_directory_recursive(from: &str, to: &str) -> io::Result<()> { - let source = Path::new(from); - let target = Path::new(to); + let source = &sandbox::checked_path(from)?; + let target = &sandbox::checked_path(to)?; if !source.is_dir() { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -250,8 +259,17 @@ mod tests { temp_file_path(&format!("hypnoscript_dir_{}", timestamp)) } + /// Disables the process-global sandbox and returns a guard that keeps + /// the sandbox tests from re-enabling it while this test runs. + fn without_sandbox() -> std::sync::MutexGuard<'static, ()> { + let guard = sandbox::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + sandbox::disable_sandbox(); + guard + } + #[test] fn test_file_operations() { + let _guard = without_sandbox(); let test_file = unique_test_file(); let test_file_str = test_file.to_string_lossy().into_owned(); @@ -298,6 +316,7 @@ mod tests { #[test] fn test_read_write_lines() { + let _guard = without_sandbox(); let test_file = unique_test_file(); let path = test_file.to_string_lossy().into_owned(); let lines = vec!["one".to_string(), "two".to_string(), "three".to_string()]; @@ -309,6 +328,7 @@ mod tests { #[test] fn test_copy_directory_recursive() { + let _guard = without_sandbox(); let source = unique_test_directory(); let dest = unique_test_directory(); fs::create_dir_all(&source).unwrap(); diff --git a/hypnoscript-runtime/src/lib.rs b/hypnoscript-runtime/src/lib.rs index c7afa00..179e214 100644 --- a/hypnoscript-runtime/src/lib.rs +++ b/hypnoscript-runtime/src/lib.rs @@ -17,6 +17,7 @@ pub mod file_builtins; pub mod hashing_builtins; pub mod localization; pub mod math_builtins; +pub mod sandbox; pub mod service_builtins; pub mod statistics_builtins; pub mod string_builtins; @@ -40,6 +41,7 @@ pub use file_builtins::FileBuiltins; pub use hashing_builtins::HashingBuiltins; pub use localization::{Locale, LocalizedMessage, detect_locale}; pub use math_builtins::MathBuiltins; +pub use sandbox::{disable_sandbox, is_sandboxed, sandbox_root, set_sandbox_root}; pub use service_builtins::{RetrySchedule, ServiceBuiltins, ServiceHealthReport}; pub use statistics_builtins::StatisticsBuiltins; pub use string_builtins::StringBuiltins; diff --git a/hypnoscript-runtime/src/sandbox.rs b/hypnoscript-runtime/src/sandbox.rs new file mode 100644 index 0000000..b9bec9e --- /dev/null +++ b/hypnoscript-runtime/src/sandbox.rs @@ -0,0 +1,225 @@ +//! Optional filesystem sandbox for HypnoScript programs. +//! +//! When a sandbox root is configured (either programmatically via +//! [`set_sandbox_root`] or through the `HYPNO_SANDBOX` environment variable), +//! every path used by the file builtins is resolved and validated against +//! that root. Paths that escape the root β€” via `..` components, absolute +//! paths, or symlinks β€” are rejected with [`io::ErrorKind::PermissionDenied`]. +//! +//! Without a configured root the sandbox is inactive and paths pass through +//! unchanged, preserving the historical behaviour of trusted scripts. + +use std::env; +use std::io; +use std::path::{Component, Path, PathBuf}; +use std::sync::RwLock; + +/// Global sandbox root. `None` inside the option means "explicitly disabled"; +/// the outer `Option` distinguishes "not yet initialised from environment". +static SANDBOX_ROOT: RwLock>> = RwLock::new(None); + +/// Configures the sandbox root directory for file builtins. +/// +/// The directory must exist; it is canonicalised so symlink tricks cannot +/// widen the sandbox later. Passing a root replaces any previously +/// configured one. +pub fn set_sandbox_root(root: impl AsRef) -> io::Result<()> { + let canonical = root.as_ref().canonicalize()?; + *SANDBOX_ROOT.write().expect("sandbox lock poisoned") = Some(Some(canonical)); + Ok(()) +} + +/// Disables the sandbox (also suppresses `HYPNO_SANDBOX` for this process). +pub fn disable_sandbox() { + *SANDBOX_ROOT.write().expect("sandbox lock poisoned") = Some(None); +} + +/// Returns the active sandbox root, initialising it from the +/// `HYPNO_SANDBOX` environment variable on first use. +pub fn sandbox_root() -> Option { + if let Some(state) = SANDBOX_ROOT.read().expect("sandbox lock poisoned").as_ref() { + return state.clone(); + } + + let from_env = env::var_os("HYPNO_SANDBOX") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .and_then(|p| p.canonicalize().ok()); + let mut guard = SANDBOX_ROOT.write().expect("sandbox lock poisoned"); + // Another thread may have initialised the state in the meantime. + if guard.is_none() { + *guard = Some(from_env); + } + guard.as_ref().and_then(|state| state.clone()) +} + +/// Returns whether the sandbox is currently active. +pub fn is_sandboxed() -> bool { + sandbox_root().is_some() +} + +/// Validates `path` against the sandbox and returns the path to use for the +/// actual filesystem operation. +/// +/// With an inactive sandbox the input is returned unchanged. With an active +/// sandbox the path is resolved (relative paths against the sandbox root, +/// symlinks via the deepest existing ancestor) and must stay within the root. +pub fn checked_path(path: &str) -> io::Result { + let Some(root) = sandbox_root() else { + return Ok(PathBuf::from(path)); + }; + + let candidate = Path::new(path); + let absolute = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + // Relative paths are interpreted relative to the sandbox root so a + // sandboxed script has a stable, predictable working directory. + root.join(candidate) + }; + + let resolved = resolve_symlinks(&normalize_lexically(&absolute))?; + if resolved.starts_with(&root) { + Ok(resolved) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "Path '{}' escapes the HypnoScript sandbox '{}'", + path, + root.display() + ), + )) + } +} + +/// Resolves `.` and `..` components without touching the filesystem. +/// `..` at the root is dropped (it cannot climb above the filesystem root). +fn normalize_lexically(path: &Path) -> PathBuf { + let mut result = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !matches!( + result.components().next_back(), + None | Some(Component::RootDir) | Some(Component::Prefix(_)) + ) { + result.pop(); + } + } + other => result.push(other.as_os_str()), + } + } + result +} + +/// Canonicalises the deepest existing ancestor of `path` (resolving +/// symlinks), then re-appends the non-existing tail. This lets sandboxed +/// scripts create new files while still preventing symlink escapes through +/// existing directories. +fn resolve_symlinks(path: &Path) -> io::Result { + let mut existing = path.to_path_buf(); + let mut tail: Vec = Vec::new(); + + while !existing.exists() { + match existing.file_name() { + Some(name) => { + tail.push(name.to_os_string()); + existing.pop(); + } + // Lexical normalisation already removed `.`/`..`, so this only + // triggers at the filesystem root, which always exists. + None => break, + } + } + + let mut resolved = existing.canonicalize()?; + for part in tail.iter().rev() { + resolved.push(part); + } + Ok(resolved) +} + +/// The sandbox root is process-global state; tests that configure it (or +/// that perform real file I/O which an active sandbox would block) must be +/// serialised through this lock. +#[cfg(test)] +pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::MutexGuard; + + fn with_sandbox(root: &Path) -> MutexGuard<'static, ()> { + let guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + set_sandbox_root(root).expect("failed to set sandbox root"); + guard + } + + fn temp_sandbox_dir(name: &str) -> PathBuf { + let dir = env::temp_dir().join(format!("hypno_sandbox_{}_{}", name, std::process::id())); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn inactive_sandbox_passes_paths_through() { + let guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + disable_sandbox(); + assert!(!is_sandboxed()); + assert_eq!( + checked_path("/etc/hostname").unwrap(), + PathBuf::from("/etc/hostname") + ); + drop(guard); + } + + #[test] + fn sandbox_allows_paths_inside_root() { + let dir = temp_sandbox_dir("inside"); + let guard = with_sandbox(&dir); + + let resolved = checked_path("notes/output.txt").unwrap(); + assert!(resolved.starts_with(dir.canonicalize().unwrap())); + + disable_sandbox(); + drop(guard); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn sandbox_rejects_escapes() { + let dir = temp_sandbox_dir("escape"); + let guard = with_sandbox(&dir); + + for attempt in ["../outside.txt", "/etc/passwd", "a/../../outside.txt"] { + let err = checked_path(attempt).expect_err(attempt); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied, "{attempt}"); + } + + disable_sandbox(); + drop(guard); + let _ = fs::remove_dir_all(dir); + } + + #[cfg(unix)] + #[test] + fn sandbox_rejects_symlink_escapes() { + let dir = temp_sandbox_dir("symlink"); + let guard = with_sandbox(&dir); + + let link = dir.join("sneaky"); + let _ = fs::remove_file(&link); + std::os::unix::fs::symlink("/", &link).unwrap(); + + let err = checked_path("sneaky/etc/passwd").expect_err("symlink escape"); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + + disable_sandbox(); + drop(guard); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/hypnoscript-runtime/src/string_builtins.rs b/hypnoscript-runtime/src/string_builtins.rs index 17668dd..faabdfd 100644 --- a/hypnoscript-runtime/src/string_builtins.rs +++ b/hypnoscript-runtime/src/string_builtins.rs @@ -199,13 +199,13 @@ impl StringBuiltins { /// Pad left with character pub fn pad_left(s: &str, total_width: usize, pad_char: char) -> String { - let padding = total_width.saturating_sub(s.len()); + let padding = total_width.saturating_sub(s.chars().count()); format!("{}{}", pad_char.to_string().repeat(padding), s) } /// Pad right with character pub fn pad_right(s: &str, total_width: usize, pad_char: char) -> String { - let padding = total_width.saturating_sub(s.len()); + let padding = total_width.saturating_sub(s.chars().count()); format!("{}{}", s, pad_char.to_string().repeat(padding)) } @@ -435,4 +435,14 @@ mod tests { let lines = StringBuiltins::wrap_text(text, 20); assert!(lines.iter().all(|line| line.chars().count() <= 20)); } + + #[test] + fn test_padding_is_unicode_aware() { + assert_eq!(StringBuiltins::pad_left("hello", 10, '-'), "-----hello"); + assert_eq!(StringBuiltins::pad_right("hello", 10, '-'), "hello-----"); + assert_eq!(StringBuiltins::pad_left("🎯", 4, '-'), "---🎯"); + assert_eq!(StringBuiltins::pad_right("🎯", 4, '-'), "🎯---"); + assert_eq!(StringBuiltins::pad_left("cafΓ©", 6, ' '), " cafΓ©"); + assert_eq!(StringBuiltins::pad_right("cafΓ©", 6, ' '), "cafΓ© "); + } } diff --git a/hypnoscript-tests/simple_test.hyp b/hypnoscript-tests/simple_test.hyp index 017c604..bfefc63 100644 Binary files a/hypnoscript-tests/simple_test.hyp and b/hypnoscript-tests/simple_test.hyp differ diff --git a/hypnoscript-tests/test.hyp b/hypnoscript-tests/test.hyp index 6cf0f7f..03f8e9c 100644 --- a/hypnoscript-tests/test.hyp +++ b/hypnoscript-tests/test.hyp @@ -1,4 +1,3 @@ -Focus { Focus { entrance { observe "🌌 HypnoScript – KomplettΓΌberblick"; @@ -42,7 +41,7 @@ Focus { awaken ToUpper(message) + "!"; } - imperative suggestion narrate(message: string) { + imperativeSuggestion narrate(message: string) { whisper message; } @@ -98,9 +97,9 @@ Focus { observe name + " erwacht sanft."; }; - suggestion closeSession(session: HypnoSession) { - observe "Session beendet fΓΌr " + session.subject; - onSessionComplete(session.subject); + suggestion closeSession(hypnoSession: HypnoSession) { + observe "Session beendet fΓΌr " + hypnoSession.subject; + onSessionComplete(hypnoSession.subject); } closeSession(nightly); diff --git a/hypnoscript-tests/test_advanced.hyp b/hypnoscript-tests/test_advanced.hyp index ca7e46c..e41afaf 100644 --- a/hypnoscript-tests/test_advanced.hyp +++ b/hypnoscript-tests/test_advanced.hyp @@ -20,7 +20,7 @@ Focus { // === DeepFocus & verschachtelte Bedingungen === induce desiredDepth: number = 6; forFocus: - loop (induce i: number = 0; i deeplyLess ArrayLength(plans); i = i + 1) { + loop (induce i: number = 0; i fallUnderMySpell ArrayLength(plans); i = i + 1) { induce plan = plans[i]; deepFocus (plan.depth lookAtTheWatch desiredDepth) { @@ -30,7 +30,7 @@ Focus { } // === entrain mit Guard & Pattern === - loop (induce i: number = 0; i deeplyLess ArrayLength(plans); i = i + 1) { + loop (induce i: number = 0; i fallUnderMySpell ArrayLength(plans); i = i + 1) { induce current = plans[i]; induce classification: string = entrain current { when SuggestionPlan { depth: d } if d lookAtTheWatch 7 => "intensiv" @@ -62,7 +62,7 @@ Focus { induce minDepth: number = plans[0].depth; induce maxDepth: number = plans[0].depth; - loop (induce i: number = 0; i deeplyLess ArrayLength(plans); i = i + 1) { + loop (induce i: number = 0; i fallUnderMySpell ArrayLength(plans); i = i + 1) { induce depthValue: number = plans[i].depth; sumDepth = sumDepth + depthValue; if (depthValue fallUnderMySpell minDepth) { diff --git a/hypnoscript-tests/test_closures_promises.hyp b/hypnoscript-tests/test_closures_promises.hyp new file mode 100644 index 0000000..ac0f242 --- /dev/null +++ b/hypnoscript-tests/test_closures_promises.hyp @@ -0,0 +1,59 @@ +// End-to-end test: closures (lexical captures + recursion) and promises. +Focus { + // Nested suggestions capture their lexical environment. + suggestion makeAdder(offset: number): number { + induce bonus: number = 10; + suggestion add(n: number): number { + awaken n + offset + bonus; + } + awaken add(5); + } + + // Nested suggestions can recurse. + suggestion sumBelow(limit: number): number { + suggestion helper(n: number): number { + if (n <= 0) deepFocus { + awaken 0; + } + awaken n + helper(n - 1); + } + awaken helper(limit); + } + + entrance { + induce added: number = makeAdder(100); + if (added != 115) deepFocus { + closureCaptureBroken(); + } + + induce total: number = sumBelow(4); + if (total != 10) deepFocus { + closureRecursionBroken(); + } + + // Promises: delayed values resolve on await (HYPNO_TIME_SCALE aware). + induce slow = delayedValue(20, "slow"); + induce fast = instantPromise("fast"); + + if (isPromiseResolved(slow)) deepFocus { + promiseShouldBePending(); + } + + induce winner = promiseRace([slow, fast]); + if (winner != "fast") deepFocus { + promiseRaceBroken(); + } + + induce all = promiseAll([delayedValue(5, 1), instantPromise(2)]); + if (ArrayLength(all) != 2) deepFocus { + promiseAllBroken(); + } + + induce value = await delayedValue(5, 42); + if (value != 42) deepFocus { + awaitBroken(); + } + + observe "closures & promises: ok"; + } +} Relax diff --git a/hypnoscript-tests/test_new_language_features.hyp b/hypnoscript-tests/test_new_language_features.hyp index cb7c386..ff42320 100644 --- a/hypnoscript-tests/test_new_language_features.hyp +++ b/hypnoscript-tests/test_new_language_features.hyp @@ -47,7 +47,7 @@ Focus { // Multiple output channels remain combinable observe "Standard output active."; - whisper "soft whisper" + whisper "soft whisper"; command "COMMAND CHANNEL"; finale { diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..95d23b8 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "latest" diff --git a/package.json b/package.json index f3dde4b..a485857 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hyp-runtime", - "version": "1.2.0", + "version": "1.3.0", "description": "Workspace documentation tooling for the HypnoScript Rust implementation.", "private": true, "scripts": { diff --git a/scripts/README.md b/scripts/README.md index a62fe9e..433f817 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -45,8 +45,8 @@ Creates a Linux release package including: - `release/linux-x64/hypnoscript` - `release/linux-x64/install.sh` -- `release/hypnoscript-1.2.0-linux-x64.tar.gz` -- `release/hypnoscript-1.2.0-linux-x64.tar.gz.sha256` +- `release/hypnoscript-1.3.0-linux-x64.tar.gz` +- `release/hypnoscript-1.3.0-linux-x64.tar.gz.sha256` **Requirements**: @@ -57,7 +57,7 @@ Creates a Linux release package including: **Installation on Linux**: ```bash -tar -xzf hypnoscript-1.2.0-linux-x64.tar.gz +tar -xzf hypnoscript-1.3.0-linux-x64.tar.gz cd linux-x64 sudo bash install.sh ``` @@ -82,9 +82,9 @@ Creates a macOS release package with multiple distribution formats: - `release/macos-universal/hypnoscript` - `release/macos-universal/install.sh` -- `release/hypnoscript-1.2.0-macos-universal.tar.gz` -- `release/hypnoscript-1.2.0-macos-universal.dmg` (macOS only) -- `release/hypnoscript-1.2.0-macos-universal.pkg` (macOS only) +- `release/hypnoscript-1.3.0-macos-universal.tar.gz` +- `release/hypnoscript-1.3.0-macos-universal.dmg` (macOS only) +- `release/hypnoscript-1.3.0-macos-universal.pkg` (macOS only) - `.sha256` files for all archives **Architecture Options**: @@ -116,20 +116,20 @@ pwsh scripts/build_macos.ps1 -PackageType all # All formats From TAR.GZ: ```bash -tar -xzf hypnoscript-1.2.0-macos-universal.tar.gz +tar -xzf hypnoscript-1.3.0-macos-universal.tar.gz cd macos-universal sudo bash install.sh ``` From DMG: -1. Open `hypnoscript-1.2.0-macos-universal.dmg` +1. Open `hypnoscript-1.3.0-macos-universal.dmg` 2. Drag `hypnoscript` to the "Install to /usr/local/bin" symlink From PKG: ```bash -sudo installer -pkg hypnoscript-1.2.0-macos-universal.pkg -target / +sudo installer -pkg hypnoscript-1.3.0-macos-universal.pkg -target / ``` --- @@ -217,10 +217,10 @@ rustup target add aarch64-apple-darwin # Apple Silicon Version information is defined in: - `Cargo.toml` (workspace root) -- `scripts/build_winget.ps1` (line 8: `$VERSION = "1.2.0"`) -- `scripts/build_linux.ps1` (line 10: `$VERSION = "1.2.0"`) -- `scripts/build_macos.ps1` (line 11: `$VERSION = "1.2.0"`) -- `scripts/build_deb.sh` (line 7: `VERSION=1.2.0`) +- `scripts/build_winget.ps1` (line 8: `$VERSION = "1.3.0"`) +- `scripts/build_linux.ps1` (line 10: `$VERSION = "1.3.0"`) +- `scripts/build_macos.ps1` (line 11: `$VERSION = "1.3.0"`) +- `scripts/build_deb.sh` (line 7: `VERSION=1.3.0`) **Important**: Keep versions synchronized across all files! @@ -239,15 +239,15 @@ Get-FileHash -Algorithm SHA256 HypnoScript-windows-x64.zip **Linux**: ```bash -sha256sum hypnoscript-1.2.0-linux-x64.tar.gz -cat hypnoscript-1.2.0-linux-x64.tar.gz.sha256 +sha256sum hypnoscript-1.3.0-linux-x64.tar.gz +cat hypnoscript-1.3.0-linux-x64.tar.gz.sha256 ``` **macOS**: ```bash -shasum -a 256 hypnoscript-1.2.0-macos-universal.tar.gz -cat hypnoscript-1.2.0-macos-universal.tar.gz.sha256 +shasum -a 256 hypnoscript-1.3.0-macos-universal.tar.gz +cat hypnoscript-1.3.0-macos-universal.tar.gz.sha256 ``` --- @@ -277,14 +277,14 @@ release/ β”‚ └── VERSION.txt β”œβ”€β”€ HypnoScript-windows-x64.zip β”œβ”€β”€ HypnoScript-windows-x64.zip.sha256 -β”œβ”€β”€ hypnoscript-1.2.0-linux-x64.tar.gz -β”œβ”€β”€ hypnoscript-1.2.0-linux-x64.tar.gz.sha256 -β”œβ”€β”€ hypnoscript-1.2.0-macos-universal.tar.gz -β”œβ”€β”€ hypnoscript-1.2.0-macos-universal.tar.gz.sha256 -β”œβ”€β”€ hypnoscript-1.2.0-macos-universal.dmg (macOS only) -β”œβ”€β”€ hypnoscript-1.2.0-macos-universal.dmg.sha256 -β”œβ”€β”€ hypnoscript-1.2.0-macos-universal.pkg (macOS only) -└── hypnoscript-1.2.0-macos-universal.pkg.sha256 +β”œβ”€β”€ hypnoscript-1.3.0-linux-x64.tar.gz +β”œβ”€β”€ hypnoscript-1.3.0-linux-x64.tar.gz.sha256 +β”œβ”€β”€ hypnoscript-1.3.0-macos-universal.tar.gz +β”œβ”€β”€ hypnoscript-1.3.0-macos-universal.tar.gz.sha256 +β”œβ”€β”€ hypnoscript-1.3.0-macos-universal.dmg (macOS only) +β”œβ”€β”€ hypnoscript-1.3.0-macos-universal.dmg.sha256 +β”œβ”€β”€ hypnoscript-1.3.0-macos-universal.pkg (macOS only) +└── hypnoscript-1.3.0-macos-universal.pkg.sha256 ``` --- @@ -330,10 +330,10 @@ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser ### GitHub Releases -1. Create a new release tag (e.g., `v1.2.0`) +1. Create a new release tag (e.g., `v1.3.0`) 2. Upload artifacts: - `HypnoScript-windows-x64.zip` - - `hypnoscript-1.2.0-linux-x64.tar.gz` + - `hypnoscript-1.3.0-linux-x64.tar.gz` - Checksum files (`.sha256`) 3. Add release notes diff --git a/scripts/build_deb.sh b/scripts/build_deb.sh index 67809ec..8c5ef31 100644 --- a/scripts/build_deb.sh +++ b/scripts/build_deb.sh @@ -5,7 +5,7 @@ set -e # Creates Linux binary and .deb package for HypnoScript (Rust implementation) NAME=hypnoscript -VERSION=1.2.0 +VERSION=1.3.0 ARCH=amd64 # Determine project directory diff --git a/scripts/build_linux.ps1 b/scripts/build_linux.ps1 index 13723d1..278e64c 100644 --- a/scripts/build_linux.ps1 +++ b/scripts/build_linux.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = "Stop" # Configuration $NAME = "hypnoscript" -$VERSION = "1.2.0" +$VERSION = "1.3.0" $ARCH = "amd64" # Determine project directory diff --git a/scripts/build_macos.ps1 b/scripts/build_macos.ps1 index 29622d1..17b2d6a 100644 --- a/scripts/build_macos.ps1 +++ b/scripts/build_macos.ps1 @@ -16,7 +16,7 @@ $ErrorActionPreference = "Stop" # Configuration $NAME = "HypnoScript" $BUNDLE_ID = "com.kinkdev.hypnoscript" -$VERSION = "1.2.0" +$VERSION = "1.3.0" $BINARY_NAME = "hypnoscript-cli" $INSTALL_NAME = "hypnoscript" diff --git a/scripts/build_winget.ps1 b/scripts/build_winget.ps1 index 70359a3..01fea25 100644 --- a/scripts/build_winget.ps1 +++ b/scripts/build_winget.ps1 @@ -59,7 +59,7 @@ if (Test-Path $licensePath) { } # Create VERSION file -$version = "1.2.0" +$version = "1.3.0" $versionFile = Join-Path $winDir "VERSION.txt" Set-Content -Path $versionFile -Value "HypnoScript Runtime v$version`n`nBuilt: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" diff --git a/scripts/debian/control b/scripts/debian/control index c9c333c..9b873d5 100644 --- a/scripts/debian/control +++ b/scripts/debian/control @@ -1,5 +1,5 @@ Package: hypnoscript -Version: 1.2.0 +Version: 1.3.0 Section: utils Priority: optional Architecture: amd64 diff --git a/scripts/winget-manifest.yaml b/scripts/winget-manifest.yaml index 0fcebee..9d691fd 100644 --- a/scripts/winget-manifest.yaml +++ b/scripts/winget-manifest.yaml @@ -1,13 +1,13 @@ # winget-manifest.yaml PackageIdentifier: HypnoScript.HypnoScript -PackageVersion: 1.2.0 +PackageVersion: 1.3.0 PackageName: HypnoScript Publisher: HypnoScript Project License: MIT Installers: - Architecture: x64 InstallerType: zip - InstallerUrl: https://github.com/Kink-Development-Group/hyp-runtime/releases/download/v1.2.0/HypnoScript-windows-x64.zip + InstallerUrl: https://github.com/Kink-Development-Group/hyp-runtime/releases/download/v1.3.0/HypnoScript-windows-x64.zip InstallerSha256: Scope: machine NestedInstallerType: exe diff --git a/template/trance.json b/template/trance.json index 8a6b04f..ee06420 100644 --- a/template/trance.json +++ b/template/trance.json @@ -29,8 +29,8 @@ "test": "hypnoscript run tests/smoke.hyp" }, "anchors": { - "hypnoscript-runtime": "^1.2.0", - "hypnoscript-cli": "^1.2.0" + "hypnoscript-runtime": "^1.3.0", + "hypnoscript-cli": "^1.3.0" }, "deepAnchors": { "@hypno/testing-lab": "^0.3.0"