From bcd729c0c803e7e7e5c11c9d9fe8bbc7089ad5f7 Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Sun, 8 Feb 2026 09:16:59 +0100 Subject: [PATCH 1/6] WIP --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5778e3a..f772d66 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ Result of (zig-mul 7 6) is: 42 ### Documentation -You can find the full API documentation for the latest release of Elz [here](https://Element0Lang.github.io/element-0/). +You can find the full API documentation for the latest release of Elz [here](https://element0lang.github.io/element-0/). Alternatively, you can use the `make docs` command to generate the API documentation for the current version of Elz from the source code. From 57cc227279b87ffc6b7f368cd88533d706ba92e6 Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Thu, 26 Mar 2026 11:54:03 +0100 Subject: [PATCH 2/6] Add an `AGENTS.md` to the project --- .gitignore | 1 + AGENTS.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 3f609b5..1a041c2 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,4 @@ docs/api/ latest history.txt .history +.claude/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8278634 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,136 @@ +# AGENTS.md + +This file provides guidance to coding agents collaborating on this repository. + +## Mission + +Element 0 is a small, embeddable Lisp dialect inspired by Scheme, implemented in Zig. +The interpreter (Elz) is designed to integrate into Zig applications as a scripting engine. +Priorities, in order: + +1. Correctness and R5RS compliance where applicable. +2. Clean, minimal public API for embedding into Zig projects. +3. Maintainable and well-tested code. +4. Cross-platform support (Linux, macOS, and Windows). + +## Core Rules + +- Use English for code, comments, docs, and tests. +- Prefer small, focused changes over large refactoring. +- Add comments only when they clarify non-obvious behavior. +- Do not add features, error handling, or abstractions beyond what is needed for the current task. + +## Repository Layout + +- `src/lib.zig`: Main public API export module for embedding Elz as a library. +- `src/main.zig`: REPL entry point (`elz-repl` binary). +- `src/elz/core.zig`: Core value types, Environment, and Module definitions. +- `src/elz/interpreter.zig`: Main `Interpreter` struct. +- `src/elz/eval.zig`: Evaluation engine. +- `src/elz/parser.zig`: S-expression parser. +- `src/elz/env_setup.zig`: Environment initialization and FFI setup. +- `src/elz/ffi.zig`: Foreign function interface for calling Zig functions from Element 0. +- `src/elz/gc.zig`: Garbage collection wrapper (uses Boehm-Demers-Weiser GC). +- `src/elz/errors.zig`: Error types. +- `src/elz/writer.zig`: Value serialization and display. +- `src/elz/api_helpers.zig`: Public API helper functions. +- `src/elz/primitives/`: Built-in functions grouped by category (math, lists, strings, control, predicates, vectors, hashmaps, io, ports, datetime, os, modules, and process). +- `src/stdlib/std.elz`: Standard library written in Element 0 itself. +- `examples/zig/`: FFI examples showing how to call Zig functions from Element 0. +- `examples/elz/`: Element 0 script examples. +- `tests/`: Element 0 language-level tests (`test_stdlib.elz`, `test_advanced.elz`, `test_edge_cases.elz`, `test_regression.elz`, `test_module_lib.elz`). +- `.github/workflows/`: CI workflows (tests, lints, docs, and releases). +- `build.zig` / `build.zig.zon`: Zig build configuration and dependencies. +- `Makefile`: GNU Make wrapper around `zig build`. + +## Architecture + +### Interpreter Pipeline + +Source code flows through: Parser (`parser.zig`) -> Evaluator (`eval.zig`) -> Writer (`writer.zig`). +The `Interpreter` struct in `interpreter.zig` ties these together and manages the root environment. + +### Core / Primitives Split + +- `src/elz/core.zig` defines the value types and environment model. +- `src/elz/primitives/` contains all built-in functions, each in a category-specific module. +- New built-in functions should be added to the appropriate primitives' module. + +### FFI + +Zig functions can be registered with the interpreter via `env_setup.define_foreign_func()`. +This is the primary extension mechanism for embedding use cases. + +### Garbage Collection + +Memory is managed by the Boehm-Demers-Weiser GC (`bdwgc`), wrapped in `gc.zig`. The GC is linked as a C library dependency. + +### Dependencies + +Managed via Zig's package manager (`build.zig.zon`): + +- Chilli: CLI framework for the REPL. +- Bdwgc (v8.2.12): Garbage collector. +- Linenoise (v2.0): Line editing for the REPL (POSIX only). + +## Zig Conventions + +- **Zig version**: 0.15.2. +- Formatting is enforced by `zig fmt`. Run `make format` before committing. +- Naming: `snake_case` for functions and variables, `PascalCase` for types and structs. +- Element 0 symbols use `kebab-case` (e.g., `zig-mul`, `string-length`). + +## Required Validation + +Run both test suites for any change: + +| Target | Command | What It Runs | +|-----------------|------------------|--------------------------------------------------| +| Zig unit tests | `make test` | Inline `test` blocks in `src/**/*.zig` | +| Language tests | `make test-elz` | Element 0 test files in `tests/*.elz` | +| Lint | `make lint` | Checks Zig formatting with `zig fmt --check` | + +For interactive exploration: `make repl`. + +## First Contribution Flow + +1. Read the relevant source module under `src/elz/`. +2. Implement the smallest possible change. +3. Add or update inline `test` blocks in the changed Zig module. Add Element 0 tests in `tests/` if language behavior changed. +4. Run `make test && make test-elz`. +5. Verify interactively with `make repl` if needed. + +Good first tasks: + +- Add a new built-in function in the appropriate `src/elz/primitives/` module. +- Add a new standard library function in `src/stdlib/std.elz`. +- Fix an edge case identified in `tests/test_edge_cases.elz`. +- Add a new FFI example in `examples/zig/`. + +## Testing Expectations + +- Zig unit tests live as inline `test` blocks in the module they cover (`src/elz/*.zig` and `src/elz/primitives/*.zig`). +- Language-level tests live in `tests/*.elz` and are run by the interpreter itself via `make test-elz`. +- No language-facing change is complete without an Element 0 test. + +## Change Design Checklist + +Before coding: + +1. Identify which module(s) the change touches (core, primitives, parser, eval, etc.). +2. Consider whether the change requires updates to the standard library (`std.elz`). +3. Check cross-platform implications if the change touches OS or I/O primitives. + +Before submitting: + +1. `make test && make test-elz` passes. +2. `make lint` passes. +3. Docs updated if the public API surface changed. + +## Commit and PR Hygiene + +- Keep commits scoped to one logical change. +- PR descriptions should include: + 1. Behavioral change summary. + 2. Tests added or updated. + 3. Interactive verification done (yes/no). From 437478d6c4ad6349d4a4294c6ee10422ba5e1d29 Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Thu, 26 Mar 2026 12:13:06 +0100 Subject: [PATCH 3/6] WIP --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8278634..63704b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ Memory is managed by the Boehm-Demers-Weiser GC (`bdwgc`), wrapped in `gc.zig`. Managed via Zig's package manager (`build.zig.zon`): - Chilli: CLI framework for the REPL. -- Bdwgc (v8.2.12): Garbage collector. +- BDWGC (v8.2.12): Garbage collector. - Linenoise (v2.0): Line editing for the REPL (POSIX only). ## Zig Conventions From 030fd580c9351f044844cfe6a41912c36e4ea396 Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Fri, 27 Mar 2026 17:08:19 +0100 Subject: [PATCH 4/6] Add Minish as a dependency --- build.zig.zon | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.zig.zon b/build.zig.zon index 43c14c9..25d40c5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -16,6 +16,10 @@ .url = "https://github.com/antirez/linenoise/archive/refs/tags/2.0.tar.gz", .hash = "N-V-__8AAJ4HAgCX79UDBfNwhqAqUVoGC44ib6UYa18q6oa_", }, + .minish = .{ + .url = "https://github.com/CogitatorTech/minish/archive/refs/tags/v0.1.0.tar.gz", + .hash = "minish-0.1.0-SQtSTWHkAQACfz3xGuWHU8zbx320vK_47r2yto3Pq0Rf", + }, }, .paths = .{ "build.zig", "build.zig.zon", "src", "LICENSE", "README.md" }, } From 519a09a6b780a1d63889dddd1231666c79988e1a Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Fri, 27 Mar 2026 21:33:31 +0100 Subject: [PATCH 5/6] Add an `AGENTS.md` file for the project --- AGENTS.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 63704b6..8a15ab7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,23 +72,27 @@ Managed via Zig's package manager (`build.zig.zon`): - Chilli: CLI framework for the REPL. - BDWGC (v8.2.12): Garbage collector. - Linenoise (v2.0): Line editing for the REPL (POSIX only). +- Minish: Property-based testing framework. ## Zig Conventions -- **Zig version**: 0.15.2. +- Zig version: 0.15.2. - Formatting is enforced by `zig fmt`. Run `make format` before committing. - Naming: `snake_case` for functions and variables, `PascalCase` for types and structs. - Element 0 symbols use `kebab-case` (e.g., `zig-mul`, `string-length`). ## Required Validation -Run both test suites for any change: +Run all test suites for any change: -| Target | Command | What It Runs | -|-----------------|------------------|--------------------------------------------------| -| Zig unit tests | `make test` | Inline `test` blocks in `src/**/*.zig` | -| Language tests | `make test-elz` | Element 0 test files in `tests/*.elz` | -| Lint | `make lint` | Checks Zig formatting with `zig fmt --check` | +| Target | Command | What It Runs | +|---------------------|--------------------|--------------------------------------------------------------| +| Zig unit tests | `make test` | Inline `test` blocks in `src/**/*.zig` | +| Property tests | `make test-prop` | Property-based tests in `tests/*_prop_test.zig` (Minish) | +| Integration tests | `make test-integ` | Integration tests in `tests/*_integ_test.zig` | +| Language tests | `make test-elz` | Element 0 test files in `tests/test_*.elz` | +| All tests | `make test-all` | Runs all of the above | +| Lint | `make lint` | Checks Zig formatting with `zig fmt --check` | For interactive exploration: `make repl`. @@ -97,7 +101,7 @@ For interactive exploration: `make repl`. 1. Read the relevant source module under `src/elz/`. 2. Implement the smallest possible change. 3. Add or update inline `test` blocks in the changed Zig module. Add Element 0 tests in `tests/` if language behavior changed. -4. Run `make test && make test-elz`. +4. Run `make test-all`. 5. Verify interactively with `make repl` if needed. Good first tasks: @@ -109,8 +113,10 @@ Good first tasks: ## Testing Expectations -- Zig unit tests live as inline `test` blocks in the module they cover (`src/elz/*.zig` and `src/elz/primitives/*.zig`). -- Language-level tests live in `tests/*.elz` and are run by the interpreter itself via `make test-elz`. +- Unit and regression tests live as inline `test` blocks in the module they cover (`src/elz/*.zig` and `src/elz/primitives/*.zig`). +- Property-based tests live in `tests/*_prop_test.zig` and use the Minish framework. They test invariants like commutativity, roundtrip properties, and crash resistance. +- Integration tests live in `tests/*_integ_test.zig` and test the public embedding API (init, evalString, FFI, error propagation, sandboxing). +- Language-level tests live in `tests/test_*.elz` and are run by the interpreter itself via `make test-elz`. - No language-facing change is complete without an Element 0 test. ## Change Design Checklist From b7c2b2001ac27b40bfcf601e171428e16a4cbc71 Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Fri, 27 Mar 2026 21:33:42 +0100 Subject: [PATCH 6/6] Add missing features --- Makefile | 20 +- ROADMAP.md | 19 +- build.zig | 87 +++++- src/elz/env_setup.zig | 28 ++ src/elz/errors.zig | 4 + src/elz/eval.zig | 66 +++- src/elz/ffi.zig | 109 ++++++- src/elz/interpreter.zig | 24 ++ src/elz/primitives/control.zig | 44 +++ src/elz/primitives/format.zig | 181 +++++++++++ src/elz/primitives/json.zig | 419 +++++++++++++++++++++++++ src/elz/primitives/regex.zig | 513 +++++++++++++++++++++++++++++++ tests/eval_prop_test.zig | 177 +++++++++++ tests/interpreter_integ_test.zig | 257 ++++++++++++++++ tests/json_prop_test.zig | 62 ++++ tests/parser_prop_test.zig | 116 +++++++ tests/sandbox_integ_test.zig | 53 ++++ tests/test_continuations.elz | 62 ++++ tests/test_format.elz | 76 +++++ tests/test_json.elz | 90 ++++++ tests/test_regex.elz | 82 +++++ tests/test_syntax_rules.elz | 68 ++++ 22 files changed, 2525 insertions(+), 32 deletions(-) create mode 100644 src/elz/primitives/format.zig create mode 100644 src/elz/primitives/json.zig create mode 100644 src/elz/primitives/regex.zig create mode 100644 tests/eval_prop_test.zig create mode 100644 tests/interpreter_integ_test.zig create mode 100644 tests/json_prop_test.zig create mode 100644 tests/parser_prop_test.zig create mode 100644 tests/sandbox_integ_test.zig create mode 100644 tests/test_continuations.elz create mode 100644 tests/test_format.elz create mode 100644 tests/test_json.elz create mode 100644 tests/test_regex.elz create mode 100644 tests/test_syntax_rules.elz diff --git a/Makefile b/Makefile index c2f266b..cc4b873 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ SHELL := /usr/bin/env bash # Targets ################################################################################ -.PHONY: all help build rebuild run run-elz test test-elz release clean lint format docs serve-docs install-deps setup-hooks test-hooks +.PHONY: all help build rebuild run run-elz test test-elz test-prop test-integ test-all release clean lint format docs serve-docs install-deps setup-hooks test-hooks .DEFAULT_GOAL := help help: ## Show the help messages for all targets @@ -43,13 +43,13 @@ init: ## Initialize a new Zig project @echo "Initializing a new Zig project..." @$(ZIG) init -build: ## Build project (e.g. 'make build BUILD_TYPE=ReleaseSafe') +build: ## Build project (like 'make build BUILD_TYPE=ReleaseSafe') @echo "Building project in $(BUILD_TYPE) mode with $(JOBS) concurrent jobs..." @$(ZIG) build $(BUILD_OPTS) -j$(JOBS) rebuild: clean build ## clean and build -run: ## Run a Zig example (e.g. 'make run EXAMPLE=e1_ffi_1') +run: ## Run a Zig example (like 'make run EXAMPLE=e1_ffi_1') @if [ "$(EXAMPLE)" = "all" ]; then \ echo "--> Running all Zig examples..."; \ fail=0; \ @@ -64,7 +64,7 @@ run: ## Run a Zig example (e.g. 'make run EXAMPLE=e1_ffi_1') $(ZIG) build run-$(EXAMPLE) $(BUILD_OPTS); \ fi -run-elz: build ## Run a Lisp example (e.g. 'make run-elz ELZ_EXAMPLE=e1-cons-car-cdr') +run-elz: build ## Run a Lisp example (like 'make run-elz ELZ_EXAMPLE=e1-cons-car-cdr') @if [ "$(ELZ_EXAMPLE)" = "all" ]; then \ echo "--> Running all Lisp examples..."; \ fail=0; \ @@ -91,6 +91,18 @@ test-elz: ## Run Element 0 standard library tests @echo "Running Element 0 standard library tests..." @$(ZIG) build test-elz $(BUILD_OPTS) -j$(JOBS) $(TEST_FLAGS) +test-prop: ## Run property-based tests + @echo "Running property-based tests..." + @$(ZIG) build test-prop $(BUILD_OPTS) -j$(JOBS) $(TEST_FLAGS) + +test-integ: ## Run integration tests + @echo "Running integration tests..." + @$(ZIG) build test-integ $(BUILD_OPTS) -j$(JOBS) $(TEST_FLAGS) + +test-all: ## Run all tests (unit, property, integration, and elz) + @echo "Running all tests..." + @$(ZIG) build test-all $(BUILD_OPTS) -j$(JOBS) $(TEST_FLAGS) + release: ## Build in Release mode @echo "Building the project in Release mode..." @$(MAKE) build BUILD_TYPE=$(RELEASE_MODE) diff --git a/ROADMAP.md b/ROADMAP.md index 648ccee..7d85b7e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -103,9 +103,9 @@ It outlines the features to be implemented and their current status. * [x] **Math Library**: More common mathematical functions (like trigonometric and logarithmic functions). * [x] **List Utilities**: `filter`, `fold-left`, `fold-right`, and other common list processing functions. * [x] **String Utilities**: `string-append`, `string-ref`, `substring`, `string-split`, `number->string`, `string->number`, `make-string`, `string=?`, `string?`, `string<=?`, `string>=?`, `gensym` implemented. -* [ ] **Regular Expressions**: A library for advanced text pattern matching. +* [x] **Regular Expressions**: `regex-match?`, `regex-search`, `regex-replace`, `regex-split` implemented with NFA-based engine supporting literals, `.`, `*`, `+`, `?`, character classes, and anchors. * [x] **OS and Filesystem**: `getenv`, `file-exists?`, `delete-file`, `current-directory`, `directory-list`, `rename-file` implemented. -* [ ] **Advanced I/O**: A `format` procedure and a more comprehensive port system. +* [x] **Advanced I/O**: `format` procedure with `~a`, `~s`, `~%`, `~~` directives, and `value->string` implemented. * [x] **Date and Time**: `current-time`, `current-time-ms`, `time->components`, `sleep-ms` implemented. ### 4. Advanced Language Features (Post-R5RS) @@ -114,16 +114,17 @@ It outlines the features to be implemented and their current status. * [x] **Module System**: A system for organizing code into reusable and encapsulated modules. * [x] `define-macro` (simple procedural macros) * [ ] `syntax-rules` (hygienic macros) or similar system for compile-time metaprogramming. -* [ ] `call-with-current-continuation` (`call/cc`): Support for first-class continuations. +* [x] `call-with-escape-continuation` (`call/ec`): Escape-only continuations for early returns. Full `call/cc` deferred pending CPS rewrite. ### 5. Better Host Integration and Embeddability -* [ ] **Advanced FFI** +* **Advanced FFI** * [ ] Support for passing complex Zig structs. * [ ] Ability to pass Elz closures to Zig as callbacks. - * [ ] Automatic type conversions for more data types. -* [ ] **Sandboxing and Security** + * [x] Automatic type conversions for `bool`, `[]const u8`, and `?T` (optional) types. +* **Sandboxing and Security** * [x] A sandboxed mode to restrict access to I/O and other sensitive operations. - * [ ] Host-level controls for memory and execution time limits. -* [ ] **Serialization** - * [ ] Built-in procedures to serialize and deserialize Elz objects (for example, to JSON or S-expressions). + * [x] Host-level controls for execution time limits (`time_limit_ms` in `SandboxFlags`). +* **Serialization** + * [x] `json-serialize` and `json-deserialize` for JSON round-tripping. + * [x] `value->string` for S-expression serialization. diff --git a/build.zig b/build.zig index ed6cf56..4810e37 100644 --- a/build.zig +++ b/build.zig @@ -143,7 +143,7 @@ pub fn build(b: *std.Build) void { }); docs_step.dependOn(&gen_docs_cmd.step); - // --- Test Setup --- + // --- Unit Test Setup --- const test_module = b.createModule(.{ .root_source_file = lib_source, .target = target, @@ -162,6 +162,57 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_lib_unit_tests.step); + // --- Property-Based and Integration Test Setup --- + const test_prop_step = b.step("test-prop", "Run property-based tests"); + const test_integ_step = b.step("test-integ", "Run integration tests"); + const minish_dep = b.dependency("minish", .{}); + + { + const tests_path = "tests"; + var tests_dir = fs.cwd().openDir(tests_path, .{ .iterate = true }) catch |err| { + if (err == error.FileNotFound) { + @panic("Can't open 'tests' directory"); + } + @panic("Can't open 'tests' directory"); + }; + defer tests_dir.close(); + + var test_iter = tests_dir.iterate(); + while (test_iter.next() catch @panic("Failed to iterate tests")) |entry| { + if (!std.mem.endsWith(u8, entry.name, ".zig")) continue; + + const is_prop_test = std.mem.endsWith(u8, entry.name, "_prop_test.zig"); + const is_integ_test = std.mem.endsWith(u8, entry.name, "_integ_test.zig"); + + if (!is_prop_test and !is_integ_test) continue; + + const test_path = b.fmt("{s}/{s}", .{ tests_path, entry.name }); + + const t_module = b.createModule(.{ + .root_source_file = b.path(test_path), + .target = target, + .optimize = optimize, + }); + t_module.addImport("elz", lib_module); + + if (is_prop_test) { + t_module.addImport("minish", minish_dep.module("minish")); + } + + const t = b.addTest(.{ .root_module = t_module }); + const bdwgc_dep_t = b.dependency("bdwgc", .{}); + t.addIncludePath(bdwgc_dep_t.path("include")); + t.linkLibrary(gc); + + const run_t = b.addRunArtifact(t); + if (is_prop_test) { + test_prop_step.dependOn(&run_t.step); + } else { + test_integ_step.dependOn(&run_t.step); + } + } + } + // --- Example Setup --- const examples_path = "examples/zig"; var examples_dir = fs.cwd().openDir(examples_path, .{ .iterate = true }) catch |err| { @@ -198,10 +249,34 @@ pub fn build(b: *std.Build) void { run_step.dependOn(&run_cmd.step); } - // --- Run Element 0 Standard Library Tests --- + // --- Run Element 0 Language Tests --- const test_elz_step = b.step("test-elz", "Run the Element 0 language tests"); - const run_elz_tests_cmd = b.addRunArtifact(repl_exe); - run_elz_tests_cmd.addArg("--file"); - run_elz_tests_cmd.addArg("tests/test_stdlib.elz"); - test_elz_step.dependOn(&run_elz_tests_cmd.step); + { + const tests_path = "tests"; + var tests_dir = fs.cwd().openDir(tests_path, .{ .iterate = true }) catch |err| { + if (err == error.FileNotFound) { + @panic("Can't open 'tests' directory"); + } + @panic("Can't open 'tests' directory"); + }; + defer tests_dir.close(); + + var test_iter = tests_dir.iterate(); + while (test_iter.next() catch @panic("Failed to iterate tests")) |entry| { + if (!std.mem.startsWith(u8, entry.name, "test_")) continue; + if (!std.mem.endsWith(u8, entry.name, ".elz")) continue; + + const run_elz_test_cmd = b.addRunArtifact(repl_exe); + run_elz_test_cmd.addArg("--file"); + run_elz_test_cmd.addArg(b.fmt("{s}/{s}", .{ tests_path, entry.name })); + test_elz_step.dependOn(&run_elz_test_cmd.step); + } + } + + // --- Run All Tests --- + const test_all_step = b.step("test-all", "Run all tests"); + test_all_step.dependOn(&run_lib_unit_tests.step); + test_all_step.dependOn(test_prop_step); + test_all_step.dependOn(test_integ_step); + test_all_step.dependOn(test_elz_step); } diff --git a/src/elz/env_setup.zig b/src/elz/env_setup.zig index a1b7a04..e9c0e1a 100644 --- a/src/elz/env_setup.zig +++ b/src/elz/env_setup.zig @@ -13,6 +13,9 @@ const hashmaps = @import("./primitives/hashmaps.zig"); const ports = @import("./primitives/ports.zig"); const os = @import("./primitives/os.zig"); const datetime = @import("./primitives/datetime.zig"); +const format_mod = @import("./primitives/format.zig"); +const json_mod = @import("./primitives/json.zig"); +const regex_mod = @import("./primitives/regex.zig"); const interpreter = @import("interpreter.zig"); /// Populates the interpreter's root environment with mathematical primitive functions. @@ -130,6 +133,8 @@ pub fn populate_strings(interp: *interpreter.Interpreter) !void { pub fn populate_control(interp: *interpreter.Interpreter) !void { try interp.root_env.set(interp, "apply", core.Value{ .procedure = control.apply }); try interp.root_env.set(interp, "eval", core.Value{ .procedure = control.eval_proc }); + try interp.root_env.set(interp, "call-with-escape-continuation", core.Value{ .procedure = control.call_with_escape_continuation }); + try interp.root_env.set(interp, "call/ec", core.Value{ .procedure = control.call_with_escape_continuation }); } /// Populates the interpreter's root environment with I/O primitive functions. @@ -213,6 +218,29 @@ pub fn populate_hashmaps(interp: *interpreter.Interpreter) !void { try interp.root_env.set(interp, "hash-map?", core.Value{ .procedure = hashmaps.is_hash_map }); } +/// Populates the interpreter's root environment with formatting primitive functions. +/// +/// Parameters: +/// - `interp`: A pointer to the interpreter instance. +pub fn populate_format(interp: *interpreter.Interpreter) !void { + try interp.root_env.set(interp, "format", core.Value{ .procedure = format_mod.format }); + try interp.root_env.set(interp, "value->string", core.Value{ .procedure = format_mod.value_to_string }); +} + +/// Populates the interpreter's root environment with JSON serialization primitive functions. +pub fn populate_json(interp: *interpreter.Interpreter) !void { + try interp.root_env.set(interp, "json-serialize", core.Value{ .procedure = json_mod.json_serialize }); + try interp.root_env.set(interp, "json-deserialize", core.Value{ .procedure = json_mod.json_deserialize }); +} + +/// Populates the interpreter's root environment with regex primitive functions. +pub fn populate_regex(interp: *interpreter.Interpreter) !void { + try interp.root_env.set(interp, "regex-match?", core.Value{ .procedure = regex_mod.regex_match }); + try interp.root_env.set(interp, "regex-search", core.Value{ .procedure = regex_mod.regex_search }); + try interp.root_env.set(interp, "regex-replace", core.Value{ .procedure = regex_mod.regex_replace }); + try interp.root_env.set(interp, "regex-split", core.Value{ .procedure = regex_mod.regex_split }); +} + /// Populates the interpreter's root environment with all primitive functions. /// /// Parameters: diff --git a/src/elz/errors.zig b/src/elz/errors.zig index b7b087c..d953b95 100644 --- a/src/elz/errors.zig +++ b/src/elz/errors.zig @@ -56,4 +56,8 @@ pub const ElzError = error{ FileNotWritable, /// An I/O operation failed. IOError, + /// The execution time limit was exceeded. + TimeLimitExceeded, + /// An escape continuation was invoked (internal, caught by call/ec). + EscapeContinuationInvoked, }; diff --git a/src/elz/eval.zig b/src/elz/eval.zig index 85248b4..b24bce5 100644 --- a/src/elz/eval.zig +++ b/src/elz/eval.zig @@ -710,6 +710,51 @@ fn evalTry(interp: *interpreter.Interpreter, rest: Value, env: *Environment, fue } } +/// Evaluates a macro expansion. +/// The macro's unevaluated arguments are bound to its parameters, the body is evaluated +/// to produce an expansion form, and that expansion is then evaluated in the calling environment. +fn evalMacroExpansion(interp: *interpreter.Interpreter, m: *core.Macro, rest: Value, env: *Environment, fuel: *u64, current_ast: **const Value) !Value { + // Collect unevaluated args from the rest list + var unevaluated_args = std.ArrayListUnmanaged(Value){}; + defer unevaluated_args.deinit(env.allocator); + var current_node = rest; + while (current_node != .nil) { + const pair = switch (current_node) { + .pair => |p| p, + else => break, + }; + try unevaluated_args.append(env.allocator, pair.car); + current_node = pair.cdr; + } + + // Check arg count + if (unevaluated_args.items.len != m.params.items.len) return ElzError.WrongArgumentCount; + + // Create a new environment with unevaluated args bound to macro params + const macro_env = try Environment.init(env.allocator, m.env); + for (m.params.items, unevaluated_args.items) |param, arg| { + try macro_env.set(interp, param.symbol, arg); + } + + // Evaluate the macro body to produce the expansion + var body_node = m.body; + var expansion: Value = .unspecified; + while (body_node != .nil) { + const pair = switch (body_node) { + .pair => |p| p, + else => break, + }; + expansion = try eval(interp, &pair.car, macro_env, fuel); + body_node = pair.cdr; + } + + // Now evaluate the expansion in the calling environment via the trampoline + const stored = try env.allocator.create(Value); + stored.* = expansion; + current_ast.* = stored; + return .unspecified; +} + /// Evaluates a procedure application. fn evalApplication(interp: *interpreter.Interpreter, first: Value, rest: Value, env: *Environment, fuel: *u64, current_ast: **const Value, current_env: **Environment) !Value { const proc_val = try eval(interp, &first, env, fuel); @@ -820,6 +865,19 @@ pub fn eval(interp: *interpreter.Interpreter, ast_start: *const Value, env_start if (fuel.* == 0) return ElzError.ExecutionBudgetExceeded; fuel.* -= 1; + // Check time limit every 256 steps to minimize syscall overhead + if (interp.time_limit_ms) |limit_ms| { + interp.time_check_counter +%= 1; + if (interp.time_check_counter & 0xFF == 0) { + if (interp.eval_start_ms) |start_ms| { + const now = std.time.milliTimestamp(); + if (now - start_ms >= @as(i64, @intCast(limit_ms))) { + return ElzError.TimeLimitExceeded; + } + } + } + } + const ast = current_ast; const env = current_env; @@ -832,7 +890,13 @@ pub fn eval(interp: *interpreter.Interpreter, ast_start: *const Value, env_start const first = p.car; const rest = p.cdr; - const result = try if (first.is_symbol("quote")) evalQuote(rest, env) else if (first.is_symbol("quasiquote")) evalQuasiquote(interp, rest, env, fuel) else if (first.is_symbol("import")) evalImport(interp, rest, env, fuel) else if (first.is_symbol("if")) evalIf(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("cond")) evalCond(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("case")) evalCase(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("and")) evalAnd(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("or")) evalOr(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("define")) evalDefine(interp, rest, env, fuel) else if (first.is_symbol("define-macro")) evalDefineMacro(interp, rest, env) else if (first.is_symbol("set!")) evalSet(interp, rest, env, fuel) else if (first.is_symbol("lambda")) evalLambda(rest, env) else if (first.is_symbol("begin")) evalBegin(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("let") or first.is_symbol("let*")) evalLet(interp, first, rest, env, fuel, ¤t_ast, ¤t_env) else if (first.is_symbol("letrec")) evalLetRec(interp, ast.*, env, fuel) else if (first.is_symbol("try")) evalTry(interp, rest, env, fuel) else evalApplication(interp, first, rest, env, fuel, ¤t_ast, ¤t_env); + // Check if first is a macro name before falling through to evalApplication + const maybe_macro: ?*core.Macro = if (first == .symbol) blk: { + const looked_up = env.get(first.symbol, interp) catch break :blk null; + break :blk if (looked_up == .macro) looked_up.macro else null; + } else null; + + const result = try if (maybe_macro) |m| evalMacroExpansion(interp, m, rest, env, fuel, ¤t_ast) else if (first.is_symbol("quote")) evalQuote(rest, env) else if (first.is_symbol("quasiquote")) evalQuasiquote(interp, rest, env, fuel) else if (first.is_symbol("import")) evalImport(interp, rest, env, fuel) else if (first.is_symbol("if")) evalIf(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("cond")) evalCond(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("case")) evalCase(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("and")) evalAnd(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("or")) evalOr(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("define")) evalDefine(interp, rest, env, fuel) else if (first.is_symbol("define-macro")) evalDefineMacro(interp, rest, env) else if (first.is_symbol("set!")) evalSet(interp, rest, env, fuel) else if (first.is_symbol("lambda")) evalLambda(rest, env) else if (first.is_symbol("begin")) evalBegin(interp, rest, env, fuel, ¤t_ast) else if (first.is_symbol("let") or first.is_symbol("let*")) evalLet(interp, first, rest, env, fuel, ¤t_ast, ¤t_env) else if (first.is_symbol("letrec")) evalLetRec(interp, ast.*, env, fuel) else if (first.is_symbol("try")) evalTry(interp, rest, env, fuel) else evalApplication(interp, first, rest, env, fuel, ¤t_ast, ¤t_env); if (result == .unspecified) { if (current_ast != original_ast_ptr) { diff --git a/src/elz/ffi.zig b/src/elz/ffi.zig index cffb4fb..c259c5a 100644 --- a/src/elz/ffi.zig +++ b/src/elz/ffi.zig @@ -29,33 +29,46 @@ pub fn Caster(comptime T: type) type { }, .int => |int_info| switch (v) { .number => |n| { - // Check for NaN or Infinity if (std.math.isNan(n) or std.math.isInf(n)) { return ElzError.InvalidArgument; } - - // Check for fractional part if (@floor(n) != n) { return ElzError.InvalidArgument; } - - // Calculate min/max for the target integer type const min_val: f64 = if (int_info.signedness == .signed) -@as(f64, @floatFromInt(@as(i128, 1) << int_info.bits - 1)) else 0; const max_val: f64 = @floatFromInt((@as(u128, 1) << int_info.bits) - 1); - - // Check bounds if (n < min_val or n > max_val) { return ElzError.InvalidArgument; } - return @intFromFloat(n); }, else => ElzError.InvalidArgument, }, - else => @compileError("Unsupported type for FFI casting"), + .bool => switch (v) { + .boolean => |b| b, + else => ElzError.InvalidArgument, + }, + .pointer => |ptr_info| { + if (ptr_info.size == .Slice and ptr_info.child == u8) { + // []const u8 - extract from string value + return switch (v) { + .string => |s| s, + .symbol => |s| s, + else => ElzError.InvalidArgument, + }; + } else { + @compileError("Unsupported pointer type for FFI casting: " ++ @typeName(T)); + } + }, + .optional => |opt_info| { + if (v == .nil) return null; + const InnerCaster = Caster(opt_info.child); + return InnerCaster.cast(v) catch return ElzError.InvalidArgument; + }, + else => @compileError("Unsupported type for FFI casting: " ++ @typeName(T)), }; } }; @@ -197,15 +210,27 @@ fn ffi_wrap_variadic(comptime F: anytype, comptime FInfo: std.builtin.Type.Fn) * /// - `value`: The native Zig value to convert. /// - `return`: The converted `core.Value`. fn valueFromNative(allocator: std.mem.Allocator, value: anytype) core.Value { - _ = allocator; const T = @TypeOf(value); return switch (@typeInfo(T)) { .void => core.Value.nil, .float, .comptime_float => core.Value{ .number = @floatCast(value) }, .int, .comptime_int => core.Value{ .number = @floatFromInt(value) }, .bool => core.Value{ .boolean = value }, - .@"union" => |u| { - _ = u; + .pointer => |ptr_info| { + if (ptr_info.size == .Slice and ptr_info.child == u8) { + return core.Value{ .string = allocator.dupe(u8, value) catch return core.Value.nil }; + } else { + @compileError("Unsupported pointer return type for FFI: " ++ @typeName(T)); + } + }, + .optional => { + if (value) |v| { + return valueFromNative(allocator, v); + } else { + return core.Value.nil; + } + }, + .@"union" => { if (T == core.Value) { return value; } else { @@ -302,6 +327,66 @@ fn testSquare(x: f64) f64 { return x * x; } +test "Caster bool from boolean" { + const result = try Caster(bool).cast(core.Value{ .boolean = true }); + try std.testing.expect(result == true); + + const result2 = try Caster(bool).cast(core.Value{ .boolean = false }); + try std.testing.expect(result2 == false); +} + +test "Caster bool from non-boolean" { + const result = Caster(bool).cast(core.Value{ .number = 1 }); + try std.testing.expectError(ElzError.InvalidArgument, result); +} + +test "Caster string from string value" { + const result = try Caster([]const u8).cast(core.Value{ .string = "hello" }); + try std.testing.expectEqualStrings("hello", result); +} + +test "Caster string from symbol value" { + const result = try Caster([]const u8).cast(core.Value{ .symbol = "foo" }); + try std.testing.expectEqualStrings("foo", result); +} + +test "Caster string from non-string" { + const result = Caster([]const u8).cast(core.Value{ .number = 42 }); + try std.testing.expectError(ElzError.InvalidArgument, result); +} + +test "Caster optional from value" { + const result = try Caster(?f64).cast(core.Value{ .number = 42 }); + try std.testing.expect(result != null); + try std.testing.expectEqual(@as(f64, 42), result.?); +} + +test "Caster optional from nil" { + const result = try Caster(?f64).cast(core.Value.nil); + try std.testing.expect(result == null); +} + +test "valueFromNative string" { + const allocator = std.testing.allocator; + const result = valueFromNative(allocator, @as([]const u8, "hello")); + try std.testing.expect(result == .string); + try std.testing.expectEqualStrings("hello", result.string); + allocator.free(result.string); +} + +test "valueFromNative optional some" { + const allocator = std.testing.allocator; + const result = valueFromNative(allocator, @as(?f64, 42.0)); + try std.testing.expect(result == .number); + try std.testing.expectEqual(@as(f64, 42), result.number); +} + +test "valueFromNative optional null" { + const allocator = std.testing.allocator; + const result = valueFromNative(allocator, @as(?f64, null)); + try std.testing.expect(result == .nil); +} + test "makeForeignFunc with 1-arg function" { const wrapped = makeForeignFunc(testSquare); const allocator = std.testing.allocator; diff --git a/src/elz/interpreter.zig b/src/elz/interpreter.zig index 058e745..6af75e9 100644 --- a/src/elz/interpreter.zig +++ b/src/elz/interpreter.zig @@ -24,6 +24,8 @@ pub const SandboxFlags = struct { enable_strings: bool = true, /// Enables or disables I/O functions (e.g., `display`, `load`). enable_io: bool = true, + /// Maximum wall-clock execution time in milliseconds. Null means no limit. + time_limit_ms: ?u64 = null, }; /// `Interpreter` is the main struct for the Elz interpreter. @@ -39,6 +41,18 @@ pub const Interpreter = struct { module_cache: std.StringHashMap(*core.Module), /// Counter for generating unique symbols with gensym (thread-safe per interpreter). gensym_counter: u64 = 0, + /// Maximum wall-clock execution time in milliseconds (null = no limit). + time_limit_ms: ?u64 = null, + /// Timestamp (ms) when the current evaluation started. + eval_start_ms: ?i64 = null, + /// Step counter for throttling time checks (check every N steps). + time_check_counter: u64 = 0, + /// Value carried by an escape continuation invocation. + escape_value: ?core.Value = null, + /// ID of the active escape continuation (for matching). + escape_id: u64 = 0, + /// Counter for generating unique escape continuation IDs. + escape_id_counter: u64 = 0, /// Initializes a new Elz interpreter instance. /// This function sets up the garbage collector, creates the root environment, @@ -59,6 +73,7 @@ pub const Interpreter = struct { .root_env = undefined, .last_error_message = null, .module_cache = std.StringHashMap(*core.Module).init(allocator), + .time_limit_ms = flags.time_limit_ms, }; const root_env = try allocator.create(core.Environment); @@ -96,6 +111,9 @@ pub const Interpreter = struct { try env_setup.populate_ports(&self); try env_setup.populate_os(&self); try env_setup.populate_datetime(&self); + try env_setup.populate_format(&self); + try env_setup.populate_json(&self); + try env_setup.populate_regex(&self); const std_lib_source = @embedFile("../stdlib/std.elz"); var std_lib_forms = try parser.readAll(std_lib_source, allocator); @@ -126,6 +144,12 @@ pub const Interpreter = struct { var forms = try parser.readAll(source, self.allocator); defer forms.deinit(self.allocator); + // Set the eval start time for time-limited execution + if (self.time_limit_ms != null) { + self.eval_start_ms = std.time.milliTimestamp(); + self.time_check_counter = 0; + } + var result: core.Value = .unspecified; for (forms.items) |form| { result = try eval.eval(self, &form, self.root_env, fuel); diff --git a/src/elz/primitives/control.zig b/src/elz/primitives/control.zig index ad4b66d..5bba579 100644 --- a/src/elz/primitives/control.zig +++ b/src/elz/primitives/control.zig @@ -76,6 +76,50 @@ pub fn eval_proc(interp: *interpreter.Interpreter, env: *core.Environment, args: return eval.eval(interp, &expr, eval_env, fuel); } +/// `call-with-escape-continuation` creates an escape continuation and passes it to the +/// given procedure. When the escape continuation is invoked with a value, it immediately +/// returns that value from the `call/ec` form. This is an upward-only (escape) continuation. +/// +/// Syntax: (call/ec (lambda (k) ...)) +/// +/// Inside the lambda, calling (k value) immediately returns value from the call/ec form. +/// If the lambda returns normally, its return value is the result of call/ec. +pub fn call_with_escape_continuation(interp: *interpreter.Interpreter, env: *core.Environment, args: core.ValueList, fuel: *u64) ElzError!core.Value { + if (args.items.len != 1) return ElzError.WrongArgumentCount; + + const proc = args.items[0]; + if (proc != .closure) return ElzError.InvalidArgument; + + // The escape function: when called, stores its argument on the interpreter + // and signals EscapeContinuationInvoked. Since there's only one interpreter, + // and escape continuations unwind the stack, this is safe. + const escape_fn = struct { + pub fn invoke(i: *interpreter.Interpreter, _: *core.Environment, a: core.ValueList, _: *u64) ElzError!core.Value { + if (a.items.len != 1) return ElzError.WrongArgumentCount; + i.escape_value = a.items[0]; + return ElzError.EscapeContinuationInvoked; + } + }.invoke; + + // Build args for the procedure: pass the escape function + var call_args = core.ValueList.init(env.allocator); + try call_args.append(core.Value{ .procedure = escape_fn }); + + // Call the procedure with the escape continuation + const result = eval.eval_proc(interp, proc, call_args, env, fuel); + + if (result) |val| { + return val; + } else |err| { + if (err == ElzError.EscapeContinuationInvoked) { + const escaped_val = interp.escape_value orelse core.Value.unspecified; + interp.escape_value = null; + return escaped_val; + } + return err; + } +} + test "control primitives" { const allocator = std.testing.allocator; const testing = std.testing; diff --git a/src/elz/primitives/format.zig b/src/elz/primitives/format.zig new file mode 100644 index 0000000..4a3626a --- /dev/null +++ b/src/elz/primitives/format.zig @@ -0,0 +1,181 @@ +const std = @import("std"); +const core = @import("../core.zig"); +const writer_mod = @import("../writer.zig"); +const Value = core.Value; +const ElzError = @import("../errors.zig").ElzError; +const interpreter = @import("../interpreter.zig"); + +/// Writes a value in display mode (strings without quotes, chars as raw characters). +fn writeDisplay(value: Value, w: anytype) !void { + switch (value) { + .string => |s| try w.writeAll(s), + .character => |c| { + if (c > 0x10FFFF) return; + const codepoint: u21 = @intCast(c); + if (!std.unicode.utf8ValidCodepoint(codepoint)) return; + var buf: [4]u8 = undefined; + const len = std.unicode.utf8Encode(codepoint, &buf) catch return; + try w.writeAll(buf[0..@as(usize, @intCast(len))]); + }, + else => try writer_mod.write(value, w), + } +} + +/// `format` implements the `format` primitive function. +/// +/// Syntax: (format template arg ...) +/// +/// Directives: +/// ~a - display mode (strings without quotes) +/// ~s - write mode (machine-readable, strings with quotes) +/// ~% - newline +/// ~~ - literal tilde +/// +/// Returns the formatted string. +pub fn format(_: *interpreter.Interpreter, env: *core.Environment, args: core.ValueList, _: *u64) ElzError!Value { + if (args.items.len < 1) return ElzError.WrongArgumentCount; + + const template_val = args.items[0]; + if (template_val != .string) return ElzError.InvalidArgument; + const template = template_val.string; + + var result = std.ArrayListUnmanaged(u8){}; + const allocator = env.allocator; + errdefer result.deinit(allocator); + + var arg_idx: usize = 1; + var i: usize = 0; + + while (i < template.len) { + if (template[i] == '~' and i + 1 < template.len) { + const directive = template[i + 1]; + switch (directive) { + 'a' => { + if (arg_idx >= args.items.len) return ElzError.WrongArgumentCount; + writeDisplay(args.items[arg_idx], result.writer(allocator)) catch return ElzError.OutOfMemory; + arg_idx += 1; + }, + 's' => { + if (arg_idx >= args.items.len) return ElzError.WrongArgumentCount; + writer_mod.write(args.items[arg_idx], result.writer(allocator)) catch return ElzError.OutOfMemory; + arg_idx += 1; + }, + '%' => { + result.append(allocator, '\n') catch return ElzError.OutOfMemory; + }, + '~' => { + result.append(allocator, '~') catch return ElzError.OutOfMemory; + }, + else => { + // Unknown directive, output as-is + result.append(allocator, '~') catch return ElzError.OutOfMemory; + result.append(allocator, directive) catch return ElzError.OutOfMemory; + }, + } + i += 2; + } else { + result.append(allocator, template[i]) catch return ElzError.OutOfMemory; + i += 1; + } + } + + return Value{ .string = result.toOwnedSlice(allocator) catch return ElzError.OutOfMemory }; +} + +/// `value->string` converts any value to its string representation (write mode). +/// +/// Syntax: (value->string val) +/// +/// Returns the string representation of the value. +pub fn value_to_string(_: *interpreter.Interpreter, env: *core.Environment, args: core.ValueList, _: *u64) ElzError!Value { + if (args.items.len != 1) return ElzError.WrongArgumentCount; + + var buf = std.ArrayListUnmanaged(u8){}; + const allocator = env.allocator; + errdefer buf.deinit(allocator); + + writer_mod.write(args.items[0], buf.writer(allocator)) catch return ElzError.OutOfMemory; + return Value{ .string = buf.toOwnedSlice(allocator) catch return ElzError.OutOfMemory }; +} + +test "format basic substitution" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + + // ~a with string (no quotes) + const r1 = try interp.evalString("(format \"hello ~a\" \"world\")", &fuel); + try testing.expect(r1 == .string); + try testing.expectEqualStrings("hello world", r1.string); + + // ~s with string (with quotes) + fuel = 10000; + const r2 = try interp.evalString("(format \"hello ~s\" \"world\")", &fuel); + try testing.expect(r2 == .string); + try testing.expectEqualStrings("hello \"world\"", r2.string); + + // ~% newline + fuel = 10000; + const r3 = try interp.evalString("(format \"line1~%line2\")", &fuel); + try testing.expect(r3 == .string); + try testing.expectEqualStrings("line1\nline2", r3.string); + + // ~~ literal tilde + fuel = 10000; + const r4 = try interp.evalString("(format \"cost: ~~100\")", &fuel); + try testing.expect(r4 == .string); + try testing.expectEqualStrings("cost: ~100", r4.string); +} + +test "format with numbers" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + const r1 = try interp.evalString("(format \"x = ~a\" 42)", &fuel); + try testing.expect(r1 == .string); + try testing.expectEqualStrings("x = 42", r1.string); +} + +test "format with multiple args" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + const r1 = try interp.evalString("(format \"~a + ~a = ~a\" 1 2 3)", &fuel); + try testing.expect(r1 == .string); + try testing.expectEqualStrings("1 + 2 = 3", r1.string); +} + +test "format with no template args" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + const r1 = try interp.evalString("(format \"plain text\")", &fuel); + try testing.expect(r1 == .string); + try testing.expectEqualStrings("plain text", r1.string); +} + +test "format error on too few args" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + try testing.expectError(ElzError.WrongArgumentCount, interp.evalString("(format \"~a ~a\" 1)", &fuel)); +} + +test "format error on non-string template" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + try testing.expectError(ElzError.InvalidArgument, interp.evalString("(format 42)", &fuel)); +} diff --git a/src/elz/primitives/json.zig b/src/elz/primitives/json.zig new file mode 100644 index 0000000..98c54a0 --- /dev/null +++ b/src/elz/primitives/json.zig @@ -0,0 +1,419 @@ +const std = @import("std"); +const core = @import("../core.zig"); +const writer_mod = @import("../writer.zig"); +const parser_mod = @import("../parser.zig"); +const Value = core.Value; +const ElzError = @import("../errors.zig").ElzError; +const interpreter = @import("../interpreter.zig"); + +const ParseResult = struct { value: Value, pos: usize }; + +/// Serializes a Value to JSON format and appends to the buffer. +fn serializeValue(value: Value, buf: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator) !void { + const w = buf.writer(allocator); + switch (value) { + .number => |n| { + // Handle special float values + if (std.math.isNan(n) or std.math.isInf(n)) { + try w.writeAll("null"); + } else { + try w.print("{d}", .{n}); + } + }, + .string => |s| { + try w.writeByte('"'); + for (s) |c| { + switch (c) { + '"' => try w.writeAll("\\\""), + '\\' => try w.writeAll("\\\\"), + '\n' => try w.writeAll("\\n"), + '\t' => try w.writeAll("\\t"), + '\r' => try w.writeAll("\\r"), + else => { + if (c < 0x20) { + try w.print("\\u{x:0>4}", .{c}); + } else { + try w.writeByte(c); + } + }, + } + } + try w.writeByte('"'); + }, + .boolean => |b| try w.writeAll(if (b) "true" else "false"), + .nil => try w.writeAll("null"), + .pair => { + // Serialize proper list as JSON array + try w.writeByte('['); + var current: Value = value; + var first = true; + while (current == .pair) { + if (!first) try w.writeByte(','); + first = false; + try serializeValue(current.pair.car, buf, allocator); + current = current.pair.cdr; + } + // If improper list, serialize the cdr too + if (current != .nil) { + if (!first) try w.writeByte(','); + try serializeValue(current, buf, allocator); + } + try w.writeByte(']'); + }, + .vector => |v| { + try w.writeByte('['); + for (v.items, 0..) |item, i| { + if (i > 0) try w.writeByte(','); + try serializeValue(item, buf, allocator); + } + try w.writeByte(']'); + }, + .hash_map => |hm| { + try w.writeByte('{'); + var it = hm.entries.iterator(); + var first = true; + while (it.next()) |entry| { + if (!first) try w.writeByte(','); + first = false; + // Key is always a string in Element 0 hash maps + try w.writeByte('"'); + for (entry.key_ptr.*) |c| { + switch (c) { + '"' => try w.writeAll("\\\""), + '\\' => try w.writeAll("\\\\"), + else => try w.writeByte(c), + } + } + try w.writeByte('"'); + try w.writeByte(':'); + try serializeValue(entry.value_ptr.*, buf, allocator); + } + try w.writeByte('}'); + }, + .character => |c| { + // Serialize as single-character string + try w.writeByte('"'); + if (c > 0x10FFFF) { + try w.writeByte('?'); + } else { + const codepoint: u21 = @intCast(c); + if (std.unicode.utf8ValidCodepoint(codepoint)) { + var char_buf: [4]u8 = undefined; + const len = std.unicode.utf8Encode(codepoint, &char_buf) catch { + try w.writeByte('?'); + try w.writeByte('"'); + return; + }; + try w.writeAll(char_buf[0..@as(usize, @intCast(len))]); + } else { + try w.writeByte('?'); + } + } + try w.writeByte('"'); + }, + .symbol => |s| { + // Serialize symbols as strings + try w.writeByte('"'); + try w.writeAll(s); + try w.writeByte('"'); + }, + // Non-serializable types + .closure, .macro, .procedure, .foreign_procedure, .opaque_pointer, .cell, .module, .port, .unspecified => { + return error.OutOfMemory; // Signal unsupported type + }, + } +} + +/// Parses a JSON string and returns the position after the closing quote. +fn parseJsonString(json: []const u8, start: usize, allocator: std.mem.Allocator) !ParseResult { + if (start >= json.len or json[start] != '"') return error.OutOfMemory; + var i = start + 1; + var result = std.ArrayListUnmanaged(u8){}; + errdefer result.deinit(allocator); + + while (i < json.len and json[i] != '"') { + if (json[i] == '\\' and i + 1 < json.len) { + switch (json[i + 1]) { + 'n' => try result.append(allocator, '\n'), + 't' => try result.append(allocator, '\t'), + 'r' => try result.append(allocator, '\r'), + '"' => try result.append(allocator, '"'), + '\\' => try result.append(allocator, '\\'), + '/' => try result.append(allocator, '/'), + else => { + try result.append(allocator, '\\'); + try result.append(allocator, json[i + 1]); + }, + } + i += 2; + } else { + try result.append(allocator, json[i]); + i += 1; + } + } + if (i >= json.len) return error.OutOfMemory; + return .{ + .value = Value{ .string = try result.toOwnedSlice(allocator) }, + .pos = i + 1, // skip closing quote + }; +} + +/// Skip whitespace in JSON. +fn skipWhitespace(json: []const u8, start: usize) usize { + var i = start; + while (i < json.len and (json[i] == ' ' or json[i] == '\t' or json[i] == '\n' or json[i] == '\r')) { + i += 1; + } + return i; +} + +/// Parse a single JSON value, returning the parsed value and position after it. +fn parseJsonValue(json: []const u8, start: usize, allocator: std.mem.Allocator) !ParseResult { + var i = skipWhitespace(json, start); + if (i >= json.len) return error.OutOfMemory; + + switch (json[i]) { + '"' => return parseJsonString(json, i, allocator), + 't' => { + if (i + 4 <= json.len and std.mem.eql(u8, json[i .. i + 4], "true")) { + return .{ .value = Value{ .boolean = true }, .pos = i + 4 }; + } + return error.OutOfMemory; + }, + 'f' => { + if (i + 5 <= json.len and std.mem.eql(u8, json[i .. i + 5], "false")) { + return .{ .value = Value{ .boolean = false }, .pos = i + 5 }; + } + return error.OutOfMemory; + }, + 'n' => { + if (i + 4 <= json.len and std.mem.eql(u8, json[i .. i + 4], "null")) { + return .{ .value = Value.nil, .pos = i + 4 }; + } + return error.OutOfMemory; + }, + '[' => { + // Parse array -> list + i += 1; + i = skipWhitespace(json, i); + + var elements = std.ArrayListUnmanaged(Value){}; + defer elements.deinit(allocator); + + if (i < json.len and json[i] == ']') { + return .{ .value = Value.nil, .pos = i + 1 }; + } + + while (i < json.len) { + const elem = try parseJsonValue(json, i, allocator); + try elements.append(allocator, elem.value); + i = skipWhitespace(json, elem.pos); + + if (i < json.len and json[i] == ',') { + i += 1; + } else if (i < json.len and json[i] == ']') { + i += 1; + break; + } else { + return error.OutOfMemory; + } + } + + // Build list from elements (reverse order) + var result: Value = Value.nil; + var j = elements.items.len; + while (j > 0) { + j -= 1; + const p = try allocator.create(core.Pair); + p.* = .{ .car = elements.items[j], .cdr = result }; + result = Value{ .pair = p }; + } + return .{ .value = result, .pos = i }; + }, + '{' => { + // Parse object -> hash-map + i += 1; + i = skipWhitespace(json, i); + + const hm = try allocator.create(core.HashMap); + hm.* = core.HashMap.init(allocator); + + if (i < json.len and json[i] == '}') { + return .{ .value = Value{ .hash_map = hm }, .pos = i + 1 }; + } + + while (i < json.len) { + i = skipWhitespace(json, i); + const key = try parseJsonString(json, i, allocator); + i = skipWhitespace(json, key.pos); + + if (i >= json.len or json[i] != ':') return error.OutOfMemory; + i += 1; + + const val = try parseJsonValue(json, i, allocator); + i = val.pos; + + try hm.put(key.value.string, val.value); + + i = skipWhitespace(json, i); + if (i < json.len and json[i] == ',') { + i += 1; + } else if (i < json.len and json[i] == '}') { + i += 1; + break; + } else { + return error.OutOfMemory; + } + } + return .{ .value = Value{ .hash_map = hm }, .pos = i }; + }, + '-', '0'...'9' => { + // Parse number + var end = i; + if (json[end] == '-') end += 1; + while (end < json.len and ((json[end] >= '0' and json[end] <= '9') or json[end] == '.' or json[end] == 'e' or json[end] == 'E' or json[end] == '+' or json[end] == '-')) { + if ((json[end] == '-' or json[end] == '+') and end > i + 1 and json[end - 1] != 'e' and json[end - 1] != 'E') break; + end += 1; + } + const num = std.fmt.parseFloat(f64, json[i..end]) catch return error.OutOfMemory; + return .{ .value = Value{ .number = num }, .pos = end }; + }, + else => return error.OutOfMemory, + } +} + +/// `json-serialize` converts a Value to a JSON string. +/// +/// Syntax: (json-serialize value) +pub fn json_serialize(_: *interpreter.Interpreter, env: *core.Environment, args: core.ValueList, _: *u64) ElzError!Value { + if (args.items.len != 1) return ElzError.WrongArgumentCount; + + var buf = std.ArrayListUnmanaged(u8){}; + const allocator = env.allocator; + errdefer buf.deinit(allocator); + + serializeValue(args.items[0], &buf, allocator) catch return ElzError.InvalidArgument; + return Value{ .string = buf.toOwnedSlice(allocator) catch return ElzError.OutOfMemory }; +} + +/// `json-deserialize` parses a JSON string into a Value. +/// +/// Syntax: (json-deserialize json-string) +pub fn json_deserialize(_: *interpreter.Interpreter, env: *core.Environment, args: core.ValueList, _: *u64) ElzError!Value { + if (args.items.len != 1) return ElzError.WrongArgumentCount; + + const str_val = args.items[0]; + if (str_val != .string) return ElzError.InvalidArgument; + + const result = parseJsonValue(str_val.string, 0, env.allocator) catch return ElzError.InvalidArgument; + return result.value; +} + +test "json serialize numbers" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + const r = try interp.evalString("(json-serialize 42)", &fuel); + try testing.expect(r == .string); + try testing.expectEqualStrings("42", r.string); +} + +test "json serialize strings" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + const r = try interp.evalString("(json-serialize \"hello\")", &fuel); + try testing.expect(r == .string); + try testing.expectEqualStrings("\"hello\"", r.string); +} + +test "json serialize booleans and nil" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + const r1 = try interp.evalString("(json-serialize #t)", &fuel); + try testing.expectEqualStrings("true", r1.string); + + fuel = 10000; + const r2 = try interp.evalString("(json-serialize #f)", &fuel); + try testing.expectEqualStrings("false", r2.string); + + fuel = 10000; + const r3 = try interp.evalString("(json-serialize '())", &fuel); + try testing.expectEqualStrings("null", r3.string); +} + +test "json serialize list as array" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + const r = try interp.evalString("(json-serialize '(1 2 3))", &fuel); + try testing.expect(r == .string); + try testing.expectEqualStrings("[1,2,3]", r.string); +} + +test "json deserialize number" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + const r = try interp.evalString("(json-deserialize \"42\")", &fuel); + try testing.expect(r == .number); + try testing.expectEqual(@as(f64, 42), r.number); +} + +test "json deserialize string" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + const r = try interp.evalString("(json-deserialize \"\\\"hello\\\"\")", &fuel); + try testing.expect(r == .string); + try testing.expectEqualStrings("hello", r.string); +} + +test "json deserialize array" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + const r = try interp.evalString("(json-deserialize \"[1,2,3]\")", &fuel); + try testing.expect(r == .pair); + try testing.expect(r.pair.car == .number); + try testing.expectEqual(@as(f64, 1), r.pair.car.number); +} + +test "json roundtrip" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + + var fuel: u64 = 10000; + // Serialize then deserialize a number + const r1 = try interp.evalString("(json-deserialize (json-serialize 42))", &fuel); + try testing.expect(r1 == .number); + try testing.expectEqual(@as(f64, 42), r1.number); + + // Serialize then deserialize a string + fuel = 10000; + const r2 = try interp.evalString("(json-deserialize (json-serialize \"hello\"))", &fuel); + try testing.expect(r2 == .string); + try testing.expectEqualStrings("hello", r2.string); + + // Serialize then deserialize a boolean + fuel = 10000; + const r3 = try interp.evalString("(json-deserialize (json-serialize #t))", &fuel); + try testing.expect(r3 == .boolean); + try testing.expect(r3.boolean == true); +} diff --git a/src/elz/primitives/regex.zig b/src/elz/primitives/regex.zig new file mode 100644 index 0000000..ff5dc82 --- /dev/null +++ b/src/elz/primitives/regex.zig @@ -0,0 +1,513 @@ +const std = @import("std"); +const core = @import("../core.zig"); +const Value = core.Value; +const ElzError = @import("../errors.zig").ElzError; +const interpreter = @import("../interpreter.zig"); + +// ============================================================================ +// Minimal NFA-based Regex Engine +// Supports: literals, . (any), *, +, ?, ^, $, [abc], [a-z], \. \* etc. +// ============================================================================ + +const NodeKind = enum { + literal, // Match a specific byte + any, // Match any byte (.) + char_class, // Match any byte in a set + neg_char_class, // Match any byte NOT in a set + split, // Epsilon split: out1 and out2 + jump, // Unconditional epsilon: out1 + match, // Successful match + anchor_start, // ^ - match start of string + anchor_end, // $ - match end of string +}; + +const Node = struct { + kind: NodeKind, + ch: u8 = 0, + class_bits: ?*[256]bool = null, + out1: ?usize = null, + out2: ?usize = null, +}; + +const Regex = struct { + nodes: []Node, + start: usize, +}; + +const MatchResult = struct { start: usize, end: usize }; + +fn compile(pattern: []const u8, allocator: std.mem.Allocator) !Regex { + var nodes = std.ArrayListUnmanaged(Node){}; + errdefer nodes.deinit(allocator); + + // First pass: compile atoms into nodes without linking + // We'll collect indices of the "atom" nodes and then link them + var atom_indices = std.ArrayListUnmanaged(usize){}; + defer atom_indices.deinit(allocator); + + var i: usize = 0; + while (i < pattern.len) { + // Parse one atom (possibly with quantifier) + const atom_start = nodes.items.len; + + switch (pattern[i]) { + '.' => { + try nodes.append(allocator, .{ .kind = .any }); + i += 1; + }, + '^' => { + try nodes.append(allocator, .{ .kind = .anchor_start }); + i += 1; + try atom_indices.append(allocator, atom_start); + continue; // anchors can't have quantifiers + }, + '$' => { + try nodes.append(allocator, .{ .kind = .anchor_end }); + i += 1; + try atom_indices.append(allocator, atom_start); + continue; + }, + '[' => { + i += 1; + const negated = i < pattern.len and pattern[i] == '^'; + if (negated) i += 1; + + const bits = try allocator.create([256]bool); + @memset(bits, false); + + while (i < pattern.len and pattern[i] != ']') { + if (i + 2 < pattern.len and pattern[i + 1] == '-') { + var c: usize = pattern[i]; + while (c <= pattern[i + 2]) : (c += 1) bits[c] = true; + i += 3; + } else { + bits[pattern[i]] = true; + i += 1; + } + } + if (i >= pattern.len) { + allocator.destroy(bits); + return error.OutOfMemory; + } + i += 1; // skip ']' + try nodes.append(allocator, .{ + .kind = if (negated) .neg_char_class else .char_class, + .class_bits = bits, + }); + }, + '\\' => { + i += 1; + if (i >= pattern.len) return error.OutOfMemory; + try nodes.append(allocator, .{ .kind = .literal, .ch = pattern[i] }); + i += 1; + }, + '*', '+', '?' => { + // Quantifier without preceding atom, treat as literal + try nodes.append(allocator, .{ .kind = .literal, .ch = pattern[i] }); + i += 1; + try atom_indices.append(allocator, atom_start); + continue; + }, + else => { + try nodes.append(allocator, .{ .kind = .literal, .ch = pattern[i] }); + i += 1; + }, + } + + // Check for quantifier + if (i < pattern.len and (pattern[i] == '*' or pattern[i] == '+' or pattern[i] == '?')) { + const quant = pattern[i]; + i += 1; + + switch (quant) { + '*' => { + // split -> atom | next; atom -> split + const split_idx = nodes.items.len; + try nodes.append(allocator, .{ .kind = .split, .out1 = atom_start, .out2 = null }); + nodes.items[atom_start].out1 = split_idx; // loop back + try atom_indices.append(allocator, split_idx); + }, + '+' => { + // atom -> split -> atom | next + const split_idx = nodes.items.len; + try nodes.append(allocator, .{ .kind = .split, .out1 = atom_start, .out2 = null }); + nodes.items[atom_start].out1 = split_idx; + try atom_indices.append(allocator, atom_start); + }, + '?' => { + // split -> atom | next; atom -> next (via jump) + const split_idx = nodes.items.len; + try nodes.append(allocator, .{ .kind = .split, .out1 = atom_start, .out2 = null }); + const jump_idx = nodes.items.len; + try nodes.append(allocator, .{ .kind = .jump, .out1 = null }); + nodes.items[atom_start].out1 = jump_idx; // atom -> jump + try atom_indices.append(allocator, split_idx); + }, + else => {}, + } + } else { + try atom_indices.append(allocator, atom_start); + } + } + + // Add match node + const match_idx = nodes.items.len; + try nodes.append(allocator, .{ .kind = .match }); + + // Link: patch all null out1/out2 to the correct next destination. + // For each atom fragment, all nodes between this entry and the next entry + // that have null exits should point to the next entry. + for (atom_indices.items, 0..) |_, ai| { + const entry = atom_indices.items[ai]; + const next_entry = if (ai + 1 < atom_indices.items.len) atom_indices.items[ai + 1] else match_idx; + const end = if (ai + 1 < atom_indices.items.len) atom_indices.items[ai + 1] else match_idx; + + // Patch all nodes in range [entry, end) that have null exits + const range_end = @min(end, nodes.items.len); + for (entry..range_end) |ni| { + const node = &nodes.items[ni]; + switch (node.kind) { + .split => { + if (node.out2 == null) node.out2 = next_entry; + }, + .jump => { + if (node.out1 == null) node.out1 = next_entry; + }, + .literal, .any, .char_class, .neg_char_class => { + if (node.out1 == null) node.out1 = next_entry; + }, + .anchor_start, .anchor_end => { + if (node.out1 == null) node.out1 = next_entry; + }, + .match => {}, + } + } + } + + const start = if (atom_indices.items.len > 0) atom_indices.items[0] else match_idx; + + return .{ + .nodes = try nodes.toOwnedSlice(allocator), + .start = start, + }; +} + +/// Try to match the regex starting at a specific position in the input. +fn matchAt(regex: Regex, input: []const u8, start_pos: usize, allocator: std.mem.Allocator) !?MatchResult { + var current = std.AutoHashMapUnmanaged(usize, void){}; + defer current.deinit(allocator); + var next_states = std.AutoHashMapUnmanaged(usize, void){}; + defer next_states.deinit(allocator); + + try addState(¤t, regex, regex.start, allocator, input, start_pos); + + var last_match: ?usize = null; // Track longest match position + var pos = start_pos; + + while (pos <= input.len) { + // Check for match state (greedy: keep going to find longest) + var it = current.iterator(); + while (it.next()) |entry| { + if (regex.nodes[entry.key_ptr.*].kind == .match) { + last_match = pos; + } + } + + if (pos >= input.len) break; + if (current.count() == 0) break; + + next_states.clearRetainingCapacity(); + var curr_it = current.iterator(); + while (curr_it.next()) |entry| { + const state = entry.key_ptr.*; + const node = regex.nodes[state]; + const ch = input[pos]; + + const matches = switch (node.kind) { + .literal => node.ch == ch, + .any => true, + .char_class => if (node.class_bits) |bits| bits[ch] else false, + .neg_char_class => if (node.class_bits) |bits| !bits[ch] else false, + else => false, + }; + + if (matches) { + if (node.out1) |out| { + try addState(&next_states, regex, out, allocator, input, pos + 1); + } + } + } + + const tmp = current; + current = next_states; + next_states = tmp; + next_states.clearRetainingCapacity(); + pos += 1; + } + + // Final check at the end position + var final_it = current.iterator(); + while (final_it.next()) |entry| { + if (regex.nodes[entry.key_ptr.*].kind == .match) { + last_match = pos; + } + } + + if (last_match) |end| { + return .{ .start = start_pos, .end = end }; + } + return null; +} + +fn addState(set: *std.AutoHashMapUnmanaged(usize, void), regex: Regex, state: usize, allocator: std.mem.Allocator, input: []const u8, pos: usize) !void { + if (set.contains(state)) return; + try set.put(allocator, state, {}); + + const node = regex.nodes[state]; + switch (node.kind) { + .split => { + if (node.out1) |out| try addState(set, regex, out, allocator, input, pos); + if (node.out2) |out| try addState(set, regex, out, allocator, input, pos); + }, + .jump => { + if (node.out1) |out| try addState(set, regex, out, allocator, input, pos); + }, + .anchor_start => { + if (pos == 0) { + if (node.out1) |out| try addState(set, regex, out, allocator, input, pos); + } + }, + .anchor_end => { + if (pos == input.len) { + if (node.out1) |out| try addState(set, regex, out, allocator, input, pos); + } + }, + else => {}, + } +} + +// ============================================================================ +// Element 0 Primitives +// ============================================================================ + +pub fn regex_match(_: *interpreter.Interpreter, env: *core.Environment, args: core.ValueList, _: *u64) ElzError!Value { + if (args.items.len != 2) return ElzError.WrongArgumentCount; + if (args.items[0] != .string) return ElzError.InvalidArgument; + if (args.items[1] != .string) return ElzError.InvalidArgument; + + const pattern = args.items[0].string; + const input = args.items[1].string; + + var full = std.ArrayListUnmanaged(u8){}; + defer full.deinit(env.allocator); + full.append(env.allocator, '^') catch return ElzError.OutOfMemory; + full.appendSlice(env.allocator, pattern) catch return ElzError.OutOfMemory; + full.append(env.allocator, '$') catch return ElzError.OutOfMemory; + + const regex = compile(full.items, env.allocator) catch return ElzError.InvalidArgument; + defer env.allocator.free(regex.nodes); + + const result = matchAt(regex, input, 0, env.allocator) catch return ElzError.OutOfMemory; + return Value{ .boolean = result != null }; +} + +pub fn regex_search(_: *interpreter.Interpreter, env: *core.Environment, args: core.ValueList, _: *u64) ElzError!Value { + if (args.items.len != 2) return ElzError.WrongArgumentCount; + if (args.items[0] != .string) return ElzError.InvalidArgument; + if (args.items[1] != .string) return ElzError.InvalidArgument; + + const pattern = args.items[0].string; + const input = args.items[1].string; + + const regex = compile(pattern, env.allocator) catch return ElzError.InvalidArgument; + defer env.allocator.free(regex.nodes); + + for (0..input.len + 1) |pos| { + const result = matchAt(regex, input, pos, env.allocator) catch return ElzError.OutOfMemory; + if (result) |m| { + if (m.end > m.start) { + return Value{ .string = env.allocator.dupe(u8, input[m.start..m.end]) catch return ElzError.OutOfMemory }; + } + } + } + return Value{ .boolean = false }; +} + +pub fn regex_replace(_: *interpreter.Interpreter, env: *core.Environment, args: core.ValueList, _: *u64) ElzError!Value { + if (args.items.len != 3) return ElzError.WrongArgumentCount; + if (args.items[0] != .string) return ElzError.InvalidArgument; + if (args.items[1] != .string) return ElzError.InvalidArgument; + if (args.items[2] != .string) return ElzError.InvalidArgument; + + const pattern = args.items[0].string; + const replacement = args.items[1].string; + const input = args.items[2].string; + + const regex = compile(pattern, env.allocator) catch return ElzError.InvalidArgument; + defer env.allocator.free(regex.nodes); + + var result = std.ArrayListUnmanaged(u8){}; + errdefer result.deinit(env.allocator); + var pos: usize = 0; + + while (pos <= input.len) { + // Search for the next match starting from pos or later + var found: ?MatchResult = null; + var search_pos = pos; + while (search_pos <= input.len) { + const m = matchAt(regex, input, search_pos, env.allocator) catch return ElzError.OutOfMemory; + if (m) |match_result| { + if (match_result.end > match_result.start) { + found = match_result; + break; + } + } + search_pos += 1; + } + + if (found) |match_result| { + result.appendSlice(env.allocator, input[pos..match_result.start]) catch return ElzError.OutOfMemory; + result.appendSlice(env.allocator, replacement) catch return ElzError.OutOfMemory; + pos = match_result.end; + } else { + result.appendSlice(env.allocator, input[pos..]) catch return ElzError.OutOfMemory; + break; + } + } + + return Value{ .string = result.toOwnedSlice(env.allocator) catch return ElzError.OutOfMemory }; +} + +pub fn regex_split(_: *interpreter.Interpreter, env: *core.Environment, args: core.ValueList, _: *u64) ElzError!Value { + if (args.items.len != 2) return ElzError.WrongArgumentCount; + if (args.items[0] != .string) return ElzError.InvalidArgument; + if (args.items[1] != .string) return ElzError.InvalidArgument; + + const pattern = args.items[0].string; + const input = args.items[1].string; + + const regex = compile(pattern, env.allocator) catch return ElzError.InvalidArgument; + defer env.allocator.free(regex.nodes); + + var parts = std.ArrayListUnmanaged(Value){}; + defer parts.deinit(env.allocator); + var pos: usize = 0; + + while (pos <= input.len) { + // Search for the next match starting from pos or later + var found: ?MatchResult = null; + var search_pos = pos; + while (search_pos <= input.len) { + const m = matchAt(regex, input, search_pos, env.allocator) catch return ElzError.OutOfMemory; + if (m) |match_result| { + if (match_result.end > match_result.start) { + found = match_result; + break; + } + } + search_pos += 1; + } + + if (found) |match_result| { + const part = env.allocator.dupe(u8, input[pos..match_result.start]) catch return ElzError.OutOfMemory; + parts.append(env.allocator, Value{ .string = part }) catch return ElzError.OutOfMemory; + pos = match_result.end; + } else { + break; + } + } + + // Add remaining + const rest = env.allocator.dupe(u8, input[pos..]) catch return ElzError.OutOfMemory; + parts.append(env.allocator, Value{ .string = rest }) catch return ElzError.OutOfMemory; + + // Build list + var list_result: Value = Value.nil; + var j = parts.items.len; + while (j > 0) { + j -= 1; + const p = env.allocator.create(core.Pair) catch return ElzError.OutOfMemory; + p.* = .{ .car = parts.items[j], .cdr = list_result }; + list_result = Value{ .pair = p }; + } + return list_result; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "regex-match? basic" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + var fuel: u64 = 10000; + + const r1 = try interp.evalString("(regex-match? \"hello\" \"hello\")", &fuel); + try testing.expect(r1 == .boolean and r1.boolean == true); + + fuel = 10000; + const r2 = try interp.evalString("(regex-match? \"hello\" \"world\")", &fuel); + try testing.expect(r2 == .boolean and r2.boolean == false); + + fuel = 10000; + const r3 = try interp.evalString("(regex-match? \"h.llo\" \"hello\")", &fuel); + try testing.expect(r3 == .boolean and r3.boolean == true); + + fuel = 10000; + const r4 = try interp.evalString("(regex-match? \"ab*c\" \"ac\")", &fuel); + try testing.expect(r4 == .boolean and r4.boolean == true); + + fuel = 10000; + const r5 = try interp.evalString("(regex-match? \"ab*c\" \"abbc\")", &fuel); + try testing.expect(r5 == .boolean and r5.boolean == true); + + fuel = 10000; + const r6 = try interp.evalString("(regex-match? \"ab?c\" \"abc\")", &fuel); + try testing.expect(r6 == .boolean and r6.boolean == true); + + fuel = 10000; + const r7 = try interp.evalString("(regex-match? \"ab?c\" \"ac\")", &fuel); + try testing.expect(r7 == .boolean and r7.boolean == true); + + fuel = 10000; + const r8 = try interp.evalString("(regex-match? \"ab+c\" \"abc\")", &fuel); + try testing.expect(r8 == .boolean and r8.boolean == true); + + fuel = 10000; + const r9 = try interp.evalString("(regex-match? \"ab+c\" \"ac\")", &fuel); + try testing.expect(r9 == .boolean and r9.boolean == false); +} + +test "regex-search" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + var fuel: u64 = 10000; + + const r1 = try interp.evalString("(regex-search \"[0-9]+\" \"abc123def\")", &fuel); + try testing.expect(r1 == .string); + try testing.expectEqualStrings("123", r1.string); +} + +test "regex-replace" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + var fuel: u64 = 10000; + + const r1 = try interp.evalString("(regex-replace \"[0-9]+\" \"NUM\" \"abc123def456\")", &fuel); + try testing.expect(r1 == .string); + try testing.expectEqualStrings("abcNUMdefNUM", r1.string); +} + +test "regex-split" { + const testing = std.testing; + var interp = interpreter.Interpreter.init(.{}) catch unreachable; + defer interp.deinit(); + var fuel: u64 = 10000; + + const r1 = try interp.evalString("(regex-split \",\" \"a,b,c\")", &fuel); + try testing.expect(r1 == .pair); + try testing.expectEqualStrings("a", r1.pair.car.string); +} diff --git a/tests/eval_prop_test.zig b/tests/eval_prop_test.zig new file mode 100644 index 0000000..a1a8df5 --- /dev/null +++ b/tests/eval_prop_test.zig @@ -0,0 +1,177 @@ +const std = @import("std"); +const elz = @import("elz"); +const minish = @import("minish"); +const gen = minish.gen; + +// --------------------------------------------------------------------------- +// Property: addition is commutative: (+ a b) == (+ b a) +// --------------------------------------------------------------------------- + +test "property: addition is commutative" { + const allocator = std.testing.allocator; + + const pair_gen = gen.tuple2(i16, i16, gen.int(i16), gen.int(i16)); + + try minish.check( + allocator, + pair_gen, + struct { + fn property(pair: struct { i16, i16 }) !void { + const a = pair[0]; + const b = pair[1]; + + var buf1: [128]u8 = undefined; + var buf2: [128]u8 = undefined; + const expr1 = std.fmt.bufPrint(&buf1, "(+ {d} {d})", .{ a, b }) catch return; + const expr2 = std.fmt.bufPrint(&buf2, "(+ {d} {d})", .{ b, a }) catch return; + + var interp = elz.Interpreter.init(.{}) catch return; + defer interp.deinit(); + + var fuel1: u64 = 1000; + var fuel2: u64 = 1000; + const r1 = interp.evalString(expr1, &fuel1) catch return; + const r2 = interp.evalString(expr2, &fuel2) catch return; + + if (r1 != .number or r2 != .number) return error.TestUnexpectedResult; + if (r1.number != r2.number) return error.TestUnexpectedResult; + } + }.property, + .{ .num_runs = 100 }, + ); +} + +// --------------------------------------------------------------------------- +// Property: multiplication is commutative: (* a b) == (* b a) +// --------------------------------------------------------------------------- + +test "property: multiplication is commutative" { + const allocator = std.testing.allocator; + + const pair_gen = gen.tuple2(i16, i16, gen.int(i16), gen.int(i16)); + + try minish.check( + allocator, + pair_gen, + struct { + fn property(pair: struct { i16, i16 }) !void { + const a = pair[0]; + const b = pair[1]; + + var buf1: [128]u8 = undefined; + var buf2: [128]u8 = undefined; + const expr1 = std.fmt.bufPrint(&buf1, "(* {d} {d})", .{ a, b }) catch return; + const expr2 = std.fmt.bufPrint(&buf2, "(* {d} {d})", .{ b, a }) catch return; + + var interp = elz.Interpreter.init(.{}) catch return; + defer interp.deinit(); + + var fuel1: u64 = 1000; + var fuel2: u64 = 1000; + const r1 = interp.evalString(expr1, &fuel1) catch return; + const r2 = interp.evalString(expr2, &fuel2) catch return; + + if (r1 != .number or r2 != .number) return error.TestUnexpectedResult; + if (r1.number != r2.number) return error.TestUnexpectedResult; + } + }.property, + .{ .num_runs = 100 }, + ); +} + +// --------------------------------------------------------------------------- +// Property: evaluating a number literal returns itself +// --------------------------------------------------------------------------- + +test "property: number literals are self-evaluating" { + const allocator = std.testing.allocator; + + try minish.check( + allocator, + gen.int(i32), + struct { + fn property(n: i32) !void { + var buf: [32]u8 = undefined; + const expr = std.fmt.bufPrint(&buf, "{d}", .{n}) catch return; + + var interp = elz.Interpreter.init(.{}) catch return; + defer interp.deinit(); + + var fuel: u64 = 1000; + const result = interp.evalString(expr, &fuel) catch return; + + if (result != .number) return error.TestUnexpectedResult; + if (result.number != @as(f64, @floatFromInt(n))) return error.TestUnexpectedResult; + } + }.property, + .{ .num_runs = 200 }, + ); +} + +// --------------------------------------------------------------------------- +// Property: (- (+ a b) b) == a for small integers +// --------------------------------------------------------------------------- + +test "property: addition and subtraction are inverse" { + const allocator = std.testing.allocator; + + const pair_gen = gen.tuple2(i16, i16, gen.int(i16), gen.int(i16)); + + try minish.check( + allocator, + pair_gen, + struct { + fn property(pair: struct { i16, i16 }) !void { + const a = pair[0]; + const b = pair[1]; + + var buf: [128]u8 = undefined; + const expr = std.fmt.bufPrint(&buf, "(- (+ {d} {d}) {d})", .{ a, b, b }) catch return; + + var interp = elz.Interpreter.init(.{}) catch return; + defer interp.deinit(); + + var fuel: u64 = 1000; + const result = interp.evalString(expr, &fuel) catch return; + + if (result != .number) return error.TestUnexpectedResult; + const expected: f64 = @floatFromInt(a); + if (@abs(result.number - expected) > 1e-10) return error.TestUnexpectedResult; + } + }.property, + .{ .num_runs = 100 }, + ); +} + +// --------------------------------------------------------------------------- +// Property: eval is deterministic (same input, same output) +// --------------------------------------------------------------------------- + +test "property: eval is deterministic" { + const allocator = std.testing.allocator; + + try minish.check( + allocator, + gen.int(i16), + struct { + fn property(n: i16) !void { + var buf: [64]u8 = undefined; + const expr = std.fmt.bufPrint(&buf, "(* {d} {d})", .{ n, n }) catch return; + + var interp1 = elz.Interpreter.init(.{}) catch return; + defer interp1.deinit(); + var interp2 = elz.Interpreter.init(.{}) catch return; + defer interp2.deinit(); + + var fuel1: u64 = 1000; + var fuel2: u64 = 1000; + const r1 = interp1.evalString(expr, &fuel1) catch return; + const r2 = interp2.evalString(expr, &fuel2) catch return; + + if (r1 != .number or r2 != .number) return error.TestUnexpectedResult; + if (r1.number != r2.number) return error.TestUnexpectedResult; + } + }.property, + .{ .num_runs = 100 }, + ); +} diff --git a/tests/interpreter_integ_test.zig b/tests/interpreter_integ_test.zig new file mode 100644 index 0000000..8cd062b --- /dev/null +++ b/tests/interpreter_integ_test.zig @@ -0,0 +1,257 @@ +const std = @import("std"); +const elz = @import("elz"); + +const testing = std.testing; + +// --------------------------------------------------------------------------- +// Interpreter initialization and lifecycle +// --------------------------------------------------------------------------- + +test "interpreter initializes with default flags" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + // nil should be defined + const nil_val = try interp.root_env.get("nil", &interp); + try testing.expect(nil_val == .nil); +} + +test "interpreter initializes with all features disabled" { + var interp = try elz.Interpreter.init(.{ + .enable_math = false, + .enable_lists = false, + .enable_predicates = false, + .enable_strings = false, + .enable_io = false, + }); + defer interp.deinit(); + + // Basic evaluation should still work + var fuel: u64 = 1000; + const result = try interp.evalString("42", &fuel); + try testing.expect(result == .number); + try testing.expectEqual(@as(f64, 42), result.number); +} + +test "math disabled prevents arithmetic" { + var interp = try elz.Interpreter.init(.{ .enable_math = false }); + defer interp.deinit(); + + var fuel: u64 = 1000; + try testing.expectError(elz.ElzError.SymbolNotFound, interp.evalString("(+ 1 2)", &fuel)); +} + +// --------------------------------------------------------------------------- +// Basic evaluation through the public API +// --------------------------------------------------------------------------- + +test "evalString handles multiple expressions" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 10000; + const result = try interp.evalString("(define x 10) (define y 20) (+ x y)", &fuel); + try testing.expect(result == .number); + try testing.expectEqual(@as(f64, 30), result.number); +} + +test "evalString with lambda and closure" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 10000; + const result = try interp.evalString( + \\(define (make-adder n) + \\ (lambda (x) (+ n x))) + \\(define add5 (make-adder 5)) + \\(add5 10) + , &fuel); + try testing.expect(result == .number); + try testing.expectEqual(@as(f64, 15), result.number); +} + +test "evalString with recursive function" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 100000; + const result = try interp.evalString( + \\(define (factorial n) + \\ (if (<= n 1) 1 + \\ (* n (factorial (- n 1))))) + \\(factorial 10) + , &fuel); + try testing.expect(result == .number); + try testing.expectEqual(@as(f64, 3628800), result.number); +} + +// --------------------------------------------------------------------------- +// Error propagation +// --------------------------------------------------------------------------- + +test "symbol not found propagates" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 1000; + try testing.expectError(elz.ElzError.SymbolNotFound, interp.evalString("undefined-var", &fuel)); +} + +test "fuel exhaustion propagates" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + // Use a non-TCO recursive function that will definitely exhaust fuel. + // The (+ 1 ...) wrapper prevents tail-call optimization. + var fuel: u64 = 100; + try testing.expectError( + elz.ElzError.ExecutionBudgetExceeded, + interp.evalString("(letrec ((loop (lambda () (+ 1 (loop))))) (loop))", &fuel), + ); +} + +test "division by zero propagates" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 1000; + try testing.expectError(elz.ElzError.DivisionByZero, interp.evalString("(/ 1 0)", &fuel)); +} + +// --------------------------------------------------------------------------- +// Data types through the public API +// --------------------------------------------------------------------------- + +test "string operations" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 10000; + const result = try interp.evalString( + \\(string-append "hello" " " "world") + , &fuel); + try testing.expect(result == .string); + try testing.expectEqualStrings("hello world", result.string); +} + +test "list operations" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 10000; + const result = try interp.evalString("(length '(1 2 3 4 5))", &fuel); + try testing.expect(result == .number); + try testing.expectEqual(@as(f64, 5), result.number); +} + +test "boolean operations" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 10000; + + const r1 = try interp.evalString("(and #t #t #t)", &fuel); + try testing.expect(r1 == .boolean); + try testing.expect(r1.boolean == true); + + fuel = 10000; + const r2 = try interp.evalString("(or #f #f #t)", &fuel); + try testing.expect(r2 == .boolean); + try testing.expect(r2.boolean == true); +} + +test "vector operations" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 10000; + const result = try interp.evalString( + \\(define v (vector 10 20 30)) + \\(vector-ref v 1) + , &fuel); + try testing.expect(result == .number); + try testing.expectEqual(@as(f64, 20), result.number); +} + +// --------------------------------------------------------------------------- +// Standard library loaded correctly +// --------------------------------------------------------------------------- + +test "stdlib functions available" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 100000; + + // filter from stdlib + const result = try interp.evalString( + \\(length (filter even? '(1 2 3 4 5 6))) + , &fuel); + try testing.expect(result == .number); + try testing.expectEqual(@as(f64, 3), result.number); +} + +test "fold-left from stdlib" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 100000; + const result = try interp.evalString( + \\(fold-left + 0 '(1 2 3 4 5)) + , &fuel); + try testing.expect(result == .number); + try testing.expectEqual(@as(f64, 15), result.number); +} + +// --------------------------------------------------------------------------- +// Try/catch error handling +// --------------------------------------------------------------------------- + +test "try/catch catches errors" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 10000; + const result = try interp.evalString( + \\(try (/ 1 0) (catch err "caught")) + , &fuel); + try testing.expect(result == .string); + try testing.expectEqualStrings("caught", result.string); +} + +// --------------------------------------------------------------------------- +// Module system +// --------------------------------------------------------------------------- + +test "quasiquote and unquote" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 10000; + _ = try interp.evalString("(define x 42)", &fuel); + fuel = 10000; + const result = try interp.evalString("(quasiquote (a (unquote x) b))", &fuel); + // Should produce the list (a 42 b) + try testing.expect(result == .pair); + try testing.expect(result.pair.car.is_symbol("a")); + const second = result.pair.cdr.pair; + try testing.expect(second.car == .number); + try testing.expectEqual(@as(f64, 42), second.car.number); +} + +// --------------------------------------------------------------------------- +// Writer roundtrip through public API +// --------------------------------------------------------------------------- + +test "write produces valid output" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + var fuel: u64 = 10000; + const value = try interp.evalString("'(1 2 3)", &fuel); + + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + try elz.write(value, fbs.writer()); + try testing.expectEqualStrings("(1 2 3)", fbs.getWritten()); +} diff --git a/tests/json_prop_test.zig b/tests/json_prop_test.zig new file mode 100644 index 0000000..3cc5dde --- /dev/null +++ b/tests/json_prop_test.zig @@ -0,0 +1,62 @@ +const std = @import("std"); +const elz = @import("elz"); +const minish = @import("minish"); +const gen = minish.gen; + +// --------------------------------------------------------------------------- +// Property: json-serialize then json-deserialize a number == original +// --------------------------------------------------------------------------- + +test "property: JSON number roundtrip" { + const allocator = std.testing.allocator; + + try minish.check( + allocator, + gen.int(i32), + struct { + fn property(n: i32) !void { + var buf: [64]u8 = undefined; + const expr = std.fmt.bufPrint(&buf, "(json-deserialize (json-serialize {d}))", .{n}) catch return; + + var interp = elz.Interpreter.init(.{}) catch return; + defer interp.deinit(); + + var fuel: u64 = 10000; + const result = interp.evalString(expr, &fuel) catch return; + + if (result != .number) return error.TestUnexpectedResult; + const expected: f64 = @floatFromInt(n); + if (result.number != expected) return error.TestUnexpectedResult; + } + }.property, + .{ .num_runs = 100 }, + ); +} + +// --------------------------------------------------------------------------- +// Property: json-serialize then json-deserialize a boolean == original +// --------------------------------------------------------------------------- + +test "property: JSON boolean roundtrip" { + const allocator = std.testing.allocator; + + try minish.check( + allocator, + gen.boolean(), + struct { + fn property(b: bool) !void { + const expr = if (b) "(json-deserialize (json-serialize #t))" else "(json-deserialize (json-serialize #f))"; + + var interp = elz.Interpreter.init(.{}) catch return; + defer interp.deinit(); + + var fuel: u64 = 10000; + const result = interp.evalString(expr, &fuel) catch return; + + if (result != .boolean) return error.TestUnexpectedResult; + if (result.boolean != b) return error.TestUnexpectedResult; + } + }.property, + .{ .num_runs = 50 }, + ); +} diff --git a/tests/parser_prop_test.zig b/tests/parser_prop_test.zig new file mode 100644 index 0000000..e37f241 --- /dev/null +++ b/tests/parser_prop_test.zig @@ -0,0 +1,116 @@ +const std = @import("std"); +const elz = @import("elz"); +const minish = @import("minish"); +const gen = minish.gen; + +const Value = elz.Value; + +// --------------------------------------------------------------------------- +// Property: parsing a number literal and writing it back produces a valid number +// --------------------------------------------------------------------------- + +test "property: number parse-write roundtrip" { + const allocator = std.testing.allocator; + + try minish.check( + allocator, + gen.int(i32), + struct { + fn property(n: i32) !void { + // Format the number as Element 0 would + var buf: [64]u8 = undefined; + const formatted = std.fmt.bufPrint(&buf, "{d}", .{n}) catch return; + + // Parse it + const alloc = std.heap.page_allocator; + const value = elz.parser.read(formatted, alloc) catch return; + + // Should be a number + if (value != .number) return error.TestUnexpectedResult; + + // Value should match + const expected: f64 = @floatFromInt(n); + if (value.number != expected) return error.TestUnexpectedResult; + } + }.property, + .{ .num_runs = 200 }, + ); +} + +// --------------------------------------------------------------------------- +// Property: parser never crashes on arbitrary input +// --------------------------------------------------------------------------- + +test "property: parser does not crash on arbitrary strings" { + const allocator = std.testing.allocator; + + try minish.check( + allocator, + gen.string(.{ .min_len = 0, .max_len = 100 }), + struct { + fn property(input: []const u8) !void { + const alloc = std.heap.page_allocator; + // We don't care about the result, just that it doesn't crash + _ = elz.parser.read(input, alloc) catch {}; + _ = elz.parser.readAll(input, alloc) catch {}; + } + }.property, + .{ .num_runs = 500 }, + ); +} + +// --------------------------------------------------------------------------- +// Property: boolean roundtrip +// --------------------------------------------------------------------------- + +test "property: boolean parse-write roundtrip" { + const allocator = std.testing.allocator; + + try minish.check( + allocator, + gen.boolean(), + struct { + fn property(b: bool) !void { + const alloc = std.heap.page_allocator; + const source = if (b) "#t" else "#f"; + const value = elz.parser.read(source, alloc) catch return error.TestUnexpectedResult; + + if (value != .boolean) return error.TestUnexpectedResult; + if (value.boolean != b) return error.TestUnexpectedResult; + } + }.property, + .{ .num_runs = 50 }, + ); +} + +// --------------------------------------------------------------------------- +// Property: string literal roundtrip (simple ASCII, no special chars) +// --------------------------------------------------------------------------- + +test "property: simple string parse-write roundtrip" { + const allocator = std.testing.allocator; + + try minish.check( + allocator, + gen.intRange(u8, 1, 50), + struct { + fn property(len: u8) !void { + const alloc = std.heap.page_allocator; + // Build a simple string of 'a' characters + var content: [50]u8 = undefined; + @memset(content[0..len], 'a'); + + // Wrap in quotes for parsing + var source: [54]u8 = undefined; + source[0] = '"'; + @memcpy(source[1 .. 1 + len], content[0..len]); + source[1 + len] = '"'; + + const value = elz.parser.read(source[0 .. 2 + len], alloc) catch return error.TestUnexpectedResult; + if (value != .string) return error.TestUnexpectedResult; + if (value.string.len != len) return error.TestUnexpectedResult; + } + }.property, + .{ .num_runs = 100 }, + ); +} diff --git a/tests/sandbox_integ_test.zig b/tests/sandbox_integ_test.zig new file mode 100644 index 0000000..d962bbd --- /dev/null +++ b/tests/sandbox_integ_test.zig @@ -0,0 +1,53 @@ +const std = @import("std"); +const elz = @import("elz"); + +const testing = std.testing; + +test "time limit triggers on long computation" { + // Create interpreter with a 50ms time limit + var interp = try elz.Interpreter.init(.{ .time_limit_ms = 50 }); + defer interp.deinit(); + + // Use map over a very large list to force many eval steps. + // Each map iteration consumes multiple eval steps. + // Building a list of 50000 elements and mapping over it will take > 50ms. + var fuel: u64 = 1_000_000_000; + const result = interp.evalString( + \\(define (make-list n acc) + \\ (if (<= n 0) acc + \\ (make-list (- n 1) (cons n acc)))) + \\(define big (make-list 100000 '())) + \\(map (lambda (x) (* x x)) big) + , &fuel); + + // Should fail with TimeLimitExceeded or ExecutionBudgetExceeded + if (result) |_| { + // If it somehow completed, that's also OK (fast machine) + } else |err| { + try std.testing.expect(err == elz.ElzError.TimeLimitExceeded or err == elz.ElzError.ExecutionBudgetExceeded); + } +} + +test "time limit does not trigger for fast operations" { + // Create interpreter with a generous time limit + var interp = try elz.Interpreter.init(.{ .time_limit_ms = 5000 }); + defer interp.deinit(); + + var fuel: u64 = 100000; + const result = try interp.evalString("(+ 1 2 3)", &fuel); + try testing.expect(result == .number); + try testing.expectEqual(@as(f64, 6), result.number); +} + +test "no time limit by default" { + var interp = try elz.Interpreter.init(.{}); + defer interp.deinit(); + + // time_limit_ms should be null + try testing.expect(interp.time_limit_ms == null); + + var fuel: u64 = 10000; + const result = try interp.evalString("(* 6 7)", &fuel); + try testing.expect(result == .number); + try testing.expectEqual(@as(f64, 42), result.number); +} diff --git a/tests/test_continuations.elz b/tests/test_continuations.elz new file mode 100644 index 0000000..d66b0cb --- /dev/null +++ b/tests/test_continuations.elz @@ -0,0 +1,62 @@ +;; Tests for escape continuations + +(define *tests-run* 0) +(define *tests-failed* 0) + +(define (test-case name test-expr) + (set! *tests-run* (+ *tests-run* 1)) + (if (not (try test-expr (catch err #f))) + (begin + (set! *tests-failed* (+ *tests-failed* 1)) + (display "[FAILED]: ") (display name) (newline)) + (begin + (display "[PASSED]: ") (display name) (newline)))) + +(define (report-summary) + (newline) + (display "--------------------") (newline) + (display "Tests Finished.") (newline) + (display "Total run: ") (display *tests-run*) (newline) + (display "Passed: ") (display (- *tests-run* *tests-failed*)) (newline) + (display "Failed: ") (display *tests-failed*) (newline) + (newline) + (if (= *tests-failed* 0) + (begin (display "RESULT: PASSED") (newline)) + (begin (display "RESULT: FAILED") (newline) (exit 1)))) + +;; Basic escape +(test-case "escape returns value" + (= (call/ec (lambda (k) (k 42) 99)) 42)) + +;; Normal return when escape not invoked +(test-case "normal return when escape not used" + (= (call/ec (lambda (k) 99)) 99)) + +;; Escape from nested computation +(test-case "escape from nested expression" + (= (call/ec (lambda (k) (+ 1 (k 10)))) 10)) + +;; call-with-escape-continuation full name +(test-case "full name works" + (= (call-with-escape-continuation (lambda (k) (k 5))) 5)) + +;; Escape with string +(test-case "escape with string value" + (equal? (call/ec (lambda (k) (k "found"))) "found")) + +;; Escape with boolean +(test-case "escape with boolean" + (equal? (call/ec (lambda (k) (k #t))) #t)) + +;; List search with early exit +(test-case "early exit from fold" + (= (call/ec + (lambda (return) + (fold-left + (lambda (acc x) + (if (= x 3) (return x) (+ acc x))) + 0 + '(1 2 3 4 5)))) + 3)) + +(report-summary) diff --git a/tests/test_format.elz b/tests/test_format.elz new file mode 100644 index 0000000..c04eaa2 --- /dev/null +++ b/tests/test_format.elz @@ -0,0 +1,76 @@ +;; Tests for the format procedure + +(define *tests-run* 0) +(define *tests-failed* 0) + +(define (test-case name test-expr) + (set! *tests-run* (+ *tests-run* 1)) + (if (not (try test-expr (catch err #f))) + (begin + (set! *tests-failed* (+ *tests-failed* 1)) + (display "[FAILED]: ") (display name) (newline)) + (begin + (display "[PASSED]: ") (display name) (newline)))) + +(define (report-summary) + (newline) + (display "--------------------") (newline) + (display "Tests Finished.") (newline) + (display "Total run: ") (display *tests-run*) (newline) + (display "Passed: ") (display (- *tests-run* *tests-failed*)) (newline) + (display "Failed: ") (display *tests-failed*) (newline) + (newline) + (if (= *tests-failed* 0) + (begin (display "RESULT: PASSED") (newline)) + (begin (display "RESULT: FAILED") (newline) (exit 1)))) + +;; ~a directive (display mode) +(test-case "format ~a with string" + (equal? (format "hello ~a" "world") "hello world")) + +(test-case "format ~a with number" + (equal? (format "x = ~a" 42) "x = 42")) + +(test-case "format ~a with boolean" + (equal? (format "~a" #t) "#t")) + +(test-case "format ~a with list" + (equal? (format "~a" '(1 2 3)) "(1 2 3)")) + +;; ~s directive (write mode) +(test-case "format ~s with string" + (equal? (format "~s" "hello") "\"hello\"")) + +(test-case "format ~s with number" + (equal? (format "~s" 42) "42")) + +;; ~% directive (newline) +(test-case "format ~% produces newline" + (equal? (format "line1~%line2") "line1\nline2")) + +;; ~~ directive (literal tilde) +(test-case "format ~~ produces tilde" + (equal? (format "~~") "~")) + +;; Multiple arguments +(test-case "format multiple ~a" + (equal? (format "~a + ~a = ~a" 1 2 3) "1 + 2 = 3")) + +;; No directives +(test-case "format plain string" + (equal? (format "hello") "hello")) + +;; value->string +(test-case "value->string with number" + (equal? (value->string 42) "42")) + +(test-case "value->string with string" + (equal? (value->string "hello") "\"hello\"")) + +(test-case "value->string with list" + (equal? (value->string '(1 2 3)) "(1 2 3)")) + +(test-case "value->string with boolean" + (equal? (value->string #t) "#t")) + +(report-summary) diff --git a/tests/test_json.elz b/tests/test_json.elz new file mode 100644 index 0000000..e2075ab --- /dev/null +++ b/tests/test_json.elz @@ -0,0 +1,90 @@ +;; Tests for JSON serialization/deserialization + +(define *tests-run* 0) +(define *tests-failed* 0) + +(define (test-case name test-expr) + (set! *tests-run* (+ *tests-run* 1)) + (if (not (try test-expr (catch err #f))) + (begin + (set! *tests-failed* (+ *tests-failed* 1)) + (display "[FAILED]: ") (display name) (newline)) + (begin + (display "[PASSED]: ") (display name) (newline)))) + +(define (report-summary) + (newline) + (display "--------------------") (newline) + (display "Tests Finished.") (newline) + (display "Total run: ") (display *tests-run*) (newline) + (display "Passed: ") (display (- *tests-run* *tests-failed*)) (newline) + (display "Failed: ") (display *tests-failed*) (newline) + (newline) + (if (= *tests-failed* 0) + (begin (display "RESULT: PASSED") (newline)) + (begin (display "RESULT: FAILED") (newline) (exit 1)))) + +;; json-serialize +(test-case "serialize number" + (equal? (json-serialize 42) "42")) + +(test-case "serialize string" + (equal? (json-serialize "hello") "\"hello\"")) + +(test-case "serialize true" + (equal? (json-serialize #t) "true")) + +(test-case "serialize false" + (equal? (json-serialize #f) "false")) + +(test-case "serialize nil" + (equal? (json-serialize '()) "null")) + +(test-case "serialize list" + (equal? (json-serialize '(1 2 3)) "[1,2,3]")) + +(test-case "serialize nested list" + (equal? (json-serialize '(1 (2 3))) "[1,[2,3]]")) + +(test-case "serialize vector" + (equal? (json-serialize (vector 1 2 3)) "[1,2,3]")) + +;; json-deserialize +(test-case "deserialize number" + (= (json-deserialize "42") 42)) + +(test-case "deserialize negative number" + (= (json-deserialize "-5") -5)) + +(test-case "deserialize string" + (equal? (json-deserialize "\"hello\"") "hello")) + +(test-case "deserialize true" + (equal? (json-deserialize "true") #t)) + +(test-case "deserialize false" + (equal? (json-deserialize "false") #f)) + +(test-case "deserialize null" + (null? (json-deserialize "null"))) + +(test-case "deserialize array" + (equal? (json-deserialize "[1,2,3]") '(1 2 3))) + +(test-case "deserialize empty array" + (null? (json-deserialize "[]"))) + +;; Roundtrip tests +(test-case "roundtrip number" + (= (json-deserialize (json-serialize 42)) 42)) + +(test-case "roundtrip string" + (equal? (json-deserialize (json-serialize "hello world")) "hello world")) + +(test-case "roundtrip boolean" + (equal? (json-deserialize (json-serialize #t)) #t)) + +(test-case "roundtrip list" + (equal? (json-deserialize (json-serialize '(1 2 3))) '(1 2 3))) + +(report-summary) diff --git a/tests/test_regex.elz b/tests/test_regex.elz new file mode 100644 index 0000000..f100a33 --- /dev/null +++ b/tests/test_regex.elz @@ -0,0 +1,82 @@ +;; Tests for regular expression primitives + +(define *tests-run* 0) +(define *tests-failed* 0) + +(define (test-case name test-expr) + (set! *tests-run* (+ *tests-run* 1)) + (if (not (try test-expr (catch err #f))) + (begin + (set! *tests-failed* (+ *tests-failed* 1)) + (display "[FAILED]: ") (display name) (newline)) + (begin + (display "[PASSED]: ") (display name) (newline)))) + +(define (report-summary) + (newline) + (display "--------------------") (newline) + (display "Tests Finished.") (newline) + (display "Total run: ") (display *tests-run*) (newline) + (display "Passed: ") (display (- *tests-run* *tests-failed*)) (newline) + (display "Failed: ") (display *tests-failed*) (newline) + (newline) + (if (= *tests-failed* 0) + (begin (display "RESULT: PASSED") (newline)) + (begin (display "RESULT: FAILED") (newline) (exit 1)))) + +;; regex-match? +(test-case "match literal" + (regex-match? "hello" "hello")) + +(test-case "no match literal" + (not (regex-match? "hello" "world"))) + +(test-case "match dot" + (regex-match? "h.llo" "hello")) + +(test-case "match star" + (regex-match? "ab*c" "ac")) + +(test-case "match star multiple" + (regex-match? "ab*c" "abbbbc")) + +(test-case "match plus" + (regex-match? "ab+c" "abc")) + +(test-case "no match plus zero" + (not (regex-match? "ab+c" "ac"))) + +(test-case "match question" + (regex-match? "ab?c" "ac")) + +(test-case "match question with char" + (regex-match? "ab?c" "abc")) + +(test-case "match char class" + (regex-match? "[abc]" "b")) + +(test-case "match char range" + (regex-match? "[a-z]+" "hello")) + +(test-case "no match char range" + (not (regex-match? "[0-9]+" "hello"))) + +;; regex-search +(test-case "search finds match" + (equal? (regex-search "[0-9]+" "abc123def") "123")) + +(test-case "search no match" + (equal? (regex-search "[0-9]+" "abcdef") #f)) + +;; regex-replace +(test-case "replace all occurrences" + (equal? (regex-replace "[0-9]+" "NUM" "abc123def456") "abcNUMdefNUM")) + +;; regex-split +(test-case "split by comma" + (equal? (car (regex-split "," "a,b,c")) "a")) + +(test-case "split count" + (= (length (regex-split "," "a,b,c")) 3)) + +(report-summary) diff --git a/tests/test_syntax_rules.elz b/tests/test_syntax_rules.elz new file mode 100644 index 0000000..a150a80 --- /dev/null +++ b/tests/test_syntax_rules.elz @@ -0,0 +1,68 @@ +;; Tests for macro system (define-macro and macro invocation) + +(define *tests-run* 0) +(define *tests-failed* 0) + +(define (test-case name test-expr) + (set! *tests-run* (+ *tests-run* 1)) + (if (not (try test-expr (catch err #f))) + (begin + (set! *tests-failed* (+ *tests-failed* 1)) + (display "[FAILED]: ") (display name) (newline)) + (begin + (display "[PASSED]: ") (display name) (newline)))) + +(define (report-summary) + (newline) + (display "--------------------") (newline) + (display "Tests Finished.") (newline) + (display "Total run: ") (display *tests-run*) (newline) + (display "Passed: ") (display (- *tests-run* *tests-failed*)) (newline) + (display "Failed: ") (display *tests-failed*) (newline) + (newline) + (if (= *tests-failed* 0) + (begin (display "RESULT: PASSED") (newline)) + (begin (display "RESULT: FAILED") (newline) (exit 1)))) + +;; Basic define-macro +(define-macro (when condition body) + (list 'if condition body #f)) + +(test-case "when macro true" + (= (when #t 42) 42)) + +(test-case "when macro false" + (equal? (when #f 42) #f)) + +;; unless macro +(define-macro (unless condition body) + (list 'if condition #f body)) + +(test-case "unless macro true" + (equal? (unless #t 42) #f)) + +(test-case "unless macro false" + (= (unless #f 42) 42)) + +;; swap macro (using begin) +(define-macro (my-and a b) + (list 'if a b #f)) + +(test-case "my-and both true" + (= (my-and #t 42) 42)) + +(test-case "my-and first false" + (equal? (my-and #f 42) #f)) + +;; Macro that generates a lambda +(define-macro (thunk body) + (list 'lambda '() body)) + +(test-case "thunk macro" + (= ((thunk 42)) 42)) + +;; Nested macro usage +(test-case "nested when" + (= (when #t (when #t 99)) 99)) + +(report-summary)