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