release 0.1.0

This commit is contained in:
rus07tam 2026-05-06 12:21:06 +03:00
commit 30d94536a9
90 changed files with 7722 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
.vscode
.envrc

1217
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

40
Cargo.toml Normal file
View file

@ -0,0 +1,40 @@
[package]
name = "owa-rs"
version = "0.1.0"
edition = "2024"
repository = "https://git.ruject.fun/RuJect/owa-rs"
description = "A Lisp-like programming language implemented in Rust"
license = "Unlicense"
readme = "README.md"
categories = ["compilers", "parsing", "development-tools", "data-structures"]
keywords = [
"owa",
"lisp",
"s-expression",
"language",
"interpreter",
"functional",
"persistent",
"immutable",
"ast",
"parser",
"runtime",
"rpds",
]
[dependencies]
archery = "1.2.2"
clap = { version = "4.6.1", features = ["derive"] }
dirs = "6.0.0"
educe = "0.6.0"
libffi = "5.1.0"
libloading = "0.9.0"
nom = "8.0.0"
num-traits = "0.2.19"
ordered-float = { version = "5.3.0", features = ["std"] }
paste = "1.0.15"
reedline = "0.47.0"
rpds = "1.2.0"
test-case = "3.3.1"
tracing = { version = "0.1.44", features = ["release_max_level_off"] }
tracing-subscriber = "0.3.23"

24
LICENSE Normal file
View file

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

329
README.md Normal file
View file

@ -0,0 +1,329 @@
# OWA-RS
A Lisp-inspired programming language written in Rust. This is a personal learning project, so expect rough edges and experimental design.
## Content
- [OWA-RS](#owa-rs)
- [Content](#content)
- [Overview](#overview)
- [Syntax 📝](#syntax-)
- [Literals](#literals)
- [Collections](#collections)
- [Special Forms](#special-forms)
- [Comments](#comments)
- [Annotations](#annotations)
- [Example](#example)
- [Quick Start 🛠️](#quick-start-)
- [Requirements](#requirements)
- [Build](#build)
- [Run a file](#run-a-file)
- [Run directly without owu](#run-directly-without-owu)
- [Flags](#flags)
- [Architecture 🏗️](#architecture-)
- [Rust core](#rust-core)
- [Modular standard library](#modular-standard-library)
- [Built-in API 🔧](#built-in-api-)
- [Core operations](#core-operations)
- [Built-in namespaces](#built-in-namespaces)
- [Standard Library 📚](#standard-library-)
- [Core Library (`modules/core/`)](#core-library-modulescore)
- [Standard Library (`modules/std/`)](#standard-library-modulesstd)
- [owu (`modules/owu/`)](#owu-modulesowu)
- [Performance ⚡](#performance-)
- [Examples 🌟](#examples-)
- [Hello World](#hello-world)
- [Using Functions from Core](#using-functions-from-core)
- [Working with Collections](#working-with-collections)
- [Development 🛠️](#development-)
- [Running Tests](#running-tests)
- [Building Documentation](#building-documentation)
- [TODOs 📋](#todos-)
- [High Priority](#high-priority)
- [Medium Priority](#medium-priority)
- [Lower Priority](#lower-priority)
- [Completed](#completed)
- [Contributing 🤝](#contributing-)
- [License 📄](#license-)
## Overview
OWA-RS is designed as a lightweight Lisp-like interpreter with:
- a small Rust runtime,
- a modular standard library,
- a simple CLI wrapper (owu),
- persistent immutable collections via `rpds`.
> ⚠️ This is a **personal project** for learning and experimentation. It's my very first Rust project, so expect some rough edges. Use at your own risk!
## Syntax 📝
OWA-RS uses a familiar Lisp-style syntax with nested expressions.
### Literals
- Integers: `42`, `-7`
- Floats: `3.14`, `-0.5`
- Strings: `"hello world"`
- Keywords: `:key`, `:value`
- Symbols: `variable-name`, `function?`
### Collections
- Lists / Calls: `(function arg1 arg2)`
- Vectors: `[1 2 3]`
- Maps: `{:key "value" :other 42}`
- Sets: `#{"a" "b" "c"}`
### Special Forms
- Lambdas: `(lambda [args] body)`
- Macros: `(macro [args] body)`
- Quotes: `'expression`
- Unquotes: `$expression`
### Comments
Comments start with `;;` and continue to the end of the line.
### Annotations
Annotations are placed in angle brackets (`<annotation>value`) and can contain any value that will be computedduring linting (⚠️ currently not implemented).
### Example
```owa
(seq
;; Define a function
(def greet (lambda [name]
(str.concat "Hello, " name "!")))
;; Call it
(trace (greet "World")))
```
## Quick Start 🛠️
### Requirements
- Rust 1.70+ (for building from source)
- Cargo
### Build
```bash
git clone https://github.com/RuJect/owa-rs.git
cd owa-rs
cargo build --release
```
### Run a file
```bash
./target/release/owa-rs run hello.owa
```
### Run directly without owu
```bash
./target/release/owa-rs --no-owu hello.owa
```
### Flags
When running via owu, these flags are available:
- `--no-builtins`: skip Rust built-ins
- `--no-core`: skip loading core library
- `--no-std`: skip loading standard library
- `--debug`: enable debug mode
- `--test`: run in test mode
## Architecture 🏗️
OWA-RS follows a **Parse → Interpret** pipeline with modular design:
### Rust core
- `src/parser/`: source code → AST
- `src/core/interpreter.rs`: evaluates AST nodes
- `src/core/scope.rs`: manages variables and closures
- `src/builtins/`: runtime primitives and host operations
### Modular standard library
- `modules/core/`: core language utilities, types, math, error handling, loops, tests
- `modules/std/`: higher-level helpers such as `path` and `platform`
- `modules/owu/`: command-line interface and `run` command support
The runtime stays intentionally small, while higher-level language features live in library code.
## Built-in API 🔧
OWA-RS follows a **minimalist principle** for Rust built-ins: the runtime only implements fundamental primitives and avoids domain/application logic. The result is a small, focused runtime with powerful libraries built on top.
### Core operations
- Language: `def`, `lambda`, `macro`, `seq`, `trace`, `apply`, `lookup`, `include`
- Scope: `scope`, `set!`, `unset!`, `exec`
- Type inspection: `typeof`, `cmp`
- Flow: `return`, `break`, `continue` (via `flow` namespace)
- Errors: `throw`, `catch` (via `errors` namespace)
### Built-in namespaces
- `math`: arithmetic and numeric helpers
- `str`: string construction and serialization
- `vec`: vector creation and folding
- `map`: maps and lookup
- `set`: set operations
- `cond`: conditional helpers like `if-eq` and `match`
- `platform`: environment, CLI parsing, debug flags
## Standard Library 📚
The standard library is organized in modules:
### Core Library (`modules/core/`)
Essential functionality available by default when running through owu:
- `assert.owa`: Assertion utilities for testing
- `types/`: Type checking and predicates, including:
- `main.owa`: Core type utilities and predicates
- `bool.owa`, `str.owa`, `vec.owa`, `map.owa`, `set.owa`, `null.owa`, `option.owa`: Type-specific operations
- `cmp.owa`: Comparison operations
- `math.owa`: Arithmetic operations
- `error.owa`: Error handling
- `loop.owa`: Looping constructs
- `test.owa`: Testing framework
### Standard Library (`modules/std/`)
Additional utilities:
- `platform.owa`: Platform-specific operations
### owu (`modules/owu/`)
The OWA Utility tool:
- `cli/`: Command-line interface framework
- `cli/run.owa`: The `run` command handler
## Performance ⚡
OWA-RS is a tree-walking interpreter with reasonable performance for its simplicity. Rust's persistent data structures (via rpds) provide efficient immutable collections.
For performance-critical workloads, consider:
- Writing hot paths in Rust as built-ins
- Avoiding deeply nested function calls
- Using tail recursion where possible
See `benchmarks/` for sample performance characteristics.
## Examples 🌟
### Hello World
```owa
(trace "Hello, OWA-RS!")
```
### Using Functions from Core
```owa
(def fib (lambda [n]
(if (>= n 2)
(+ (fib (- n 1)) (fib (- n 2)))
n)))
(trace (fib 10))
```
Run with: `./target/release/owa-rs run fib.owa`
### Working with Collections
```owa
(def numbers [1 2 3 4 5])
(trace (vec.len numbers))
(trace (vec.first numbers))
(def config {:name "OWA" :version "0.1.0"})
(trace (map.get config :name))
```
## Development 🛠️
### Running Tests
The core library has a built-in testing framework:
```bash
./target/release/owa-rs run --test modules/core/tests/main.owa
```
### Building Documentation
To understand the codebase:
- `src/core/interpreter.rs` - Main evaluation logic
- `src/builtins/` - Rust built-in implementations
- `modules/core/` - OWA standard library code
- `modules/owu/cli/` - Command-line interface implementation
## TODOs 📋
### High Priority
- [ ] Add proper error reporting with location information
- [ ] Improve debugging/tracing utilities
- [ ] Add REPL mode for interactive development
- [ ] Implement tail call optimization
### Medium Priority
- [ ] Pattern matching enhancements
- [x] Destructuring in set!/def
- [ ] More comprehensive std library
- [ ] Package manager / module repository
- [ ] Performance profiling tools
### Lower Priority
- [ ] Concurrency support
- [x] FFI (Foreign Function Interface)
- [ ] File system operations (fs module)
- [ ] Random number generation
- [ ] Compilation to bytecode
- [ ] Lint phase
### Completed
- [x] Annotations
- [x] Separate core and std libraries
- [x] CLI framework with commands
- [x] Testing framework
- [x] Error handling macros
## Contributing 🤝
This is a learning project exploring language design and implementation. Contributions are welcome!
Areas where help is appreciated:
- Standard library expansion
- Bug fixes and optimizations
- Documentation and examples
- Testing and benchmarking
## License 📄
Unlicense License - see LICENSE file.
---
Made with ❤️ and lots of ☕ as a Rust learning project!

17
benchmarks/bench.ps1 Normal file
View file

@ -0,0 +1,17 @@
$fileDirname = $PSScriptRoot
$owaCmd = ".\target\release\owa-rs.exe"
for ($i = 1; $i -le 1; $i++) {
$pythonFile = "$fileDirname/case$i/main.py"
$owaFile = "$fileDirname/case$i/main.owa"
$noOwuFile = "$fileDirname/case$i/no_owu.owa"
Write-Host "Running case #$i"
hyperfine `
--warmup 5 `
--min-runs 50 `
--show-output `
--export-markdown "$fileDirname/case$i/result.md" `
"python3.14 $pythonFile" `
"$owaCmd run $owaFile" `
"$owaCmd --no-owu $noOwuFile"
}

18
benchmarks/bench.sh Normal file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env sh
file_dirname="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
owa_cmd="./target/release/owa-rs"
for i in $(seq 1 1); do
python_file="${file_dirname}/case${i}/main.py"
owa_file="${file_dirname}/case${i}/main.owa"
no_owu_file="${file_dirname}/case${i}/no_owu.owa"
echo "Running case #${i}"
hyperfine \
--warmup 5 \
--min-runs 50 \
--show-output \
--export-markdown "${file_dirname}/case${i}/result.md" \
"python3 $python_file" \
"$owa_cmd run $owa_file" \
"$owa_cmd --no-owu $no_owu_file"
done

View file

@ -0,0 +1,8 @@
(seq
(def fib (lambda [n]
(match n
(0 0)
(1 1)
(_ (+ (this (- n 1)) (this (- n 2)))))))
(trace (fib 12))
)

9
benchmarks/case1/main.py Normal file
View file

@ -0,0 +1,9 @@
def fib(n):
if n == 0:
return 0
if n == 1:
return 1
return fib(n - 1) + fib(n - 2)
print(fib(12))

View file

@ -0,0 +1,8 @@
(builtins.seq
(builtins.def fib (builtins.lambda [n]
(builtins.cond.match n
(0 0)
(1 1)
(_ (builtins.math.add (this (builtins.math.sub n 1)) (this (builtins.math.sub n 2)))))))
(builtins.trace (fib 12))
)

View file

@ -0,0 +1,5 @@
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|:---|---:|---:|---:|---:|
| `python3.14 D:\Tools\Projects\owa-rs\benchmarks/case1/main.py` | 40.5 ± 0.7 | 39.5 | 43.1 | 2.38 ± 0.36 |
| `.\target\release\owa-rs.exe run D:\Tools\Projects\owa-rs\benchmarks/case1/main.owa` | 53.3 ± 1.2 | 51.5 | 57.6 | 3.14 ± 0.48 |
| `.\target\release\owa-rs.exe --no-owu D:\Tools\Projects\owa-rs\benchmarks/case1/no_owu.owa` | 17.0 ± 2.6 | 15.3 | 37.5 | 1.00 |

175
flake.lock generated Normal file
View file

@ -0,0 +1,175 @@
{
"nodes": {
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1776497206,
"narHash": "sha256-Em+RSdFnwyyKPGUBFtQYtVjm+1UvIc9gOR91Y22zlzg=",
"owner": "nix-community",
"repo": "fenix",
"rev": "df2295365fb081fe0745449762a771290782c22d",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"fenix_2": {
"inputs": {
"nixpkgs": [
"naersk",
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src_2"
},
"locked": {
"lastModified": 1752475459,
"narHash": "sha256-z6QEu4ZFuHiqdOPbYss4/Q8B0BFhacR8ts6jO/F/aOU=",
"owner": "nix-community",
"repo": "fenix",
"rev": "bf0d6f70f4c9a9cf8845f992105652173f4b617f",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"naersk": {
"inputs": {
"fenix": "fenix_2",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1776200608,
"narHash": "sha256-broZ6RFQr4Fv0wT73gGmzNX14A43TmTFF8g4wDKlNss=",
"owner": "nix-community",
"repo": "naersk",
"rev": "8b23250ab45c2a38cd91031aee26478ca4d0a28e",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "naersk",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1752077645,
"narHash": "sha256-HM791ZQtXV93xtCY+ZxG1REzhQenSQO020cu6rHtAPk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "be9e214982e20b8310878ac2baa063a961c1bdf6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1776169885,
"narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils",
"naersk": "naersk",
"nixpkgs": "nixpkgs_2"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1776441750,
"narHash": "sha256-1rVfG+mj8R4ze+lSYCa4iAv7FzrB03Cprtxmd1MfZak=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "251df518d73abb5c5d573c4d5d266a3edae9ca5a",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"rust-analyzer-src_2": {
"flake": false,
"locked": {
"lastModified": 1752428706,
"narHash": "sha256-EJcdxw3aXfP8Ex1Nm3s0awyH9egQvB2Gu+QEnJn2Sfg=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "591e3b7624be97e4443ea7b5542c191311aa141d",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

48
flake.nix Normal file
View file

@ -0,0 +1,48 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
naersk.url = "github:nix-community/naersk";
};
outputs = {
nixpkgs,
fenix,
flake-utils,
naersk,
...
}:
flake-utils.lib.eachDefaultSystem (
system: let
pkgs = (import nixpkgs) {
inherit system;
overlays = [fenix.overlays.default];
};
naersk' = pkgs.callPackage naersk {};
in {
defaultPackage = naersk'.buildPackage {
src = ./.;
};
devShell = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
hyperfine
python3
alejandra
rust-analyzer
(pkgs.fenix.stable.withComponents [
"cargo"
"clippy"
"rust-src"
"rustc"
"rustfmt"
])
];
};
}
);
}

17
modules/core/assert.owa Normal file
View file

@ -0,0 +1,17 @@
(namespace assert
(defmacro ok! [] (if (bool.and $%&)
:true
(throw! "assertion failed")))
(defmacro not! [] (if (bool.and $%&)
(throw! "assertion failed")
:true))
(defmacro eq! [] (if-eq $(%&)
:true
(throw! "assertion failed")))
(defmacro nq! [] (if-eq $(%&)
(throw! "assertion failed")
:true))
)

10
modules/core/ast.owa Normal file
View file

@ -0,0 +1,10 @@
(namespace ast
(defmacro pack! [v]
(builtins.ast.pack $v))
(defmacro value! [v]
(map.get (ast.pack! $v) :data))
(defmacro type! [v]
(map.get (ast.pack! $v) :type))
)

12
modules/core/branch.owa Normal file
View file

@ -0,0 +1,12 @@
(seq
;; builtins
(def if-eq builtins.cond.if_eq)
(def match builtins.cond.match)
(def if-has builtins.cond.if_has)
(defmacro if [cond then]
(if-eq (:false $cond) (seq $%&) (seq $then)))
(defmacro unless [cond then]
(if-eq (:false $cond) (seq $then) (seq $%&)))
)

25
modules/core/cmp.owa Normal file
View file

@ -0,0 +1,25 @@
(seq
(defmacro eq? <Bool>[]
(if-eq ($%%) :true :false))
(defmacro nq? <Bool>[]
(if-eq ($%%) :false :true))
(fn cmp <Keyword>[left right]
(match (builtins.cmp left right)
(-1 :less)
(0 :equal)
(1 :greater)))
(fn lt? <Bool>[left right]
(eq? (cmp left right) :less))
(fn lte? <Bool>[left right]
(bool.or (lt? left right) (eq? left right)))
(fn gt? <Bool>[left right]
(eq? (cmp left right) :greater))
(fn gte? <Bool>[left right]
(bool.or (gt? left right) (eq? left right)))
)

22
modules/core/error.owa Normal file
View file

@ -0,0 +1,22 @@
(seq
(fn throw! []
(throw.runtime_error! (str.join "" %%)))
(fn panic! [v]
(throw.panic! v))
(fn unreachable! []
(throw! "Reached unreachable code"))
(fn not_implemented! []
(throw! "Not implemented"))
(def try builtins.errors.try)
(defmacro catch [pattern then]
(lambda [msg type] (if-eq (type $pattern)
$then
:null)))
(namespace throw
(def panic! builtins.errors.throw_panic)
(def type_error! builtins.errors.throw_type_error)
(def arity_error! builtins.errors.throw_arity_error)
(def runtime_error! builtins.errors.throw_runtime_error)
))

32
modules/core/ffi.owa Normal file
View file

@ -0,0 +1,32 @@
(namespace ffi
(def unload builtins.ffi.unload)
(def ctypes [
:i8 :i16 :i32 :i64
:u8 :u16 :u32 :u64
:f32 :f64
:ptr
:str
])
(defmacro extern [name libname]
(namespace $name
(builtins.ffi.load $libname)
(fn call [symbol signature args]
(builtins.ffi.call $libname symbol signature args)
)
)
)
(defmacro native [name libname]
(namespace $name
(builtins.ffi.load $libname)
(defmacro fn [name symbol args ret]
(def '$name #(builtins.ffi.call $libname '$symbol ['$args '$ret] %&))
)
$%&
)
)
(extern ucrtbase "ucrtbase.dll")
)

8
modules/core/lambda.owa Normal file
View file

@ -0,0 +1,8 @@
(seq
;; builtins
(def apply builtins.apply)
(def lambda builtins.lambda)
(defmacro fn [name params body]
(def $name (lambda $params $body)))
)

23
modules/core/loop.owa Normal file
View file

@ -0,0 +1,23 @@
(seq
(def break builtins.flow.break)
(def continue builtins.flow.continue)
(def loop builtins.flow.loop)
(defmacro return []
(builtins.flow.return $%% null))
(defmacro while [cond] (loop
(if (eq? $cond :false) (break) :null)
$%&))
(defmacro do-while [cond] (loop
$%&
(if (eq? $cond :false) (break) :null)))
(defmacro for [var iter] (vec.map
(lambda [x] (seq
(def $var x)
$%&))
(vec.from $iter)
))
)

25
modules/core/main.owa Normal file
View file

@ -0,0 +1,25 @@
(builtins.seq
(builtins.def include builtins.include)
(include "scope")
(include "branch")
(include "lambda")
(def exec builtins.exec)
(def trace builtins.trace)
(def typeof builtins.typeof)
(fn identity [x] x)
(include "types")
(include "assert")
(include "ast")
(include "cmp")
(include "error")
(include "ffi")
(include "loop")
(include "math")
(include "test")
(test.space "core" (include "tests/main"))
)

14
modules/core/math.owa Normal file
View file

@ -0,0 +1,14 @@
(seq
(def + builtins.math.add)
(def - builtins.math.sub)
(def * builtins.math.mul)
(def / builtins.math.div)
(def % builtins.math.mod)
(def ** builtins.math.pow)
(fn ++ <Number>[<Number>x]
(+ x 1))
(fn -- <Number>[<Number>x]
(- x 1))
)

22
modules/core/scope.owa Normal file
View file

@ -0,0 +1,22 @@
(builtins.seq
;; builtins
(builtins.def def builtins.def)
(def lookup builtins.lookup)
(def set! builtins.set!)
(def scope builtins.scope)
;; macro
(def macro builtins.macro)
(def defmacro (macro [name params body]
(def $name (macro $params $body))))
;; flow
(defmacro seq []
(builtins.seq :null $%&))
(defmacro namespace [name] (def $name (scope $%&)))
;; other
(defmacro mut! [v fn] (seq
(set! (ast.value! $v) ($fn $v $%&)))
)
)

16
modules/core/test.owa Normal file
View file

@ -0,0 +1,16 @@
(namespace test
(def target (lookup __test__ null))
(defmacro if-target [then]
(if-eq (test.target null) :null (seq $%%)))
(defmacro case [name] (test.if-target
(builtins.errors.try
(scope $%& (trace "Running test \"" $name "\": OK ✅"))
#(trace "Running test \"" $name "\": FAILED ❌ (" %2 ": " %1 ")"))))
(defmacro space [name] (if
(bool.or (eq? test.target $name) (eq? test.target "."))
(namespace $name (seq $%&))
))
)

View file

@ -0,0 +1,38 @@
(namespace tests.bool
(test.case "bool.bool?"
(assert.ok! (bool? true))
(assert.ok! (bool? false))
(assert.not! (bool? 1))
)
(test.case "bool.not"
(assert.eq! (bool.not true) false)
(assert.eq! (bool.not false) true)
)
(test.case "bool.and"
(assert.ok! (bool.and true true))
(assert.not! (bool.and true false))
(assert.not! (bool.and false true))
(assert.not! (bool.and false false))
)
(test.case "bool.or"
(assert.ok! (bool.or true true))
(assert.ok! (bool.or true false))
(assert.ok! (bool.or false true))
(assert.not! (bool.or false false))
)
(test.case "bool.nand"
(assert.not! (bool.nand true true))
(assert.ok! (bool.nand true false))
(assert.ok! (bool.nand false true))
(assert.ok! (bool.nand false false))
)
(test.case "bool.new"
(assert.eq! (bool.new true) true)
(assert.eq! (bool.new false) false)
)
)

View file

@ -0,0 +1,41 @@
(namespace tests.cmp
(test.case "cmp.eq?"
(assert.ok! (eq? 1 1))
(assert.not! (eq? 1 2))
)
(test.case "cmp.nq?"
(assert.ok! (nq? 1 2))
(assert.not! (nq? 1 1))
)
(test.case "cmp.cmp"
(assert.eq! (cmp 1 2) :less)
(assert.eq! (cmp 2 1) :greater)
(assert.eq! (cmp 1 1) :equal)
)
(test.case "cmp.lt?"
(assert.ok! (lt? 1 2))
(assert.not! (lt? 2 1))
(assert.not! (lt? 1 1))
)
(test.case "cmp.lte?"
(assert.ok! (lte? 1 2))
(assert.ok! (lte? 1 1))
(assert.not! (lte? 2 1))
)
(test.case "cmp.gt?"
(assert.ok! (gt? 2 1))
(assert.not! (gt? 1 2))
(assert.not! (gt? 1 1))
)
(test.case "cmp.gte?"
(assert.ok! (gte? 2 1))
(assert.ok! (gte? 1 1))
(assert.not! (gte? 1 2))
)
)

View file

@ -0,0 +1,42 @@
(namespace tests.loop
(test.case "while.basic-true"
(def counter 0)
(while (eq? counter 0)
(set! counter 1))
(assert.eq! counter 1)
)
(test.case "while.condition-false"
(def counter 5)
(while (lt? counter 0)
(set! counter (+ counter 1)))
(assert.eq! counter 5)
)
(test.case "while.counting-loop"
(def n 0)
(while (lt? n 10)
(set! n (+ n 1)))
(assert.eq! n 10)
)
(test.case "while.loop-condition-reached"
(def i 0)
(while (lt? i 5)
(set! i (+ i 1)))
(assert.eq! i 5)
)
(test.case "while.nested"
(def outer 0)
(def inner 0)
(def total 0)
(while (lt? outer 3)
(set! inner 0)
(while (lt? inner 2)
(set! total (+ total 1))
(set! inner (+ inner 1)))
(set! outer (+ outer 1)))
(assert.eq! total 6)
)
)

View file

@ -0,0 +1,14 @@
(seq
(include "vec")
(include "str")
(include "set")
(include "map")
(include "bool")
(include "option")
(include "scope")
(include "null")
(include "cmp")
(include "math")
(include "types")
(include "loop")
)

View file

@ -0,0 +1,83 @@
(namespace tests.map
(test.case "map.len"
(assert.eq! (map.len (map.new)) 0)
)
(test.case "map.empty?"
(assert.ok! (map.empty? (map.new)))
)
(test.case "map.is-empty?"
(assert.ok! (map.is-empty? (map.new)))
)
(test.case "map.get"
(seq
(def m (map.new :a 1 :b 2))
(assert.eq! (map.get m :a) 1)
(assert.eq! (map.get m :b) 2)
(assert.eq! (map.get m :c) null)
)
)
(test.case "map.put"
(seq
(def m (map.new :a 1))
(def m2 (map.put m :b 2))
(assert.eq! (map.len m2) 2)
(assert.eq! (map.get m2 :b) 2)
)
)
(test.case "map.has-key?"
(seq
(def m (map.new :a 1 :b 2))
(assert.ok! (map.has-key? m :a))
(assert.not! (map.has-key? m :c))
)
)
(test.case "map.has-value?"
(seq
(def m (map.new :a 1 :b 2))
(assert.ok! (map.has-value? m 1))
(assert.not! (map.has-value? m 5))
)
)
(test.case "map.keys"
(seq
(def m (map.new :a 1 :b 2))
(def ks (map.keys m))
(assert.eq! (vec.len ks) 2)
)
)
(test.case "map.values"
(seq
(def m (map.new :a 1 :b 2))
(def vs (map.values m))
(assert.eq! (vec.len vs) 2)
)
)
(test.case "map.merge"
(seq
(def m1 (map.new :a 1 :b 2))
(def m2 (map.new :b 20 :c 3))
(def m3 (map.merge m1 m2))
(assert.eq! (map.get m3 :a) 1)
(assert.eq! (map.get m3 :b) 20)
(assert.eq! (map.get m3 :c) 3)
)
)
(test.case "map.dissoc"
(seq
(def m (map.new :a 1 :b 2 :c 3))
(def m2 (map.dissoc m :b))
(assert.eq! (map.len m2) 2)
(assert.not! (map.has-key? m2 :b))
)
)
)

View file

@ -0,0 +1,40 @@
(namespace tests.math
(test.case "math.add"
(assert.eq! (+ 1 2) 3)
(assert.eq! (+ 0 0) 0)
)
(test.case "math.sub"
(assert.eq! (- 5 3) 2)
(assert.eq! (- 1 1) 0)
)
(test.case "math.mul"
(assert.eq! (* 3 4) 12)
(assert.eq! (* 0 5) 0)
)
(test.case "math.div"
(assert.eq! (/ 10 2) 5)
)
(test.case "math.mod"
(assert.eq! (% 10 3) 1)
(assert.eq! (% 5 5) 0)
)
(test.case "math.pow"
(assert.eq! (** 2 3) 8)
(assert.eq! (** 5 2) 25)
)
(test.case "math.++"
(assert.eq! (++ 5) 6)
(assert.eq! (++ 0) 1)
)
(test.case "math.--"
(assert.eq! (-- 5) 4)
(assert.eq! (-- 0) -1)
)
)

View file

@ -0,0 +1,8 @@
(namespace tests.null
(test.case "null.null?"
(assert.ok! (null? null))
(assert.ok! (null? :nil))
(assert.not! (null? 0))
(assert.not! (null? false))
)
)

View file

@ -0,0 +1,33 @@
(namespace tests.option
(test.case "option.ok"
(seq
(def opt (option.ok 42))
(assert.ok! (option.ok? opt))
(assert.eq! (vec.get opt 1) 42)
)
)
(test.case "option.err"
(seq
(def opt (option.err "error"))
(assert.ok! (option.err? opt))
(assert.eq! (vec.get opt 1) "error")
)
)
(test.case "option.ok?"
(assert.ok! (option.ok? (option.ok 1)))
(assert.not! (option.ok? (option.err "x")))
)
(test.case "option.err?"
(assert.ok! (option.err? (option.err "x")))
(assert.not! (option.err? (option.ok 1)))
)
(test.case "option.option?"
(assert.ok! (option? (option.ok 1)))
(assert.ok! (option? (option.err "x")))
(assert.not! (option? [1 2]))
)
)

View file

@ -0,0 +1,78 @@
(namespace tests.scopes
(test.case "def"
(def x 1)
(assert.eq! x 1)
(def [y z] [1 2])
(assert.eq! y 1)
(assert.eq! z 2)
)
(test.case "def bounded"
(def x 1)
(try
(def x 2)
(catch :Panic (return))
)
(unreachable!)
)
(test.case "set!"
(def [x y] [1 1])
(set! x 2)
(assert.eq! x 2)
(set! [x y] [3 4])
(assert.eq! x 3)
(assert.eq! y 4)
)
(test.case "set! unbounded"
(try
(set! x 2)
(catch :UnboundVariable (return))
)
(unreachable!)
)
(test.case "lookup"
(def x 1)
(assert.eq! (lookup x null) 1)
(assert.eq! (lookup y null) null)
)
(test.case "inheritance"
(scope
(def x 1)
(scope
(assert.eq! x 1)
(set! x 2)
)
(assert.eq! x 2)
)
)
(test.case "shadowing"
(scope
(def x 100)
(scope
(def x 1)
)
(assert.eq! x 100)
)
(assert.eq! (lookup x null) null)
)
(test.case "leakage"
(scope (def x 1))
(assert.eq! (lookup x null) null)
(scope (def y 2))
(assert.eq! (lookup y null) null)
(scope
(scope (def z 1))
(assert.eq! (lookup z null) null)
)
)
)

View file

@ -0,0 +1,44 @@
(namespace tests.set
(test.case "set.empty?"
(assert.ok! (set.empty? (set.new)))
(assert.not! (set.empty? (set.new 1)))
)
(test.case "set.len"
(assert.eq! (set.len (set.new)) 0)
(assert.eq! (set.len (set.new 1)) 1)
(assert.eq! (set.len (set.new 1 2 3)) 3)
)
(test.case "set.has"
(assert.ok! (set.has (set.new 1 2 3) 2))
(assert.not! (set.has (set.new 1 2 3) 5))
)
(test.case "set.add"
(assert.ok! (set.has (set.add (set.new 1 2) 3) 3))
(assert.eq! (set.len (set.add (set.new 1 2) 3)) 3)
)
(test.case "set.remove"
(assert.not! (set.has (set.remove (set.new 1 2 3) 2) 2))
(assert.eq! (set.len (set.remove (set.new 1 2 3) 2)) 2)
)
(test.case "set.union"
(assert.eq! (set.len (set.union (set.new 1 2) (set.new 2 3))) 3)
(assert.ok! (set.has (set.union (set.new 1 2) (set.new 2 3)) 1))
(assert.ok! (set.has (set.union (set.new 1 2) (set.new 2 3)) 3))
)
(test.case "set.intersection"
(assert.eq! (set.len (set.intersection (set.new 1 2 3) (set.new 2 3 4))) 2)
(assert.ok! (set.has (set.intersection (set.new 1 2 3) (set.new 2 3 4)) 2))
(assert.ok! (set.has (set.intersection (set.new 1 2 3) (set.new 2 3 4)) 3))
)
(test.case "set.difference"
(assert.eq! (set.len (set.difference (set.new 1 2 3) (set.new 2 3))) 1)
(assert.ok! (set.has (set.difference (set.new 1 2 3) (set.new 2 3)) 1))
)
)

View file

@ -0,0 +1,67 @@
(namespace tests.str
(test.case "str.len"
(assert.eq! (str.len "") 0)
(assert.eq! (str.len "hello") 5)
)
(test.case "str.empty?"
(assert.ok! (str.empty? ""))
(assert.not! (str.empty? "a"))
)
(test.case "str.is-empty?"
(assert.ok! (str.is-empty? ""))
(assert.not! (str.is-empty? "a"))
)
(test.case "str.first"
(assert.eq! (str.first "hello") "h")
)
(test.case "str.last"
(assert.eq! (str.last "hello") "o")
)
(test.case "str.concat"
(assert.eq! (str.concat "hello" " " "world") "hello world")
)
(test.case "str.substring"
(assert.eq! (str.substring "hello" 0 3) "hel")
(assert.eq! (str.substring "hello" 1 4) "ell")
)
(test.case "str.take"
(assert.eq! (str.take "hello" 3) "hel")
)
(test.case "str.drop"
(assert.eq! (str.drop "hello" 2) "llo")
)
(test.case "str.reverse"
(assert.eq! (str.reverse "hello") "olleh")
)
(test.case "str.starts-with?"
(assert.ok! (str.starts-with? "hello" "hel"))
(assert.not! (str.starts-with? "hello" "xyz"))
)
(test.case "str.ends-with?"
(assert.ok! (str.ends-with? "hello" "lo"))
(assert.not! (str.ends-with? "hello" "xyz"))
)
(test.case "str.split"
(assert.eq! (str.split "hello world" " ") ["hello" "world"])
(assert.eq! (str.split "hello world" "w") ["hello " "orld"])
(assert.eq! (str.split "hello world" "x") ["hello world"])
)
(test.case "str.join"
(assert.eq! (str.join " " ["hello" "world"]) "hello world")
(assert.eq! (str.join "w" ["hello " "orld"]) "hello world")
(assert.eq! (str.join "x" ["hello world"]) "hello world")
)
)

View file

@ -0,0 +1,53 @@
(namespace tests.types
(test.case "types.is?"
(assert.ok! (is? 42 :int))
(assert.ok! (is? "hello" :str))
(assert.ok! (is? [] :vec))
(assert.not! (is? 42 :str))
)
(test.case "types.guard"
(seq
(def int-guard (guard :int))
(assert.ok! (int-guard 42))
(assert.not! (int-guard "42"))
)
)
(test.case "types.kw?"
(assert.ok! (kw? :keyword))
(assert.not! (kw? "not-keyword"))
)
(test.case "types.type?"
(assert.ok! (type? :int))
(assert.ok! (type? :str))
(assert.not! (type? :unknown))
)
(test.case "types.str?"
(assert.ok! (str? "hello"))
(assert.not! (str? 42))
)
(test.case "types.vec?"
(assert.ok! (vec? []))
(assert.ok! (vec? [1 2 3]))
(assert.not! (vec? 42))
)
(test.case "types.bool?"
(assert.ok! (bool? true))
(assert.ok! (bool? false))
(assert.not! (bool? 1))
)
(test.case "types.null?"
(assert.ok! (null? null))
(assert.not! (null? 0))
)
(test.case "types.map.is?"
(assert.ok! (map.is? (map.new)))
)
)

View file

@ -0,0 +1,91 @@
(namespace tests.vec
(test.case "vec.empty?"
(assert.ok! (vec.empty? []))
(assert.not! (vec.empty? [1]))
)
(test.case "vec.len"
(assert.eq! (vec.len []) 0)
(assert.eq! (vec.len [1]) 1)
(assert.eq! (vec.len [1 2 3]) 3)
)
(test.case "vec.first"
(assert.eq! (vec.first [1 2 3]) 1)
(assert.eq! (vec.first ["a"]) "a")
)
(test.case "vec.last"
(assert.eq! (vec.last [1 2 3]) 3)
(assert.eq! (vec.last ["x"]) "x")
)
(test.case "vec.get"
(assert.eq! (vec.get [10 20 30] 0) 10)
(assert.eq! (vec.get [10 20 30] 1) 20)
(assert.eq! (vec.get [10 20 30] 2) 30)
)
(test.case "vec.map"
(assert.eq!
(vec.map (lambda [x _] (+ x 1)) [1 2 3])
[2 3 4])
)
(test.case "vec.filter"
(assert.eq!
(vec.filter (lambda [x _] (gt? x 1)) [1 2 3])
[2 3])
)
(test.case "vec.has"
(assert.ok! (vec.has [1 2 3] 2))
(assert.not! (vec.has [1 2 3] 5))
)
(test.case "vec.includes?"
(assert.ok! (vec.includes? [1 2 3] 2))
(assert.not! (vec.includes? [1 2 3] 5))
)
(test.case "vec.find"
(assert.eq!
(vec.find (lambda [x _] (gt? x 2)) [1 2 3 4])
3)
)
(test.case "vec.reverse"
(assert.eq! (vec.reverse [1 2 3]) [3 2 1])
)
(test.case "vec.take"
(assert.eq! (vec.take [1 2 3 4 5] 3) [1 2 3])
)
(test.case "vec.drop"
(assert.eq! (vec.drop [1 2 3 4 5] 2) [3 4 5])
)
(test.case "vec.uniq"
(assert.eq! (vec.uniq [1 2 2 3 3 3]) [1 2 3])
)
(test.case "vec.every?"
(assert.ok! (vec.every? (lambda [x _] (gt? x 0)) [1 2 3]))
(assert.not! (vec.every? (lambda [x _] (gt? x 1)) [1 2 3]))
)
(test.case "vec.some?"
(assert.ok! (vec.some? (lambda [x _] (gt? x 2)) [1 2 3]))
(assert.not! (vec.some? (lambda [x _] (gt? x 5)) [1 2 3]))
)
(test.case "vec.count"
(assert.eq! (vec.count (lambda [x _] (gt? x 1)) [1 2 3]) 2)
)
(test.case "vec.sum"
(assert.eq! (vec.sum [1 2 3]) 6)
(assert.eq! (vec.sum []) 0)
)
)

View file

@ -0,0 +1,23 @@
(seq
(def true :true)
(def false :false)
(fn bool? [x]
(bool.or (eq? x true) (eq? x false)))
(namespace bool
(fn new [x]
(eq? x true))
(def __call__ new)
(defmacro or []
(if-eq (false $%%) false true))
(defmacro and []
(if-has false ($%%) false true))
(defmacro nand []
(if-has false ($%%) true false))
(fn not [x]
(if-eq (x true) false true))
))

View file

@ -0,0 +1,39 @@
(seq
(def types [
:symbol
:keyword
:str
:float
:int
:vec
:call
:set
:map
:quote
:lambda
:macro
])
(fn is? [x type]
(eq? (typeof x) type))
(fn guard [type]
(lambda [x] (is? x type)))
(def kw? (guard :keyword))
(def kw builtins.str.kw)
(include "bool")
(include "map")
(include "null")
(include "option")
(include "set")
(include "str")
(include "struct")
(include "vec")
(def vec? (guard :vec))
(def map? (guard :map))
(fn type? [x]
(vec.has types x))
)

View file

@ -0,0 +1,94 @@
(namespace map
(def type :map)
(def is? (guard type))
(def new builtins.map.new)
(def __call__ new)
(fn keys [m]
(vec.map #(vec.first %1) (vec.from m)))
(fn values [m]
(vec.map #(vec.last %1) (vec.from m)))
(fn len [m]
(vec.len (vec.from m)))
(fn empty? [m]
(eq? (len m) 0))
(fn is-empty? [m]
(empty? m))
(fn get [m key]
(vec.fold
(lambda [result pair _]
(if (eq? (vec.get pair 0) key)
(vec.get pair 1)
result))
null
(vec.from m)))
(fn put [m key value]
(apply apply new (vec.from m) [[key value]]))
(fn assoc [m key value]
(put m key value))
(fn dissoc [m key]
(apply apply new (vec.filter
(lambda [pair _] (nq?
(vec.get pair 0)
key
))
(vec.from m))
))
(fn has-key? [m key]
(if (null? (get m key))
false
true))
(fn has-value? [m value]
(vec.fold
(lambda [result pair _]
(seq
(def k (vec.get pair 0))
(def v (vec.get pair 1))
(bool.or result (eq? v value))))
false
(vec.from m)))
(fn map [callback m]
(apply new (vec.apply-flat (vec.map
(lambda [pair _]
(seq
(def k (vec.get pair 0))
(def v (vec.get pair 1))
(def new-v (callback v k))
[k new-v]))
(vec.from m)))))
(fn filter [callback m]
(apply new (vec.apply-flat (vec.filter
(lambda [pair _]
(seq
(def k (vec.get pair 0))
(def v (vec.get pair 1))
(callback v k)))
(vec.from m)))))
(fn merge [m1 m2]
(apply apply new (vec.from m1) (vec.from m2)))
(fn merge-all []
(vec.fold
(lambda [acc m _]
(merge acc m))
(new)
%%))
(fn conj [m other]
(if (map.is? other)
(merge m other)
m))
)

View file

@ -0,0 +1,5 @@
(seq
(def null :null)
(fn null? [x]
(bool.or (eq? x null) (eq? x :nil)))
)

View file

@ -0,0 +1,26 @@
(seq
(namespace option
(fn ok <Option>[value]
(vec :ok value))
(fn ok? <Bool>[x]
(bool.and
(vec? x)
(eq? (vec.first x) :ok)))
(fn err <Option>[value]
(vec :err value))
(fn err? <Bool>[x]
(bool.and
(vec? x)
(eq? (vec.first x) :err)))
)
(fn option? [x]
(bool.and
(vec? x)
(bool.or
(eq? (vec.first x) :err)
(eq? (vec.first x) :ok))))
)

View file

@ -0,0 +1,60 @@
(seq
(namespace set
(def type :set)
(def is? (guard type))
(fn new [] (apply from %%))
(def __call__ new)
(def fold builtins.set.fold)
(def from builtins.set.from)
(def empty #{})
(fn empty? <Bool>[s]
(eq? s empty))
(fn len <Int>[<Set>s]
(fold (lambda [acc _] (+ acc 1)) 0 s))
(fn has <Bool>[<Set>s item]
(fold
(lambda [acc elem]
(bool.or acc (eq? elem item)))
false
s))
(fn add <Set>[<Set>s item]
(from s [item]))
(fn remove <Set>[<Set>s item]
(fold
(lambda [acc elem]
(if (eq? elem item)
acc
(from acc elem)))
#{}
s))
(fn union <Set>[]
(apply from %%))
(fn intersection <Set>[<Set>s1 <Set>s2]
(fold
(lambda [acc elem]
(if (has s2 elem)
(add acc elem)
acc))
#{}
s1))
(fn difference <Set>[<Set>s1 <Set>s2]
(fold
(lambda [acc elem]
(if (has s2 elem)
acc
(add acc elem)))
#{}
s1))
)
(def set? (guard set.type))
)

View file

@ -0,0 +1,76 @@
(seq
(namespace str
(def type :str)
(def new builtins.str.new)
(def __call__ new)
(fn len <Int>[<Str>s]
(vec.len (vec.from s)))
(fn concat <Str>[]
(apply new %%))
(fn empty? <Bool>[<Str>s]
(eq? s ""))
(fn is-empty? <Bool>[<Str>s]
(empty? s))
(fn chars-at <Str>[<Str>s <Int>idx]
(vec.get (vec.from s) idx))
(fn first <Str>[<Str>s]
(chars-at s 0))
(fn last <Str>[<Str>s]
(chars-at s (-- (len s))))
(fn substring <Str>[<Str>s <Int>start <Int>end]
(apply concat (vec.slice (vec.from s) start end)))
(fn substr <Str>[<Str>s <Int>start <Int>len-sub]
(substring s start (+ start len-sub)))
(fn take <Str>[<Str>s <Int>n]
(apply concat (vec.take (vec.from s) n)))
(fn drop <Str>[<Str>s <Int>n]
(apply concat (vec.drop (vec.from s) n)))
(fn reverse <Str>[<Str>s]
(apply concat (vec.reverse (vec.from s))))
(fn starts-with? <Bool>[<Str>s <Str>prefix]
(eq? (substring s 0 (len prefix)) prefix))
(fn ends-with? <Bool>[<Str>s <Str>suffix]
(eq? (substring s (- (len s) (len suffix)) (len s)) suffix))
(fn split <Vec>[<Str>s <Str>sep] (seq
(vec.fold
(lambda [acc elem _]
(if-eq (elem sep)
(vec.conj acc "")
(vec.conj (vec.take acc (- (len acc) 1))
(str.concat (vec.last acc) elem)
)
))
[""]
(vec.from s)
)))
(fn join <Str>[<Str>sep <Vec>strs]
(if (vec.empty? strs)
""
(vec.fold
(lambda [acc elem _]
(new acc sep elem))
(vec.first strs)
(vec.tail strs))))
(fn map <Str>[callback self]
(join "" (vec.map callback (vec.from self))))
)
(def str? (guard str.type))
)

View file

@ -0,0 +1,22 @@
(seq
(defmacro struct [name fields] (seq
(for field $fields
(if (bool.not (kw? %1))
(throw! "Struct fields must be keywords"))
)
(def $name {
:__struct_type__ true
:new (lambda [data] (vec.map
#(if (map.has-key? data %1)
map.get data %1
(throw! "Missing field: " %1)
)
$fields))
})
))
(fn struct-type? [v]
(if (bool.and (map? v) (map.get v :__struct_type__))
true
false))
)

301
modules/core/types/vec.owa Normal file
View file

@ -0,0 +1,301 @@
(namespace vec
(def type :vec)
(def is? (guard type))
(def new builtins.vec.new)
(def __call__ new)
(def fold builtins.vec.fold)
(def from builtins.vec.from)
;; Empty vector
(def empty [])
;; Guard to check if a value is a empty vector
(fn empty? [<Vec>v]
(eq? v empty))
;; Get the count of items in a vector
(fn len [<Vec>v]
(fold (lambda [acc _] (+ acc 1)) 0 v))
;; Append an item to the end of a vector
(fn conj [<Vec>v item]
(apply new v [item]))
;; Check if a vector contains an item
(fn has [<Vec>v item]
(fold
(lambda [acc elem _]
(bool.or acc (eq? elem item)))
false
v))
;; Get an item from a vector by index
(fn get [<Vec>v idx]
(fold
(lambda [result elem index]
(if (eq? index idx)
elem
result))
null
v))
;; Update an item in a vector
;; If the index is out of bounds, the original vector is returned
(fn assoc [<Vec>v <Int>index item]
(fold
(lambda [acc elem idx]
(if (eq? idx index)
(conj acc item)
(conj acc elem)))
[]
v))
;; Remove an item from a vector by index
;; If the index is out of bounds, the original vector is returned
(fn remove [<Vec>v <Int>index]
(filter
(lambda [elem idx]
(nq? idx index))
v))
;; Reverse a vector
(fn reverse [<Vec>v]
(fold
(lambda [acc elem _]
(apply merge elem acc))
[]
v))
;; Flatten a vector of vectors into a single vector
(fn flat [<Vec>v]
(fold
(lambda [acc elem _]
(apply conj [acc elem]))
[]
v))
;; Get the first item in a vector
(fn first [<Vec>v]
(get v 0))
;; Get the last item in a vector
(fn last [<Vec>v]
(get v (-- (len v))))
;; Get all items except the first
(fn tail [<Vec>v]
(filter
(lambda [elem idx]
(nq? idx 0))
v))
;; Get the first n items from a vector
(fn take [<Vec>v <Int>n]
(filter
(lambda [elem idx]
(lt? idx n))
v))
;; Get all items except the first n
(fn drop [<Vec>v n]
(filter
(lambda [elem idx]
(gte? idx n))
v))
;; Get all items except the last
(fn drop-last [<Vec>v]
(filter
(lambda [elem idx]
(nq? idx (-- (len v))))
v))
(fn enumerate [<Vec>v]
(map
(lambda [elem idx]
(new [idx elem]))
v))
(fn zip [v1 v2]
(map
(lambda [elem idx]
(new elem (get v2 idx)))
v1))
(fn slice [<Vec>v start end]
(filter
(lambda [elem idx]
(bool.and (gte? idx start) (lt? idx end)))
v))
(fn includes? [<Vec>v item]
(has v item))
(fn index-of [<Vec>v item]
(fold
(lambda [result elem idx]
(if (null? result)
(if (eq? elem item)
idx
result)
result))
null
v))
(fn join-str [sep v]
(if (empty? v)
""
(fold
(lambda [acc elem _]
(str.new acc sep elem))
(first v)
(tail v))))
;; Map a callback to each item in a vector
(fn map [callback <Vec>v]
(fold
(lambda [acc elem index]
(apply conj [acc (callback elem index)]))
[]
v))
;; Filter a vector by a callback
(fn filter [callback <Vec>v]
(fold
(lambda [acc elem index]
(if (callback elem index)
(apply conj [acc elem])
acc))
[]
v))
;; Find the first item that matches the callback
(fn find [callback <Vec>v]
(fold
(lambda [result elem index]
(if (null? result)
(if (callback elem index)
elem
result)
result))
null
v))
;; Check if every item in a vector matches the callback
;; TODO: Break at first non-match for better performance
(fn every? <Bool>[callback <Vec>v]
(fold
(lambda [result elem idx]
(bool.and result (callback elem idx)))
true
v))
;; Check if any item in a vector matches the callback
;; TODO: Break at first match for better performance
(fn some? <Bool>[callback <Vec>v]
(fold
(lambda [result elem idx]
(bool.or result (callback elem idx)))
false
v))
;; Reduce a vector into a single value
(fn reduce <Bool>[callback initial <Vec>v]
(fold callback initial v))
;; Count the number of items that match the callback
(fn count [callback v]
(fold
(lambda [count elem idx]
(if (callback elem idx)
(+ count 1)
count))
0
v))
;; Merge all vectors into a single vector
(fn merge []
(fold
(lambda [acc elem _]
(apply conj [acc elem]))
[]
%%))
(fn max-by [comparator v]
(fold
(lambda [current elem _]
(if (null? current)
elem
(if (eq? (comparator elem current) :greater)
elem
current)))
null
v))
(fn min-by [comparator v]
(fold
(lambda [current elem _]
(if (null? current)
elem
(if (eq? (comparator elem current) :less)
elem
current)))
null
v))
(fn uniq [<Vec>v]
(fold
(lambda [result elem _]
(if (has result elem)
result
(conj result elem)))
[]
v))
(fn partition [callback v]
(fold
(lambda [result elem idx]
(seq
(def group (if (callback elem idx) 0 1))
(def current-part (get result group))
(assoc result group (conj current-part elem))))
[[] []]
v))
(fn group-by [key-fn v]
(fold
(lambda [result elem idx]
(seq
(def key (key-fn elem idx))
(def group (map.get result key))
(def new-group (if (null? group) [] group))
(map.put result key (conj new-group elem))))
(map.new)
v))
(fn flatten [<Vec>v]
(fold
(lambda [result elem _]
(if (vec.is? elem)
(conj result (flatten elem))
(conj result elem)))
[]
v))
(fn distinct [<Vec>v]
(uniq v))
(fn compact [<Vec>v]
(filter
(lambda [elem _]
(not (null? elem)))
v))
(fn sum [<Vec>v]
(fold
(lambda [acc elem _]
(+ acc elem))
0
v))
)

19
modules/owu/cli/main.owa Normal file
View file

@ -0,0 +1,19 @@
(seq
(def cmd (vec.first argv))
(def args (vec.tail argv))
(if (bool.or (null? cmd) (str.starts-with? cmd "-")) (seq
(trace "Error: No command specified")
(return 1)
))
(try
(match cmd
("run" (include "run"))
(_ (trace "Error: Command '" cmd "' not found"))
)
(lambda [msg type] (seq
(trace type ": " msg)
(return 1)))
)
)

43
modules/owu/cli/run.owa Normal file
View file

@ -0,0 +1,43 @@
(seq
(def target (vec.find #(bool.not (str.starts-with? %1 "-")) (vec.reverse args)))
(if (null? target) (seq
(trace "No target specified")
(return)
))
(fn parse-arg [args name] (seq
(def idx (vec.index-of args name))
(if (null? idx)
null
(vec.get args (+ idx 1))
)
))
(def no-builtins? (vec.has args "--no-builtins"))
(def no-core? (vec.has args "--no-core"))
(def no-std? (vec.has args "--no-std"))
(def is-debug? (vec.has args "--debug"))
(def test-target (parse-arg args "--test"))
(exec
{
:__runtime__ :owu
:__dir__ ""
:__main__ target
:__test__ test-target
}
(seq
(unless no-builtins?
(def builtins builtins))
(unless (bool.or no-builtins? no-core?)
(include (str.concat modules-dir "/core")))
(unless (bool.or no-builtins? no-core? no-std?)
(include (str.concat modules-dir "/std")))
(def __is_debug__ is-debug?)
)
target
)
)

15
modules/owu/main.owa Normal file
View file

@ -0,0 +1,15 @@
(builtins.seq
(builtins.include "../core")
(builtins.include "../std")
(namespace owu
(def argv (platform.args))
(def modules-dir (str.concat __dir__ "/.."))
(if (eq? (vec.len argv) 0) (seq
(return 1)
))
(include "cli")
(return 0)
)
)

6
modules/std/main.owa Normal file
View file

@ -0,0 +1,6 @@
(seq
(include "platform")
(include "path")
(test.space "std" (include "tests/main"))
)

222
modules/std/path.owa Normal file
View file

@ -0,0 +1,222 @@
(namespace path
;; Platform-specific path separator.
(def sep (match platform.os-family
(:windows "\\")
(_ "/")
))
;; Internal: converts "/" to "\" on Windows; no-op on other platforms.
(fn norm-seps <Str>[<Str>p]
(match platform.os-family
(:windows (str.map (lambda [c _] (if (eq? c "/") "\\" c)) p))
(_ p)))
;; Returns the root prefix of a path.
;;
;; Unix — "/" for paths starting with "/", "" for relative.
;; Windows — "\" for UNC/rooted paths ("\" or "/" prefix)
;; "X:\" for drive-letter absolute paths ("X:\" or "X:/")
;; "" for all relative paths ("X:path" is relative on Windows)
(fn root <Str>[<Str>p]
(match platform.os-family
(:windows
(if (bool.or (str.starts-with? p "\\") (str.starts-with? p "/"))
"\\"
(if (bool.and (gte? (str.len p) 3)
(bool.and (eq? (str.chars-at p 1) ":")
(bool.or (eq? (str.chars-at p 2) "\\")
(eq? (str.chars-at p 2) "/"))))
(str.concat (str.take p 2) "\\")
"")))
(_ (if (str.starts-with? p "/") "/" ""))
))
;; Returns true if the path is absolute (has a non-empty root prefix).
(fn absolute? <Bool>[<Str>p]
(bool.not (str.empty? (root p))))
;; Returns true if the path is relative (has no root prefix).
(fn relative? <Bool>[<Str>p]
(bool.not (absolute? p)))
;; Splits a path into its string components using the platform separator.
;; On Windows "/" is also accepted as a separator.
;; The root prefix (if any) is preserved as the first component so that
;; split + join is a round-trip operation.
(fn split <Vec>[<Str>p]
(if (str.empty? p)
[]
(str.split (norm-seps p) sep)))
;; Joins path components with the platform separator.
(fn join <Str>[<Vec>p]
(str.join sep p))
;; Removes "." components, resolves ".." components, and returns the
;; canonical form of the path using the platform separator.
;;
;; Bug fixes vs. previous version:
;; • absolute paths that collapse entirely return the root (e.g. "/" not "")
;; • Windows drive-letter paths no longer get a spurious leading "\"
(fn normalize <Str>[<Str>p]
(seq
(def r (root p))
;; Work only on the non-root body so that root components never
;; participate in ".." resolution.
(def body (norm-seps (str.drop p (str.len r))))
(def parts (if (str.empty? body) [] (str.split body sep)))
(def normalized [])
(for part parts
(if (bool.or (eq? part ".") (str.empty? part)) (continue))
(set! normalized (if (eq? part "..")
(vec.drop-last normalized)
(vec.conj normalized part))))
(def result (join normalized))
(if (str.empty? r)
result
(if (str.empty? result)
r
(str.concat r result)))))
;; Returns the parent directory of the path.
;; • Paths directly under the root return the root string.
;; • Top-level relative paths (e.g. "a") return "".
;;
;; Bug fix vs. previous version: returns sep-based root, not hardcoded "/".
(fn parent <Str>[<Str>p] (seq
(def norm (normalize p))
(def r (root norm))
(def body (str.drop norm (str.len r)))
(def parts (if (str.empty? body)
[]
(vec.filter
(lambda [part _] (bool.not (str.empty? part)))
(str.split body sep))))
(def parent-parts (vec.drop-last parts))
(if (vec.empty? parent-parts)
r
(str.concat r (join parent-parts)))))
;; Returns the last component of the path (filename or final directory name).
;; Returns "" for paths that end with a separator.
(fn basename <Str>[<Str>p] (seq
(def parts (split p))
(if (vec.empty? parts) "" (vec.last parts))))
;; Returns the file extension including the leading dot, or "" if none.
;;
;; Rules:
;; • Hidden files whose name starts with "." have no extension.
;; • A trailing dot (e.g. "file.") is not considered an extension.
;; • Only the last dot in the basename is used (e.g. "a.tar.gz" → ".gz").
;;
;; Examples:
;; "file.txt" → ".txt"
;; "archive.tar.gz" → ".gz"
;; "file" → ""
;; ".hidden" → ""
;; "file." → ""
(fn ext <Str>[<Str>p] (seq
(def base (basename p))
(def dot-idx (vec.fold
(lambda [acc elem idx]
(if (eq? elem ".") idx acc))
-1
(vec.from base)))
(if (bool.or (lte? dot-idx 0) (eq? dot-idx (-- (str.len base))))
""
(str.drop base dot-idx))))
;; Returns the filename without its extension.
;;
;; Examples:
;; "file.txt" → "file"
;; "archive.tar.gz" → "archive.tar"
;; "file" → "file"
;; ".hidden" → ".hidden"
(fn stem <Str>[<Str>p] (seq
(def base (basename p))
(def e (ext p))
(if (str.empty? e)
base
(str.take base (- (str.len base) (str.len e))))))
;; Returns the path with its extension replaced by new-ext.
;; Pass "" as new-ext to strip the extension entirely.
;; The directory prefix and stem are preserved unchanged.
;;
;; Examples:
;; (with-ext "file.txt" ".md") → "file.md"
;; (with-ext "file.txt" "") → "file"
;; (with-ext "file" ".txt") → "file.txt"
(fn with-ext <Str>[<Str>p <Str>new-ext] (seq
(def e (ext p))
(def without-ext
(if (str.empty? e)
p
(str.take p (- (str.len p) (str.len e)))))
(str.concat without-ext new-ext)))
;; Resolves path relative to base and returns the normalized result.
;; If path is already absolute it is returned normalized, ignoring base.
;; Otherwise base + sep + path is normalized.
;;
;; Examples (Unix):
;; (resolve "/base" "file.txt") → "/base/file.txt"
;; (resolve "/base/dir" "../file.txt") → "/base/file.txt"
;; (resolve "/other" "/abs") → "/abs"
(fn resolve <Str>[<Str>base <Str>p]
(if (absolute? p)
(normalize p)
(normalize (str.concat base sep p))))
;; Computes the relative path from base to target.
;;
;; • Returns "." when base and target are the same path.
;; • When the roots differ (e.g. different Windows drive letters)
;; the normalized target is returned unchanged.
;;
;; Examples (Unix):
;; (relative-to "/a/b" "/a/b/c/d") → "c/d"
;; (relative-to "/a/b" "/a/c") → "../c"
;; (relative-to "/a/b/c" "/a") → "../.."
;; (relative-to "/a/b" "/a/b") → "."
(fn relative-to <Str>[<Str>base <Str>target] (seq
(def norm-base (normalize base))
(def norm-target (normalize target))
(def base-r (root norm-base))
(def target-r (root norm-target))
(if (nq? base-r target-r)
norm-target
(seq
(def base-body (str.drop norm-base (str.len base-r)))
(def target-body (str.drop norm-target (str.len target-r)))
(fn parts-of [body]
(if (str.empty? body)
[]
(vec.filter
(lambda [part _] (bool.not (str.empty? part)))
(str.split body sep))))
(def base-parts (parts-of base-body))
(def target-parts (parts-of target-body))
(def base-len (vec.len base-parts))
(def target-len (vec.len target-parts))
;; Find length of the common prefix.
(def i 0)
(while (bool.and (lt? i base-len)
(bool.and (lt? i target-len)
(eq? (vec.get base-parts i)
(vec.get target-parts i))))
(set! i (++ i)))
;; One ".." per remaining component in base, then the remaining target components.
(def ups (vec.map (lambda [_e _i] "..") (vec.drop base-parts i)))
(def remaining (vec.drop target-parts i))
(def result-parts
(vec.fold
(lambda [acc elem _] (vec.conj acc elem))
ups
remaining))
(if (vec.empty? result-parts)
"."
(join result-parts))))))
)

55
modules/std/platform.owa Normal file
View file

@ -0,0 +1,55 @@
(namespace platform
(def os-name builtins.platform.os-name)
(def os-family builtins.platform.os-family)
(def arch builtins.platform.arch)
(def argv builtins.platform.argv)
(def executable #(vec.get (argv) 0))
(def args #(vec.slice (argv) 1 (vec.len (argv))))
(def debug? (lookup __is_debug__ false))
(def os-names [
:linux
:windows
:macos
:android
:ios
:openbsd
:freebsd
:netbsd
:wasi
:hermit
:aix
:apple
:dragonfly
:emscripten
:espidf
:fortanix
:uefi
:fuchsia
:haiku
:hermit
:watchos
:visionos
:tvos
:horizon
:hurd
:illumos
:l4re
:nto
:redox
:solaris
:solid_asp3
:vexos
:vita
:vxworks
:xous
])
(def os-families [
:unix
:windows
:itron
:wasm
])
)

View file

@ -0,0 +1,3 @@
(seq
(include "path")
)

286
modules/std/tests/path.owa Normal file
View file

@ -0,0 +1,286 @@
(namespace test.path
(test.case "path.sep"
(assert.eq! path.sep (match platform.os-family (:windows "\\") (_ "/")))
)
(test.case "path.root"
;; Relative paths and empty string have no root on all platforms.
(assert.eq! (path.root "relative") "")
(assert.eq! (path.root "") "")
(match platform.os-family
(:windows (seq
;; Rooted by backslash or forward-slash → canonical "\"
(assert.eq! (path.root "\\a\\b") "\\")
(assert.eq! (path.root "/a/b") "\\")
;; Drive-letter absolute ("X:\" or "X:/")
(assert.eq! (path.root "C:\\a\\b") "C:\\")
(assert.eq! (path.root "C:/a/b") "C:\\")
(assert.eq! (path.root "C:\\") "C:\\")
;; Drive letter WITHOUT a separator is relative on Windows
(assert.eq! (path.root "C:") "")
(assert.eq! (path.root "C:relative") "")
))
(_ (seq
(assert.eq! (path.root "/") "/")
(assert.eq! (path.root "/a/b") "/")
))
)
)
(test.case "path.absolute?"
;; "/" is recognised as absolute on all platforms.
(assert.ok! (path.absolute? "/absolute/unix"))
(assert.ok! (path.absolute? "/"))
(assert.not! (path.absolute? "relative/unix"))
(assert.not! (path.absolute? "./relative/unix"))
(match platform.os-family
(:windows (seq
;; Drive-letter prefix (both separators)
(assert.ok! (path.absolute? "C:\\absolute\\windows"))
(assert.ok! (path.absolute? "C:/absolute/windows"))
;; UNC / rooted backslash
(assert.ok! (path.absolute? "\\absolute\\unc"))
;; Relative Windows paths
(assert.not! (path.absolute? "relative\\windows"))
(assert.not! (path.absolute? ".\\relative\\windows"))
;; Drive letter without separator is relative
(assert.not! (path.absolute? "C:relative"))
))
(_ (void))
)
)
(test.case "path.relative?"
(assert.ok! (path.relative? "relative/unix"))
(assert.not! (path.relative? "/absolute/unix"))
(match platform.os-family
(:windows (seq
(assert.ok! (path.relative? "relative\\windows"))
(assert.not! (path.relative? "C:\\absolute\\windows"))
(assert.not! (path.relative? "\\absolute\\unc"))
(assert.ok! (path.relative? "C:relative"))
))
(_ (void))
)
)
(test.case "path.split"
(assert.eq! (path.split "") [])
(assert.eq! (path.split "a") ["a"])
(match platform.os-family
(:windows (seq
(assert.eq! (path.split "a\\b\\c") ["a" "b" "c"])
;; Windows also accepts "/" as separator
(assert.eq! (path.split "a/b/c") ["a" "b" "c"])
;; Drive-letter prefix is kept as the first component (round-trip with join)
(assert.eq! (path.split "C:\\a\\b") ["C:" "a" "b"])
(assert.eq! (path.split "C:/a/b") ["C:" "a" "b"])
))
(_ (seq
(assert.eq! (path.split "a/b/c") ["a" "b" "c"])
))
)
)
(test.case "path.join"
(assert.eq! (path.join []) "")
(assert.eq! (path.join ["a"]) "a")
(match platform.os-family
(:windows (seq
(assert.eq! (path.join ["a" "b" "c"]) "a\\b\\c")
(assert.eq! (path.join ["C:" "a" "b"]) "C:\\a\\b")
))
(_ (seq
(assert.eq! (path.join ["a" "b" "c"]) (str.concat "a" path.sep "b" path.sep "c"))
))
)
)
(test.case "path.normalize"
(match platform.os-family
(:windows (seq
;; Dot component is stripped
(assert.eq! (path.normalize "a\\.\\b") (path.join ["a" "b"]))
;; Double-dot collapses parent
(assert.eq! (path.normalize "a\\..\\b") "b")
;; Rooted-backslash absolute path
(assert.eq! (path.normalize "\\a\\..\\b") (str.concat path.sep "b"))
;; Bug fix: absolute path collapsing to root returns the root, not ""
(assert.eq! (path.normalize "\\") "\\")
(assert.eq! (path.normalize "\\a\\..") "\\")
;; Drive-letter paths: no spurious leading "\" added
(assert.eq! (path.normalize "C:\\a\\..\\b") "C:\\b")
(assert.eq! (path.normalize "C:\\") "C:\\")
(assert.eq! (path.normalize "C:\\a\\b\\..\\..") "C:\\")
;; Windows normalises "/" separators as well
(assert.eq! (path.normalize "a/./b") (path.join ["a" "b"]))
(assert.eq! (path.normalize "C:/a/../b") "C:\\b")
))
(_ (seq
;; Dot component is stripped
(assert.eq! (path.normalize "a/./b") (path.join ["a" "b"]))
;; Double-dot collapses parent
(assert.eq! (path.normalize "a/../b") "b")
;; Rooted absolute path
(assert.eq! (path.normalize "/a/../b") (str.concat path.sep "b"))
;; Bug fix: absolute path collapsing to root returns "/", not ""
(assert.eq! (path.normalize "/") "/")
(assert.eq! (path.normalize "/a/..") "/")
(assert.eq! (path.normalize "/a/b/../..") "/")
))
)
)
(test.case "path.parent"
;; Top-level relative path always returns "" on every platform.
(assert.eq! (path.parent "a") "")
(match platform.os-family
(:windows (seq
(assert.eq! (path.parent "a\\b\\c") "a\\b")
(assert.eq! (path.parent "a\\b") "a")
;; Bug fix: rooted paths return "\" not hardcoded "/"
(assert.eq! (path.parent "\\") "\\")
(assert.eq! (path.parent "\\..\\a\\.\\..\\") "\\")
;; Drive-letter paths
(assert.eq! (path.parent "C:\\") "C:\\")
(assert.eq! (path.parent "C:\\a") "C:\\")
(assert.eq! (path.parent "C:\\a\\b") "C:\\a")
(assert.eq! (path.parent "C:\\a\\b\\c") "C:\\a\\b")
))
(_ (seq
(assert.eq! (path.parent "a/b/c") "a/b")
(assert.eq! (path.parent "a/b") "a")
(assert.eq! (path.parent "/") "/")
(assert.eq! (path.parent "/../a/./../") "/")
))
)
)
(test.case "path.basename"
(assert.eq! (path.basename "file.txt") "file.txt")
(match platform.os-family
(:windows (seq
(assert.eq! (path.basename "a\\b\\c") "c")
;; Trailing separator → empty basename
(assert.eq! (path.basename "a\\b\\") "")
(assert.eq! (path.basename "C:\\Users\\file.txt") "file.txt")
;; Windows also handles "/" separator
(assert.eq! (path.basename "a/b/c") "c")
))
(_ (seq
(assert.eq! (path.basename "a/b/c") "c")
(assert.eq! (path.basename "a/b/") "")
))
)
)
(test.case "path.ext"
;; Basic cases work on all platforms (basename is a simple name)
(assert.eq! (path.ext "file.txt") ".txt")
(assert.eq! (path.ext "file") "")
(assert.eq! (path.ext ".hidden") "")
(assert.eq! (path.ext "archive.tar.gz") ".gz")
(assert.eq! (path.ext "file.") "")
(assert.eq! (path.ext "") "")
(match platform.os-family
(:windows (seq
(assert.eq! (path.ext "C:\\Users\\file.txt") ".txt")
(assert.eq! (path.ext "a\\b\\no-ext") "")
))
(_ (seq
(assert.eq! (path.ext "a/b/file.txt") ".txt")
(assert.eq! (path.ext "a/b/no-ext") "")
))
)
)
(test.case "path.stem"
(assert.eq! (path.stem "file.txt") "file")
(assert.eq! (path.stem "file") "file")
(assert.eq! (path.stem ".hidden") ".hidden")
(assert.eq! (path.stem "archive.tar.gz") "archive.tar")
(assert.eq! (path.stem "") "")
(match platform.os-family
(:windows (seq
(assert.eq! (path.stem "C:\\Users\\file.txt") "file")
))
(_ (seq
(assert.eq! (path.stem "a/b/file.txt") "file")
))
)
)
(test.case "path.with-ext"
(assert.eq! (path.with-ext "file.txt" ".md") "file.md")
(assert.eq! (path.with-ext "file.txt" "") "file")
(assert.eq! (path.with-ext "file" ".txt") "file.txt")
(assert.eq! (path.with-ext "archive.tar.gz" ".bz2") "archive.tar.bz2")
;; Hidden file: no extension → new-ext is appended
(assert.eq! (path.with-ext ".hidden" ".txt") ".hidden.txt")
;; Directory prefix is preserved
(match platform.os-family
(:windows (seq
(assert.eq! (path.with-ext "C:\\a\\file.txt" ".md") "C:\\a\\file.md")
))
(_ (seq
(assert.eq! (path.with-ext "a/b/file.txt" ".md") "a/b/file.md")
))
)
)
(test.case "path.resolve"
(match platform.os-family
(:windows (seq
;; Relative path is joined to base and normalized
(assert.eq! (path.resolve "C:\\base" "file.txt") "C:\\base\\file.txt")
;; ".." is resolved against base
(assert.eq! (path.resolve "C:\\base\\dir" "..\\file.txt") "C:\\base\\file.txt")
;; Absolute path ignores base
(assert.eq! (path.resolve "C:\\other" "C:\\absolute") "C:\\absolute")
;; Deep traversal
(assert.eq! (path.resolve "C:\\a\\b\\c" "..\\..\\d") "C:\\a\\d")
))
(_ (seq
(assert.eq! (path.resolve "/base" "file.txt") "/base/file.txt")
(assert.eq! (path.resolve "/base/dir" "../file.txt") "/base/file.txt")
;; Absolute path ignores base
(assert.eq! (path.resolve "/other" "/absolute") "/absolute")
;; Deep traversal
(assert.eq! (path.resolve "/a/b/c" "../../d") "/a/d")
;; Relative base + relative path
(assert.eq! (path.resolve "base/dir" "file.txt") "base/dir/file.txt")
))
)
)
(test.case "path.relative-to"
(match platform.os-family
(:windows (seq
;; Child path
(assert.eq! (path.relative-to "C:\\a\\b" "C:\\a\\b\\c\\d") "c\\d")
;; Sibling path
(assert.eq! (path.relative-to "C:\\a\\b" "C:\\a\\c") "..\\c")
;; Ancestor path
(assert.eq! (path.relative-to "C:\\a\\b\\c" "C:\\a") "..\\..")
;; Same path → "."
(assert.eq! (path.relative-to "C:\\a\\b" "C:\\a\\b") ".")
;; From root
(assert.eq! (path.relative-to "C:\\" "C:\\a\\b") "a\\b")
;; Different drives → return normalized target unchanged
(assert.eq! (path.relative-to "C:\\a" "D:\\b") "D:\\b")
))
(_ (seq
(assert.eq! (path.relative-to "/a/b" "/a/b/c/d") "c/d")
(assert.eq! (path.relative-to "/a/b" "/a/c") "../c")
(assert.eq! (path.relative-to "/a/b/c" "/a") "../..")
;; Same path → "."
(assert.eq! (path.relative-to "/a/b" "/a/b") ".")
;; From root
(assert.eq! (path.relative-to "/" "/a/b") "a/b")
;; Relative paths
(assert.eq! (path.relative-to "a/b" "a/b/c") "c")
(assert.eq! (path.relative-to "a/b" "a/c") "../c")
))
)
)
)

14
src/builtins/ast.rs Normal file
View file

@ -0,0 +1,14 @@
use crate::core::{OwaValue, Scope, except_arity};
#[must_use]
pub fn owa_pack() -> OwaValue {
OwaValue::native_macro(|_, _, args| {
let [arg] = except_arity(args)?;
Ok(arg.pack().into())
})
}
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([("pack", owa_pack())])
}

97
src/builtins/cond.rs Normal file
View file

@ -0,0 +1,97 @@
use crate::{
core::{OwaError, OwaValue, Scope, except_arity, except_arity_min},
parser::ast::OwaAst,
};
#[must_use]
pub fn owa_if_eq() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let [cond_ast, then_ast, else_ast] = except_arity(args)?;
let cond_ast = cond_ast.as_call()?;
let mut result = true;
let mut prev = None;
for arg in cond_ast {
if !result {
break;
}
// Condition args are evaluated as values (control-flow here is a programming error)
let arg = interpreter.eval_value(arg, scope_id)?;
if let Some(prev) = prev {
result = prev == arg;
}
prev = Some(arg);
}
if result {
// Branch body — propagate Completion (break/continue/return pass through)
interpreter.eval(then_ast, scope_id)
} else {
interpreter.eval(else_ast, scope_id)
}
})
}
#[must_use]
pub fn owa_if_has() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let [target_ast, cond_ast, then_ast, else_ast] = except_arity(args)?;
let target_ast = interpreter.eval_value(target_ast, scope_id)?;
let items_ast = cond_ast.as_call()?;
for arg in items_ast {
let arg = interpreter.eval_value(arg, scope_id)?;
if arg == target_ast {
return interpreter.eval(then_ast, scope_id);
}
}
interpreter.eval(else_ast, scope_id)
})
}
#[must_use]
pub fn owa_match() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let ([value_ast], branches) = except_arity_min(args)?;
// Evaluate the matched value
let value = interpreter.eval_value(value_ast, scope_id)?;
let mut default = None;
for branch in branches {
let branch = branch.as_call()?;
let [pattern_ast, expr_ast] = except_arity(branch)?;
if pattern_ast == &OwaAst::sym("_") {
default = Some(expr_ast);
continue;
}
// Evaluate pattern as a value
let pattern = interpreter.eval_value(pattern_ast, scope_id)?;
if pattern == value {
// Branch body — propagate Completion
return interpreter.eval(expr_ast, scope_id);
}
}
default.map_or_else(
|| {
Err(OwaError::RuntimeError(OwaValue::Str(
"Unmatched pattern".into(),
)))
},
|default| interpreter.eval(default, scope_id),
)
})
}
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([
("if_eq", owa_if_eq()),
("if_has", owa_if_has()),
("match", owa_match()),
])
}

83
src/builtins/errors.rs Normal file
View file

@ -0,0 +1,83 @@
use crate::{
core::{Completion, OwaError, OwaValue, Scope, except_arity, except_arity_min},
parser::ast::OwaAst,
};
#[must_use]
pub fn owa_throw_panic() -> OwaValue {
OwaValue::native_function(|_, _, args| {
let [message] = except_arity(args)?;
let message = message.as_str()?;
Err(OwaError::Panic(message.to_string()))
})
}
#[must_use]
pub fn owa_throw_type_error() -> OwaValue {
OwaValue::native_function(|_, _, args| {
let [expected, got] = except_arity(args)?;
let expected = expected.as_str()?;
let got = got.as_str()?;
Err(OwaError::TypeError(expected.to_string(), got.to_string()))
})
}
#[must_use]
pub fn owa_throw_arity_error() -> OwaValue {
OwaValue::native_function(|_, _, args| {
let [expected, got] = except_arity(args)?;
let expected = expected.as_int()?;
let got = got.as_int()?;
Err(OwaError::ArityError(expected as usize, got as usize))
})
}
#[must_use]
pub fn owa_throw_runtime_error() -> OwaValue {
OwaValue::native_function(|_, _, args| {
let [value] = except_arity(args)?;
Err(OwaError::RuntimeError(value.clone()))
})
}
#[must_use]
pub fn owa_try_catch() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let ([try_expr], catch_exprs) = except_arity_min(args)?;
// Normalise both signal channels first so that control-flow signals
// (e.g. `break` inside try) are never mistaken for real errors.
match Completion::from_result(interpreter.eval(try_expr, scope_id)) {
// Propagate any completion (Value, Break, Continue, Return) unchanged
Ok(completion) => Ok(completion),
// Signal errors that escaped normalisation are also propagated
Err(OwaError::Signal(f)) => Ok(f.into()),
Err(err) => {
for catch_expr in catch_exprs {
let catch_call = OwaAst::call([
catch_expr.clone(),
OwaAst::str(err.message()),
OwaAst::kw(err.type_name()),
]);
// Run catch handler; propagate control flow if it emits any
match Completion::from_result(interpreter.eval(&catch_call, scope_id))? {
Completion::Value(_) => {} // discard value
c => return Ok(c), // propagate Break/Continue/Return
}
}
Err(OwaError::Panic(format!("Unhandled error: {err}")))
}
}
})
}
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([
("throw_panic", owa_throw_panic()),
("throw_type_error", owa_throw_type_error()),
("throw_arity_error", owa_throw_arity_error()),
("throw_runtime_error", owa_throw_runtime_error()),
("try", owa_try_catch()),
])
}

299
src/builtins/ffi.rs Normal file
View file

@ -0,0 +1,299 @@
use std::{
collections::HashMap,
ffi::{CStr, CString, c_char, c_void},
sync::{Mutex, OnceLock},
};
use libffi::middle::{Arg, Cif, CodePtr, Type};
use libloading::Library;
use crate::core::{OwaError, OwaValue, Scope, except_arity};
static LIBRARIES: OnceLock<Mutex<HashMap<String, Library>>> = OnceLock::new();
fn libraries() -> &'static Mutex<HashMap<String, Library>> {
LIBRARIES.get_or_init(|| Mutex::new(HashMap::new()))
}
fn runtime_err(msg: impl AsRef<str>) -> OwaError {
OwaError::RuntimeError(OwaValue::str(msg.as_ref()))
}
fn kw_to_ffi_type(kw: &str) -> Result<Type, OwaError> {
Ok(match kw {
"void" => Type::void(),
"ptr" | "str" => Type::pointer(), // both are just pointers at the ABI level
"i8" => Type::i8(),
"i16" => Type::i16(),
"i32" => Type::i32(),
"i64" => Type::i64(),
"u8" => Type::u8(),
"u16" => Type::u16(),
"u32" => Type::u32(),
"u64" => Type::u64(),
"f32" => Type::f32(),
"f64" => Type::f64(),
_ => return Err(runtime_err(format!("Unknown FFI type keyword: :{kw}"))),
})
}
// libffi expects a pointer-to-argument for each arg (void**). We box every
// value so the address stays stable regardless of Vec reallocations.
enum ArgBacking {
I8(Box<i8>),
I16(Box<i16>),
I32(Box<i32>),
I64(Box<i64>),
U8(Box<u8>),
U16(Box<u16>),
U32(Box<u32>),
U64(Box<u64>),
F32(Box<f32>),
F64(Box<f64>),
Ptr(Box<*const c_void>),
// _cstring keeps the allocation alive; ptr is a boxed *const c_char into it
Str {
_cstring: Box<CString>,
ptr: Box<*const c_char>,
},
}
impl ArgBacking {
fn to_ffi_arg(&self) -> Arg<'_> {
match self {
Self::I8(v) => Arg::new(v.as_ref()),
Self::I16(v) => Arg::new(v.as_ref()),
Self::I32(v) => Arg::new(v.as_ref()),
Self::I64(v) => Arg::new(v.as_ref()),
Self::U8(v) => Arg::new(v.as_ref()),
Self::U16(v) => Arg::new(v.as_ref()),
Self::U32(v) => Arg::new(v.as_ref()),
Self::U64(v) => Arg::new(v.as_ref()),
Self::F32(v) => Arg::new(v.as_ref()),
Self::F64(v) => Arg::new(v.as_ref()),
Self::Ptr(p) => Arg::new(p.as_ref()),
Self::Str { ptr, .. } => Arg::new(ptr.as_ref()),
}
}
}
#[allow(clippy::cast_precision_loss)]
fn prepare_backing(type_kw: &str, value: &OwaValue) -> Result<ArgBacking, OwaError> {
Ok(match type_kw {
"i8" => ArgBacking::I8(Box::new(value.as_int()? as i8)),
"i16" => ArgBacking::I16(Box::new(value.as_int()? as i16)),
"i32" => ArgBacking::I32(Box::new(value.as_int()? as i32)),
"i64" => ArgBacking::I64(Box::new(value.as_int()?)),
"u8" => ArgBacking::U8(Box::new(value.as_int()? as u8)),
"u16" => ArgBacking::U16(Box::new(value.as_int()? as u16)),
"u32" => ArgBacking::U32(Box::new(value.as_int()? as u32)),
"u64" => ArgBacking::U64(Box::new(value.as_int()? as u64)),
"f32" => {
let v = match value {
OwaValue::Float(f) => f.0 as f32,
OwaValue::Int(i) => *i as f32,
_ => {
return Err(OwaError::TypeError(
"float or int".into(),
value.type_name().into(),
));
}
};
ArgBacking::F32(Box::new(v))
}
"f64" => {
let v = match value {
OwaValue::Float(f) => f.0,
OwaValue::Int(i) => *i as f64,
_ => {
return Err(OwaError::TypeError(
"float or int".into(),
value.type_name().into(),
));
}
};
ArgBacking::F64(Box::new(v))
}
"ptr" => {
let raw = value.as_int()? as *const c_void;
ArgBacking::Ptr(Box::new(raw))
}
"str" => {
let s = value.as_str()?;
let cstring = Box::new(
CString::new(s.as_bytes())
.map_err(|e| runtime_err(format!("Invalid string for FFI: {e}")))?,
);
// box the pointer separately so libffi can dereference it once
let ptr = Box::new(cstring.as_ptr());
ArgBacking::Str {
_cstring: cstring,
ptr,
}
}
_ => return Err(runtime_err(format!("Unknown FFI type keyword: :{type_kw}"))),
})
}
// # Safety: caller must ensure func_ptr matches the cif signature
#[allow(clippy::cast_lossless, clippy::cast_possible_wrap)]
unsafe fn dispatch_call(
cif: &Cif,
func_ptr: CodePtr,
ffi_args: &[Arg<'_>],
return_type: &str,
) -> Result<OwaValue, OwaError> {
Ok(match return_type {
"void" => {
// libffi doesn't write the return slot for FFI_TYPE_VOID, so () is fine
unsafe { cif.call::<()>(func_ptr, ffi_args) };
OwaValue::int(0)
}
"i8" => OwaValue::int(unsafe { cif.call::<i8>(func_ptr, ffi_args) } as i64),
"i16" => OwaValue::int(unsafe { cif.call::<i16>(func_ptr, ffi_args) } as i64),
"i32" => OwaValue::int(unsafe { cif.call::<i32>(func_ptr, ffi_args) } as i64),
"i64" => OwaValue::int(unsafe { cif.call::<i64>(func_ptr, ffi_args) }),
"u8" => OwaValue::int(unsafe { cif.call::<u8>(func_ptr, ffi_args) } as i64),
"u16" => OwaValue::int(unsafe { cif.call::<u16>(func_ptr, ffi_args) } as i64),
"u32" => OwaValue::int(unsafe { cif.call::<u32>(func_ptr, ffi_args) } as i64),
"u64" => OwaValue::int(unsafe { cif.call::<u64>(func_ptr, ffi_args) } as i64),
"f32" => OwaValue::float(unsafe { cif.call::<f32>(func_ptr, ffi_args) } as f64),
"f64" => OwaValue::float(unsafe { cif.call::<f64>(func_ptr, ffi_args) }),
"ptr" => OwaValue::int(unsafe { cif.call::<usize>(func_ptr, ffi_args) } as i64),
"str" => {
let raw = unsafe { cif.call::<*const c_char>(func_ptr, ffi_args) };
if raw.is_null() {
OwaValue::str("")
} else {
let cstr = unsafe { CStr::from_ptr(raw) };
OwaValue::str(cstr.to_string_lossy().as_ref())
}
}
_ => {
return Err(runtime_err(format!(
"Unknown FFI return type: :{return_type}"
)));
}
})
}
#[must_use]
pub fn owa_load() -> OwaValue {
OwaValue::native_function(|_, _, args| {
let [libname] = except_arity(args)?;
let libname = libname.clone();
let libname_str = libname.as_str()?;
if libraries()
.lock()
.map_err(|_| runtime_err("Failed to acquire library registry lock"))?
.contains_key(libname_str.as_ref())
{
return Ok(libname.into());
}
// Load the library without holding the lock to avoid unnecessary contention
let lib = unsafe {
Library::new(libname_str.as_ref())
.map_err(|e| runtime_err(format!("Failed to load '{libname_str}': {e}")))?
};
libraries()
.lock()
.map_err(|_| runtime_err("Failed to acquire library registry lock"))?
.insert(libname_str.to_string(), lib);
Ok(libname.into())
})
}
#[must_use]
pub fn owa_unload() -> OwaValue {
OwaValue::native_function(|_, _, args| {
let [libname] = except_arity(args)?;
let libname = libname.clone();
let libname_str = libname.as_str()?;
libraries()
.lock()
.map_err(|_| runtime_err("Failed to acquire library registry lock"))?
.remove(libname_str.as_ref());
Ok(libname.into())
})
}
// (ffi.call libname funcname [[param-type ...] return-type] [args ...])
//
// Supported types: :void :ptr :str :i8 :i16 :i32 :i64 :u8 :u16 :u32 :u64 :f32 :f64
// :str — takes OwaValue::Str, passes const char*
// :ptr — takes OwaValue::Int as a raw address
#[must_use]
pub fn owa_call() -> OwaValue {
OwaValue::native_function(|_, _, args| {
let [libname, funcname, signature, call_args] = except_arity(args)?;
let libname = libname.as_str()?;
let funcname = funcname.as_str()?;
let signature = signature.as_vec()?;
let call_args = call_args.as_vec()?;
if signature.len() != 2 {
return Err(runtime_err(
"Signature must be [[param-type ...] return-type]",
));
}
let param_types: Vec<_> = signature[0]
.as_vec()?
.iter()
.map(OwaValue::as_kw)
.collect::<Result<_, _>>()?;
let return_type = signature[1].as_kw()?;
if param_types.len() != call_args.len() {
return Err(OwaError::ArityError(param_types.len(), call_args.len()));
}
let ffi_param_types: Vec<Type> = param_types
.iter()
.map(|kw| kw_to_ffi_type(kw.as_ref()))
.collect::<Result<_, _>>()?;
let cif = Cif::new(ffi_param_types, kw_to_ffi_type(return_type.as_ref())?);
// grab the fn pointer while holding the lock, then release before calling
let func_ptr: CodePtr = unsafe {
let libs = libraries()
.lock()
.map_err(|_| runtime_err("Failed to acquire library registry lock"))?;
let ptr = {
let sym: libloading::Symbol<unsafe extern "C" fn()> = libs
.get(libname.as_ref())
.ok_or_else(|| runtime_err(format!("Library not loaded: '{libname}'")))?
.get(funcname.as_bytes())
.map_err(|e| {
runtime_err(format!("Symbol '{funcname}' not found in '{libname}': {e}"))
})?;
// fn ptr -> usize -> *mut c_void is the portable cast in Rust
CodePtr(*sym as usize as *mut c_void)
}; // sym (and its borrow of libs) dropped here
drop(libs);
ptr
};
let backings: Vec<ArgBacking> = param_types
.iter()
.zip(call_args.iter())
.map(|(kw, val)| prepare_backing(kw.as_ref(), val))
.collect::<Result<_, _>>()?;
let ffi_args: Vec<Arg<'_>> = backings.iter().map(ArgBacking::to_ffi_arg).collect();
unsafe { dispatch_call(&cif, func_ptr, &ffi_args, return_type.as_ref()) }.map(Into::into)
})
}
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([
("load", owa_load()),
("unload", owa_unload()),
("call", owa_call()),
])
}

51
src/builtins/flow.rs Normal file
View file

@ -0,0 +1,51 @@
use crate::core::{Completion, OwaError, OwaValue, Scope, special_names};
#[must_use]
pub fn owa_break() -> OwaValue {
OwaValue::native_function(|_, _, _| Ok(Completion::Break))
}
#[must_use]
pub fn owa_continue() -> OwaValue {
OwaValue::native_function(|_, _, _| Ok(Completion::Continue))
}
#[must_use]
pub fn owa_return() -> OwaValue {
OwaValue::native_function(|_, _, args| {
args.first().map_or_else(
|| Err(OwaError::ArityMinError(1, 0)),
|result| Ok(Completion::Return(result.clone())),
)
})
}
#[must_use]
pub fn owa_loop() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
'outer: loop {
for arg in args {
// Normalise both signal channels: Ok(Completion::*) from direct
// calls and Err(Signal(...)) from nested argument evaluation.
match Completion::from_result(interpreter.eval(arg, scope_id))? {
Completion::Value(_) => {}
Completion::Break => break 'outer,
Completion::Continue => continue 'outer,
Completion::Return(v) => return Ok(Completion::Value(v)),
}
}
}
Ok(OwaValue::kw(special_names::KW_NULL).into())
})
}
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([
("break", owa_break()),
("continue", owa_continue()),
("return", owa_return()),
("loop", owa_loop()),
])
}

14
src/builtins/map.rs Normal file
View file

@ -0,0 +1,14 @@
use crate::core::{OwaValue, Scope};
#[must_use]
pub fn owa_map() -> OwaValue {
OwaValue::native_function(|_, _, args| {
let value = OwaValue::map(args.iter().cloned());
Ok(value.into())
})
}
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([("new", owa_map())])
}

83
src/builtins/math.rs Normal file
View file

@ -0,0 +1,83 @@
#![allow(clippy::cast_precision_loss)]
use crate::core::{OwaError, OwaValue, Scope, except_arity};
macro_rules! math_binary_op {
($fn_name:ident, $int_op:tt, $float_op:tt) => {
#[must_use]
pub fn $fn_name() -> OwaValue {
OwaValue::native_function(|_, _, args: &[OwaValue]| {
match args {
[arg] => {
match (arg) {
OwaValue::Int(a) => Ok(OwaValue::Int(0 $int_op a).into()),
OwaValue::Float(a) => Ok(OwaValue::float(0.0 $float_op a.as_ref()).into()),
_ => Err(OwaError::TypeError("number".into(), arg.type_name().into())),
}
},
[first, rest @ ..] => {
let mut first = first.clone();
for arg in rest {
first = match (&first, arg) {
(OwaValue::Int(a), OwaValue::Int(b)) => OwaValue::Int(*a $int_op b),
(OwaValue::Float(a), OwaValue::Float(b)) => OwaValue::Float(*a $float_op b),
(OwaValue::Int(a), OwaValue::Float(b)) => OwaValue::float((*a as f64) $float_op b.as_ref()),
(OwaValue::Float(a), OwaValue::Int(b)) => OwaValue::Float(*a $float_op (*b as f64)),
_ => return Err(OwaError::TypeError("number".into(), arg.type_name().into())),
}
};
Ok(first.into())
},
_ => Err(OwaError::ArityMinError(1, args.len())),
}
})
}
};
}
macro_rules! math_binary_op_method {
($fn_name:ident, $int_method:ident, $float_method:ident) => {
#[must_use]
pub fn $fn_name() -> OwaValue {
OwaValue::native_function(|_, _, args: &[OwaValue]| {
let [value1, value2] = except_arity(args)?;
match (value1, value2) {
(OwaValue::Int(a), OwaValue::Int(b)) => {
Ok(OwaValue::Int(a.pow((*b).try_into().unwrap_or(0))).into())
}
(OwaValue::Float(a), OwaValue::Float(b)) => {
Ok(OwaValue::float(a.powf(**b)).into())
}
(OwaValue::Int(a), OwaValue::Float(b)) => {
Ok(OwaValue::float((*a as f64).powf(**b)).into())
}
(OwaValue::Float(a), OwaValue::Int(b)) => {
Ok(OwaValue::float(a.powf(*b as f64)).into())
}
_ => Err(OwaError::TypeError(
"number".into(),
value1.type_name().into(),
)),
}
})
}
};
}
math_binary_op!(owa_add, +, +);
math_binary_op!(owa_sub, -, -);
math_binary_op!(owa_mul, *, *);
math_binary_op!(owa_div, /, /);
math_binary_op!(owa_mod, %, %);
math_binary_op_method!(owa_pow, pow, powf);
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([
("add", owa_add()),
("sub", owa_sub()),
("mul", owa_mul()),
("div", owa_div()),
("mod", owa_mod()),
("pow", owa_pow()),
])
}

324
src/builtins/mod.rs Normal file
View file

@ -0,0 +1,324 @@
use std::sync::Arc;
use rpds::VectorSync;
use crate::{
core::{Completion, Interpreter, OwaError, OwaValue, Scope, except_arity, except_arity_min},
parser::ast::OwaAst,
};
pub mod ast;
pub mod cond;
pub mod errors;
pub mod ffi;
pub mod flow;
pub mod map;
pub mod math;
pub mod platform;
pub mod set;
pub mod str;
pub mod vec;
#[must_use]
pub fn owa_apply() -> OwaValue {
OwaValue::native_function(|interpreter, _, args| {
let ([first], rest) = except_arity_min(args)?;
let calle = first.as_callable()?;
let mut flat_args: Vec<OwaValue> = Vec::with_capacity(args.len());
for arg in rest {
match arg {
OwaValue::Vec(vec) => flat_args.extend(vec.iter().cloned()),
_ => flat_args.push(arg.clone()),
}
}
calle.call(interpreter, flat_args.as_slice())
})
}
#[must_use]
pub fn owa_cmp() -> OwaValue {
OwaValue::native_function(|_, _, args| {
let [left, right] = except_arity(args)?;
let result = match left.cmp(right) {
std::cmp::Ordering::Less => OwaValue::int(-1),
std::cmp::Ordering::Equal => OwaValue::int(0),
std::cmp::Ordering::Greater => OwaValue::int(1),
};
Ok(result.into())
})
}
/// Returns a tuple of names and values
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if `names_ast` or `values_ast` are not the expected types
pub fn get_names_and_values(
interpreter: &mut Interpreter,
scope_id: usize,
names_ast: &OwaAst,
values_ast: &OwaAst,
) -> Result<(Vec<String>, Vec<OwaValue>), OwaError> {
let names = match names_ast {
OwaAst::Symbol(v) | OwaAst::Str(v) | OwaAst::Keyword(v) => vec![v.to_string()],
OwaAst::Vec(v) => v
.iter()
.map(super::parser::ast::OwaAst::to_sym)
.collect::<Result<_, _>>()?,
_ => {
let result = interpreter.eval_value(names_ast, scope_id)?;
let result = result.to_sym()?;
vec![result.to_string()]
}
};
let values = if names.len() == 1 {
vec![interpreter.eval_value(values_ast, scope_id)?]
} else {
let result = interpreter.eval_value(values_ast, scope_id)?;
let result = result.as_vec()?;
result.iter().cloned().collect()
};
Ok((names, values))
}
#[must_use]
pub fn owa_def() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let [key, value_ast] = except_arity(args)?;
let (names, values) = get_names_and_values(interpreter, scope_id, key, value_ast)?;
for (name, value) in names.iter().zip(values.iter()) {
interpreter.define_runtime(scope_id, name, value.clone())?;
}
if names.len() == 1 && values.len() == 1 {
Ok(values[0].clone().into())
} else {
Ok(OwaValue::vec(values).into())
}
})
}
#[must_use]
pub fn owa_exec() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let [variables_ast, bootstrap_ast, target] = except_arity(args)?;
let variables = interpreter.eval_value(variables_ast, scope_id)?;
let variables = variables.as_map()?;
let target = interpreter.eval_value(target, scope_id)?.as_str()?;
// create temp scope
let temp_scope_id = interpreter.inherit_scope(scope_id)?;
// load shared
for (key, value) in variables {
let key = &key.to_sym()?;
let value = value.clone();
interpreter.define_runtime(temp_scope_id, key, value)?;
}
// eval bootstrap
interpreter.eval_value(bootstrap_ast, temp_scope_id)?;
// isolate scope
let mut temp_scope = interpreter.get_scope_cloned(temp_scope_id)?;
temp_scope.parent = None;
temp_scope.token = std::sync::Arc::new(());
temp_scope.child_count = 0;
let isolated_scope_id = interpreter.allocate_scope(temp_scope)?;
// release temp scope (no longer needed after cloning)
interpreter.try_release_scope(temp_scope_id);
// eval
interpreter.run(target.as_ref(), isolated_scope_id)?;
let result = interpreter.get_scope_as_map(isolated_scope_id)?;
Ok(result.into())
})
}
#[must_use]
pub fn owa_include() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let [raw_filename] = except_arity(args)?;
let raw_filename = interpreter.eval_value(raw_filename, scope_id)?;
let raw_filename = raw_filename.as_str()?;
interpreter
.run(raw_filename.as_ref(), scope_id)
.map(Into::into)
})
}
#[must_use]
pub fn owa_lambda() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let [params, body] = except_arity(args)?;
let params = params.as_vec()?;
let params = params
.iter()
.map(OwaAst::as_sym)
.collect::<Result<VectorSync<_>, _>>()?;
let token = interpreter.get_scope_token(scope_id)?;
let value = OwaValue::new_lambda(body.clone(), params, scope_id, token);
Ok(value.into())
})
}
#[must_use]
pub fn owa_lookup() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let ([key], rest) = except_arity_min(args)?;
let key = key.as_sym().or_else(|_| {
let result = interpreter
.eval_value(&args[0], scope_id)
.map(|v| v.unparse())?;
Ok(Arc::from(result.as_str()))
})?;
let default = rest.first();
let value = interpreter.lookup_runtime(scope_id, &key);
match value {
Ok(v) => Ok(v.clone().into()),
Err(e) => default.map_or_else(
|| Err(e),
|default| interpreter.eval_value(default, scope_id).map(Into::into),
),
}
})
}
#[must_use]
pub fn owa_macro() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let [params, body] = except_arity(args)?;
let params = params.as_vec()?;
let params = params
.iter()
.map(OwaAst::as_sym)
.collect::<Result<VectorSync<_>, _>>()?;
let token = interpreter.get_scope_token(scope_id)?;
let value = OwaValue::new_macro(body.clone(), params, scope_id, token);
Ok(value.into())
})
}
#[must_use]
pub fn owa_trace() -> OwaValue {
OwaValue::native_function(|_, _, args| {
for arg in args {
match arg {
OwaValue::Str(v) => print!("{v}"),
_ => print!("{}", arg.unparse()),
}
}
println!();
Ok(OwaValue::str("").into())
})
}
#[must_use]
pub fn owa_typeof() -> OwaValue {
OwaValue::native_function(|_, _, args| {
let [value] = except_arity(args)?;
Ok(OwaValue::kw(value.type_name()).into())
})
}
#[must_use]
pub fn owa_scope() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let inner_scope_id = interpreter.inherit_scope(scope_id)?;
let _ = interpreter.eval_collection::<_, Vec<_>>(args, inner_scope_id)?;
let inner_map = interpreter.get_scope_as_map(inner_scope_id)?;
interpreter.try_release_scope(inner_scope_id);
Ok(inner_map.into())
})
}
#[must_use]
pub fn owa_set() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let [key, value_ast] = except_arity(args)?;
let (names, values) = get_names_and_values(interpreter, scope_id, key, value_ast)?;
for (name, value) in names.iter().zip(values.iter()) {
interpreter.set_runtime(scope_id, name, value.clone())?;
}
if names.len() == 1 && values.len() == 1 {
Ok(values[0].clone().into())
} else {
Ok(OwaValue::vec(values).into())
}
})
}
#[must_use]
pub fn owa_unset() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let mut result = Vec::with_capacity(args.len());
for arg in args {
let value = interpreter.lookup_runtime(scope_id, &arg.to_str())?.clone();
interpreter.unset_runtime(scope_id, &arg.to_str())?;
result.push(value);
}
Ok(OwaValue::vec(result).into())
})
}
#[must_use]
pub fn owa_seq() -> OwaValue {
OwaValue::native_macro(|interpreter, scope_id, args| {
let mut last = None;
for arg in args {
// Normalise both signal channels: Ok(Completion::*) from direct calls
// and Err(Signal(...)) from nested argument evaluation (e.g. inside
// a `namespace` / `scope` that uses eval_collection).
match Completion::from_result(interpreter.eval(arg, scope_id))? {
Completion::Value(v) => last = Some(v),
// Return is caught here — it exits the seq with the returned value
Completion::Return(v) => return Ok(Completion::Value(v)),
// Break/Continue propagate outward (they belong to an enclosing loop)
c => return Ok(c),
}
}
last.ok_or_else(|| OwaError::ArityMinError(1, args.len()))
.map(Completion::Value)
})
}
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([
("apply", owa_apply()),
("cmp", owa_cmp()),
("def", owa_def()),
("exec", owa_exec()),
("lambda", owa_lambda()),
("lookup", owa_lookup()),
("macro", owa_macro()),
("trace", owa_trace()),
("typeof", owa_typeof()),
("include", owa_include()),
("scope", owa_scope()),
("set!", owa_set()),
("unset!", owa_unset()),
("seq", owa_seq()),
("ast", ast::scope().local_runtime_to_map()),
("cond", cond::scope().local_runtime_to_map()),
("errors", errors::scope().local_runtime_to_map()),
("ffi", ffi::scope().local_runtime_to_map()),
("flow", flow::scope().local_runtime_to_map()),
("map", map::scope().local_runtime_to_map()),
("math", math::scope().local_runtime_to_map()),
("platform", platform::scope().local_runtime_to_map()),
("set", set::scope().local_runtime_to_map()),
("str", str::scope().local_runtime_to_map()),
("vec", vec::scope().local_runtime_to_map()),
])
}

19
src/builtins/platform.rs Normal file
View file

@ -0,0 +1,19 @@
use crate::core::{OwaValue, Scope};
#[must_use]
pub fn owa_argv() -> OwaValue {
OwaValue::native_function(|_, _, _| {
let args = std::env::args().map(OwaValue::str);
Ok(OwaValue::vec(args).into())
})
}
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([
("os-name", OwaValue::kw(std::env::consts::OS)),
("os-family", OwaValue::kw(std::env::consts::FAMILY)),
("arch", OwaValue::str(std::env::consts::ARCH)),
("argv", owa_argv()),
])
}

47
src/builtins/set.rs Normal file
View file

@ -0,0 +1,47 @@
use crate::core::{Completion, OwaValue, Scope, except_arity};
#[must_use]
pub fn owa_set_from() -> OwaValue {
OwaValue::native_function(|_, _, args: &[OwaValue]| {
let mut elements = Vec::new();
for arg in args {
match arg {
OwaValue::Vec(values) => elements.extend(values.iter().cloned()),
OwaValue::Set(values) => elements.extend(values.iter().cloned()),
OwaValue::Map(values) => elements.extend(values.keys().cloned()),
OwaValue::Str(v) => {
elements.extend(v.chars().map(|v| OwaValue::str(v.to_string())));
}
_ => elements.push(arg.clone()),
}
}
Ok(OwaValue::set(elements).into())
})
}
#[must_use]
pub fn owa_fold() -> OwaValue {
OwaValue::native_function(|interpreter, _, args| {
let [lambda, initial, set] = except_arity(args)?;
let callable = lambda.as_callable()?;
let set = set.as_set()?;
let mut initial = initial.clone();
for value in set {
let call_args = [initial.clone(), value.clone()];
match Completion::from_result(callable.call(interpreter, &call_args))? {
Completion::Value(v) => initial = v,
Completion::Break => break,
Completion::Continue => continue,
Completion::Return(v) => return Ok(Completion::Value(v)),
}
}
Ok(initial.into())
})
}
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([("from", owa_set_from()), ("fold", owa_fold())])
}

48
src/builtins/str.rs Normal file
View file

@ -0,0 +1,48 @@
use crate::core::{OwaValue, Scope};
#[must_use]
pub fn owa_to_str() -> OwaValue {
OwaValue::native_function(|_, _, args: &[OwaValue]| {
let value = OwaValue::str(
args.iter()
.map(|v| match v {
OwaValue::Str(v) => v.to_string(),
_ => v.unparse(),
})
.collect::<String>(),
);
Ok(value.into())
})
}
#[must_use]
pub fn owa_to_kw() -> OwaValue {
OwaValue::native_function(|_, _, args: &[OwaValue]| {
let value = OwaValue::kw(
args.iter()
.map(|v| match v {
OwaValue::Str(v) | OwaValue::Keyword(v) => v.to_string(),
_ => v.unparse(),
})
.collect::<String>(),
);
Ok(value.into())
})
}
#[must_use]
pub fn owa_serialize() -> OwaValue {
OwaValue::native_function(|_, _, args| {
let value = OwaValue::str(args.iter().map(OwaValue::unparse).collect::<String>());
Ok(value.into())
})
}
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([
("new", owa_to_str()),
("kw", owa_to_kw()),
("serialize", owa_serialize()),
])
}

66
src/builtins/vec.rs Normal file
View file

@ -0,0 +1,66 @@
use crate::core::{Completion, OwaValue, Scope, except_arity};
#[must_use]
pub fn owa_vec_new() -> OwaValue {
OwaValue::native_function(|_, _, args: &[OwaValue]| {
let value = OwaValue::vec(args.iter().cloned());
Ok(value.into())
})
}
#[must_use]
pub fn owa_vec_from() -> OwaValue {
OwaValue::native_function(|_, _, args: &[OwaValue]| {
let mut result = Vec::with_capacity(args.len());
for arg in args {
match arg {
OwaValue::Vec(values) => result.extend(values.iter().cloned()),
OwaValue::Set(values) => result.extend(values.iter().cloned()),
OwaValue::Map(values) => {
result.extend(
values
.iter()
.map(|(k, v)| OwaValue::vec([k.clone(), v.clone()])),
);
}
OwaValue::Str(v) => result.extend(v.chars().map(|v| OwaValue::str(v.to_string()))),
_ => result.push(arg.clone()),
}
}
Ok(OwaValue::vec(result).into())
})
}
#[must_use]
pub fn owa_fold() -> OwaValue {
OwaValue::native_function(|interpreter, _, args| {
let [lambda, initial, vector] = except_arity(args)?;
let callable = lambda.as_callable()?;
let vector = vector.as_vec()?;
let mut acc = initial.clone();
let mut call_args = [OwaValue::Int(0), OwaValue::Int(0), OwaValue::Int(0)];
for (idx, value) in vector.iter().enumerate() {
call_args[0] = std::mem::replace(&mut acc, OwaValue::Int(0));
call_args[1] = value.clone();
call_args[2] = OwaValue::int(idx);
match Completion::from_result(callable.call(interpreter, &call_args))? {
Completion::Value(v) => acc = v,
Completion::Break => break,
Completion::Continue => continue,
Completion::Return(v) => return Ok(Completion::Value(v)),
}
}
Ok(acc.into())
})
}
#[must_use]
pub fn scope() -> Scope {
Scope::new_with_runtime([
("new", owa_vec_new()),
("from", owa_vec_from()),
("fold", owa_fold()),
])
}

234
src/core/callable.rs Normal file
View file

@ -0,0 +1,234 @@
#![allow(unpredictable_function_pointer_comparisons)]
use std::{hash::Hash, sync::Arc};
use educe::Educe;
use rpds::VectorSync;
use tracing::debug;
use crate::{
core::{Completion, Interpreter, OwaError, OwaValue, ScopeId, special_names},
parser::ast::{Expander, OwaAst},
vec_unparse,
};
pub type OwaNativeFunction =
fn(&mut Interpreter, ScopeId, &[OwaValue]) -> Result<Completion, OwaError>;
pub type OwaNativeMacro = fn(&mut Interpreter, ScopeId, &[OwaAst]) -> Result<Completion, OwaError>;
#[derive(Debug, Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum OwaCallableCell {
NativeLambda(OwaNativeFunction),
NativeMacro(OwaNativeMacro),
Lambda(Arc<OwaAst>),
Macro(Arc<OwaAst>),
}
#[derive(Educe)]
#[educe(Debug, Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct OwaCallable {
#[educe(Debug(ignore), Hash(ignore), PartialEq(ignore), PartialOrd(ignore))]
pub scope_id: ScopeId,
#[educe(Debug(ignore), Hash(ignore), PartialEq(ignore), PartialOrd(ignore))]
pub scope_token: Arc<()>,
pub params: VectorSync<Arc<str>>,
pub body: OwaCallableCell,
}
impl OwaCallable {
fn call_macro<'a, I>(
&self,
interpreter: &mut Interpreter,
args: I,
scope_id: ScopeId,
) -> Result<Completion, OwaError>
where
I: IntoIterator<Item = &'a OwaAst>,
{
// remove annotations
let args = args.into_iter().map(|a| a.unannotate()).collect::<Vec<_>>();
// check arity
if args.len() < self.params.len() {
return Err(OwaError::ArityError(self.params.len(), args.len()));
}
// create call scope
self.load_expand_args(interpreter, scope_id, &args)?;
match &self.body {
OwaCallableCell::NativeMacro(native) => native(interpreter, scope_id, &args),
OwaCallableCell::Macro(body) => {
let expanded = Expander::new(interpreter, scope_id).expand(body, false, false);
debug!("expanded: {expanded:?}");
interpreter.eval(&expanded, scope_id)
}
_ => Err(OwaError::TypeError("macro".into(), self.type_name().into())),
}
}
/// Calls this callable with the given pre-evaluated arguments.
///
/// # Errors
///
/// Returns [`OwaError::ArityError`] if too few arguments are provided.
/// Returns [`OwaError::TypeError`] if the body is not a lambda variant.
/// Propagates any error returned by the lambda body or native function.
pub fn call(
&self,
interpreter: &mut Interpreter,
args: &[OwaValue],
) -> Result<Completion, OwaError> {
// check arity
if args.len() < self.params.len() {
return Err(OwaError::ArityError(self.params.len(), args.len()));
}
// create scope
let call_scope_id = interpreter.inherit_scope(self.scope_id)?;
self.load_args(interpreter, call_scope_id, args)?;
// eval
let result = match &self.body {
OwaCallableCell::Lambda(body) => interpreter.eval(body, call_scope_id),
OwaCallableCell::NativeLambda(native) => native(interpreter, call_scope_id, args),
_ => Err(OwaError::TypeError(
"lambda".into(),
self.type_name().into(),
)),
};
interpreter.try_release_scope(call_scope_id);
result
}
/// Evaluates the callable with the given raw AST arguments.
///
/// # Errors
///
/// Returns [`OwaError`] if argument evaluation or the underlying call fails.
pub fn eval<'a, I>(
&self,
interpreter: &mut Interpreter,
raw_args: I,
scope_id: ScopeId,
) -> Result<Completion, OwaError>
where
I: IntoIterator<Item = &'a OwaAst>,
{
match &self.body {
OwaCallableCell::NativeMacro(_) | OwaCallableCell::Macro(_) => {
self.call_macro(interpreter, raw_args, scope_id)
}
OwaCallableCell::NativeLambda(_) | OwaCallableCell::Lambda(_) => {
let args: Vec<OwaValue> = interpreter.eval_collection(raw_args, scope_id)?;
self.call(interpreter, &args)
}
}
}
/// Defines all call arguments (`this`, `%%`, `%&`, named params, indexed params)
/// in the given scope.
///
/// # Errors
///
/// Returns [`OwaError::Panic`] if any variable is already defined in the scope.
#[allow(clippy::needless_pass_by_value)]
fn load_args(
&self,
interpreter: &mut Interpreter,
scope_id: ScopeId,
args: &[OwaValue],
) -> Result<(), OwaError> {
// define self
interpreter.define_runtime(
scope_id,
special_names::THIS_CALLABLE,
OwaValue::Callable(self.clone()),
)?;
// define special arguments
interpreter.define_runtime(
scope_id,
special_names::ALL_ARGS,
OwaValue::vec(args.iter().cloned()),
)?;
interpreter.define_runtime(
scope_id,
special_names::REST_ARGS,
OwaValue::vec(args.iter().skip(self.params.len()).cloned()),
)?;
// define named arguments
for (idx, param) in self.params.iter().enumerate() {
interpreter.define_runtime(scope_id, param, args[idx].clone())?;
}
// define indexed arguments
for (idx, arg) in args.iter().enumerate() {
interpreter.define_runtime(scope_id, &format!("%{}", idx + 1), arg.clone())?;
}
Ok(())
}
#[allow(clippy::needless_pass_by_value)]
fn load_expand_args(
&self,
interpreter: &mut Interpreter,
scope_id: ScopeId,
args: &[OwaAst],
) -> Result<(), OwaError> {
// define special arguments
interpreter.set_expand(
scope_id,
special_names::ALL_ARGS,
OwaAst::vec(args.iter().cloned()),
)?;
interpreter.set_expand(
scope_id,
special_names::REST_ARGS,
OwaAst::vec(args.iter().skip(self.params.len()).cloned()),
)?;
// define named arguments
for (idx, param) in self.params.iter().enumerate() {
interpreter.set_expand(scope_id, param, args[idx].clone())?;
}
// define indexed arguments
for (idx, arg) in args.iter().enumerate() {
interpreter.set_expand(scope_id, &format!("%{}", idx + 1), arg.clone())?;
}
Ok(())
}
#[must_use]
pub fn get_param_index(&self, name: &str) -> Option<usize> {
self.params.iter().position(|p| p.clone().as_ref() == name)
}
#[must_use]
pub const fn type_name(&self) -> &'static str {
match self.body {
OwaCallableCell::NativeLambda(_) | OwaCallableCell::Lambda(_) => "lambda",
OwaCallableCell::NativeMacro(_) | OwaCallableCell::Macro(_) => "macro",
}
}
#[must_use]
pub fn unparse(&self) -> String {
match &self.body {
OwaCallableCell::Lambda(v) => match v.as_ref() {
OwaAst::Call(items) => format!("#{}", vec_unparse!(items.iter())),
_ => format!("#({})", v.unparse()),
},
OwaCallableCell::NativeLambda(_) => "#(!native.lambda!)".to_string(),
OwaCallableCell::Macro(v) => match v.as_ref() {
OwaAst::Call(items) => format!("#{}", vec_unparse!(items.iter())),
_ => format!("@({})", v.unparse()),
},
OwaCallableCell::NativeMacro(_) => "#(!native.macro!)".to_string(),
}
}
}

207
src/core/error.rs Normal file
View file

@ -0,0 +1,207 @@
use crate::core::OwaValue;
/// Payload carried by `break`, `continue`, and `return` signals.
///
/// This is kept separate from [`OwaError`] so that control flow can travel
/// through two independent channels:
///
/// * **`Ok(Completion::*)`** — produced directly by the `break`/`continue`/
/// `return` native functions and propagated through `eval`/`callable::call`.
/// * **`Err(OwaError::Signal(...))`** — produced when a signal passes through
/// a value-evaluation context (e.g. argument lists via `eval_collection`).
/// This mirrors the old viral-error propagation so that constructs like
/// `(for x v (continue))` still work correctly even though the `continue`
/// originates inside a nested lambda.
///
/// Both channels are unified by [`Completion::from_result`].
#[derive(Debug, Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum FlowOperation {
Break,
Continue,
Return(OwaValue),
}
/// Represents the outcome of evaluating an expression.
///
/// Normal expressions produce `Value`. Control-flow constructs (`break`,
/// `continue`, `return`) produce the corresponding variant. These are NOT
/// errors — they are intentional signals that loops and sequences catch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Completion {
/// The expression evaluated to a value normally.
Value(OwaValue),
/// A `break` signal was emitted.
Break,
/// A `continue` signal was emitted.
Continue,
/// A `return` signal was emitted with its return value.
Return(OwaValue),
}
impl Completion {
/// Normalises a result by converting `OwaError::Signal` errors back into
/// `Completion` variants.
///
/// Control-flow signals may travel via two channels:
/// * `Ok(Completion::Break/Continue/Return)` — from a direct lambda call.
/// * `Err(OwaError::Signal(...))` — from nested argument evaluation.
///
/// Call this function wherever both channels must be handled (loops, seq).
///
/// # Errors
///
/// * `Ok(Completion::Value(v))` — the expression evaluated to a value normally.
/// * `Ok(Completion::Break/Continue/Return)` — a control-flow signal was emitted.
/// * `Err(OwaError::Panic(_))` — a `break` signal was emitted outside a loop.
pub fn from_result(result: Result<Self, OwaError>) -> Result<Self, OwaError> {
match result {
Err(OwaError::Signal(f)) => Ok(f.into()),
other => other,
}
}
/// Unwraps the inner value, converting control-flow signals into
/// user-visible panic errors.
///
/// Use this only when control flow is genuinely unexpected (e.g. at the
/// top level of a file). Inside loops / sequences use [`from_result`] +
/// a pattern match instead.
///
/// # Errors
///
/// Returns [`OwaError::Panic`] if this is `Break`, `Continue`, or `Return`.
pub fn into_value(self) -> Result<OwaValue, OwaError> {
match self {
Self::Value(v) => Ok(v),
Self::Break => Err(OwaError::Panic("Unexpected break outside loop".into())),
Self::Continue => Err(OwaError::Panic("Unexpected continue outside loop".into())),
Self::Return(v) => Err(OwaError::Panic(format!(
"Unexpected return: {}",
v.unparse()
))),
}
}
}
impl From<OwaValue> for Completion {
fn from(v: OwaValue) -> Self {
Self::Value(v)
}
}
impl From<FlowOperation> for Completion {
fn from(f: FlowOperation) -> Self {
match f {
FlowOperation::Break => Self::Break,
FlowOperation::Continue => Self::Continue,
FlowOperation::Return(v) => Self::Return(v),
}
}
}
#[derive(Debug, Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum OwaError {
TypeError(String, String), // expected type A, got type B
ArityError(usize, usize), // expected N, got M
ArityMaxError(usize, usize), // expected max N args, got M
ArityMinError(usize, usize), // expected min N args, got M
InvalidMemberAccess(String, &'static str), // name A; type B
Panic(String),
EmptyCall,
RuntimeError(OwaValue),
UnboundVariable(String),
/// Internal: a control-flow signal propagating through value-evaluation
/// contexts (e.g. argument lists). Never shown to the user as a top-level
/// error; always caught by the nearest loop / sequence construct.
Signal(FlowOperation),
}
impl OwaError {
#[must_use]
pub const fn type_name(&self) -> &'static str {
match self {
Self::TypeError(_, _) => "TypeError",
Self::ArityError(_, _) => "ArityError",
Self::ArityMaxError(_, _) => "ArityMaxError",
Self::ArityMinError(_, _) => "ArityMinError",
Self::InvalidMemberAccess(_, _) => "InvalidMemberAccess",
Self::Panic(_) => "Panic",
Self::EmptyCall => "EmptyCall",
Self::RuntimeError(_) => "RuntimeError",
Self::UnboundVariable(_) => "UnboundVariable",
Self::Signal(_) => "Signal",
}
}
#[must_use]
pub fn message(&self) -> String {
match self {
Self::TypeError(expected, got) => {
format!("expected type {expected}, got type {got}")
}
Self::ArityError(expected, got) => {
format!("expected {expected} args, got {got}")
}
Self::ArityMaxError(max, got) => {
format!("expected max {max} args, got {got}")
}
Self::ArityMinError(min, got) => {
format!("expected min {min} args, got {got}")
}
Self::InvalidMemberAccess(name, type_name) => {
format!("cannot access member '{name}' on type {type_name}")
}
Self::Panic(message) => message.to_owned(),
Self::EmptyCall => "empty call".to_string(),
Self::RuntimeError(value) => format!("runtime error: {}", value.unparse()),
Self::UnboundVariable(name) => format!("unbound variable {name}"),
Self::Signal(FlowOperation::Break) => "break outside loop".to_string(),
Self::Signal(FlowOperation::Continue) => "continue outside loop".to_string(),
Self::Signal(FlowOperation::Return(v)) => {
format!("unexpected return: {}", v.unparse())
}
}
}
}
impl std::fmt::Display for OwaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.type_name(), self.message())
}
}
/// Collects `args` into a fixed-size array of length `N`.
///
/// # Errors
///
/// Returns [`OwaError::ArityError`] if the number of items in `args` is not exactly `N`.
pub fn except_arity<T: std::fmt::Debug, C, const N: usize>(args: C) -> Result<[T; N], OwaError>
where
C: IntoIterator<Item = T>,
{
let args = args.into_iter().collect::<Vec<_>>();
let len = args.len();
args.try_into().map_err(|_| OwaError::ArityError(N, len))
}
/// Collects `args` into a fixed-size array of length `N` or more,
/// returning the fixed-size array and any remaining items as a [`Vec`].
///
/// # Errors
///
/// Returns [`OwaError::ArityMinError`] if the number of items in `args` is less than `N`.
pub fn except_arity_min<T: std::fmt::Debug, C, const N: usize>(
args: C,
) -> Result<([T; N], Vec<T>), OwaError>
where
C: IntoIterator<Item = T>,
{
let mut args = args.into_iter().collect::<Vec<_>>();
let len = args.len();
if len < N {
return Err(OwaError::ArityMinError(N, len));
}
let rest = args.split_off(N);
let fixed: [T; N] = args.try_into().map_err(|_| OwaError::ArityError(N, len))?;
Ok((fixed, rest))
}

492
src/core/interpreter.rs Normal file
View file

@ -0,0 +1,492 @@
use std::collections::HashMap;
use std::sync::Arc;
use rpds::{RedBlackTreeMapSync, RedBlackTreeSetSync, VectorSync};
use tracing::trace;
use crate::{
core::{Completion, FlowOperation, OwaError, OwaValue, Scope, ScopeId, special_names},
parser::{ast::OwaAst, parse_file},
};
#[derive(Debug, Clone, Default)]
pub struct Interpreter {
scopes: Vec<Option<Scope>>,
free_list: Vec<ScopeId>,
pub max_scopes_count: Option<usize>,
}
impl Interpreter {
pub const fn new(max_scopes_count: Option<usize>) -> Self {
Self {
scopes: Vec::new(),
free_list: Vec::new(),
max_scopes_count,
}
}
/// Resolves, parses and evaluates an OWA source file relative to the current
/// `__dir__` runtime variable.
///
/// # Errors
///
/// Returns [`OwaError::UnboundVariable`] if `__dir__` is not set in the scope.
/// Returns [`OwaError::Panic`] if the file cannot be found, read, or parsed.
/// Propagates any [`OwaError`] produced while evaluating the file.
pub fn run<S>(&mut self, filename: S, scope_id: ScopeId) -> Result<OwaValue, OwaError>
where
S: Into<String>,
{
// resolve
let olddir = self.lookup_runtime(scope_id, special_names::DIR)?.clone();
let filename: String = filename.into();
let filename_path = std::path::Path::new(olddir.as_str()?.as_ref()).join(&filename);
trace!("run {}", filename_path.display());
let filename_resolved = resolve_owa_path(&filename_path)
.ok_or_else(|| OwaError::Panic(format!("Could not find file {filename}")))?;
// parse
let ast = parse_file(&filename_resolved).map_err(OwaError::Panic)?;
// update __dir__
let dir = std::path::Path::new(&filename_resolved)
.parent()
.and_then(|p| p.to_str());
if let Some(dir) = dir {
self.set_runtime(scope_id, special_names::DIR, OwaValue::str(dir))?;
}
// eval — normalise both signal channels, then catch Return at the top level
let result = match Completion::from_result(self.eval(&ast, scope_id))? {
Completion::Value(v) | Completion::Return(v) => v,
Completion::Break => {
return Err(OwaError::Panic("Unexpected break at top level".into()));
}
Completion::Continue => {
return Err(OwaError::Panic("Unexpected continue at top level".into()));
}
};
// restore __dir__
self.set_runtime(scope_id, special_names::DIR, olddir)?;
Ok(result)
}
/// Evaluates a single AST node and returns a [`Completion`].
///
/// Returns `Completion::Value` for normal expressions and
/// `Completion::Break`/`Continue`/`Return` for control-flow signals.
///
/// # Errors
///
/// Propagates any [`OwaError`] produced during evaluation.
pub fn eval(&mut self, value: &OwaAst, scope_id: ScopeId) -> Result<Completion, OwaError> {
trace!("scopes count: {}", self.scopes.len());
match value {
OwaAst::Symbol(v) => self.eval_symbol(v, scope_id).map(Completion::Value),
OwaAst::Keyword(v) => Ok(OwaValue::kw(v).into()),
OwaAst::Str(v) => Ok(OwaValue::str(v).into()),
OwaAst::Float(v) => Ok(OwaValue::Float(*v).into()),
OwaAst::Int(v) => Ok(OwaValue::Int(*v).into()),
OwaAst::Vec(v) => self.eval_vector(v, scope_id).map(Completion::Value),
OwaAst::Call(v) => self.eval_call(v, scope_id),
OwaAst::Set(v) => self.eval_set(v, scope_id).map(Completion::Value),
OwaAst::Map(v) => self.eval_map(v, scope_id).map(Completion::Value),
OwaAst::Quote(v) => Ok(OwaValue::quote(v.clone()).into()),
OwaAst::Unquote(v) => self.eval(v.as_ref(), scope_id),
OwaAst::Lambda(v) => {
let call = OwaAst::call(v.iter().cloned());
let token = self.get_scope_token(scope_id)?;
let lambda = OwaValue::new_lambda(call, VectorSync::default(), scope_id, token);
Ok(lambda.into())
}
OwaAst::Annotation(v) => self.eval(&v.value, scope_id),
}
}
/// Evaluates a single AST node and unwraps it to an [`OwaValue`].
///
/// Control-flow signals (`Break`, `Continue`, `Return`) are converted to
/// [`OwaError::Signal`] so they propagate virally through argument
/// evaluation until the nearest loop / sequence catches them via
/// [`Completion::from_result`].
///
/// # Errors
///
/// Returns [`OwaError::Signal`] if the result is a control-flow signal.
/// Propagates any other [`OwaError`] produced during evaluation.
pub fn eval_value(&mut self, value: &OwaAst, scope_id: ScopeId) -> Result<OwaValue, OwaError> {
match self.eval(value, scope_id)? {
Completion::Value(v) => Ok(v),
Completion::Break => Err(OwaError::Signal(FlowOperation::Break)),
Completion::Continue => Err(OwaError::Signal(FlowOperation::Continue)),
Completion::Return(v) => Err(OwaError::Signal(FlowOperation::Return(v))),
}
}
fn eval_symbol(&self, symbol: &str, scope_id: ScopeId) -> Result<OwaValue, OwaError> {
// specials
if is_special_symbol(symbol) {
match symbol {
special_names::SCOPE => return self.get_scope_as_map(scope_id),
_ => (),
}
}
// normal
self.lookup_runtime(scope_id, symbol).cloned()
}
fn eval_vector(
&mut self,
value: &VectorSync<OwaAst>,
scope_id: ScopeId,
) -> Result<OwaValue, OwaError> {
self.eval_collection(value, scope_id).map(OwaValue::Vec)
}
fn eval_call(
&mut self,
value: &VectorSync<OwaAst>,
scope_id: ScopeId,
) -> Result<Completion, OwaError> {
let raw_calle = value.first().ok_or(OwaError::EmptyCall)?;
let raw_calle = self.eval_value(raw_calle, scope_id)?;
let callable = raw_calle.as_callable()?;
callable.eval(self, value.iter().skip(1), scope_id)
}
fn eval_set(
&mut self,
value: &RedBlackTreeSetSync<OwaAst>,
scope_id: ScopeId,
) -> Result<OwaValue, OwaError> {
self.eval_collection(value.iter(), scope_id)
.map(OwaValue::Set)
}
fn eval_map(
&mut self,
value: &RedBlackTreeMapSync<OwaAst, OwaAst>,
scope_id: ScopeId,
) -> Result<OwaValue, OwaError> {
let iter = value.iter().flat_map(|(k, v)| [k, v]);
self.eval_collection::<_, Vec<_>>(iter, scope_id)
.map(OwaValue::map)
}
/// Evaluates every item in the iterator as a value and collects the results into `B`.
///
/// Control-flow signals are forwarded as [`OwaError::Signal`] so they
/// propagate virally through argument-evaluation contexts and are later
/// caught by the nearest loop / sequence via [`Completion::from_result`].
///
/// # Errors
///
/// Returns the first [`OwaError`] (including `Signal`) encountered.
pub fn eval_collection<'a, C, B>(&mut self, items: C, scope_id: ScopeId) -> Result<B, OwaError>
where
C: IntoIterator<Item = &'a OwaAst>,
B: FromIterator<OwaValue>,
{
let mut result = Vec::new();
for item in items {
match self.eval(item, scope_id)? {
Completion::Value(v) => result.push(v),
Completion::Break => return Err(OwaError::Signal(FlowOperation::Break)),
Completion::Continue => return Err(OwaError::Signal(FlowOperation::Continue)),
Completion::Return(v) => return Err(OwaError::Signal(FlowOperation::Return(v))),
}
}
Ok(result.into_iter().collect())
}
/// Looks up `name` by walking the scope parent chain without dot-path splitting.
///
/// # Errors
///
/// Returns [`OwaError::Panic`] if any scope ID in the chain is invalid.
/// Returns [`OwaError::UnboundVariable`] if `name` is not found in any scope.
pub fn lookup_expand(&self, scope: ScopeId, name: &str) -> Result<&OwaAst, OwaError> {
let scope = self.get_scope(scope)?;
if let Some(v) = scope.expand.get(name) {
return Ok(v);
}
Err(OwaError::UnboundVariable(name.to_string()))
}
/// Defines a new macro argument `name` in the given scope.
///
/// # Errors
///
/// Returns [`OwaError::Panic`] if scope ID is invalid.
pub fn set_expand(
&mut self,
scope: ScopeId,
name: &str,
value: OwaAst,
) -> Result<(), OwaError> {
let scope = self.get_scope_mut(scope)?;
scope.expand.insert(name.to_string(), value);
Ok(())
}
/// Looks up `name` by walking the scope parent chain without dot-path splitting.
///
/// # Errors
///
/// Returns [`OwaError::Panic`] if any scope ID in the chain is invalid.
/// Returns [`OwaError::UnboundVariable`] if `name` is not found in any scope.
pub fn lookup_runtime_direct(&self, scope: ScopeId, name: &str) -> Result<&OwaValue, OwaError> {
let mut scope_id = Some(scope);
while let Some(s) = scope_id {
let scope = self.get_scope(s)?;
if let Some(v) = scope.runtime.get(name) {
return Ok(v);
}
scope_id = scope.parent;
}
Err(OwaError::UnboundVariable(name.to_string()))
}
/// Looks up a (potentially dot-separated) `name` in the scope chain.
///
/// # Errors
///
/// Returns [`OwaError::UnboundVariable`] if the name (or any path segment) is not found.
/// Returns [`OwaError::InvalidMemberAccess`] if a non-map value is accessed as a map.
/// Returns [`OwaError::Panic`] if a scope ID is invalid.
pub fn lookup_runtime(&self, scope_id: ScopeId, name: &str) -> Result<&OwaValue, OwaError> {
// fast path
if !name.contains('.') {
return self.lookup_runtime_direct(scope_id, name);
}
let mut parts = name.splitn(2, '.');
let first_part = parts
.next()
.ok_or_else(|| OwaError::UnboundVariable(name.to_string()))?;
let rest = parts.next();
let mut current = self.lookup_runtime_direct(scope_id, first_part)?;
if let Some(rest) = rest {
for part in rest.split('.') {
let OwaValue::Map(map) = current else {
return Err(OwaError::InvalidMemberAccess(
name.to_string(),
current.type_name(),
));
};
let key = OwaValue::kw(part);
current = map
.get(&key)
.ok_or_else(|| OwaError::UnboundVariable(name.to_string()))?;
}
}
Ok(current)
}
/// Defines a new variable `name` in the given scope.
///
/// # Errors
///
/// Returns [`OwaError::Panic`] if `name` is already defined in that scope
/// or if the scope ID is invalid.
pub fn define_runtime(
&mut self,
scope: ScopeId,
name: &str,
value: OwaValue,
) -> Result<(), OwaError> {
let scope = self.get_scope_mut(scope)?;
if scope.runtime.contains_key(name) {
let msg = format!("Variable {name} is already defined in this scope");
Err(OwaError::Panic(msg))
} else {
scope.runtime.insert(name.to_string(), value);
Ok(())
}
}
/// Updates the value of an existing variable `name`, searching from `scope`
/// upward through the parent chain.
///
/// # Errors
///
/// Returns [`OwaError::UnboundVariable`] if `name` is not found in any scope.
/// Returns [`OwaError::Panic`] if a scope ID is invalid.
pub fn set_runtime(
&mut self,
scope: ScopeId,
name: &str,
value: OwaValue,
) -> Result<(), OwaError> {
let target = self.search_runtime(scope, name)?;
let scope_mut = self.get_scope_mut(target)?;
if let Some(slot) = scope_mut.runtime.get_mut(name) {
*slot = value;
} else {
scope_mut.runtime.insert(name.to_string(), value);
}
Ok(())
}
/// Removes the variable `name` from the scope where it is first found.
///
/// # Errors
///
/// Returns [`OwaError::UnboundVariable`] if `name` is not found in any scope.
/// Returns [`OwaError::Panic`] if a scope ID is invalid.
pub fn unset_runtime(&mut self, scope: ScopeId, name: &str) -> Result<(), OwaError> {
let target = self.search_runtime(scope, name)?;
let scope_mut = self.get_scope_mut(target)?;
scope_mut.runtime.remove(name);
Ok(())
}
fn search_runtime(&self, scope: ScopeId, name: &str) -> Result<usize, OwaError> {
let mut cur = Some(scope);
let mut target = None;
while let Some(s) = cur {
let scope_ref = self.get_scope(s)?;
if scope_ref.runtime.contains_key(name) {
target = Some(s);
break;
}
cur = scope_ref.parent;
}
target.ok_or_else(|| OwaError::UnboundVariable(name.to_string()))
}
pub fn allocate_scope(&mut self, scope: Scope) -> Result<ScopeId, OwaError> {
if let Some(free_id) = self.free_list.pop() {
self.scopes[free_id] = Some(scope);
return Ok(free_id);
}
if let Some(max) = self.max_scopes_count
&& self.scopes.len() >= max
{
return Err(OwaError::Panic("scope limit exceeded".to_string()));
}
let scope_id = self.scopes.len();
self.scopes.push(Some(scope));
Ok(scope_id)
}
pub fn inherit_scope(&mut self, parent: ScopeId) -> Result<ScopeId, OwaError> {
self.get_scope_mut(parent)?.child_count += 1;
self.allocate_scope(Scope::new(Some(parent)))
}
pub fn get_scope_token(&self, scope_id: ScopeId) -> Result<Arc<()>, OwaError> {
Ok(self.get_scope(scope_id)?.token.clone())
}
pub fn try_release_scope(&mut self, scope_id: ScopeId) {
let Some(scope) = self.scopes.get(scope_id).and_then(|s| s.as_ref()) else {
return;
};
if scope.child_count > 0 {
return;
}
if Arc::strong_count(&scope.token) > 1 {
return;
}
let parent = match scope.parent {
Some(p) => p,
None => return,
};
self.scopes[scope_id] = None;
self.free_list.push(scope_id);
if let Some(parent_scope) = self.scopes[parent].as_mut() {
parent_scope.child_count = parent_scope.child_count.saturating_sub(1);
}
}
/// Returns the parent scope ID of the given scope, if any.
///
/// # Errors
///
/// Returns [`OwaError::Panic`] if `scope_id` is invalid.
pub fn get_scope_parent(&self, scope_id: ScopeId) -> Result<Option<ScopeId>, OwaError> {
let scope = self.get_scope(scope_id)?;
Ok(scope.parent)
}
/// Collects all variables visible from `scope_id` (including parent scopes)
/// into a map value.
///
/// # Errors
///
/// Returns [`OwaError::Panic`] if any scope ID in the chain is invalid.
/// Returns [`OwaError::TypeError`] if a parent scope map cannot be read as a map.
pub fn get_scope_as_map(&self, scope_id: ScopeId) -> Result<OwaValue, OwaError> {
let mut chain = Vec::new();
let mut cur = Some(scope_id);
while let Some(id) = cur {
let scope = self.get_scope(id)?;
chain.push(id);
cur = scope.parent;
}
let mut map = HashMap::new();
for &id in chain.iter().rev() {
let scope = self.get_scope(id)?;
for (k, v) in &scope.runtime {
map.insert(OwaValue::kw(k), v.clone());
}
}
map.insert(OwaValue::kw(special_names::IS_SCOPE), OwaValue::int(1));
Ok(OwaValue::Map(RedBlackTreeMapSync::from_iter(map)))
}
/// Returns a clone of the scope with the given ID.
///
/// # Errors
///
/// Returns [`OwaError::Panic`] if `scope_id` does not correspond to a live scope.
pub fn get_scope_cloned(&self, scope_id: ScopeId) -> Result<Scope, OwaError> {
let scope = self.scopes.get(scope_id).and_then(|inner| inner.as_ref());
scope
.cloned()
.ok_or_else(|| OwaError::Panic(format!("Attempt to access invalid scope {scope_id}")))
}
fn get_scope(&self, scope_id: ScopeId) -> Result<&Scope, OwaError> {
let scope = self.scopes.get(scope_id).and_then(|inner| inner.as_ref());
scope.ok_or_else(|| OwaError::Panic(format!("Attempt to access invalid scope {scope_id}")))
}
fn get_scope_mut(&mut self, scope_id: ScopeId) -> Result<&mut Scope, OwaError> {
let scope = self.scopes.get_mut(scope_id).and_then(|x| x.as_mut());
scope.ok_or_else(|| OwaError::Panic(format!("Attempt to access invalid scope {scope_id}")))
}
}
fn resolve_owa_path(base: &std::path::Path) -> Option<String> {
[
base.to_path_buf(),
base.with_extension("owa"),
base.join("main.owa"),
]
.into_iter()
.find(|p| p.is_file())
.and_then(|v| v.to_str().map(std::string::ToString::to_string))
}
fn is_special_symbol(s: &str) -> bool {
s.starts_with("__") && s.ends_with("__")
}

12
src/core/mod.rs Normal file
View file

@ -0,0 +1,12 @@
mod callable;
mod error;
mod interpreter;
mod scope;
pub mod special_names;
mod value;
pub use callable::*;
pub use error::*;
pub use interpreter::*;
pub use scope::*;
pub use value::*;

77
src/core/scope.rs Normal file
View file

@ -0,0 +1,77 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::{core::OwaValue, parser::ast::OwaAst};
pub type ScopeId = usize;
#[derive(Debug, Clone)]
pub struct Scope {
pub parent: Option<ScopeId>,
pub runtime: HashMap<String, OwaValue>,
pub expand: HashMap<String, OwaAst>,
pub child_count: usize,
pub token: Arc<()>,
}
impl Default for Scope {
fn default() -> Self {
Self {
parent: None,
runtime: HashMap::new(),
expand: HashMap::new(),
child_count: 0,
token: Arc::new(()),
}
}
}
impl PartialEq for Scope {
fn eq(&self, other: &Self) -> bool {
self.parent == other.parent && self.runtime == other.runtime && self.expand == other.expand
}
}
impl Eq for Scope {}
impl Scope {
#[must_use]
pub fn new(parent: Option<ScopeId>) -> Self {
Self {
parent,
runtime: HashMap::new(),
expand: HashMap::new(),
child_count: 0,
token: Arc::new(()),
}
}
#[must_use]
pub fn new_with_runtime<K, V, I>(runtime: I) -> Self
where
K: Into<String>,
V: Into<OwaValue>,
I: IntoIterator<Item = (K, V)>,
{
Self {
parent: None,
runtime: runtime
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
expand: HashMap::new(),
child_count: 0,
token: Arc::new(()),
}
}
#[must_use]
pub fn local_runtime_to_map(&self) -> OwaValue {
OwaValue::Map(
self.runtime
.iter()
.map(|(k, v)| (OwaValue::kw(k), v.clone()))
.collect(),
)
}
}

23
src/core/special_names.rs Normal file
View file

@ -0,0 +1,23 @@
/// Self-reference binding available inside every lambda/macro call body.
pub const THIS_CALLABLE: &str = "this";
/// Binding containing all arguments passed to the current callable.
pub const ALL_ARGS: &str = "%%";
/// Binding containing arguments beyond the declared named parameters.
pub const REST_ARGS: &str = "%&";
/// Runtime variable holding the resolved directory of the currently executing file.
pub const DIR: &str = "__dir__";
/// Special symbol that evaluates to the current scope as a map value.
pub const SCOPE: &str = "__scope__";
/// Map key inserted into every scope-map to identify it as a scope object.
pub const IS_SCOPE: &str = "__is_scope__";
/// Map key that makes a map value callable (callable-object protocol).
pub const CALL: &str = "__call__";
/// Keyword representing the null / absent value.
pub const KW_NULL: &str = "null";

347
src/core/value.rs Normal file
View file

@ -0,0 +1,347 @@
use num_traits::ToPrimitive;
use ordered_float::OrderedFloat;
use rpds::{RedBlackTreeMapSync, RedBlackTreeSetSync, VectorSync};
use std::{hash::Hash, sync::Arc};
use crate::core::{
OwaCallable, OwaCallableCell, OwaError, OwaNativeFunction, OwaNativeMacro, ScopeId,
special_names,
};
use crate::parser::ast::OwaAst;
/// Formats an iterator of items-with-`unparse()` into a space-separated string.
#[macro_export]
macro_rules! vec_unparse {
($iter:expr) => {{
$iter
.map(|v| v.unparse())
.collect::<Vec<String>>()
.join(" ")
}};
}
/// Generates constructor and accessor methods shared by `OwaValue` and `OwaAst`.
///
/// The `kw()` constructor strips all characters listed in the parser's
/// `INDENT_IGNORE` set so that keyword identifiers are always clean.
#[macro_export]
macro_rules! owa_base_impl {
() => {
pub fn kw<S: AsRef<str>>(s: S) -> Self {
Self::Keyword(Arc::from(s.as_ref()))
}
/// Returns the inner string if this value is a `Keyword`.
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if this is not a `Keyword` variant.
pub fn as_kw(&self) -> Result<Arc<str>, OwaError> {
match self {
Self::Keyword(s) => Ok(s.clone()),
_ => Err(OwaError::TypeError(
"keyword".into(),
self.type_name().into(),
)),
}
}
pub fn str<S: AsRef<str>>(s: S) -> Self {
Self::Str(Arc::from(s.as_ref()))
}
/// Returns the inner string if this value is a `Str`.
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if this is not a `Str` variant.
pub fn as_str(&self) -> Result<Arc<str>, OwaError> {
match self {
Self::Str(s) => Ok(s.clone()),
_ => Err(OwaError::TypeError("str".into(), self.type_name().into())),
}
}
#[must_use]
pub fn to_sym(&self) -> Result<String, OwaError> {
match self {
Self::Str(s) => Ok(s.as_ref().into()),
Self::Keyword(s) => Ok(s.as_ref().into()),
_ => Err(OwaError::TypeError(
"str/keyword".into(),
self.type_name().into(),
)),
}
}
pub fn int<T: ToPrimitive>(i: T) -> Self {
Self::Int(i.to_i64().unwrap_or(0))
}
/// Returns the inner integer if this value is an `Int`.
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if this is not an `Int` variant.
pub fn as_int(&self) -> Result<i64, OwaError> {
match self {
Self::Int(i) => Ok(*i),
_ => Err(OwaError::TypeError("int".into(), self.type_name().into())),
}
}
pub const fn float(f: f64) -> Self {
Self::Float(OrderedFloat(f))
}
/// Returns the inner float if this value is a `Float`.
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if this is not a `Float` variant.
pub fn as_float(&self) -> Result<OrderedFloat<f64>, OwaError> {
match self {
Self::Float(f) => Ok(*f),
_ => Err(OwaError::TypeError("float".into(), self.type_name().into())),
}
}
pub fn map<C>(coll: C) -> Self
where
C: IntoIterator<Item = Self>,
{
let mut map = RedBlackTreeMapSync::default();
let mut iter = coll.into_iter();
while let (Some(k), Some(v)) = (iter.next(), iter.next()) {
map = map.insert(k, v);
}
Self::Map(map)
}
/// Returns a reference to the inner map if this value is a `Map`.
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if this is not a `Map` variant.
pub fn as_map(&self) -> Result<&RedBlackTreeMapSync<Self, Self>, OwaError> {
match self {
Self::Map(m) => Ok(m),
_ => Err(OwaError::TypeError("map".into(), self.type_name().into())),
}
}
#[must_use]
pub fn vec<C>(coll: C) -> Self
where
C: IntoIterator<Item = Self>,
{
Self::Vec(VectorSync::from_iter(coll.into_iter()))
}
#[must_use]
pub fn to_vec(self) -> Self {
Self::Vec(VectorSync::from_iter(vec![self]))
}
/// Returns a reference to the inner vector if this value is a `Vec`.
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if this is not a `Vec` variant.
pub fn as_vec(&self) -> Result<&VectorSync<Self>, OwaError> {
match self {
Self::Vec(v) => Ok(v),
_ => Err(OwaError::TypeError("vec".into(), self.type_name().into())),
}
}
/// Returns a reference to the inner set if this value is a `Set`.
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if this is not a `Set` variant.
pub fn as_set(&self) -> Result<&RedBlackTreeSetSync<Self>, OwaError> {
match self {
Self::Set(s) => Ok(s),
_ => Err(OwaError::TypeError("set".into(), self.type_name().into())),
}
}
#[must_use]
pub fn set<C>(coll: C) -> Self
where
C: IntoIterator<Item = Self>,
{
Self::Set(RedBlackTreeSetSync::from_iter(coll.into_iter()))
}
#[must_use]
pub fn to_set(self) -> Self {
Self::Set(RedBlackTreeSetSync::from_iter(vec![self]))
}
};
}
#[derive(Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum OwaValue {
Keyword(Arc<str>), // :ident
Str(Arc<str>), // "..."
Float(OrderedFloat<f64>), // 1.0
Int(i64), // 1
Vec(VectorSync<Self>), // [...]
Set(RedBlackTreeSetSync<Self>), // {...}
Map(RedBlackTreeMapSync<Self, Self>), // #{...}
Quote(Arc<OwaAst>), // '...
Callable(OwaCallable),
}
impl OwaValue {
owa_base_impl!();
#[must_use]
pub fn new_macro(
body: OwaAst,
params: VectorSync<Arc<str>>,
scope_id: ScopeId,
scope_token: Arc<()>,
) -> Self {
Self::Callable(OwaCallable {
scope_id,
scope_token,
params,
body: OwaCallableCell::Macro(Arc::new(body)),
})
}
#[must_use]
pub fn new_lambda(
body: OwaAst,
params: VectorSync<Arc<str>>,
scope_id: ScopeId,
scope_token: Arc<()>,
) -> Self {
Self::Callable(OwaCallable {
scope_id,
scope_token,
params,
body: OwaCallableCell::Lambda(Arc::new(body)),
})
}
/// Returns the callable wrapped by this value, following `__call__` map keys.
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if the value (or the resolved `__call__`
/// chain) is not a `Callable` variant.
pub fn as_callable(&self) -> Result<&OwaCallable, OwaError> {
let mut calle = self;
// magic __call__
while let Self::Map(items) = calle {
let item = items.get(&Self::kw(special_names::CALL));
match item {
Some(v) => calle = v,
None => break,
}
}
match calle {
Self::Callable(lambda) => Ok(lambda),
_ => Err(OwaError::TypeError(
"lambda or macro".into(),
calle.type_name().into(),
)),
}
}
pub fn native_function(function: OwaNativeFunction) -> Self {
Self::native(OwaCallableCell::NativeLambda(function))
}
pub fn native_macro(r#macro: OwaNativeMacro) -> Self {
Self::native(OwaCallableCell::NativeMacro(r#macro))
}
fn native(body: OwaCallableCell) -> Self {
Self::Callable(OwaCallable {
scope_id: 0,
scope_token: Arc::new(()),
params: VectorSync::from_iter(vec![]),
body,
})
}
#[must_use]
pub const fn quote(value: Arc<OwaAst>) -> Self {
Self::Quote(value)
}
/// Returns the inner AST node if this value is a `Quote`.
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if this is not a `Quote` variant.
pub fn as_quote(&self) -> Result<Arc<OwaAst>, OwaError> {
match self {
Self::Quote(q) => Ok(q.clone()),
_ => Err(OwaError::TypeError("quote".into(), self.type_name().into())),
}
}
#[must_use]
#[allow(clippy::missing_const_for_fn)]
pub fn type_name(&self) -> &'static str {
match self {
Self::Keyword(_) => "keyword",
Self::Str(_) => "str",
Self::Float(_) => "float",
Self::Int(_) => "int",
Self::Vec(_) => "vec",
Self::Set(_) => "set",
Self::Map(_) => "map",
Self::Quote(_) => "quote",
Self::Callable(lambda) => lambda.type_name(),
}
}
#[must_use]
pub fn unparse(&self) -> String {
match self {
Self::Keyword(s) => format!(":{s}"),
Self::Str(s) => format!("\"{s}\""),
Self::Float(f) => {
let s = format!("{f}");
if s.contains('.') { s } else { format!("{s}.0") }
}
Self::Int(i) => i.to_string(),
Self::Vec(v) => {
format!("[{}]", vec_unparse!(v.iter()))
}
Self::Set(v) => {
format!("#{{{}}}", vec_unparse!(v.iter()))
}
Self::Map(v) => format!(
"{{{}}}",
vec_unparse!(v.iter().flat_map(|(k, v)| vec![k, v]))
),
Self::Quote(v) => format!("'{}", v.unparse()),
Self::Callable(v) => v.unparse(),
}
}
}
impl std::fmt::Debug for OwaValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.unparse())
}
}
impl From<&str> for OwaValue {
fn from(val: &str) -> Self {
Self::Str(Arc::from(val))
}
}

18
src/lib.rs Normal file
View file

@ -0,0 +1,18 @@
#![warn(
clippy::all,
clippy::pedantic,
clippy::nursery,
clippy::cargo,
clippy::perf
)]
#![allow(
clippy::multiple_crate_versions, // idk how to fix this
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::needless_continue
)]
#![cfg_attr(test, allow(clippy::needless_pass_by_value))]
pub mod builtins;
pub mod core;
pub mod parser;

51
src/main.rs Normal file
View file

@ -0,0 +1,51 @@
use owa_rs::{
builtins,
core::{Interpreter, OwaValue, Scope, special_names},
};
use tracing::error;
fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();
// flags
let argv = std::env::args().collect::<Vec<String>>();
let argv = argv.iter().map(|s| s.as_str()).collect::<Vec<_>>();
let no_owu = argv.contains(&"--no-owu");
// builtins
let builtins = [("builtins", builtins::scope().local_runtime_to_map())];
// init root scope
let mut interpreter = Interpreter::new(None);
let scope = Scope::new_with_runtime(builtins);
let scope_id = interpreter.allocate_scope(scope).unwrap();
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
interpreter
.define_runtime(
scope_id,
special_names::DIR,
OwaValue::str(cwd.to_str().unwrap_or(".")),
)
.unwrap();
// eval target
let target_path = if no_owu {
let target = argv.iter().find(|s| s.ends_with(".owa"));
if let Some(target) = target {
target
} else {
error!("No owa file specified");
std::process::exit(1);
}
} else {
"modules/owu/main.owa"
};
interpreter
.run(target_path, scope_id)
.unwrap_or_else(|err| {
error!("Failed to load owu: {}", err.message());
std::process::exit(1)
});
Ok(())
}

37
src/parser/annotation.rs Normal file
View file

@ -0,0 +1,37 @@
use nom::{
IResult, Parser as _, bytes::complete::tag, character::multispace0, combinator::cut,
sequence::delimited,
};
use crate::parser::{OwaAst, parse_value, ws};
pub(super) fn parse_annotation(input: &str) -> IResult<&str, OwaAst> {
delimited(
ws(tag("<")),
cut(parse_value),
cut((multispace0(), tag(">"))),
)
.parse(input)
}
#[test]
fn test_annotation() {
assert_eq!(
parse_annotation("<(:a :b :c)>"),
Ok((
"",
OwaAst::call([OwaAst::kw("a"), OwaAst::kw("b"), OwaAst::kw("c")])
))
);
assert_eq!(
parse_annotation("<(:a [:b :c] [(:d)])>garbage"),
Ok((
"garbage",
OwaAst::call([
OwaAst::kw("a"),
OwaAst::vec([OwaAst::kw("b"), OwaAst::kw("c"),]),
OwaAst::vec([OwaAst::call([OwaAst::kw("d")]),]),
])
))
);
}

353
src/parser/ast.rs Normal file
View file

@ -0,0 +1,353 @@
use super::to_indent;
use crate::{
core::{Interpreter, OwaError, OwaValue, special_names},
owa_base_impl, vec_unparse,
};
use num_traits::ToPrimitive;
use ordered_float::OrderedFloat;
use rpds::{RedBlackTreeMapSync, RedBlackTreeSetSync, VectorSync};
use std::sync::Arc;
#[derive(Debug, Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[warn(unpredictable_function_pointer_comparisons)]
pub struct OwaAnnotation {
pub annotation: Arc<OwaAst>,
pub value: Arc<OwaAst>,
}
#[derive(Hash, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[warn(unpredictable_function_pointer_comparisons)]
#[non_exhaustive]
pub enum OwaAst {
Annotation(OwaAnnotation), // <...>...
Call(VectorSync<Self>), // (...)
Float(OrderedFloat<f64>), // 1.0
Int(i64), // 1
Keyword(Arc<str>), // :ident
Lambda(VectorSync<Self>), // #(...)
Map(RedBlackTreeMapSync<Self, Self>), // #{...}
Quote(Arc<Self>), // '...
Set(RedBlackTreeSetSync<Self>), // {...}
Str(Arc<str>), // "..."
Symbol(Arc<str>), // ident
Unquote(Arc<Self>), // $...
Vec(VectorSync<Self>), // [...]
}
impl OwaAst {
owa_base_impl!();
#[must_use]
pub fn call<C>(coll: C) -> Self
where
C: IntoIterator<Item = Self>,
{
Self::Call(VectorSync::from_iter(coll))
}
#[must_use]
pub fn to_call(self) -> Self {
Self::Call(VectorSync::from_iter([self]))
}
/// Returns a reference to the inner vector if this node is a [`OwaAst::Call`].
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if this node is not the `Call` variant.
pub fn as_call(&self) -> Result<&VectorSync<Self>, OwaError> {
match self {
Self::Call(v) => Ok(v),
_ => Err(OwaError::TypeError("call".into(), self.type_name().into())),
}
}
pub fn new_lambda<B>(body: B) -> Self
where
B: IntoIterator<Item = Self>,
{
Self::Lambda(VectorSync::from_iter(body))
}
pub fn sym<S: AsRef<str>>(s: S) -> Self {
let s = to_indent(s);
Self::Symbol(Arc::from(s))
}
/// Returns the inner symbol string if this node is a [`OwaAst::Symbol`].
///
/// # Errors
///
/// Returns [`OwaError::TypeError`] if this node is not the `Symbol` variant.
pub fn as_sym(&self) -> Result<Arc<str>, OwaError> {
match self {
Self::Symbol(s) => Ok(s.clone()),
_ => Err(OwaError::TypeError(
"symbol".into(),
self.type_name().into(),
)),
}
}
#[must_use]
pub fn new_quote(v: Self) -> Self {
Self::Quote(Arc::from(v))
}
#[must_use]
pub fn to_quote(self) -> Self {
Self::Quote(Arc::from(self))
}
#[must_use]
pub fn new_unquote(v: Self) -> Self {
Self::Unquote(Arc::from(v))
}
#[must_use]
pub fn to_unquote(self) -> Self {
Self::Unquote(Arc::from(self))
}
#[must_use]
pub fn to_annotation(self, ann: Self) -> Self {
Self::Annotation(OwaAnnotation {
annotation: Arc::from(ann),
value: Arc::from(self),
})
}
#[must_use]
pub fn type_name(&self) -> &'static str {
match self {
Self::Symbol(_) => "symbol",
Self::Keyword(_) => "keyword",
Self::Str(_) => "str",
Self::Float(_) => "float",
Self::Int(_) => "int",
Self::Vec(_) => "vec",
Self::Call(_) => "call",
Self::Set(_) => "set",
Self::Map(_) => "map",
Self::Quote(_) => "quote",
Self::Unquote(_) => "unquote",
Self::Lambda(_) => "lambda",
Self::Annotation(v) => v.value.type_name(),
}
}
#[must_use]
pub fn unparse(&self) -> String {
match self {
Self::Symbol(v) => v.to_string(),
Self::Keyword(v) => format!(":{v}"),
Self::Str(v) => format!("\"{v}\""),
Self::Float(v) => {
let s = format!("{v}");
if s.contains('.') { s } else { format!("{s}.0") }
}
Self::Int(v) => v.to_string(),
Self::Vec(v) => {
format!("[{}]", vec_unparse!(v.iter()))
}
Self::Call(v) => format!("({})", vec_unparse!(v.iter())),
Self::Set(v) => {
format!("#{{{}}}", vec_unparse!(v.iter()))
}
Self::Map(v) => format!("{{{}}}", vec_unparse!(v.iter().flat_map(|(k, v)| [k, v]))),
Self::Quote(v) => format!("'{}", v.unparse()),
Self::Unquote(v) => format!("${}", v.unparse()),
Self::Lambda(v) => format!("#({})", vec_unparse!(v.iter())),
Self::Annotation(ann) => {
format!("<{}>{}", ann.annotation.unparse(), ann.value.unparse())
}
}
}
#[must_use]
pub fn to_str(&self) -> String {
match self {
Self::Symbol(s) => s.to_string(),
Self::Str(s) => s.as_ref().to_string(),
_ => self.unparse(),
}
}
#[must_use]
pub fn unannotate(&self) -> Self {
match self {
Self::Annotation(ann) => ann.value.unannotate(),
Self::Vec(v) => Self::Vec(v.iter().map(Self::unannotate).collect()),
Self::Call(v) => Self::Call(v.iter().map(Self::unannotate).collect()),
Self::Set(v) => Self::Set(v.iter().map(Self::unannotate).collect()),
Self::Map(v) => Self::map(
v.iter()
.flat_map(|(k, v)| [Self::unannotate(k), Self::unannotate(v)]),
),
Self::Lambda(v) => Self::Lambda(v.iter().map(Self::unannotate).collect()),
Self::Quote(v) => Self::Quote(Arc::from(v.unannotate())),
Self::Unquote(v) => Self::Unquote(Arc::from(v.unannotate())),
_ => self.clone(),
}
}
#[must_use]
pub fn pack(&self) -> OwaValue {
let type_name = OwaValue::kw(self.type_name());
let data = match self {
Self::Symbol(v) => OwaValue::str(v),
Self::Keyword(v) => OwaValue::kw(v),
Self::Str(v) => OwaValue::str(v),
Self::Float(v) => OwaValue::float(v.into_inner()),
Self::Int(v) => OwaValue::Int(*v),
Self::Vec(v) => OwaValue::vec(v.iter().map(Self::pack)),
Self::Call(v) => OwaValue::vec(v.iter().map(Self::pack)),
Self::Set(v) => OwaValue::set(v.iter().map(Self::pack)),
Self::Map(v) => OwaValue::map(v.iter().flat_map(|(k, v)| [k.pack(), v.pack()])),
Self::Quote(v) => OwaValue::quote(v.clone()),
Self::Unquote(v) => v.pack(),
Self::Lambda(v) => OwaValue::vec(v.iter().map(Self::pack)),
Self::Annotation(v) => OwaValue::map(vec![
OwaValue::kw("value"),
v.value.as_ref().pack(),
OwaValue::kw("annotation"),
v.annotation.as_ref().pack(),
]),
};
OwaValue::map(vec![
OwaValue::kw("type"),
type_name,
OwaValue::kw("data"),
data,
])
}
}
pub struct Expander<'a> {
interpreter: &'a mut Interpreter,
scope_id: usize,
}
impl<'a> Expander<'a> {
pub const fn new(interpreter: &'a mut Interpreter, scope_id: usize) -> Self {
Self {
interpreter,
scope_id,
}
}
#[must_use]
pub fn expand(&self, ast: &OwaAst, in_unquote: bool, in_quote: bool) -> OwaAst {
self.expand_priv(ast, in_unquote, in_quote)
.pop()
.unwrap_or_else(|| ast.clone())
}
#[must_use]
fn resolve(&self, sym: &str) -> Vec<OwaAst> {
let result = match sym {
special_names::ALL_ARGS | special_names::REST_ARGS => {
let val = self.interpreter.lookup_expand(self.scope_id, sym);
val.and_then(|v| v.as_vec())
.map(|v| v.iter().cloned().collect::<Vec<_>>())
}
_ => {
let val = self.interpreter.lookup_expand(self.scope_id, sym);
val.map(|v| vec![v.clone()])
}
};
result.unwrap_or_else(|_| vec![OwaAst::sym(sym)])
}
#[must_use]
fn expand_priv(&self, ast: &OwaAst, in_unquote: bool, in_quote: bool) -> Vec<OwaAst> {
match ast {
OwaAst::Symbol(s) => {
if !in_unquote {
return vec![ast.clone()];
}
self.resolve(s.as_ref())
}
OwaAst::Vec(v) => {
let transformed = v.iter().map(|v| self.expand_priv(v, in_unquote, in_quote));
vec![OwaAst::vec(transformed.flatten())]
}
OwaAst::Call(v) => {
let transformed = v.iter().map(|v| self.expand_priv(v, in_unquote, in_quote));
vec![OwaAst::call(transformed.flatten())]
}
OwaAst::Set(v) => {
let transformed = v.iter().map(|v| self.expand_priv(v, in_unquote, in_quote));
vec![OwaAst::set(transformed.flatten())]
}
OwaAst::Map(v) => {
let transformed = v.iter().flat_map(|(k, v)| {
[
self.expand_priv(k, in_unquote, in_quote),
self.expand_priv(v, in_unquote, in_quote),
]
});
vec![OwaAst::map(transformed.flatten())]
}
OwaAst::Lambda(v) => {
let transformed = v.iter().map(|v| self.expand_priv(v, in_unquote, in_quote));
vec![OwaAst::new_lambda(transformed.flatten())]
}
OwaAst::Quote(v) => vec![if in_quote {
ast.clone()
} else {
v.as_ref().clone()
}],
OwaAst::Unquote(v) => {
if in_unquote {
vec![ast.clone()]
} else {
self.expand_priv(v, true, in_quote)
}
}
_ => vec![ast.clone()],
}
}
}
impl std::fmt::Debug for OwaAst {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.unparse())
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_case::test_case;
#[test_case(OwaAst::sym("foo"), "foo")]
#[test_case(OwaAst::kw("foo"), ":foo")]
#[test_case(OwaAst::str("foo"), "\"foo\"")]
#[test_case(OwaAst::float(1.0), "1.0")]
#[test_case(OwaAst::float(1.2), "1.2")]
#[test_case(OwaAst::float(1.234), "1.234")]
#[test_case(
OwaAst::vec([OwaAst::int(1), OwaAst::str("foo"), OwaAst::kw("a")]),
"[1 \"foo\" :a]"
)]
#[test_case(
OwaAst::call([OwaAst::int(1), OwaAst::float(1.0), OwaAst::kw("b"), OwaAst::str("foo")]),
"(1 1.0 :b \"foo\")"
)]
#[test_case(
OwaAst::set([OwaAst::int(1), OwaAst::float(1.0), OwaAst::kw("b"), OwaAst::str("foo")]),
"#{1.0 1 :b \"foo\"}"
)]
#[test_case(
OwaAst::map([
OwaAst::int(1), OwaAst::float(1.0),
OwaAst::kw("b"), OwaAst::str("foo"),
OwaAst::str("foo"), OwaAst::kw("b")
]),
"{1 1.0 :b \"foo\" \"foo\" :b}"
)]
fn unparse_tests(value: OwaAst, expected: &str) {
assert_eq!(value.unparse(), expected);
}
}

29
src/parser/call.rs Normal file
View file

@ -0,0 +1,29 @@
use nom::{IResult, Parser as _, combinator::map};
use crate::parser::{OwaAst, collection};
pub(super) fn parse_call(input: &str) -> IResult<&str, OwaAst> {
map(collection("(", ")"), OwaAst::call).parse(input)
}
#[test]
fn test_call() {
assert_eq!(
parse_call("(:a :b :c)"),
Ok((
"",
OwaAst::call([OwaAst::kw("a"), OwaAst::kw("b"), OwaAst::kw("c")])
))
);
assert_eq!(
parse_call("(:a [:b :c] [(:d)])garbage"),
Ok((
"garbage",
OwaAst::call([
OwaAst::kw("a"),
OwaAst::vec([OwaAst::kw("b"), OwaAst::kw("c"),]),
OwaAst::vec([OwaAst::call([OwaAst::kw("d")]),]),
])
))
);
}

14
src/parser/keyword.rs Normal file
View file

@ -0,0 +1,14 @@
use nom::{IResult, Parser as _, bytes::complete::tag, combinator::map, sequence::preceded};
use crate::parser::{OwaAst, identifier};
pub(super) fn parse_keyword(input: &str) -> IResult<&str, OwaAst> {
map(preceded(tag(":"), identifier), OwaAst::kw).parse(input)
}
#[test]
fn test_keyword() {
assert_eq!(parse_keyword(":foo"), Ok(("", OwaAst::kw("foo"))));
assert_eq!(parse_keyword(":foo1232"), Ok(("", OwaAst::kw("foo1232"))));
assert_eq!(parse_keyword(":foo:s"), Ok((":s", OwaAst::kw("foo"))));
}

30
src/parser/lambda.rs Normal file
View file

@ -0,0 +1,30 @@
use nom::{IResult, Parser as _, combinator::map};
use crate::parser::{OwaAst, collection};
pub(super) fn parse_lambda(input: &str) -> IResult<&str, OwaAst> {
map(collection("#(", ")"), OwaAst::new_lambda).parse(input)
}
#[test]
fn test_call() {
assert_eq!(
parse_lambda("#(:a :b :c)"),
Ok((
"",
OwaAst::new_lambda([OwaAst::kw("a"), OwaAst::kw("b"), OwaAst::kw("c")])
))
);
assert_eq!(
parse_lambda("#(:a [:b :c] [(:d)] #(:e))garbage"),
Ok((
"garbage",
OwaAst::new_lambda([
OwaAst::kw("a"),
OwaAst::vec([OwaAst::kw("b"), OwaAst::kw("c")]),
OwaAst::vec([OwaAst::call([OwaAst::kw("d")])]),
OwaAst::new_lambda([OwaAst::kw("e")])
])
))
);
}

39
src/parser/map.rs Normal file
View file

@ -0,0 +1,39 @@
use nom::{IResult, Parser as _, combinator::map};
use crate::parser::{OwaAst, collection};
pub(super) fn parse_map(input: &str) -> IResult<&str, OwaAst> {
map(collection("{", "}"), OwaAst::map).parse(input)
}
#[test]
fn test_call() {
assert_eq!(
parse_map("{:a :b :c}"),
Ok(("", OwaAst::map([OwaAst::kw("a"), OwaAst::kw("b")])))
);
assert_eq!(
parse_map("{:a :b :c :d}"),
Ok((
"",
OwaAst::map([
OwaAst::kw("a"),
OwaAst::kw("b"),
OwaAst::kw("c"),
OwaAst::kw("d")
])
))
);
assert_eq!(
parse_map("{:a #{:b :c} [(:d)] :e}garbage"),
Ok((
"garbage",
OwaAst::map([
OwaAst::kw("a"),
OwaAst::set([OwaAst::kw("b"), OwaAst::kw("c")]),
OwaAst::vec([OwaAst::call([OwaAst::kw("d"),]),]),
OwaAst::kw("e")
])
))
);
}

163
src/parser/mod.rs Normal file
View file

@ -0,0 +1,163 @@
use nom::{
IResult, Parser,
branch::alt,
bytes::complete::{is_not, tag},
character::complete::multispace1,
combinator::{cut, map, recognize},
error::Error,
multi::{many0, many1},
sequence::{delimited, pair, preceded},
};
mod annotation;
pub mod ast;
mod call;
mod keyword;
mod lambda;
mod map;
mod number;
mod quote;
mod set;
mod string;
mod symbol;
mod unquote;
mod vector;
use annotation::parse_annotation;
use ast::OwaAst;
use call::parse_call;
use keyword::parse_keyword;
use map::parse_map;
use number::{parse_float, parse_integer};
use quote::parse_quote;
use set::parse_set;
use string::parse_string;
use symbol::parse_symbol;
use unquote::parse_unquote;
use vector::parse_vector;
use crate::parser::lambda::parse_lambda;
pub(crate) const INDENT_IGNORE: &str = " \t\n\r,;:{}[]()<>$#\"'";
pub(crate) fn to_indent<S: AsRef<str>>(s: S) -> String {
s.as_ref()
.chars()
.filter(|c| !INDENT_IGNORE.contains(*c))
.collect()
}
fn identifier(input: &str) -> IResult<&str, &str> {
recognize(many1(is_not(INDENT_IGNORE))).parse(input)
}
fn parse_comment(input: &str) -> IResult<&str, ()> {
map(preceded(tag(";;"), is_not("\n")), |_| ()).parse(input)
}
fn skip_ws(input: &str) -> IResult<&str, ()> {
map(
many0(alt((map(multispace1, |_| ()), parse_comment))),
|_| (),
)
.parse(input)
}
fn collection<'a>(
start: &str,
end: &str,
) -> impl Parser<&'a str, Output = Vec<OwaAst>, Error = Error<&'a str>> {
delimited(
ws(tag(start)),
cut(many0(parse_value)),
cut((skip_ws, tag(end))),
)
}
fn ws<'a, O, F>(inner: F) -> impl Parser<&'a str, Output = O, Error = Error<&'a str>>
where
F: Parser<&'a str, Output = O, Error = Error<&'a str>>,
{
delimited(skip_ws, inner, skip_ws)
}
fn parse_atom(input: &str) -> IResult<&str, OwaAst> {
preceded(
skip_ws,
alt((
parse_float, // must be before parse_integer
parse_integer,
parse_string,
parse_keyword, // must be before parse_symbol
parse_symbol,
parse_lambda, // must be before parse_call
parse_call,
parse_set, // must be before parse_map
parse_map,
parse_vector,
parse_unquote,
parse_quote,
)),
)
.parse(input)
}
/// Parses a single OWA value (with optional leading annotations) from `input`.
///
/// # Errors
///
/// Returns a [`nom::Err`] if the input does not contain a valid OWA expression.
pub fn parse_value(input: &str) -> IResult<&str, OwaAst> {
map(
pair(many0(parse_annotation), parse_atom),
|(annotations, value)| {
let mut result = value;
for annotation in annotations {
result = result.to_annotation(annotation);
}
result
},
)
.parse(input)
}
/// Reads a file from disk and parses its contents as a single OWA value.
///
/// # Errors
///
/// Returns an error string if the file cannot be read or if its contents
/// cannot be parsed as a valid OWA expression.
pub fn parse_file(raw_filename: &str) -> Result<OwaAst, String> {
let content = std::fs::read_to_string(raw_filename)
.map_err(|err| format!("Failed to read file {raw_filename}: {err}"))?;
match parse_value(&content) {
Ok((_, value)) => Ok(value),
Err(err) => Err(format!("Failed to parse file {raw_filename}: {err:?}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use test_case::test_case;
#[test_case("(1 2 3)", OwaAst::call([OwaAst::int(1), OwaAst::int(2), OwaAst::int(3)]))]
#[test_case(r#"("hi" 1 "bye")"#, OwaAst::call([OwaAst::str("hi"), OwaAst::int(1), OwaAst::str("bye")]))]
#[test_case("[1 2 3]", OwaAst::vec([OwaAst::int(1), OwaAst::int(2), OwaAst::int(3)]))]
#[test_case(r#"["hello world!" 1 "bye"]"#, OwaAst::vec([OwaAst::str("hello world!"), OwaAst::int(1), OwaAst::str("bye")]))]
#[test_case(";; comment\n(1 2 3)", OwaAst::call([OwaAst::int(1), OwaAst::int(2), OwaAst::int(3)]))]
#[test_case("(1 ;; comment\n 2 3)", OwaAst::call([OwaAst::int(1), OwaAst::int(2), OwaAst::int(3)]))]
#[test_case("(1 2 ;; comment\n 3)", OwaAst::call([OwaAst::int(1), OwaAst::int(2), OwaAst::int(3)]))]
#[test_case("(1 2 3) ;; trailing comment", OwaAst::call([OwaAst::int(1), OwaAst::int(2), OwaAst::int(3)]))]
fn parse_tests(input: &str, expected: OwaAst) {
assert_eq!(parse_value(input).unwrap().1, expected);
}
#[test]
fn parse_comment_test() {
let input = ";; this is a comment\n(1 2 3)";
let expected = OwaAst::call([OwaAst::int(1), OwaAst::int(2), OwaAst::int(3)]);
assert_eq!(parse_value(input).unwrap().1, expected);
}
}

170
src/parser/number.rs Normal file
View file

@ -0,0 +1,170 @@
use nom::{
IResult, Parser as _,
branch::alt,
bytes::complete::tag,
character::complete::{char, one_of},
combinator::{map_res, opt, recognize},
multi::{many0, many1},
sequence::{preceded, terminated},
};
use crate::parser::OwaAst;
fn hexadecimal(input: &str) -> IResult<&str, isize> {
map_res(
preceded(
alt((tag("0x"), tag("0X"))),
recognize(many1(terminated(
one_of("0123456789abcdefABCDEF"),
many0(char('_')),
))),
),
|out: &str| isize::from_str_radix(&str::replace(out, "_", ""), 16),
)
.parse(input)
}
fn octal(input: &str) -> IResult<&str, isize> {
map_res(
preceded(
alt((tag("0o"), tag("0O"))),
recognize(many1(terminated(one_of("01234567"), many0(char('_'))))),
),
|out: &str| isize::from_str_radix(&str::replace(out, "_", ""), 8),
)
.parse(input)
}
fn binary(input: &str) -> IResult<&str, isize> {
map_res(
preceded(
alt((tag("0b"), tag("0B"))),
recognize(many1(terminated(one_of("01"), many0(char('_'))))),
),
|out: &str| isize::from_str_radix(&str::replace(out, "_", ""), 2),
)
.parse(input)
}
fn decimal(input: &str) -> IResult<&str, isize> {
map_res(
recognize((
opt(one_of("+-")),
many1(terminated(one_of("0123456789"), many0(char('_')))),
)),
|out: &str| str::replace(out, "_", "").parse::<isize>(),
)
.parse(input)
}
fn decimal_str(input: &str) -> IResult<&str, &str> {
recognize(many1(terminated(one_of("0123456789"), many0(char('_'))))).parse(input)
}
pub fn parse_integer(input: &str) -> IResult<&str, OwaAst> {
let (input, value) = (alt((hexadecimal, octal, binary, decimal))).parse(input)?;
Ok((input, OwaAst::int(value)))
}
pub fn parse_float(input: &str) -> IResult<&str, OwaAst> {
let result = map_res(
alt((
// Case one: .42
recognize((
opt(one_of("+-")),
char('.'),
decimal_str,
opt((one_of("eE"), opt(one_of("+-")), decimal_str)),
)), // Case two: 42e42 and 42.42e42
recognize((
opt(one_of("+-")),
decimal_str,
opt(preceded(char('.'), decimal_str)),
one_of("eE"),
opt(one_of("+-")),
decimal_str,
)), // Case three: 42. and 42.42
recognize((opt(one_of("+-")), decimal_str, char('.'), opt(decimal_str))),
)),
|out: &str| -> Result<OwaAst, std::num::ParseFloatError> {
let result = out.parse::<f64>()?;
Ok(OwaAst::float(result))
},
)
.parse(input)?;
Ok(result)
}
#[test]
fn test_integer() {
// decimal
assert_eq!(parse_integer("12garbage"), Ok(("garbage", OwaAst::int(12))));
assert_eq!(parse_integer("12"), Ok(("", OwaAst::int(12))));
assert_eq!(parse_integer("-12garbage"), Ok(("garbage", OwaAst::int(-12))));
// assert_eq!(integer("-12"), Ok(("", OwaAst::int(-12))));
// hexadecimal
assert_eq!(parse_integer("0x12"), Ok(("", OwaAst::int(0x12))));
assert_eq!(
parse_integer("0x12garbage"),
Ok(("garbage", OwaAst::int(0x12)))
);
// assert_eq!(integer("-0x12"), Ok(("", OwaAst::int(-0x12))));
// assert_eq!(
// integer("-0x12garbage"),
// Ok(("garbage", OwaAst::int(-0x12)))
// );
// octal
assert_eq!(parse_integer("0o12"), Ok(("", OwaAst::int(0o12))));
assert_eq!(
parse_integer("0o12garbage"),
Ok(("garbage", OwaAst::int(0o12)))
);
// assert_eq!(integer("-0o12"), Ok(("", OwaAst::int(-0o12))));
// assert_eq!(
// integer("-0o12garbage"),
// Ok(("garbage", OwaAst::int(-0o12)))
// );
// binary
assert_eq!(parse_integer("0b10"), Ok(("", OwaAst::int(0b10))));
assert_eq!(
parse_integer("0b10garbage"),
Ok(("garbage", OwaAst::int(0b10)))
);
// assert_eq!(integer("-0b10"), Ok(("", OwaAst::int(-0b10))));
// assert_eq!(
// integer("-0b10garbage"),
// Ok(("garbage", OwaAst::int(-0b10)))
// );
}
#[test]
fn test_float() {
// normal
assert_eq!(parse_float("12."), Ok(("", OwaAst::float(12.0))));
assert_eq!(parse_float("12.0"), Ok(("", OwaAst::float(12.0))));
assert_eq!(parse_float("-12.0"), Ok(("", OwaAst::float(-12.0))));
assert_eq!(parse_float("12.garbage"), Ok(("garbage", OwaAst::float(12.0))));
assert_eq!(
parse_float("12.0garbage"),
Ok(("garbage", OwaAst::float(12.0)))
);
assert_eq!(
parse_float("-12.0garbage"),
Ok(("garbage", OwaAst::float(-12.0)))
);
// with exponent
assert_eq!(parse_float("12.0e12"), Ok(("", OwaAst::float(12.0e12))));
assert_eq!(
parse_float("12.0e12garbage"),
Ok(("garbage", OwaAst::float(12.0e12)))
);
assert_eq!(parse_float("-12.0e12"), Ok(("", OwaAst::float(-12.0e12))));
assert_eq!(
parse_float("-12.0e12garbage"),
Ok(("garbage", OwaAst::float(-12.0e12)))
);
}

24
src/parser/quote.rs Normal file
View file

@ -0,0 +1,24 @@
use nom::{IResult, Parser as _, bytes::complete::tag, combinator::map, sequence::preceded};
use crate::parser::{OwaAst, parse_value};
pub fn parse_quote(input: &str) -> IResult<&str, OwaAst> {
map(preceded(tag("'"), parse_value), OwaAst::new_quote).parse(input)
}
#[test]
fn test_keyword() {
assert_eq!(parse_quote("'foo"), Ok(("", OwaAst::sym("foo").to_quote())));
assert_eq!(
parse_quote("':foo1232"),
Ok(("", OwaAst::kw("foo1232").to_quote()))
);
assert_eq!(
parse_quote("'\"foo\":s"),
Ok((":s", OwaAst::str("foo").to_quote()))
);
assert_eq!(
parse_quote("''[\"foo\"]:s"),
Ok((":s", OwaAst::str("foo").to_vec().to_quote().to_quote()))
);
}

29
src/parser/set.rs Normal file
View file

@ -0,0 +1,29 @@
use nom::{IResult, Parser as _, combinator::map};
use crate::parser::{OwaAst, collection};
pub fn parse_set(input: &str) -> IResult<&str, OwaAst> {
map(collection("#{", "}"), OwaAst::set).parse(input)
}
#[test]
fn test_set() {
assert_eq!(
parse_set("#{:a :b :c}"),
Ok((
"",
OwaAst::set([OwaAst::kw("a"), OwaAst::kw("b"), OwaAst::kw("c")])
))
);
assert_eq!(
parse_set("#{:a [:b #{\"c\"}] [(:d)]}garbage"),
Ok((
"garbage",
OwaAst::set([
OwaAst::kw("a"),
OwaAst::vec([OwaAst::kw("b"), OwaAst::set([OwaAst::str("c")]),]),
OwaAst::vec([OwaAst::call([OwaAst::kw("d"),]),])
])
))
);
}

96
src/parser/string.rs Normal file
View file

@ -0,0 +1,96 @@
use nom::combinator::opt;
use nom::{IResult, Parser};
use nom::{
bytes::complete::{escaped, tag},
character::complete::{none_of, one_of},
combinator::map_res,
sequence::delimited,
};
use crate::parser::OwaAst;
fn unescape_string(s: &str) -> Result<String, String> {
let mut result = String::new();
let mut chars = s.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('n') => result.push('\n'),
Some('r') => result.push('\r'),
Some('t') => result.push('\t'),
Some('b') => result.push('\x08'),
Some('f') => result.push('\x0C'),
Some('\\') => result.push('\\'),
Some('"') => result.push('"'),
Some(c) => {
return Err(format!("Unknown escape sequence: \\{c}"));
}
None => {
return Err("Unexpected end of string after backslash".to_string());
}
}
} else {
result.push(ch);
}
}
Ok(result)
}
pub fn parse_string(i: &str) -> IResult<&str, OwaAst> {
let (input, value) = delimited(
tag("\""),
opt(map_res(
escaped(none_of("\"\\"), '\\', one_of("\"\\nrtbf")),
unescape_string,
)),
tag("\""),
)
.parse(i)?;
Ok((input, OwaAst::str(value.unwrap_or_else(String::new))))
}
#[test]
fn test_symbol() {
assert_eq!(parse_string("\"foo\""), Ok(("", OwaAst::str("foo"))));
assert_eq!(
parse_string("\"foo1232\""),
Ok(("", OwaAst::str("foo1232")))
);
assert_eq!(
parse_string("\"foo\"garbage"),
Ok(("garbage", OwaAst::str("foo")))
);
assert_eq!(
parse_string("\"foo\\\"bar\"garbage"),
Ok(("garbage", OwaAst::str("foo\"bar")))
);
// Test newline escape
assert_eq!(
parse_string("\"hello\\nworld\""),
Ok(("", OwaAst::str("hello\nworld")))
);
// Test tab escape
assert_eq!(
parse_string("\"tab\\there\""),
Ok(("", OwaAst::str("tab\there")))
);
// Test backslash escape
assert_eq!(
parse_string("\"path\\\\file\""),
Ok(("", OwaAst::str("path\\file")))
);
// Test carriage return
assert_eq!(
parse_string("\"line\\rend\""),
Ok(("", OwaAst::str("line\rend")))
);
// Test space
assert_eq!(
parse_string("\"hello world\""),
Ok(("", OwaAst::str("hello world")))
);
}

22
src/parser/symbol.rs Normal file
View file

@ -0,0 +1,22 @@
use nom::{IResult, Parser as _, combinator::map};
use crate::parser::{OwaAst, identifier};
pub(super) fn parse_symbol(input: &str) -> IResult<&str, OwaAst> {
map(identifier, OwaAst::sym).parse(input)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn symbol() {
assert_eq!(parse_symbol("foo"), Ok(("", OwaAst::sym("foo"))));
assert_eq!(parse_symbol("foo1232"), Ok(("", OwaAst::sym("foo1232"))));
assert_eq!(
parse_symbol("foo:garbage"),
Ok((":garbage", OwaAst::sym("foo")))
);
}
}

27
src/parser/unquote.rs Normal file
View file

@ -0,0 +1,27 @@
use nom::{IResult, Parser as _, bytes::complete::tag, combinator::map, sequence::preceded};
use crate::parser::{OwaAst, parse_value};
pub fn parse_unquote(input: &str) -> IResult<&str, OwaAst> {
map(preceded(tag("$"), parse_value), OwaAst::new_unquote).parse(input)
}
#[test]
fn test_keyword() {
assert_eq!(
parse_unquote("$foo"),
Ok(("", OwaAst::sym("foo").to_unquote()))
);
assert_eq!(
parse_unquote("$:foo1232"),
Ok(("", OwaAst::kw("foo1232").to_unquote()))
);
assert_eq!(
parse_unquote("$\"foo\":s"),
Ok((":s", OwaAst::str("foo").to_unquote()))
);
assert_eq!(
parse_unquote("$$[\"foo\"]:s"),
Ok((":s", OwaAst::str("foo").to_vec().to_unquote().to_unquote()))
);
}

29
src/parser/vector.rs Normal file
View file

@ -0,0 +1,29 @@
use nom::{IResult, Parser as _, combinator::map};
use crate::parser::{OwaAst, collection};
pub fn parse_vector(input: &str) -> IResult<&str, OwaAst> {
map(collection("[", "]"), OwaAst::vec).parse(input)
}
#[test]
fn test_vector() {
assert_eq!(
parse_vector("[:a :b :c]"),
Ok((
"",
OwaAst::vec([OwaAst::kw("a"), OwaAst::kw("b"), OwaAst::kw("c"),])
))
);
assert_eq!(
parse_vector("[:a [:b :c] [[:d]]]garbage"),
Ok((
"garbage",
OwaAst::vec([
OwaAst::kw("a"),
OwaAst::vec([OwaAst::kw("b"), OwaAst::kw("c"),]),
OwaAst::vec([OwaAst::vec([OwaAst::kw("d"),]),]),
])
))
);
}