From 7ed774089e51341c6bc941f4f8899eb8450098d1 Mon Sep 17 00:00:00 2001 From: allexanderbergmns Date: Tue, 25 Aug 2026 16:20:11 +0200 Subject: [PATCH] More buildins and vscode exstention --- .github/workflows/ci.yml | 13 + .github/workflows/release.yml | 22 +- README.MD | 49 +- compiler/check.v | 11 +- compiler/codegen.v | 264 +++++++++- compiler/lexer.v | 20 + compiler/opcodes.v | 21 + examples/stdlib.vr | 102 ++++ examples/tests.vr | 75 ++- extension/client/extension.js | 53 ++ extension/language-configuration.json | 23 + extension/package.json | 61 +++ extension/syntaxes/vuurraaf.tmLanguage.json | 160 ++++++ lib/crypto.vr | 23 + lib/csv.vr | 13 + lib/http.vr | 23 + lib/os.vr | 20 + lib/regex.vr | 30 ++ lib/time.vr | 23 + lsp.v | 537 ++++++++++++++++++++ main.v | 112 +++- vm/gc.v | 10 + vm/json.v | 4 +- vm/native.v | 213 +++++++- vm/opcodes.v | 21 + vm/types.v | 49 +- vm/vm.v | 250 ++++++--- 27 files changed, 2080 insertions(+), 122 deletions(-) create mode 100644 examples/stdlib.vr create mode 100644 extension/client/extension.js create mode 100644 extension/language-configuration.json create mode 100644 extension/package.json create mode 100644 extension/syntaxes/vuurraaf.tmLanguage.json create mode 100644 lib/crypto.vr create mode 100644 lib/csv.vr create mode 100644 lib/regex.vr create mode 100644 lsp.v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c18648a..858a650 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6793e5..64af92f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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/ diff --git a/README.MD b/README.MD index 30aeae4..ca7beea 100644 --- a/README.MD +++ b/README.MD @@ -41,6 +41,7 @@ vr up rebuild bin/vr vr symlink link bin/vr into your PATH vr config [set ] toolchain config (outdir, verbose) vr repl interactive session +vr lsp language server (JSON-RPC over stdio) vr fmt [-w] format source (keeps comments) vr init [name] scaffold a project (vr.mod + main.vr) vr get 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 ``` diff --git a/compiler/check.v b/compiler/check.v index d93fde4..7c30b32 100644 --- a/compiler/check.v +++ b/compiler/check.v @@ -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 } } } } diff --git a/compiler/codegen.v b/compiler/codegen.v index 8d95874..9df1494 100644 --- a/compiler/codegen.v +++ b/compiler/codegen.v @@ -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 {} + } +} diff --git a/compiler/lexer.v b/compiler/lexer.v index 5b6f6bc..93389db 100644 --- a/compiler/lexer.v +++ b/compiler/lexer.v @@ -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 := '' diff --git a/compiler/opcodes.v b/compiler/opcodes.v index 857e43e..ff9ce08 100644 --- a/compiler/opcodes.v +++ b/compiler/opcodes.v @@ -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 diff --git a/examples/stdlib.vr b/examples/stdlib.vr new file mode 100644 index 0000000..29a1ef9 --- /dev/null +++ b/examples/stdlib.vr @@ -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 + } +} diff --git a/examples/tests.vr b/examples/tests.vr index ad669b2..02c25b8 100644 --- a/examples/tests.vr +++ b/examples/tests.vr @@ -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 diff --git a/extension/client/extension.js b/extension/client/extension.js new file mode 100644 index 0000000..4f96823 --- /dev/null +++ b/extension/client/extension.js @@ -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 }; diff --git a/extension/language-configuration.json b/extension/language-configuration.json new file mode 100644 index 0000000..3e56cb8 --- /dev/null +++ b/extension/language-configuration.json @@ -0,0 +1,23 @@ +{ + "comments": { + "lineComment": "//" + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"", "notIn": ["string"] }, + { "open": "'", "close": "'", "notIn": ["string", "comment"] } + ], + "surroundingPairs": [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["\"", "\""] + ] +} diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000..5ae206a --- /dev/null +++ b/extension/package.json @@ -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" + } +} diff --git a/extension/syntaxes/vuurraaf.tmLanguage.json b/extension/syntaxes/vuurraaf.tmLanguage.json new file mode 100644 index 0000000..fe1fd7b --- /dev/null +++ b/extension/syntaxes/vuurraaf.tmLanguage.json @@ -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": "==|!=|<=|>=|<<|>>|\\+|\\-|\\*|/|%|&|\\||\\^|~|=|<|>" + } + ] + } + } +} diff --git a/lib/crypto.vr b/lib/crypto.vr new file mode 100644 index 0000000..241f71b --- /dev/null +++ b/lib/crypto.vr @@ -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) +} diff --git a/lib/csv.vr b/lib/csv.vr new file mode 100644 index 0000000..bf2b3d3 --- /dev/null +++ b/lib/csv.vr @@ -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) +} diff --git a/lib/http.vr b/lib/http.vr index 30dd0e6..3a74c16 100644 --- a/lib/http.vr +++ b/lib/http.vr @@ -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) +} diff --git a/lib/os.vr b/lib/os.vr index 5830bc2..6c0ff45 100644 --- a/lib/os.vr +++ b/lib/os.vr @@ -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) +} diff --git a/lib/regex.vr b/lib/regex.vr new file mode 100644 index 0000000..3893de2 --- /dev/null +++ b/lib/regex.vr @@ -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) +} diff --git a/lib/time.vr b/lib/time.vr index 52c9126..7c07dda 100644 --- a/lib/time.vr +++ b/lib/time.vr @@ -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 +} diff --git a/lsp.v b/lsp.v new file mode 100644 index 0000000..9aa4c76 --- /dev/null +++ b/lsp.v @@ -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 +} diff --git a/main.v b/main.v index f63f109..86bf39d 100644 --- a/main.v +++ b/main.v @@ -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 run every test_* function') println(' bench [iterations] benchmark main()') println(' repl interactive session') + println(' lsp language server (JSON-RPC over stdio)') println(' fmt [-w] format source') println(' init [name] scaffold a project') println(' get 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 [program-args...]') + return error('usage: vr run [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 [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 ') @@ -394,9 +437,14 @@ fn toolchain_debug(args []string) ! { fn toolchain_test(args []string) ! { if args.len == 0 { - return error('usage: vr test ') + return error('usage: vr test ') } - 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}') diff --git a/vm/gc.v b/vm/gc.v index 95c61a2..724e91e 100644 --- a/vm/gc.v +++ b/vm/gc.v @@ -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 {} diff --git a/vm/json.v b/vm/json.v index b04dfe1..ffa2c5f 100644 --- a/vm/json.v +++ b/vm/json.v @@ -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) } diff --git a/vm/native.v b/vm/native.v index 9041900..e601383 100644 --- a/vm/native.v +++ b/vm/native.v @@ -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))! } diff --git a/vm/opcodes.v b/vm/opcodes.v index e5952e8..ff01edd 100644 --- a/vm/opcodes.v +++ b/vm/opcodes.v @@ -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 diff --git a/vm/types.v b/vm/types.v index 95978d8..830519a 100644 --- a/vm/types.v +++ b/vm/types.v @@ -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 { diff --git a/vm/vm.v b/vm/vm.v index 56bec0b..4c8e090 100644 --- a/vm/vm.v +++ b/vm/vm.v @@ -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.