More buildins and vscode exstention <CO-AUthored, ai>

This commit is contained in:
allexanderbergmns
2026-08-25 16:20:11 +02:00
parent 5daadce7a9
commit 7ed774089e
27 changed files with 2080 additions and 122 deletions
+13
View File
@@ -64,3 +64,16 @@ jobs:
grep -n 'fn test_failing' examples/tests.vr || true
sed '/fn test_failing/,/^}/d' examples/tests.vr > /tmp/ci_tests.vr
./bin/vr test /tmp/ci_tests.vr
- name: Run stdlib test suite
run: |
./bin/vr test examples/stdlib.vr
- name: LSP smoke test
run: |
BODY='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
printf 'Content-Length: %d\r\n\r\n%s' "${#BODY}" "$BODY" | ./bin/vr lsp | grep -q '"id":1' && echo "LSP responded"
- name: Watch-mode and directory-test smoke
run: |
./bin/vr test examples | grep -q 'passed'
+19 -3
View File
@@ -19,6 +19,9 @@ jobs:
- os: macos-latest
artifact: vr-macos-arm64
binary: vr
- os: windows-latest
artifact: vr-windows-amd64.exe
binary: vr.exe
runs-on: ${{ matrix.os }}
@@ -26,17 +29,30 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install V
- name: Install V (unix)
if: runner.os != 'Windows'
shell: bash
run: |
git clone --depth 1 https://github.com/vlang/v /tmp/v
cd /tmp/v && make
sudo ln -sf /tmp/v/v /usr/local/bin/v
v version
- name: Install V (windows)
if: runner.os == 'Windows'
shell: bash
run: |
curl -L -o v_windows.zip https://github.com/vlang/v/releases/latest/download/v_windows.zip
unzip -o v_windows.zip -d /tmp/vwin
echo "/tmp/vwin/v" >> "$GITHUB_PATH"
v version
- name: Build toolchain
shell: bash
run: v -o ${{ matrix.binary }} .
- name: Smoke test
shell: bash
run: ./${{ matrix.binary }} run examples/hello.vr
- name: Upload artifact
@@ -57,7 +73,7 @@ jobs:
- name: Verify tag matches v.mod version
run: |
TAG="${GITHUB_REF#refs/tags/v}"
MOD_VERSION=$(grep -oP "version: '\K[^"]+" v.mod)
MOD_VERSION=$(grep -oP "version: '\\K[^\"]+" v.mod)
if [ "$TAG" != "$MOD_VERSION" ]; then
echo "::error::Tag version (v${TAG}) does not match v.mod version (${MOD_VERSION})"
exit 1
@@ -74,7 +90,7 @@ jobs:
mkdir -p release
for dir in artifacts/*/; do
name=$(basename "$dir")
cp "$dir"/vr release/"$name"
cp "$dir"/* release/"$name"
chmod +x release/"$name"
done
ls -lh release/
+42 -7
View File
@@ -41,6 +41,7 @@ vr up rebuild bin/vr
vr symlink link bin/vr into your PATH
vr config [set <key> <value>] toolchain config (outdir, verbose)
vr repl interactive session
vr lsp language server (JSON-RPC over stdio)
vr fmt [-w] <file.vr> format source (keeps comments)
vr init [name] scaffold a project (vr.mod + main.vr)
vr get <owner/repo | git-url | ./path> fetch a package into vendor/
@@ -53,6 +54,9 @@ vr info | loader | alloc | version | help
./bin/vr repl # try expressions and functions interactively
./bin/vr fmt -w f.vr # normalize a file's indentation/spacing in place
./bin/vr init myproj # start a project; vr get owner/repo fetches packages
./bin/vr run -w f.vr # watch f.vr and rerun on every save
./bin/vr test examples # run every test_* fn in every .vr file under examples/
./bin/vr lsp # language server for editors (diagnostics, go-to-def)
```
Quick start:
@@ -165,12 +169,17 @@ Module functions are namespaced: `os.exists()`, `json.encode()`, ...
| module | functions |
| --- | --- |
| `os` | filesystem + process: `exists`, `is_dir`, `is_file`, `mkdir`, `remove`, `copy`, `list_dir`, `glob`, `join`, `base`, `dir`, `read_lines`, `write_lines`, `cwd`, `env`, `exec` |
| `os` | filesystem + process: `exists`, `is_dir`, `is_file`, `mkdir`, `remove`, `copy`, `list_dir`, `glob`, `join`, `base`, `dir`, `read_lines`, `write_lines`, `cwd`, `env`, `exec`, `exec_full` (`{code, stdout, stderr}`), `ext`, `abs`, `rel` |
| `json` | `encode`, `decode`, `pretty` — objects become structs, integral numbers decode as ints, `null``none` |
| `strings` | `length`, `lines`, `split`, `join`, `replace`, `contains`, `starts_with`, `ends_with`, `upper`, `lower`, `trim`, `pad`, `pad_left`, `repeat`, `format`, `capitalize` |
| `math` | `abs`, `min`, `max`, `floor`, `ceil`, `round`, `sqrt`, `pow`, `clamp`, `sign`, `pi` |
| `http` | `get(url)`, `post(url, data)`, `get_text(url)` — return `{status, body}`; network failures throw (catchable), HTTP errors (404) are normal responses |
| `time` | `now()` (epoch s), `ms()` (epoch ms), `format(t, "YYYY-MM-DD HH:mm:ss")`, `date(t)`, `clock(t)`, `parse("2026-08-25 13:36:45")` — Moment-style tokens |
| `http` | `get(url)`, `post(url, data)`, `get_text(url)`, `request(method, url, data, headers, timeout_ms)`, `get_with_headers`, `post_with_headers`, `get_timeout`, `post_timeout` — return `{status, body}`; network failures throw (catchable), HTTP errors (404) are normal responses |
| `time` | `now()` (epoch s), `ms()` (epoch ms), `format(t, "YYYY-MM-DD HH:mm:ss")`, `date(t)`, `clock(t)`, `parse("2026-08-25 13:36:45")`, `add_days/add_hours/add_minutes/add_seconds`, `weekday(t)` — Moment-style tokens |
| `regex` | `is_match(pattern, s)`, `find_all(pattern, s)`, `replace(pattern, s, repl)`, `split(pattern, s)` — RE2 syntax; use raw strings `r"\d+"` for patterns |
| `crypto` | `sha256(s)`, `md5(s)`, `base64_encode(s)`, `base64_decode(s)` |
| `csv` | `parse(s)` — returns an array of rows (arrays of cell strings) |
Examples: `examples/imports.vr`, `examples/http.vr`, `examples/time.vr`, `examples/stdlib.vr` (tests for the regex/crypto/csv/os/time additions).
Examples: `examples/imports.vr`, `examples/http.vr`, `examples/time.vr`.
@@ -257,7 +266,12 @@ fn main() {
- functions: `fn name(a, b) { ... }` with `return expr`; default parameter
values `fn f(a, b = 10)`, variadic params `fn f(nums...)`, destructuring
`let { a, b } = rec` and `let [x, y] = arr`, and anonymous closures
`let f = fn(x) { return x * 2 }` stored in variables and arrays
`let f = fn(x) { return x * 2 }` stored in variables and arrays; a closure
**captures the enclosing locals it references by value** (nested closures
included), so `let n = 1; let bump = fn() { n = n + 1; return n }` works —
writes hit the closure's own copy, the outer variable is untouched
- raw strings: `r"..."` passes text through verbatim (no escape processing),
ideal for regex patterns like `r"\d+"`
- generics: `fn first[T](arr) { return arr[0] }` with checked call sites
`first[int](arr)` — the VM is dynamically typed, so type parameters erase
to a single function but arity and duplicates are validated
@@ -269,6 +283,9 @@ fn main() {
and slice by character (runes), and methods like `s.to_upper()`,
`s.contains(x)`, `s.split(d)`, `s.index_of(x)`, `s.to_int()`, `s.len()`
work on any string-valued expression
- maps: structs serve as string-keyed maps with **hash-indexed O(1)** access;
keys may be computed expressions (`m[key_var] = v`, `m[key_var]`), not just
literals; `has(m, k)`, `delete(m, k)`, `keys(m)`, `len(m)` all work
- arrays: `[e1, e2, ...]`, indexing `a[i]` (read and write), `len(a)`,
`push/insert/remove/pop/sort/reverse/clone/index_of/join`; array literals
may nest
@@ -355,7 +372,7 @@ field name as a string first, exactly like the compiler does).
| `compiler/` | lexer, parser, type checker, bytecode codegen (VROBJ) |
| `assembler/` | `.vasm` -> VROBJ |
| `linker/` | resolves relocations, rebases strings, emits VRBIN |
| `vm/` | stack VM: tagged values, call frames, string/array heaps |
| `vm/` | stack VM: tagged values, call frames, string/array heaps, hash-indexed structs, GC-traced closure captures |
| `obj/` | VROBJ/VRBIN binary formats |
| `bin/` | small standalone tools: `tl_alloc.v`, `tl_loader.v` |
@@ -364,13 +381,31 @@ ints, string/array/struct/float/closure handles — so no integer ever
collides with a heap handle. A mark-and-sweep garbage collector runs between
opcodes when the heap grows past a threshold, tracing the stack (which holds
every frame's locals) and compacting the pools; string constants baked into
bytecode are never collected. Bytecode carries a line table, so runtime
errors report the source line. A conservative compile-time type checker
bytecode are never collected. Structs carry a hash index, so map-style field
access is O(1); closure captures live inside the closure and are traced and
remapped by the GC like any other heap value. Bytecode carries a line table,
so runtime errors report the source line **and a full call-stack trace**
(`fn` → line for every frame). A conservative compile-time type checker
(`compiler/check.v`) rejects provably wrong programs (unknown variables,
field access on numbers, arithmetic on strings, wrong arity) while leaving
dynamic programs alone. `vr debug` prints every instruction with the stack
contents (arrays rendered as `[1, 2, ...]`).
## Editor support
`vr lsp` speaks LSP over stdio, so any LSP-capable editor gets diagnostics
(the compiler's errors, positioned at the right line), go-to-definition
(functions, structs, enums, constants, locals, and stdlib module functions),
and hover (symbol kind + line). The `extension/` folder contains a VS Code
client (`code --install-extension extension/` after `npm install` in
`extension/`) with syntax highlighting, or point any other LSP client at
`vr lsp`.
## Releases
Tagging `vX.Y.Z` (matching `v.mod`) builds `vr` for Linux, macOS and
Windows and attaches them to a GitHub Release.
## Repository layout
```
+10 -1
View File
@@ -613,7 +613,16 @@ fn builtin_result_type(name string) TypeInfo {
'http_get', 'http_post' { TypeInfo{ kind: .struct_t } }
// date/time
'now', 'time_ms', 'parse_time' { TypeInfo{ kind: .int_t } }
'format_time' { TypeInfo{ kind: .string_t } }
'format_time', 'weekday' { TypeInfo{ kind: .string_t } }
// regex
'regex_match' { TypeInfo{ kind: .int_t } }
'regex_find_all', 'regex_split', 'csv_parse' { TypeInfo{ kind: .array_t } }
'regex_replace' { TypeInfo{ kind: .string_t } }
// crypto/encoding
'base64_encode', 'base64_decode', 'sha256', 'md5' { TypeInfo{ kind: .string_t } }
// extended HTTP + path/process helpers
'http_req', 'exec_full' { TypeInfo{ kind: .struct_t } }
'path_ext', 'path_abs', 'path_rel' { TypeInfo{ kind: .string_t } }
else { TypeInfo{ kind: .unknown } }
}
}
+262 -2
View File
@@ -44,6 +44,7 @@ mut:
enter_off u32
next_lbl int
modules map[string]bool // imported module names (bare `import os`)
captures []string // enclosing locals captured by the closure being compiled
}
fn gen(prog Program) !obj.Obj {
@@ -121,6 +122,7 @@ fn gen(prog Program) !obj.Obj {
}
}
for fd in prog.fns {
g.captures = []string{} // top-level functions capture nothing
g.gen_fn(fd)!
}
return obj.Obj{
@@ -142,9 +144,16 @@ fn (mut g Gen) gen_fn(fd FnDecl) ! {
g.locals.clear()
g.types.clear()
g.local_cnt = 0
g.argc = fd.params.len + if fd.recv_type.len > 0 { 1 } else { 0 }
// closure captures occupy the leading local slots (filled by the caller's
// op_call_closure), then the receiver (methods), then the parameters
g.argc = fd.params.len + g.captures.len + if fd.recv_type.len > 0 { 1 } else { 0 }
mut next := 0
if fd.recv_type.len > 0 {
if g.captures.len > 0 {
for i, c in g.captures {
g.locals[c] = i
}
next = g.captures.len
} else if fd.recv_type.len > 0 {
g.locals[fd.recv_name] = 0
g.types[fd.recv_name] = fd.recv_type
next = 1
@@ -711,6 +720,9 @@ fn (mut g Gen) gen_expr(e Expr) ! {
.anon_fn {
g.lam_counter++
name := '__lam_${g.lam_counter}'
// find the enclosing locals the body references (its free
// variables); they become this closure's captures
caps := g.scan_captures(e.fn_body, e.fparams)
// jump over the lambda body so callers don't fall through
g.code << op_jmp
g.code << obj.encode_i64(0)
@@ -735,8 +747,10 @@ fn (mut g Gen) gen_expr(e Expr) ! {
saved_local_cnt := g.local_cnt
saved_enter_off := g.enter_off
saved_argc := g.argc
saved_captures := g.captures
g.labels.clear()
g.fixups = []Fixup{}
g.captures = caps
g.gen_fn(fd)!
// Restore the enclosing state.
g.fixups = saved_fixups
@@ -746,12 +760,18 @@ fn (mut g Gen) gen_expr(e Expr) ! {
g.local_cnt = saved_local_cnt
g.enter_off = saved_enter_off
g.argc = saved_argc
g.captures = saved_captures
// Patch the skip jump to land at the closure opcode we emit next
// (PC-relative, like all other jump targets).
obj.patch_i64(mut g.code, skip_fix_off, i64(g.code.len - (int(skip_fix_off) + 8)))
// capture the enclosing locals' current values (capture by value)
for cname in caps {
g.emit_load(g.locals[cname])
}
g.code << op_closure
g.code << obj.encode_i64(0)
g.relocs << obj.Reloc{ offset: u32(g.code.len) - 8, name: name, kind: 0 }
g.code << obj.encode_i64(i64(caps.len))
}
.bool_lit {
g.code << op_push_i
@@ -982,6 +1002,24 @@ fn builtin_spec(name string) (int, int) {
'time_ms' { native_time_ms, 0 }
'format_time' { native_format_time, 2 }
'parse_time' { native_parse_time, 1 }
'weekday' { native_weekday, 1 }
// regex
'regex_match' { native_regex_match, 2 }
'regex_find_all' { native_regex_find_all, 2 }
'regex_replace' { native_regex_replace, 3 }
'regex_split' { native_regex_split, 2 }
// crypto/encoding
'base64_encode' { native_base64_encode, 1 }
'base64_decode' { native_base64_decode, 1 }
'sha256' { native_sha256, 1 }
'md5' { native_md5, 1 }
'csv_parse' { native_csv_parse, 1 }
// extended HTTP + path/process helpers
'http_req' { native_http_req, 5 }
'path_ext' { native_path_ext, 1 }
'path_abs' { native_path_abs, 1 }
'path_rel' { native_path_rel, 2 }
'exec_full' { native_exec_full, 1 }
else { -1, 0 }
}
}
@@ -1377,3 +1415,225 @@ fn (mut g Gen) new_label() string {
fn (mut g Gen) emit_label(name string) {
g.labels[name] = g.code.len
}
// ---------------------------------------------------------------------------
// closure capture analysis
//
// A closure (anonymous `fn`) may reference the enclosing function's locals.
// Since the VM compiles each function with its own frame, those references
// are resolved by capturing the values at closure-creation time: the compiler
// scans the body for free variables, registers them as the lambda's leading
// local slots, and emits loads of their current values before op_closure.
// scan_captures returns the enclosing locals a closure body references, in
// first-reference order (stable and deterministic for codegen).
fn (mut g Gen) scan_captures(body []Stmt, fparams []string) []string {
mut bound := map[string]bool{}
for p in fparams {
bound[p] = true
}
mut caps := []string{}
mut seen := map[string]bool{}
for st in body {
g.scan_stmt(st, mut bound, mut caps, mut seen)
}
return caps
}
fn (mut g Gen) maybe_capture(name string, bound map[string]bool, mut caps []string, mut seen map[string]bool) {
if name in bound {
return // bound inside the closure — a plain local
}
if name !in g.locals {
return // not an enclosing local (global fn/const/enum — resolved elsewhere)
}
if name !in seen {
seen[name] = true
caps << name
}
}
fn (mut g Gen) scan_stmt(st Stmt, mut bound map[string]bool, mut caps []string, mut seen map[string]bool) {
match st.kind {
.expr_stmt {
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
}
.let_stmt {
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
bound[st.target] = true
}
.destruct_stmt {
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
for t in st.destruct_targets {
bound[t] = true
}
}
.assign_stmt {
// assignment to a name that is not a closure-local references the
// enclosing local's captured copy
g.maybe_capture(st.target, bound, mut caps, mut seen)
bound[st.target] = true
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
}
.index_assign {
g.scan_expr(st.base, mut bound, mut caps, mut seen)
g.scan_expr(st.idx, mut bound, mut caps, mut seen)
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
}
.field_assign {
g.scan_expr(st.base, mut bound, mut caps, mut seen)
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
}
.if_stmt {
g.scan_expr(st.cond, mut bound, mut caps, mut seen)
for s in st.body {
g.scan_stmt(s, mut bound, mut caps, mut seen)
}
for s in st.els {
g.scan_stmt(s, mut bound, mut caps, mut seen)
}
}
.match_stmt {
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
for arm in st.arms {
g.scan_expr(arm.val, mut bound, mut caps, mut seen)
for s in arm.body {
g.scan_stmt(s, mut bound, mut caps, mut seen)
}
}
for s in st.els_body {
g.scan_stmt(s, mut bound, mut caps, mut seen)
}
}
.while_stmt {
g.scan_expr(st.cond, mut bound, mut caps, mut seen)
for s in st.body {
g.scan_stmt(s, mut bound, mut caps, mut seen)
}
}
.for_range_stmt {
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
g.scan_expr(st.cond, mut bound, mut caps, mut seen)
had := st.target in bound
bound[st.target] = true
for s in st.body {
g.scan_stmt(s, mut bound, mut caps, mut seen)
}
if !had {
bound.delete(st.target)
}
}
.for_in_stmt {
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
had := st.target in bound
bound[st.target] = true
mut had_idx := false
if st.idx_target.len > 0 {
had_idx = st.idx_target in bound
bound[st.idx_target] = true
}
for s in st.body {
g.scan_stmt(s, mut bound, mut caps, mut seen)
}
if !had {
bound.delete(st.target)
}
if st.idx_target.len > 0 && !had_idx {
bound.delete(st.idx_target)
}
}
.ret_stmt {
if st.has_val {
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
}
}
.assert_stmt {
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
}
.try_stmt {
for s in st.body {
g.scan_stmt(s, mut bound, mut caps, mut seen)
}
had := st.target in bound
bound[st.target] = true
for s in st.els {
g.scan_stmt(s, mut bound, mut caps, mut seen)
}
if !had {
bound.delete(st.target)
}
}
.throw_stmt {
g.scan_expr(st.expr, mut bound, mut caps, mut seen)
}
.break_stmt, .continue_stmt {}
}
}
fn (mut g Gen) scan_expr(e Expr, mut bound map[string]bool, mut caps []string, mut seen map[string]bool) {
match e.kind {
.ident {
g.maybe_capture(e.name, bound, mut caps, mut seen)
}
.call {
// a call to an enclosing local holding a closure must capture it too
g.maybe_capture(e.name, bound, mut caps, mut seen)
for a in e.args {
g.scan_expr(a, mut bound, mut caps, mut seen)
}
}
.field {
g.scan_expr(*e.left, mut bound, mut caps, mut seen)
}
.method_call {
g.scan_expr(*e.left, mut bound, mut caps, mut seen)
for a in e.args {
g.scan_expr(a, mut bound, mut caps, mut seen)
}
}
.index {
g.scan_expr(*e.left, mut bound, mut caps, mut seen)
g.scan_expr(*e.right, mut bound, mut caps, mut seen)
}
.slice {
g.scan_expr(*e.left, mut bound, mut caps, mut seen)
g.scan_expr(*e.right, mut bound, mut caps, mut seen)
g.scan_expr(*e.extra, mut bound, mut caps, mut seen)
}
.unary {
g.scan_expr(*e.right, mut bound, mut caps, mut seen)
}
.binary {
g.scan_expr(*e.left, mut bound, mut caps, mut seen)
g.scan_expr(*e.right, mut bound, mut caps, mut seen)
}
.array_lit {
for el in e.elems {
g.scan_expr(el, mut bound, mut caps, mut seen)
}
}
.struct_lit {
for f in e.fields {
g.scan_expr(f.val, mut bound, mut caps, mut seen)
}
}
.anon_fn {
// a nested closure: its parameters bind inside it, but references to
// enclosing locals still belong to this closure's capture set
mut saved := map[string]bool{}
for p in e.fparams {
saved[p] = p in bound
bound[p] = true
}
for s in e.fn_body {
g.scan_stmt(s, mut bound, mut caps, mut seen)
}
for p in e.fparams {
if !saved[p] {
bound.delete(p)
}
}
}
else {}
}
}
+20
View File
@@ -220,6 +220,11 @@ fn (mut l Lexer) next() !Tok {
return l.lex_number(line, col)
}
else {
// r"..." raw strings (no escape processing) — handy for regex
// patterns like r"\d+" that would otherwise need double escaping
if c == `r` && l.peek2() == `\"` {
return l.lex_raw_string(line, col)!
}
if (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || c == `_` {
return l.lex_ident(line, col)
}
@@ -310,6 +315,21 @@ fn (mut l Lexer) lex_ident(line int, col int) Tok {
return Tok{ kind: kind, lit: lit, line: line, col: col }
}
// lex_raw_string reads an r"..." string verbatim: backslashes, quotes and
// ${...} sequences are all kept literally, so regex patterns pass through
// untouched. The token is a plain str_lit whose content is the raw text.
fn (mut l Lexer) lex_raw_string(line int, col int) !Tok {
l.advance() // 'r'
l.advance() // opening quote
start := l.pos
for l.pos < l.src.len {
if l.advance() == `\"` {
return Tok{ kind: .str_lit, lit: l.src[start..l.pos - 1], line: line, col: col }
}
}
return error('unterminated raw string at line ${line}, col ${col}')
}
fn (mut l Lexer) lex_string(line int, col int) !Tok {
l.advance() // opening quote
mut s := ''
+21
View File
@@ -151,3 +151,24 @@ const native_now = 170
const native_time_ms = 171
const native_format_time = 172
const native_parse_time = 173
// regex builtins
const native_regex_match = 174
const native_regex_find_all = 175
const native_regex_replace = 176
const native_regex_split = 177
// crypto/encoding builtins
const native_base64_encode = 178
const native_base64_decode = 179
const native_sha256 = 180
const native_md5 = 181
const native_csv_parse = 182
// extended HTTP + filesystem/process builtins
const native_http_req = 183
const native_path_ext = 184
const native_path_abs = 185
const native_path_rel = 186
const native_exec_full = 187
const native_weekday = 188
+102
View File
@@ -0,0 +1,102 @@
// stdlib.vr — tests for the extended stdlib modules (regex, crypto, csv,
// path/process helpers, date arithmetic). Run with `vr test examples/stdlib.vr`.
import regex
import crypto
import csv
import os
import time
import http
fn test_regex() {
assert regex.is_match(r"^[a-z]+$", "hello") == 1
assert regex.is_match(r"^[a-z]+$", "Hello1") == 0
let nums = regex.find_all(r"\d+", "a1b22c333")
assert len(nums) == 3
assert nums[0] == "1"
assert nums[2] == "333"
assert regex.replace(r"\d+", "x1y22z", "#") == "x#y#z"
let parts = regex.split(r"[,;]", "a,b;c")
assert len(parts) == 3
assert parts[1] == "b"
// replacing a match in context (no group refs — use find_all for captures)
assert regex.replace(r"\d+", "id:42 name:bob", "#") == "id:# name:bob"
// invalid pattern throws (catchable)
try {
regex.is_match("([", "x")
assert false
} catch e {
assert len(e) > 0
}
}
fn test_crypto() {
assert crypto.sha256("hello") == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
assert crypto.md5("hello") == "5d41402abc4b2a76b9719d911017c592"
let enc = crypto.base64_encode("hello")
assert enc == "aGVsbG8="
assert crypto.base64_decode(enc) == "hello"
// hashes differ across inputs
assert crypto.sha256("a") != crypto.sha256("b")
}
fn test_csv() {
let rows = csv.parse("name,age\n\"amy, smith\",30\nbob,41")
assert len(rows) == 3
assert rows[0][0] == "name"
assert rows[1][0] == "amy, smith"
assert rows[2][1] == "41"
// empty input yields no rows
assert len(csv.parse("")) == 0
}
fn test_path_helpers() {
assert os.ext("a/b/file.txt") == ".txt"
assert os.ext("noext") == ""
assert os.base("a/b/c.vr") == "c.vr"
assert os.dir("a/b/c.vr") == "a/b"
assert os.rel("a/b/c.txt", "a") == "b/c.txt"
assert os.rel("a/b/c.txt", "a/b/c.txt") == "."
let abs_p = os.abs("main.v")
assert abs_p != "main.v"
assert len(abs_p) > 0
}
fn test_exec_full() {
let res = os.exec_full("echo hello-out; echo hello-err 1>&2")
assert res.code == 0
assert res.stdout == "hello-out\n"
assert res.stderr == "hello-err\n"
let bad = os.exec_full("exit 3")
assert bad.code == 3
}
fn test_time_arithmetic() {
let t = time.now()
assert time.add_days(t, 1) - t == 86400
assert time.add_hours(t, 2) - t == 7200
assert time.add_minutes(t, 5) - t == 300
assert time.add_seconds(t, 10) - t == 10
// 2026-08-25 was a Tuesday
let fixed = time.parse("2026-08-25 12:00:00")
assert time.weekday(fixed) == "Tue"
let later = time.add_days(fixed, 1)
assert time.date(later) == "2026-08-26"
}
fn test_http_headers_timeout() {
// offline-safe: connecting to a closed local port fails fast
try {
let h = { "X-Test": "1" }
http.request("GET", "http://127.0.0.1:1/nope", "", h, 500)
assert false
} catch e {
assert len(e) > 0
}
try {
http.get_timeout("http://127.0.0.1:1/nope", 500)
assert false
} catch e {
assert len(e) > 0
}
}
+74 -1
View File
@@ -319,7 +319,7 @@ fn test_module_imports() {
}
assert os.base("a/b/c.vr") == "c.vr"
assert os.dir("a/b/c.vr") == "a/b"
assert len(os.glob("lib/*.vr")) == 6
assert len(os.glob("lib/*.vr")) == 9
let tmp = os.join(os.cwd(), "tmp_test.txt")
os.write_lines(tmp, ["x", "y"])
assert len(os.read_lines(tmp)) == 2
@@ -455,6 +455,79 @@ fn test_time() {
}
}
fn test_closure_capture() {
// closures capture enclosing locals by value
let factor = 3
let dbl = fn(x) {
return x * factor
}
assert dbl(7) == 21
let name = "alice"
let age = 30
let greet = fn() {
return "hi " + name + " age=" + str(age)
}
assert greet() == "hi alice age=30"
// capture by value: later changes to the outer var don't affect the closure
let counter = 10
let read = fn() {
return counter
}
assert read() == 10
counter = 99
assert read() == 10
// nested closures capture through both levels
let base = 100
let outer = fn() {
let mid = 5
let inner = fn(x) {
return base + mid + x
}
return inner(1)
}
assert outer() == 106
// assignment to a captured variable writes the closure's own copy
let n = 1
let bump = fn() {
n = n + 1
return n
}
assert bump() == 2
assert n == 1
// closure stored in a struct, called dynamically
let handler = fn() {
return "handled"
}
let rec = { run: handler }
assert rec.run() == "handled"
}
fn test_dynamic_map_keys() {
// map keys may be computed expressions, not just string literals
let m = {}
for i in 0..3 {
m["key" + str(i)] = i * 10
}
assert m["key0"] == 0
assert m["key2"] == 20
let k = "key1"
assert m[k] == 10
assert has(m, "key1") == 1
assert len(m) == 3
delete(m, "key1")
assert has(m, "key1") == 0
assert len(m) == 2
// nested computed access
let cfg = { "db": { "host": "localhost", "port": 5432 } }
let which = "db"
assert cfg[which]["host"] == "localhost"
}
fn test_failing() {
// this one is meant to fail — shows up in `vr test` output
assert 1 == 2
+53
View File
@@ -0,0 +1,53 @@
// extension.js — VS Code client for the VuurRaaf language server.
//
// Spawns `vr lsp` (the toolchain's built-in language server) and wires up
// diagnostics, go-to-definition, and hover for .vr / .vrmm files.
// The toolchain binary defaults to `vr` on PATH; override it with the
// `vuurraaf.toolchainPath` setting.
const vscode = require('vscode');
const {
LanguageClient,
TransportKind,
} = require('vscode-languageclient/node');
let client;
function activate(context) {
const config = () => vscode.workspace.getConfiguration('vuurraaf');
const toolchain = () => config().get('toolchainPath', 'vr');
const serverOptions = {
command: toolchain(),
args: ['lsp'],
transport: TransportKind.stdio,
};
const clientOptions = {
documentSelector: [{ scheme: 'file', language: 'vuurraaf' }],
synchronize: {
// re-publish diagnostics when a settings change or a file is saved
fileEvents: vscode.workspace.createFileSystemWatcher('**/*.{vr,vrmm}'),
},
};
client = new LanguageClient('vuurraaf', 'VuurRaaf', serverOptions, clientOptions);
client.start();
context.subscriptions.push(
vscode.commands.registerCommand('vuurraaf.restartServer', async () => {
await client.stop();
client = new LanguageClient('vuurraaf', 'VuurRaaf', serverOptions, clientOptions);
client.start();
vscode.window.showInformationMessage('VuurRaaf language server restarted.');
})
);
}
function deactivate() {
if (client) {
return client.stop();
}
}
module.exports = { activate, deactivate };
+23
View File
@@ -0,0 +1,23 @@
{
"comments": {
"lineComment": "//"
},
"brackets": [
["{", "}"],
["[", "]"],
["(", ")"]
],
"autoClosingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"", "notIn": ["string"] },
{ "open": "'", "close": "'", "notIn": ["string", "comment"] }
],
"surroundingPairs": [
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""]
]
}
+61
View File
@@ -0,0 +1,61 @@
{
"name": "vuurraaf",
"displayName": "VuurRaaf",
"description": "Language support for VuurRaaf: syntax highlighting, diagnostics, and go-to-definition via the vr language server.",
"version": "0.1.0",
"publisher": "vuurraaf",
"license": "MIT",
"engines": {
"vscode": "^1.75.0"
},
"categories": [
"Programming Languages",
"Linters"
],
"activationEvents": [
"onLanguage:vuurraaf"
],
"main": "./client/extension.js",
"contributes": {
"languages": [
{
"id": "vuurraaf",
"aliases": [
"VuurRaaf",
"vr"
],
"extensions": [
".vr",
".vrmm"
],
"configuration": "./language-configuration.json"
}
],
"grammars": [
{
"language": "vuurraaf",
"scopeName": "source.vuurraaf",
"path": "./syntaxes/vuurraaf.tmLanguage.json"
}
],
"configuration": {
"title": "VuurRaaf",
"properties": {
"vuurraaf.toolchainPath": {
"type": "string",
"default": "vr",
"description": "Path to the vr toolchain binary (defaults to `vr` on PATH)."
}
}
}
},
"scripts": {
"vscode:prepublish": "npm install"
},
"dependencies": {
"vscode-languageclient": "^8.1.0"
},
"devDependencies": {
"@types/vscode": "^1.75.0"
}
}
+160
View File
@@ -0,0 +1,160 @@
{
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "VuurRaaf",
"scopeName": "source.vuurraaf",
"patterns": [
{
"include": "#comments"
},
{
"include": "#strings"
},
{
"include": "#numbers"
},
{
"include": "#keywords"
},
{
"include": "#function-declaration"
},
{
"include": "#function-call"
},
{
"include": "#identifiers"
},
{
"include": "#operators"
}
],
"repository": {
"comments": {
"patterns": [
{
"name": "comment.line.double-slash.vuurraaf",
"match": "//.*$"
}
]
},
"strings": {
"patterns": [
{
"name": "string.quoted.raw.vuurraaf",
"begin": "r\"",
"end": "\"",
"patterns": [
{
"name": "constant.character.escape.vuurraaf",
"match": "\\\\(\\\\|n|t|r|\"|$)"
}
]
},
{
"name": "string.quoted.double.vuurraaf",
"begin": "\"",
"end": "\"",
"patterns": [
{
"name": "constant.character.escape.vuurraaf",
"match": "\\\\(\\\\|n|t|r|\"|$)"
},
{
"name": "string.interpolated.vuurraaf",
"begin": "\\$\\{",
"end": "\\}",
"patterns": [
{
"include": "#identifiers"
}
]
}
]
}
]
},
"numbers": {
"patterns": [
{
"name": "constant.numeric.vuurraaf",
"match": "\\b(0x[0-9a-fA-F]+|\\d+(\\.\\d+)?([eE][+-]?\\d+)?)\\b"
}
]
},
"keywords": {
"patterns": [
{
"name": "keyword.control.vuurraaf",
"match": "\\b(fn|struct|enum|interface|import|const|let|if|else|while|for|in|match|break|continue|return|assert|try|catch|throw|and|or|not|true|false|none)\\b"
}
]
},
"function-declaration": {
"patterns": [
{
"name": "meta.function.vuurraaf",
"begin": "\\b(fn)\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(",
"beginCaptures": {
"1": {
"name": "keyword.control.vuurraaf"
},
"2": {
"name": "entity.name.function.vuurraaf"
}
},
"end": "\\)",
"patterns": [
{
"include": "#identifiers"
}
]
}
]
},
"function-call": {
"patterns": [
{
"name": "meta.function-call.vuurraaf",
"begin": "\\b([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(",
"beginCaptures": {
"1": {
"name": "entity.name.function.vuurraaf"
}
},
"end": "\\)",
"patterns": [
{
"include": "#identifiers"
},
{
"include": "#numbers"
},
{
"include": "#strings"
}
]
}
]
},
"identifiers": {
"patterns": [
{
"name": "entity.name.type.vuurraaf",
"match": "\\b[A-Z][a-zA-Z0-9_]*\\b"
},
{
"name": "variable.other.vuurraaf",
"match": "\\b[a-z_][a-zA-Z0-9_]*\\b"
}
]
},
"operators": {
"patterns": [
{
"name": "keyword.operator.vuurraaf",
"match": "==|!=|<=|>=|<<|>>|\\+|\\-|\\*|/|%|&|\\||\\^|~|=|<|>"
}
]
}
}
}
+23
View File
@@ -0,0 +1,23 @@
// crypto.vr — hashing and encoding (part of the VuurRaaf stdlib).
//
// import crypto
// println(crypto.sha256("hello"))
// println(crypto.md5("hello"))
// println(crypto.base64_encode("hello")) // aGVsbG8=
// println(crypto.base64_decode("aGVsbG8="))
fn sha256(s) {
return sha256(s)
}
fn md5(s) {
return md5(s)
}
fn base64_encode(s) {
return base64_encode(s)
}
fn base64_decode(s) {
return base64_decode(s)
}
+13
View File
@@ -0,0 +1,13 @@
// csv.vr — comma-separated values parsing (part of the VuurRaaf stdlib).
//
// import csv
// let rows = csv.parse("name,age\namy,30\nbob,41")
// // rows == [["name", "age"], ["amy", "30"], ["bob", "41"]]
// println(rows[1][0]) // amy
//
// parse returns an array of rows; each row is an array of cell strings.
// Quoted fields, embedded commas and newlines are handled.
fn parse(s) {
return csv_parse(s)
}
+23
View File
@@ -21,3 +21,26 @@ fn post(url, data) {
fn get_text(url) {
return http_get(url).body
}
// request sends any method with custom headers (a {name: value} struct) and
// a timeout in milliseconds, and returns {status, body}. A timeout <= 0 uses
// the default (30s). Network failures throw; HTTP errors are normal responses.
fn request(method, url, data, headers, timeout_ms) {
return http_req(method, url, data, headers, timeout_ms)
}
fn get_with_headers(url, headers) {
return http_req("GET", url, "", headers, 0)
}
fn post_with_headers(url, data, headers) {
return http_req("POST", url, data, headers, 0)
}
fn get_timeout(url, timeout_ms) {
return http_req("GET", url, "", {}, timeout_ms)
}
fn post_timeout(url, data, timeout_ms) {
return http_req("POST", url, data, {}, timeout_ms)
}
+20
View File
@@ -73,3 +73,23 @@ fn env(name) {
fn exec(cmd) {
return build_exec(cmd)
}
// ext returns the file extension including the dot ("file.txt" -> ".txt").
fn ext(path) {
return path_ext(path)
}
// abs converts a path to an absolute one.
fn abs(path) {
return path_abs(path)
}
// rel returns path relative to base ("a/b/c.txt" relative to "a" -> "b/c.txt").
fn rel(path, base) {
return path_rel(path, base)
}
// exec_full runs a shell command and returns {code, stdout, stderr}.
fn exec_full(cmd) {
return exec_full(cmd)
}
+30
View File
@@ -0,0 +1,30 @@
// regex.vr — regular expressions (part of the VuurRaaf stdlib).
//
// import regex
// if regex.match(r"^[a-z]+$", "hello") {
// println("all lowercase")
// }
// for m in regex.find_all(r"\d+", "a1b22c333") {
// println(m) // "1", "22", "333"
// }
//
// Patterns use RE2 syntax (no backreferences). Invalid patterns throw and
// can be caught with try/catch.
fn is_match(pattern, s) {
return regex_match(pattern, s)
}
// find_all returns an array of every substring that matches the pattern.
fn find_all(pattern, s) {
return regex_find_all(pattern, s)
}
// replace substitutes every match with `repl`.
fn replace(pattern, s, repl) {
return regex_replace(pattern, s, repl)
}
fn split(pattern, s) {
return regex_split(pattern, s)
}
+23
View File
@@ -34,3 +34,26 @@ fn date(t) {
fn clock(t) {
return format_time(t, "HH:mm:ss")
}
// weekday(t) returns the day of the week, e.g. "Mon", "Tue", ... "Sun".
fn weekday(t) {
return weekday(t)
}
// --- date arithmetic (all values are Unix epoch seconds) ---
fn add_seconds(t, n) {
return t + n
}
fn add_minutes(t, n) {
return t + n * 60
}
fn add_hours(t, n) {
return t + n * 3600
}
fn add_days(t, n) {
return t + n * 86400
}
+537
View File
@@ -0,0 +1,537 @@
// lsp.v — a minimal Language Server Protocol server for VuurRaaf.
//
// vr lsp
//
// Speaks JSON-RPC 2.0 over stdio with Content-Length framing. The editor
// (see extension/ for a VS Code client) sends the open document, and the
// server replies with:
// - diagnostics: the compiler's first error, positioned via its line/col
// - go-to-definition: jumps to function/struct/enum/const/let declarations
// in the current file (and stdlib module functions)
// - hover: the kind of the symbol under the cursor
//
// Text sync is "full document" (version 1), which keeps the client simple.
module main
import os
import json2
import compiler
// stdio bindings used for the JSON-RPC transport (Content-Length framing)
fn C.fgetc(stream voidptr) int
fn C.fread(dest voidptr, size usize, count usize, stream voidptr) usize
fn C.fwrite(src voidptr, size usize, count usize, stream voidptr) usize
fn C.fflush(stream voidptr) int
// ---------------------------------------------------------------------------
// JSON-RPC framing
struct LspParams {
mut:
text_document TextDocParam @[json: 'textDocument']
position LspPosition
content_changes []TextChangeParam @[json: 'contentChanges']
}
struct TextDocParam {
mut:
uri string
text string
version int
}
struct LspPosition {
mut:
line int
character int
}
struct TextChangeParam {
mut:
text string
}
struct LspMsg {
mut:
jsonrpc string
id int
method string
params LspParams
}
fn toolchain_lsp() {
run_lsp()
}
fn run_lsp() {
mut docs := map[string]string{} // uri -> latest text (full sync)
for {
raw := read_rpc() or { break }
if raw.len == 0 {
continue
}
handle_rpc(raw, mut docs)
}
}
fn handle_rpc(raw string, mut docs map[string]string) {
msg := json2.decode[LspMsg](raw) or { return }
match msg.method {
'initialize' {
// textDocumentSync 1 = full document sync
send_response(msg.id,
'{"capabilities":{"textDocumentSync":1,"definitionProvider":true,"hoverProvider":true}}')
}
'initialized' {
// notification — nothing to do
}
'shutdown' {
send_response(msg.id, 'null')
}
'exit' {
exit(0)
}
'textDocument/didOpen' {
docs[msg.params.text_document.uri] = msg.params.text_document.text
publish_diagnostics(msg.params.text_document.uri, msg.params.text_document.text)
}
'textDocument/didChange' {
// full sync: the last content change carries the whole document
if msg.params.content_changes.len > 0 {
docs[msg.params.text_document.uri] = msg.params.content_changes[msg.params.content_changes.len -
1].text
}
text := docs[msg.params.text_document.uri] or { '' }
publish_diagnostics(msg.params.text_document.uri, text)
}
'textDocument/didSave' {
text := docs[msg.params.text_document.uri] or { '' }
publish_diagnostics(msg.params.text_document.uri, text)
}
'textDocument/definition' {
loc := definition_at(msg.params.text_document.uri, docs, msg.params.position)
send_response(msg.id, loc)
}
'textDocument/hover' {
h := hover_at(msg.params.text_document.uri, docs, msg.params.position)
send_response(msg.id, h)
}
else {
// respond null to unknown requests so editors don't hang
if msg.id > 0 {
send_response(msg.id, 'null')
}
}
}
}
// read_rpc reads one Content-Length framed JSON-RPC message from stdin.
fn read_rpc() !string {
mut content_len := 0
for {
line := read_stdin_line() or { return err }
if line.len == 0 {
break // blank line ends the header block
}
if line.starts_with('Content-Length:') {
content_len = line.all_after(':').trim_space().int()
}
}
if content_len <= 0 {
return ''
}
return read_stdin_bytes(content_len).bytestr()
}
fn read_stdin_line() !string {
mut line := []u8{}
for {
b := read_stdin_byte() or { return err }
if b == `\n` {
break
}
if b != `\r` {
line << b
}
}
return line.bytestr()
}
fn read_stdin_byte() !u8 {
c := unsafe { C.fgetc(C.stdin) }
if c == -1 {
return error('stdin closed')
}
return u8(c)
}
fn read_stdin_bytes(n int) []u8 {
mut buf := []u8{len: n}
if n > 0 {
unsafe {
C.fread(buf.data, 1, usize(n), C.stdin)
}
}
return buf
}
fn send_response(id int, result string) {
send_raw('{"jsonrpc":"2.0","id":${id},"result":${result}}')
}
fn send_raw(body string) {
header := 'Content-Length: ${body.len}\r\n\r\n'
unsafe {
C.fwrite(header.str, 1, usize(header.len), C.stdout)
C.fwrite(body.str, 1, usize(body.len), C.stdout)
C.fflush(C.stdout)
}
}
// ---------------------------------------------------------------------------
// diagnostics
fn publish_diagnostics(uri string, text string) {
diags := collect_diagnostics(text)
send_raw('{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"${json_escape(uri)}","diagnostics":[${diags}]}}')
}
// collect_diagnostics runs the compiler over `text` and renders the first
// error as an LSP diagnostic. Returns the diagnostics array body (or empty).
fn collect_diagnostics(text string) string {
_ = compiler.compile(text) or {
mut line, mut col := extract_pos(err.msg())
if line < 1 {
line = 1
}
start_line := line - 1
msg := json_escape(err.msg())
return '{"range":{"start":{"line":${start_line},"character":${col}},"end":{"line":${start_line},"character":${col + 1}}},"severity":1,"source":"vr","message":"${msg}"}'
}
return ''
}
// extract_pos pulls "line N[, col M]" out of a compiler error message.
// The toolchain's errors are formatted as `... at line 12, col 5`,
// `... at line 12` or `... (line 12)`.
fn extract_pos(msg string) (int, int) {
mut line := 0
mut col := 0
if start := msg.index('line ') {
mut i := start + 5
mut num := ''
for i < msg.len && msg[i] >= `0` && msg[i] <= `9` {
num += msg[i].ascii_str()
i++
}
if num.len > 0 {
line = num.int()
}
if c := msg.index('col ') {
mut j := c + 4
mut cnum := ''
for j < msg.len && msg[j] >= `0` && msg[j] <= `9` {
cnum += msg[j].ascii_str()
j++
}
if cnum.len > 0 {
col = cnum.int()
}
}
}
return line, col
}
// ---------------------------------------------------------------------------
// symbols (definition + hover)
// symbol_at resolves the identifier under the cursor and returns its kind and
// definition line, looking in the current document and then in imported
// stdlib modules.
fn symbol_at(uri string, docs map[string]string, pos LspPosition) (string, int, string) {
text := docs[uri] or { return '', 0, '' }
word := word_at(text, pos)
if word.len == 0 {
return '', 0, ''
}
line, kind, ok := find_symbol(text, word)
if ok {
return uri, line, kind
}
// module call: the cursor may sit on the module part ("os" in "os.exists")
// or the function part ("exists"). Look up imported modules either way.
mut mod_name := ''
mut fn_name := word
if word.contains('.') {
parts := word.split('.')
if parts.len == 2 {
mod_name = parts[0]
fn_name = parts[1]
}
} else {
// cursor on the module part: extend to the dotted name
if pos.character > 0 && pos.character < text.len {
if e := word_at_ext(text, pos) {
parts := e.split('.')
if parts.len == 2 && parts[0] == word {
mod_name = parts[0]
fn_name = parts[1]
}
}
}
}
if mod_name.len > 0 {
if path := compiler.resolve_import(mod_name) {
src := os.read_file(path) or { return '', 0, '' }
l2, k2, ok2 := find_symbol(src, fn_name)
if ok2 {
return 'file://${path}', l2, k2
}
}
}
// bare function name: search every module this file imports
for m in imported_modules(text) {
if path := compiler.resolve_import(m) {
src := os.read_file(path) or { continue }
l2, k2, ok2 := find_symbol(src, word)
if ok2 {
return 'file://${path}', l2, k2
}
}
}
return '', 0, ''
}
// word_at_ext returns the dotted identifier starting at `pos` when the cursor
// is on the module part of a call like os.exists (word_at alone would stop
// at the dot).
fn word_at_ext(text string, pos LspPosition) ?string {
mut line_i := 0
for l in text.split('\n') {
if line_i == pos.line {
if pos.character < 0 || pos.character > l.len {
return none
}
// scan back to the start of the dotted identifier
mut start := pos.character
for start > 0 && (is_ident_char(l[start - 1]) || l[start - 1] == `.`) {
start--
}
mut end := pos.character
for end < l.len && (is_ident_char(l[end]) || l[end] == `.`) {
end++
}
if start == end {
return none
}
return l[start..end]
}
line_i++
}
return none
}
// imported_modules returns the bare module names (`import os`) in a file.
fn imported_modules(text string) []string {
toks := compiler.tokenize(text) or { return []string{} }
prog := compiler.parse(toks) or { return []string{} }
mut out := []string{}
for imp in prog.imports {
if imp.name.len > 0 {
out << imp.name
}
}
return out
}
fn definition_at(uri string, docs map[string]string, pos LspPosition) string {
loc_uri, line, _ := symbol_at(uri, docs, pos)
if loc_uri.len == 0 {
return 'null'
}
return location_json(loc_uri, line)
}
fn hover_at(uri string, docs map[string]string, pos LspPosition) string {
_, line, kind := symbol_at(uri, docs, pos)
if line == 0 {
return 'null'
}
return '{"contents":{"kind":"markdown","value":"**${kind}** at line ${line}"}}'
}
fn location_json(uri string, line int) string {
// 1-based source line → 0-based LSP position; character 0 (we only track lines)
return '{"uri":"${json_escape(uri)}","range":{"start":{"line":${line - 1},"character":0},"end":{"line":${line - 1},"character":0}}}'
}
// word_at returns the identifier covering the given position in `text`.
fn word_at(text string, pos LspPosition) string {
mut line_i := 0
for l in text.split('\n') {
if line_i == pos.line {
if pos.character < 0 || pos.character > l.len {
return ''
}
mut start := pos.character
mut end := pos.character
for start > 0 && is_ident_char(l[start - 1]) {
start--
}
for end < l.len && is_ident_char(l[end]) {
end++
}
if start == end {
return ''
}
return l[start..end]
}
line_i++
}
return ''
}
fn is_ident_char(c u8) bool {
lo := c >= `a` && c <= `z`
hi := c >= `A` && c <= `Z`
dig := c >= `0` && c <= `9`
return lo || hi || dig || c == `_`
}
// find_symbol parses VuurRaaf source and locates the definition line and kind
// of a named symbol (functions, structs, enums, constants, locals, params).
fn find_symbol(src string, name string) (int, string, bool) {
toks := compiler.tokenize(src) or { return 0, '', false }
prog := compiler.parse(toks) or { return 0, '', false }
mut line := 0
mut kind := ''
for fd in prog.fns {
if fd.name == name {
return fd.line, 'function', true
}
for p in fd.params {
if p == name {
line = fd.line
kind = 'parameter'
}
}
}
for sd in prog.structs {
if sd.name == name {
return sd.line, 'struct', true
}
}
for ed in prog.enums {
if ed.name == name {
return ed.line, 'enum', true
}
}
for cd in prog.consts {
if cd.name == name {
return cd.line, 'constant', true
}
}
for fd in prog.fns {
for st in fd.body {
l, k, ok := find_symbol_stmt(st, name)
if ok {
line = l
kind = k
}
}
}
if line > 0 {
return line, kind, true
}
return 0, '', false
}
fn find_symbol_stmt(st compiler.Stmt, name string) (int, string, bool) {
match st.kind {
.let_stmt {
if st.target == name {
return st.line, 'variable', true
}
}
.destruct_stmt {
for t in st.destruct_targets {
if t == name {
return st.line, 'variable', true
}
}
}
.for_range_stmt, .for_in_stmt {
if st.target == name {
return st.line, 'loop variable', true
}
if st.idx_target == name {
return st.line, 'loop variable', true
}
}
.try_stmt {
if st.target == name {
return st.line, 'catch variable', true
}
}
else {}
}
// recurse into nested statements so inner blocks are covered too
for s in st.body {
l, k, ok := find_symbol_stmt(s, name)
if ok {
return l, k, true
}
}
for s in st.els {
l, k, ok := find_symbol_stmt(s, name)
if ok {
return l, k, true
}
}
for s in st.els_body {
l, k, ok := find_symbol_stmt(s, name)
if ok {
return l, k, true
}
}
for arm in st.arms {
for s in arm.body {
l, k, ok := find_symbol_stmt(s, name)
if ok {
return l, k, true
}
}
}
return 0, '', false
}
// ---------------------------------------------------------------------------
// helpers
fn json_escape(s string) string {
mut out := ''
for c in s {
match c {
`"` {
out += '\\"'
}
`\\` {
out += '\\\\'
}
`\n` {
out += '\\n'
}
`\r` {
out += '\\r'
}
`\t` {
out += '\\t'
}
else {
out += c.ascii_str()
}
}
}
return out
}
+107 -5
View File
@@ -73,6 +73,9 @@ fn main() {
'repl', 'i' {
toolchain_repl() or { die('repl', err) }
}
'lsp' {
toolchain_lsp()
}
'fmt' {
toolchain_fmt(rest) or { die('fmt', err) }
}
@@ -190,6 +193,7 @@ fn toolchain_help() {
println(' test <file.vr> run every test_* function')
println(' bench <file.vr> [iterations] benchmark main()')
println(' repl interactive session')
println(' lsp language server (JSON-RPC over stdio)')
println(' fmt [-w] <file.vr> format source')
println(' init [name] scaffold a project')
println(' get <owner/repo | url | ./path> fetch a package into vendor/')
@@ -358,10 +362,23 @@ fn toolchain_link(args []string) ! {
fn toolchain_run(args []string) ! {
if args.len == 0 {
return error('usage: vr run <file.vr|file.vbin> [program-args...]')
return error('usage: vr run <file.vr|file.vbin> [program-args...] (add -w to watch for changes)')
}
mut watch := false
mut rest := args.clone()
if rest[0] == '-w' || rest[0] == '--watch' {
watch = true
rest = rest[1..]
}
if rest.len == 0 {
return error('usage: vr run <file.vr|file.vbin> [program-args...] (add -w to watch for changes)')
}
f := rest[0]
prog_args := rest[1..]
if watch {
run_watch(f, prog_args)!
return
}
f := args[0]
prog_args := args[1..]
if f.ends_with('.vbin') {
bin := obj.read_bin(f)!
vm.run_with_args(bin, 'main', false, prog_args)!
@@ -374,6 +391,32 @@ fn toolchain_run(args []string) ! {
return error('unsupported file type: ${f} (expected .vr or .vbin)')
}
// run_watch recompiles and reruns the program whenever the source file (or
// anything it imports) changes — the classic develop-run-edit loop.
fn run_watch(f string, prog_args []string) ! {
if !f.ends_with('.vr') {
return error('watch mode works on .vr source files, got ${f}')
}
mut last := os.file_last_mod_unix(f)
println('watching ${f} (Ctrl-C to stop)')
for {
// clear the screen between runs for a clean diff of output
print('\x1b[2J\x1b[H')
println('== ${os.file_name(f)} ${time.now().custom_format('HH:mm:ss')} ==')
run_src_with_args(f, 'main', false, prog_args) or {
eprintln('${err.msg()}')
}
for {
time.sleep(400 * time.millisecond)
cur := os.file_last_mod_unix(f)
if cur != last {
last = cur
break
}
}
}
}
fn toolchain_debug(args []string) ! {
if args.len == 0 {
return error('usage: vr debug <file.vr|file.vbin>')
@@ -394,9 +437,14 @@ fn toolchain_debug(args []string) ! {
fn toolchain_test(args []string) ! {
if args.len == 0 {
return error('usage: vr test <file.vr>')
return error('usage: vr test <file.vr|dir>')
}
src := args[0]
target := args[0]
if os.is_dir(target) {
test_dir(target)!
return
}
src := target
o := compiler.compile_file(src)!
mut tests := []string{}
for s in o.symbols {
@@ -434,6 +482,60 @@ fn toolchain_test(args []string) ! {
}
}
// test_dir runs every test_* function in every .vr file under a directory
// (recursively), so a whole project's suite runs with one command.
fn test_dir(dir string) ! {
files := os.walk_ext(dir, '.vr', os.WalkParams{})
mut files_sorted := files.clone()
files_sorted.sort()
mut total_pass := 0
mut total_fail := 0
mut file_count := 0
for src in files_sorted {
if os.file_name(src).starts_with('.') {
continue
}
o := compiler.compile_file(src) or {
eprintln(' COMPILE FAIL ${src} ${err.msg()}')
total_fail++
continue
}
mut tests := []string{}
for s in o.symbols {
if s.name.starts_with('test_') {
tests << s.name
}
}
if tests.len == 0 {
continue
}
file_count++
println('-- ${src}')
tmp_obj := os.join_path(os.temp_dir(), 'vr_${os.getpid()}_${file_count}.vobj')
tmp_bin := os.join_path(os.temp_dir(), 'vr_${os.getpid()}_${file_count}.vbin')
defer {
os.rm(tmp_obj) or {}
os.rm(tmp_bin) or {}
}
obj.write(tmp_obj, o)!
linker.link([tmp_obj], tmp_bin)!
bin := obj.read_bin(tmp_bin)!
for t in tests {
if run_test(bin, t) {
println(' PASS ${t}')
total_pass++
} else {
total_fail++
}
}
}
println('')
println('${total_pass} passed, ${total_fail} failed across ${file_count} file(s)')
if total_fail > 0 {
exit(1)
}
}
fn run_test(bin obj.Bin, name string) bool {
vm.run(bin, name, false) or {
eprintln(' FAIL ${name} ${err}')
+10
View File
@@ -84,6 +84,13 @@ fn (mut v Vm) collect() {
}
}
}
for h in 0..v.closures.len {
if closure_mark[h] {
for j in 0..v.closures[h].captured.len {
v.closures[h].captured[j] = v.remap(v.closures[h].captured[j], str_new, arr_new, struct_new, float_new, closure_new)
}
}
}
// ---- compact pools ----
mut strings := v.strings[..v.const_strs]
for i in v.const_strs..v.strings.len {
@@ -164,6 +171,9 @@ fn (mut v Vm) mark_value(x i64, mut str_mark []bool, mut arr_mark []bool, mut st
h := v.hand(val)
if h >= 0 && h < closure_mark.len && !closure_mark[h] {
closure_mark[h] = true
for cv in v.closures[h].captured {
work << cv
}
}
}
else {}
+2 -2
View File
@@ -113,7 +113,7 @@ fn (mut v Vm) json_parse_object(mut p JsonParser) !i64 {
p.skip_ws()
if p.peek() == `}` {
p.pos++
v.structs << StructVal{ fields: fields }
v.structs << StructVal{ fields: fields, by_name: v.index_fields(fields) }
return v.mkstruct_handle(v.structs.len - 1)
}
for {
@@ -152,7 +152,7 @@ fn (mut v Vm) json_parse_object(mut p JsonParser) !i64 {
}
return p.error('expected "," or "}" in object')
}
v.structs << StructVal{ fields: fields }
v.structs << StructVal{ fields: fields, by_name: v.index_fields(fields) }
return v.mkstruct_handle(v.structs.len - 1)
}
+207 -6
View File
@@ -16,6 +16,11 @@ import obj
import compiler
import assembler
import linker
import regex
import encoding.base64
import crypto.sha256
import crypto.md5
import encoding.csv
fn (mut v Vm) native(id int, _argc int) ! {
match id {
@@ -248,11 +253,11 @@ fn (mut v Vm) native(id int, _argc int) ! {
if v.is_arr(x) && v.valid_arr_handle(x) {
v.arrays << v.arrays[v.hand(x)].clone()
v.push(v.mkarr(v.arrays.len - 1))!
} else if v.is_struct(x) && v.valid_struct_handle(x) {
s := v.structs[v.hand(x)]
v.structs << StructVal{ fields: s.fields.clone() }
v.push(v.mkstruct_handle(v.structs.len - 1))!
} else if v.is_str(x) && v.valid_handle(x) {
} else if v.is_struct(x) && v.valid_struct_handle(x) {
s := v.structs[v.hand(x)]
v.structs << StructVal{ fields: s.fields.clone(), by_name: s.by_name.clone() }
v.push(v.mkstruct_handle(v.structs.len - 1))!
} else if v.is_str(x) && v.valid_handle(x) {
v.push(v.alloc_str(v.strings[v.hand(x)]))!
} else if v.is_float(x) {
v.push(v.push_float(v.fval(x)))!
@@ -679,12 +684,208 @@ fn (mut v Vm) native(id int, _argc int) ! {
t := time.parse(s) or { return error('parse_time: ${err.msg()}') }
v.push(v.enc_int(t.unix()))!
}
native_weekday {
t := v.dec_int(v.pop()!)
v.push(v.alloc_str(time.unix(t).weekday_str()))!
}
// -------------------------------------------------------------------
// regex — RE2-style patterns via V's regex module. Every call compiles
// the pattern fresh; the VuurRaaf-level module can cache if it needs to.
native_regex_match {
s := v.pop_str()!
pat := v.pop_str()!
mut re := regex.regex_opt(pat) or { return error('regex: ${err.msg()}') }
v.push(v.enc_int(bool_i64(re.matches_string(s))))!
}
native_regex_find_all {
s := v.pop_str()!
pat := v.pop_str()!
mut re := regex.regex_opt(pat) or { return error('regex: ${err.msg()}') }
matches := re.find_all_str(s)
mut arr := []i64{}
for m in matches {
v.strings << m
arr << v.mkstr(v.strings.len - 1)
}
v.arrays << arr
v.push(v.mkarr(v.arrays.len - 1))!
}
native_regex_replace {
repl := v.pop_str()!
s := v.pop_str()!
pat := v.pop_str()!
mut re := regex.regex_opt(pat) or { return error('regex: ${err.msg()}') }
v.push(v.alloc_str(re.replace(s, repl)))!
}
native_regex_split {
s := v.pop_str()!
pat := v.pop_str()!
mut re := regex.regex_opt(pat) or { return error('regex: ${err.msg()}') }
parts := re.split(s)
mut arr := []i64{}
for p in parts {
v.strings << p
arr << v.mkstr(v.strings.len - 1)
}
v.arrays << arr
v.push(v.mkarr(v.arrays.len - 1))!
}
// -------------------------------------------------------------------
// base64 + hashes
native_base64_encode {
s := v.pop_str()!
v.push(v.alloc_str(base64.encode_str(s)))!
}
native_base64_decode {
s := v.pop_str()!
v.push(v.alloc_str(base64.decode_str(s)))!
}
native_sha256 {
s := v.pop_str()!
v.push(v.alloc_str(sha256.hexhash(s)))!
}
native_md5 {
s := v.pop_str()!
v.push(v.alloc_str(md5.hexhash(s)))!
}
native_csv_parse {
s := v.pop_str()!
mut r := csv.new_reader(s)
// read() yields one row ([]string) at a time until EOF
mut rows := [][]string{}
for {
row := r.read() or { break }
if row.len == 0 {
break
}
rows << row
}
mut outer := []i64{}
for row in rows {
mut inner := []i64{}
for cell in row {
v.strings << cell
inner << v.mkstr(v.strings.len - 1)
}
v.arrays << inner
outer << v.mkarr(v.arrays.len - 1)
}
v.arrays << outer
v.push(v.mkarr(v.arrays.len - 1))!
}
// -------------------------------------------------------------------
// extended HTTP: any method, custom headers (a {name: value} struct),
// and a per-request timeout in milliseconds
native_http_req {
timeout_ms := int(v.dec_int(v.pop()!))
headers_h := v.pop()!
data := v.pop_str()!
url := v.pop_str()!
method := v.pop_str()!
mut h := http.new_header()
if v.is_struct(headers_h) && v.valid_struct_handle(headers_h) {
for f in v.structs[v.hand(headers_h)].fields {
if v.is_str(f.val) && v.valid_handle(f.val) {
h.add_custom(f.name, v.strings[v.hand(f.val)]) or {}
}
}
}
m := match method.to_upper() {
'GET' { http.Method.get }
'POST' { http.Method.post }
'PUT' { http.Method.put }
'DELETE' { http.Method.delete }
'PATCH' { http.Method.patch }
'HEAD' { http.Method.head }
else { return error('http_req: unsupported method "${method}"') }
}
resp := http.fetch(method: m, url: url, data: data, header: h,
read_timeout: i64(timeout_ms) * time.millisecond) or {
return error('http_req: ${err.msg()}')
}
v.push_http_response(resp)!
}
// -------------------------------------------------------------------
// filesystem path helpers
native_path_ext {
p := v.pop_str()!
v.push(v.alloc_str(os.file_ext(p)))!
}
native_path_abs {
p := v.pop_str()!
v.push(v.alloc_str(os.abs_path(p)))!
}
native_path_rel {
base := v.pop_str()!
p := v.pop_str()!
v.push(v.alloc_str(v.rel_path(p, base)))!
}
// -------------------------------------------------------------------
// process spawn with separate stdout/stderr pipes
native_exec_full {
cmd := v.pop_str()!
mut code := 0
mut out := ''
mut errs := ''
if os.user_os() == 'windows' {
res := os.execute(cmd)
code = res.exit_code
out = res.output
} else {
mut p := os.new_process('sh')
p.set_args(['-c', cmd])
p.use_stdio_ctl = true
p.run()
p.wait()
if p.err.len > 0 {
return error('exec_full: ${p.err}')
}
code = p.code
out = p.stdout_slurp()
errs = p.stderr_slurp()
}
out_h := v.alloc_str(out)
err_h := v.alloc_str(errs)
mut fields := []Field{len: 3}
fields[0] = Field{ name: 'code', val: v.enc_int(i64(code)) }
fields[1] = Field{ name: 'stdout', val: out_h }
fields[2] = Field{ name: 'stderr', val: err_h }
v.structs << StructVal{ fields: fields, by_name: v.index_fields(fields) }
v.push(v.mkstruct_handle(v.structs.len - 1))!
}
else {
return error('unknown native builtin ${id}')
}
}
}
// rel_path computes a relative path from `base` to `p` (both absolutized),
// e.g. rel_path("/a/b/c.txt", "/a") == "b/c.txt". Used by os.rel().
fn (v Vm) rel_path(p string, base string) string {
ap := os.abs_path(p).replace('\\', '/')
ab := os.abs_path(base).replace('\\', '/')
if ap == ab {
return '.'
}
aparts := ap.split('/')
bparts := ab.split('/')
mut i := 0
for i < aparts.len && i < bparts.len && aparts[i] == bparts[i] {
i++
}
mut out := []string{}
for _ in i..bparts.len {
out << '..'
}
for j in i..aparts.len {
out << aparts[j]
}
if out.len == 0 {
return '.'
}
return out.join('/')
}
// push_http_response wraps an HTTP response as a {status, body} struct value
// so scripts can read res.status and res.body.
fn (mut v Vm) push_http_response(resp http.Response) ! {
@@ -692,7 +893,7 @@ fn (mut v Vm) push_http_response(resp http.Response) ! {
mut fields := []Field{len: 2}
fields[0] = Field{ name: 'status', val: v.enc_int(i64(resp.status_code)) }
fields[1] = Field{ name: 'body', val: body_h }
v.structs << StructVal{ fields: fields }
v.structs << StructVal{ fields: fields, by_name: v.index_fields(fields) }
v.push(v.mkstruct_handle(v.structs.len - 1))!
}
+21
View File
@@ -150,3 +150,24 @@ const native_now = 170
const native_time_ms = 171
const native_format_time = 172
const native_parse_time = 173
// regex builtins
const native_regex_match = 174
const native_regex_find_all = 175
const native_regex_replace = 176
const native_regex_split = 177
// crypto/encoding builtins
const native_base64_encode = 178
const native_base64_decode = 179
const native_sha256 = 180
const native_md5 = 181
const native_csv_parse = 182
// extended HTTP + filesystem/process builtins
const native_http_req = 183
const native_path_ext = 184
const native_path_abs = 185
const native_path_rel = 186
const native_exec_full = 187
const native_weekday = 188
+28 -21
View File
@@ -12,14 +12,20 @@ mut:
val i64
}
// StructVal is a struct (or map) value. `fields` keeps insertion order for
// rendering and JSON encoding; `by_name` is a hash index for O(1) field
// lookup by name, so map-style access on large records stays fast.
struct StructVal {
mut:
fields []Field
fields []Field
by_name map[string]int // field name -> index into fields
}
// Handler records a try/catch handler pushed at runtime.
struct Closure {
entry int // code IP of the function body
mut:
captured []i64 // values of the enclosing locals this closure captures (by value)
}
struct Handler {
@@ -30,26 +36,27 @@ struct Handler {
struct Vm {
mut:
code []u8
strings []string
arrays [][]i64
structs []StructVal
floats []f64
closures []Closure
stack []i64
sp int
bp int
ip int
trace bool
halted bool
prog_args []string
exit_code i64
did_exit bool
handlers []Handler
lines []obj.LineInfo // debug info: code offset -> source line
const_strs int // strings[0..const_strs] are bytecode constants, never collected
last_heap int // heap size at the last GC check (allocation trigger)
build_root string // directory of the .vrmm build module (build_root() builtin)
code []u8
strings []string
arrays [][]i64
structs []StructVal
floats []f64
closures []Closure
stack []i64
sp int
bp int
ip int
trace bool
halted bool
prog_args []string
exit_code i64
did_exit bool
handlers []Handler
lines []obj.LineInfo // debug info: code offset -> source line
fns []obj.BinFn // function table (for stack traces)
const_strs int // strings[0..const_strs] are bytecode constants, never collected
last_heap int // heap size at the last GC check (allocation trigger)
build_root string // directory of the .vrmm build module (build_root() builtin)
}
fn bool_i64(b bool) i64 {
+176 -74
View File
@@ -35,6 +35,7 @@ fn run_internal(bin obj.Bin, entry string, trace bool, args []string, root strin
trace: trace
prog_args: args
lines: bin.lines
fns: bin.fns
const_strs: bin.strings.len
build_root: root
}
@@ -61,7 +62,7 @@ fn run_internal(bin obj.Bin, entry string, trace bool, args []string, root strin
v.bp = v.sp
v.ip = entry_ip
v.exec() or {
return error('${err.msg()} at ${v.where()}')
return error('${err.msg()} at ${v.where()}\n${v.stack_trace()}')
}
if v.did_exit {
return v.exit_code
@@ -75,14 +76,57 @@ fn run_internal(bin obj.Bin, entry string, trace bool, args []string, root strin
// where returns a source-level location for the current instruction pointer:
// `line 12 (ip 345)` when debug info is available, otherwise just `(ip 345)`.
fn (v Vm) where() string {
return 'line ${v.line_at(v.ip)} (ip ${v.ip})'
}
// line_at maps a code offset to its source line via the line table.
fn (v Vm) line_at(ip int) int {
// line table entries are recorded in code order, so walk backwards from
// the most recent entry to find the last one at or before v.ip
// the most recent entry to find the last one at or before ip
for i := v.lines.len - 1; i >= 0; i-- {
if v.ip >= int(v.lines[i].off) {
return 'line ${v.lines[i].line} (ip ${v.ip})'
if ip >= int(v.lines[i].off) {
return v.lines[i].line
}
}
return '(ip ${v.ip})'
return 0
}
// func_at returns the name of the function whose body contains the given
// code offset. Functions are laid out sequentially, so the enclosing
// function is the one with the greatest entry point <= ip.
fn (v Vm) func_at(ip int) string {
mut name := '?'
for f in v.fns {
if f.entry <= ip {
name = f.name
}
}
return name
}
// stack_trace renders the call chain at the moment an error is raised, from
// the innermost frame out to main. Each frame's return address and saved bp
// live in the frame header pushed by `call`: [retaddr, old_bp, argc] at
// bp-3..bp-1. The synthetic entry frame has retaddr == -1 (the halt sentinel).
fn (mut v Vm) stack_trace() string {
mut out := []string{}
mut bp := v.bp
mut ip := v.ip
mut guard := 0
// note: sp may have dropped below bp (an error handler pops values), so
// the frame chain is bounded by the guard and the old_bp < bp invariant
for bp >= 3 && guard < 10000 {
out << ' at ${v.func_at(ip)} (line ${v.line_at(ip)})'
ret := v.dec_int(v.stack[bp - 3])
old_bp := int(v.dec_int(v.stack[bp - 2]))
if ret == -1 || old_bp < 0 || old_bp >= bp {
break // reached the synthetic entry frame
}
ip = int(ret)
bp = old_bp
guard++
}
return out.join('\n')
}
fn (mut v Vm) exec() ! {
@@ -303,37 +347,67 @@ fn (mut v Vm) exec() ! {
}
op_aget {
v.ip++
idx := int(v.dec_int(v.pop()!))
idxv := v.pop()!
h := v.pop()!
if v.is_arr(h) && v.valid_arr_handle(h) {
a := v.arrays[v.hand(h)]
if idx < 0 || idx >= a.len {
return error('array index ${idx} out of bounds (len ${a.len})')
// dynamic map read: m[key_expr] where the base is a struct and the
// index evaluates to a string
if v.is_struct(h) && v.valid_struct_handle(h) && v.is_str(idxv) && v.valid_handle(idxv) {
fname := v.strings[v.hand(idxv)]
s := v.structs[v.hand(h)]
idx, ok := v.field_idx(s, fname)
if ok {
v.push(s.fields[idx].val)!
} else {
return error('no field "${fname}" on struct')
}
v.push(a[idx])!
} else if v.is_str(h) && v.valid_handle(h) {
// rune-based string indexing: s[i] is the i-th character
runes := v.strings[v.hand(h)].runes()
if idx < 0 || idx >= runes.len {
return error('string index ${idx} out of bounds (len ${runes.len})')
}
v.push(v.alloc_str(runes[idx].str()))!
} else {
return error('indexing a non-array, non-string value')
idx := int(v.dec_int(idxv))
if v.is_arr(h) && v.valid_arr_handle(h) {
a := v.arrays[v.hand(h)]
if idx < 0 || idx >= a.len {
return error('array index ${idx} out of bounds (len ${a.len})')
}
v.push(a[idx])!
} else if v.is_str(h) && v.valid_handle(h) {
// rune-based string indexing: s[i] is the i-th character
runes := v.strings[v.hand(h)].runes()
if idx < 0 || idx >= runes.len {
return error('string index ${idx} out of bounds (len ${runes.len})')
}
v.push(v.alloc_str(runes[idx].str()))!
} else {
return error('indexing a non-array, non-string value')
}
}
}
op_aset {
v.ip++
val := v.pop()!
idx := int(v.dec_int(v.pop()!))
idxv := v.pop()!
h := v.pop()!
if !v.is_arr(h) || !v.valid_arr_handle(h) {
return error('indexing a non-array value')
// dynamic map write: m[key_expr] = v (string key on a struct)
if v.is_struct(h) && v.valid_struct_handle(h) && v.is_str(idxv) && v.valid_handle(idxv) {
fname := v.strings[v.hand(idxv)]
mut s := v.structs[v.hand(h)]
idx, ok := v.field_idx(s, fname)
if ok {
s.fields[idx].val = val
} else {
// setting a missing field adds it, so maps can grow
s.fields << Field{ name: fname, val: val }
s.by_name[fname] = s.fields.len - 1
}
v.structs[v.hand(h)] = s
} else {
idx := int(v.dec_int(idxv))
if !v.is_arr(h) || !v.valid_arr_handle(h) {
return error('indexing a non-array value')
}
if idx < 0 || idx >= v.arrays[v.hand(h)].len {
return error('array index ${idx} out of bounds (len ${v.arrays[v.hand(h)].len})')
}
v.arrays[v.hand(h)][idx] = val
}
if idx < 0 || idx >= v.arrays[v.hand(h)].len {
return error('array index ${idx} out of bounds (len ${v.arrays[v.hand(h)].len})')
}
v.arrays[v.hand(h)][idx] = val
}
op_alen {
v.ip++
@@ -371,7 +445,7 @@ fn (mut v Vm) exec() ! {
}
fields[i] = Field{ name: v.strings[v.hand(name)], val: val }
}
v.structs << StructVal{ fields: fields }
v.structs << StructVal{ fields: fields, by_name: v.index_fields(fields) }
v.push(v.mkstruct_handle(v.structs.len - 1))!
}
op_sget {
@@ -385,15 +459,11 @@ fn (mut v Vm) exec() ! {
return error('internal: field name is not a string')
}
fname := v.strings[v.hand(name)]
mut found := false
for f in v.structs[v.hand(h)].fields {
if f.name == fname {
v.push(f.val)!
found = true
break
}
}
if !found {
s := v.structs[v.hand(h)]
idx, ok := v.field_idx(s, fname)
if ok {
v.push(s.fields[idx].val)!
} else {
return error('no field "${fname}" on struct')
}
}
@@ -411,18 +481,14 @@ fn (mut v Vm) exec() ! {
}
fname := v.strings[v.hand(name)]
mut s := v.structs[v.hand(h)]
mut found := false
for i, f in s.fields {
if f.name == fname {
s.fields[i].val = val
found = true
break
}
}
if !found {
idx, ok := v.field_idx(s, fname)
if ok {
s.fields[idx].val = val
} else {
// setting a missing field adds it, so records can be built
// incrementally from an empty `{}`
s.fields << Field{ name: fname, val: val }
s.by_name[fname] = s.fields.len - 1
}
v.structs[v.hand(h)] = s
}
@@ -526,7 +592,14 @@ fn (mut v Vm) exec() ! {
op_closure {
v.ip++
entry := int(v.read_i64())
v.closures << Closure{ entry: entry }
n := int(v.read_i64())
// the captured values were pushed by the compiler in capture
// order; pop them back into the closure's own array
mut captured := []i64{len: n}
for i := n - 1; i >= 0; i-- {
captured[i] = v.pop()!
}
v.closures << Closure{ entry: entry, captured: captured }
v.push(v.mkclosure(v.closures.len - 1))!
}
op_call_closure {
@@ -537,17 +610,30 @@ fn (mut v Vm) exec() ! {
if !v.is_closure(h) || !v.valid_closure_handle(h) {
return error('cannot call a non-function value')
}
entry := v.closures[v.hand(h)].entry
// Shift args left to overwrite the closure slot (and drop the
// duplicated tail), so the callee's retv lands exactly where the
// call sequence began and no stale value is left below the
// result. The caller's own local holding the closure sits below
// the pushed sequence and is never touched.
for i := 0; i < argc; i++ {
v.stack[v.sp - argc - 1 + i] = v.stack[v.sp - argc + i]
cl := v.closures[v.hand(h)]
n := cl.captured.len
c := v.sp - argc - 1 // closure slot
// Rearrange the stack from [...closure, arg_0..arg_{argc-1}] to
// [capture_0..capture_{n-1}, arg_0..arg_{argc-1}]: the captures
// take over the closure slot, and the args shift by (1 - n) so
// the callee sees captures as its leading locals followed by the
// real arguments. v.call cleans the whole region up on ret.
if n > 1 {
// shifting right: copy backwards to avoid clobbering
for i := argc - 1; i >= 0; i-- {
v.stack[c + n + i] = v.stack[c + 1 + i]
}
} else {
// shifting left (or no shift): copy forwards
for i := 0; i < argc; i++ {
v.stack[c + n + i] = v.stack[c + 1 + i]
}
}
v.sp-- // drop the duplicated arg tail; closure slot was consumed
v.call(entry, argc)
for i in 0..n {
v.stack[c + i] = cl.captured[i]
}
v.sp = c + n + argc
v.call(cl.entry, argc + n)
}
op_argc {
v.ip++
@@ -595,6 +681,25 @@ fn (mut v Vm) exec() ! {
}
}
// field_idx returns the index of a named field via the hash index, or
// (0, false) when the field does not exist.
fn (v Vm) field_idx(s StructVal, fname string) (int, bool) {
if fname in s.by_name {
return s.by_name[fname], true
}
return 0, false
}
// index_fields builds the name -> index hash map for a freshly built field
// list (used by mkstruct and other struct constructors).
fn (v Vm) index_fields(fields []Field) map[string]int {
mut m := map[string]int{}
for i, f in fields {
m[f.name] = i
}
return m
}
// op_shas checks if a struct has a field with the given name.
// stack: struct, "key" → pushes 1 if found, 0 if not.
fn (mut v Vm) op_shas() ! {
@@ -608,13 +713,7 @@ fn (mut v Vm) op_shas() ! {
return error('internal: field name is not a string')
}
fname := v.strings[v.hand(name)]
mut found := false
for f in v.structs[v.hand(h)].fields {
if f.name == fname {
found = true
break
}
}
found := fname in v.structs[v.hand(h)].by_name
v.push(v.enc_int(if found { 1 } else { 0 }))!
}
@@ -629,19 +728,22 @@ fn (mut v Vm) op_sdel() ! {
}
if !v.is_str(name) || !v.valid_handle(name) {
return error('internal: field name is not a string')
}
fname := v.strings[v.hand(name)]
mut s := v.structs[v.hand(h)]
mut new_fields := []Field{}
for f in s.fields {
if f.name != fname {
new_fields << f
} fname := v.strings[v.hand(name)]
mut s := v.structs[v.hand(h)]
idx, ok := v.field_idx(s, fname)
if ok {
mut new_fields := []Field{}
for i, f in s.fields {
if i != idx {
new_fields << f
}
}
s.fields = new_fields
s.by_name = v.index_fields(new_fields)
v.structs[v.hand(h)] = s
}
v.push(h)!
}
s.fields = new_fields
v.structs[v.hand(h)] = s
v.push(h)!
}
// op_slen returns the number of fields in a struct.
// stack: struct → pushes field count.