diff --git a/el-compiler/runtime/el_runtime.c b/el-compiler/runtime/el_runtime.c index 5f97c6c..d091134 100644 --- a/el-compiler/runtime/el_runtime.c +++ b/el-compiler/runtime/el_runtime.c @@ -155,6 +155,39 @@ el_val_t readline(void) { return el_wrap_str(el_strdup(buf)); } +/* __read_n — read exactly n bytes from stdin. + * Allocates a buffer of size n+1, calls fread(buf, 1, n, stdin) to read + * exactly n raw bytes (including \r, \n, NUL, etc.), null-terminates, and + * returns the buffer as an El String. Returns "" on EOF or I/O error. + * + * Used by the El LSP server to read JSON-RPC message bodies after parsing + * the Content-Length header. readline() cannot be used for the body because + * it stops at the first \n and LSP JSON bodies are not newline-terminated. */ +el_val_t __read_n(el_val_t nv) { + int64_t n = EL_INT(nv); + if (n <= 0) return el_wrap_str(el_strdup("")); + char* buf = malloc((size_t)n + 1); + if (!buf) { fputs("el_runtime: __read_n: out of memory\n", stderr); return el_wrap_str(el_strdup("")); } + size_t got = fread(buf, 1, (size_t)n, stdin); + buf[got] = '\0'; + if (got == 0) { free(buf); return el_wrap_str(el_strdup("")); } + /* Track in arena so the allocation is freed when the request ends. */ + el_arena_track(buf); + return el_wrap_str(buf); +} + +/* __print_raw — write a string to stdout without any modification. + * Unlike println/print (which call puts/fputs and may add newlines or flush + * in platform-specific ways), this uses fwrite with the exact byte count so + * that embedded \r\n pairs in LSP Content-Length headers survive intact. */ +void __print_raw(el_val_t sv) { + const char* s = EL_CSTR(sv); + if (!s) return; + size_t len = strlen(s); + fwrite(s, 1, len, stdout); + fflush(stdout); +} + /* ── String builtins ─────────────────────────────────────────────────────── */ el_val_t el_str_concat(el_val_t av, el_val_t bv) { diff --git a/el-compiler/runtime/el_runtime.h b/el-compiler/runtime/el_runtime.h index 1a8c48d..ae34612 100644 --- a/el-compiler/runtime/el_runtime.h +++ b/el-compiler/runtime/el_runtime.h @@ -80,6 +80,16 @@ void println(el_val_t s); void print(el_val_t s); el_val_t readline(void); +/* __read_n — read exactly n bytes from stdin; returns String of length n. + * Returns "" on EOF or read error. Used by the El LSP server to read + * JSON-RPC message bodies after the Content-Length header is parsed. */ +el_val_t __read_n(el_val_t n); + +/* __print_raw — write a string to stdout using fwrite + fflush, preserving + * embedded \r\n byte pairs as-is. Used by the El LSP server to emit + * Content-Length framed JSON-RPC messages. */ +void __print_raw(el_val_t s); + /* ── String builtins ─────────────────────────────────────────────────────── */ el_val_t el_str_concat(el_val_t a, el_val_t b); diff --git a/el-compiler/src/codegen.el b/el-compiler/src/codegen.el index bf24ed5..9375843 100644 --- a/el-compiler/src/codegen.el +++ b/el-compiler/src/codegen.el @@ -2049,6 +2049,9 @@ fn builtin_arity(name: String) -> Int { if str_eq(name, "println") { return 1 } if str_eq(name, "print") { return 1 } if str_eq(name, "readline") { return 0 } + // LSP seed primitives + if str_eq(name, "__read_n") { return 1 } + if str_eq(name, "__print_raw") { return 1 } // String if str_eq(name, "el_str_concat") { return 2 } if str_eq(name, "str_eq") { return 2 } diff --git a/tools/lsp/README.md b/tools/lsp/README.md new file mode 100644 index 0000000..ceed699 --- /dev/null +++ b/tools/lsp/README.md @@ -0,0 +1,198 @@ +# El LSP — Language Server for El + +Full Language Server Protocol implementation for the El programming language. + +## Features + +| Feature | Status | +|---------|--------| +| Syntax highlighting | Full TextMate grammar | +| Completions | Builtins (130+), user-defined fns, keywords, types | +| Hover | Signatures + descriptions for all builtins and user fns | +| Go-to-definition | Jump to `fn name(` in the open document | +| Diagnostics | Unclosed braces/parens/brackets, unterminated strings | +| Document sync | Full (re-sends entire document on every change) | + +## Building + +### Prerequisites + +- The `elc` compiler binary at `dist/platform/elc` (built from the repo root) +- `cc` (clang or gcc), `libcurl`, `pthreads` + +### Build + +```bash +# From the el repo root: +./tools/lsp/build.sh + +# Or with a custom elc path: +ELC=/path/to/elc ./tools/lsp/build.sh +``` + +Output: `tools/lsp/dist/el-lsp` + +### Install system-wide + +```bash +sudo cp tools/lsp/dist/el-lsp /usr/local/bin/el-lsp +``` + +## VSCode Extension + +### Development install (recommended) + +1. Build the binary first (see above). +2. Install the npm dependency: + ```bash + cd tools/lsp/vscode-extension + npm install + ``` +3. Open `tools/lsp/vscode-extension/` in VSCode. +4. Press **F5** — this launches the Extension Development Host. +5. Open any `.el` file in the dev host window. + +### Package as .vsix + +```bash +npm install -g @vscode/vsce +cd tools/lsp/vscode-extension +vsce package +# Produces: el-language-1.0.0.vsix +``` + +Install the .vsix: +```bash +code --install-extension el-language-1.0.0.vsix +``` + +### Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| `el.lspPath` | (bundled) | Path to `el-lsp` binary. Empty = use `../dist/el-lsp`. | +| `el.trace.server` | `off` | LSP message tracing. Set to `verbose` to see all messages. | + +## Neovim / other editors + +Any editor that supports LSP can use `el-lsp`. Example configuration for +Neovim with `nvim-lspconfig`: + +```lua +local lspconfig = require('lspconfig') +local configs = require('lspconfig.configs') + +if not configs.el then + configs.el = { + default_config = { + cmd = { 'el-lsp' }, + filetypes = { 'el' }, + root_dir = lspconfig.util.root_pattern('.git', '*.el'), + settings = {}, + }, + } +end + +lspconfig.el.setup({}) +``` + +Add to `ftdetect/el.vim`: +```vim +au BufRead,BufNewFile *.el set filetype=el +``` + +## Wire protocol + +El LSP communicates over stdin/stdout using standard LSP framing: + +``` +Content-Length: \r\n +\r\n + +``` + +The `__read_n(n: Int) -> String` primitive in `el_runtime.c` reads exactly +`n` bytes from stdin — needed because `readline()` stops at `\n` and LSP +JSON bodies are not newline-terminated. `__print_raw(s: String)` writes with +`fwrite + fflush` to preserve embedded `\r\n` in headers. + +## Smoke test + +After building, verify the server responds to `initialize`: + +```bash +python3 - << 'PY' +import subprocess, json + +def frame(obj): + body = json.dumps(obj).encode() + return f"Content-Length: {len(body)}\r\n\r\n".encode() + body + +def read_response(proc): + hdr = b"" + while not hdr.endswith(b"\r\n\r\n"): + hdr += proc.stdout.read(1) + cl = int([l for l in hdr.decode().split("\r\n") if "Content-Length" in l][0].split(": ")[1]) + return json.loads(proc.stdout.read(cl)) + +proc = subprocess.Popen(["./dist/el-lsp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) +proc.stdin.write(frame({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}})) +proc.stdin.flush() +r = read_response(proc) +print("Server name:", r["result"]["serverInfo"]["name"]) +print("Capabilities:", list(r["result"]["capabilities"].keys())) +proc.stdin.write(frame({"jsonrpc":"2.0","method":"exit","params":{}})) +proc.stdin.flush() +proc.wait() +print("OK") +PY +``` + +## Architecture + +``` +el-lsp.el + lsp_read_message() reads header bytes one-by-one, then __read_n(body_len) + lsp_write_message() __print_raw("Content-Length: N\r\n\r\n" + json) + lsp_dispatch() routes method string to handler + lsp_builtin_catalog() [String] of "name|signature|description" entries + lsp_extract_fns() scan source for "fn name(" patterns + lsp_word_at() expand identifier under cursor + lsp_compute_diagnostics() scan for unclosed brackets + unterminated strings +``` + +## Files + +``` +tools/lsp/ + el-lsp.el LSP server source (El language) + build.sh Build script + README.md This file + dist/ + el-lsp Compiled binary (after build) + el-lsp.c Generated C (after build) + vscode-extension/ + extension.js Extension entry point + package.json Extension manifest + language-configuration.json Bracket matching, comment config + syntaxes/ + el.tmGrammar.json TextMate syntax grammar + .vscode/ + launch.json F5 debug configuration + tasks.json Pre-launch npm install task +``` + +## Runtime additions + +Two new primitives added to `el-compiler/runtime/`: + +### `__read_n(n: Int) -> String` +Reads exactly `n` bytes from stdin using `fread`. Returns `""` on EOF. +Required for reading JSON-RPC message bodies. + +### `__print_raw(s: String) -> Void` +Writes a string to stdout using `fwrite + fflush`. Preserves embedded +`\r\n` bytes exactly. Required for LSP Content-Length headers. + +Both are declared in `el_runtime.h` and registered in the `builtin_arity` +table in `el-compiler/src/codegen.el`. diff --git a/tools/lsp/build.sh b/tools/lsp/build.sh index 3a15063..14b48c4 100755 --- a/tools/lsp/build.sh +++ b/tools/lsp/build.sh @@ -1,124 +1,125 @@ #!/usr/bin/env bash -# tools/lsp/build.sh — Build the El LSP server binary. +# tools/lsp/build.sh — Build the El LSP server binary end-to-end. +# +# Pipeline: +# 1. Compile el-lsp.el → C source (using elc) +# 2. Compile C source → binary (using cc with el_runtime.c) # # Output: tools/lsp/dist/el-lsp # -# Usage: -# cd tools/lsp && ./build.sh -# EL_HOME=/path/to/el ./build.sh # if el root is elsewhere -# ELC=/path/to/custom/elc ./build.sh # use a specific compiler binary -# # Prerequisites: -# - elc binary (either on PATH or ELC env var) -# - el-compiler/runtime/el_runtime.c +# - dist/platform/elc (the El self-hosted compiler) +# - el-compiler/runtime/el_runtime.c + el_runtime.h # - cc (clang or gcc), libcurl, pthreads # -# NOTE — seed primitive __read_n: -# el-lsp.el calls __read_n(n: Int) -> String, which reads exactly n bytes -# from stdin. This primitive must be present in el_runtime.c and declared -# in el_runtime.h for the compiled binary to work correctly. +# Usage: +# cd +# ./tools/lsp/build.sh # -# Until __read_n is added to the runtime, the LSP will compile and link -# successfully but lsp_read_message() will silently use the __read_n symbol -# — causing a linker error. The seed agent must: -# 1. Add el_val_t __read_n(el_val_t n) to el-compiler/runtime/el_runtime.c -# 2. Declare el_val_t __read_n(el_val_t n) in el-compiler/runtime/el_runtime.h -# 3. Add "__read_n" with arity 1 to the builtin_arity table in -# el-compiler/src/codegen.el -# 4. Rebuild the elc binary -# -# Implementation sketch for __read_n in C: -# -# el_val_t __read_n(el_val_t n) { -# int64_t count = (int64_t)n; -# if (count <= 0) return EL_STR(""); -# char* buf = malloc(count + 1); -# if (!buf) return EL_STR(""); -# size_t total = 0; -# while (total < (size_t)count) { -# size_t got = fread(buf + total, 1, (size_t)count - total, stdin); -# if (got == 0) break; -# total += got; -# } -# buf[total] = '\0'; -# return EL_STR(buf); -# } +# Or from anywhere: +# EL_HOME=/path/to/el-root ./tools/lsp/build.sh set -euo pipefail -cd "$(dirname "$0")" -# ── Locate el root ───────────────────────────────────────────────────────────── -EL_HOME="${EL_HOME:-$(cd ../.. && pwd)}" +# ── Locate el root ───────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +EL_HOME="${EL_HOME:-$(cd "${SCRIPT_DIR}/../.." && pwd)}" + ELC="${ELC:-${EL_HOME}/dist/platform/elc}" RUNTIME_DIR="${EL_HOME}/el-compiler/runtime" +LSP_DIR="${SCRIPT_DIR}" +OUT_DIR="${LSP_DIR}/dist" + +# ── Validate prerequisites ───────────────────────────────────────────────── +echo "==> Checking prerequisites..." +echo " EL_HOME = ${EL_HOME}" +echo " elc = ${ELC}" +echo " runtime = ${RUNTIME_DIR}" -# ── Validate prerequisites ───────────────────────────────────────────────────── if [ ! -x "${ELC}" ]; then echo "error: elc not found at ${ELC}" >&2 - echo " Set ELC=/path/to/elc or EL_HOME=/path/to/el-root" >&2 + echo " Build it first: cd && make (or compile elc-bootstrap.c)" >&2 exit 1 fi if [ ! -f "${RUNTIME_DIR}/el_runtime.c" ]; then - echo "error: el_runtime.c not found at ${RUNTIME_DIR}/el_runtime.c" >&2 + echo "error: el_runtime.c not found at ${RUNTIME_DIR}" >&2 exit 1 fi -mkdir -p dist - -# ── Concatenate runtime modules + LSP source ─────────────────────────────────── -# Load order follows runtime/manifest.el: string → math → state → env → -# fs → exec → time → json → http → then our LSP source. -RUNTIME_SRCS=( - "${EL_HOME}/runtime/string.el" - "${EL_HOME}/runtime/math.el" - "${EL_HOME}/runtime/state.el" - "${EL_HOME}/runtime/env.el" - "${EL_HOME}/runtime/fs.el" - "${EL_HOME}/runtime/exec.el" - "${EL_HOME}/runtime/time.el" - "${EL_HOME}/runtime/json.el" -) - -# Check that runtime modules exist -MISSING=0 -for f in "${RUNTIME_SRCS[@]}"; do - if [ ! -f "${f}" ]; then - echo "warning: runtime module not found: ${f}" >&2 - MISSING=$((MISSING + 1)) - fi -done -if [ "${MISSING}" -gt 0 ]; then - echo "error: ${MISSING} runtime module(s) missing — cannot build" >&2 +if [ ! -f "${LSP_DIR}/el-lsp.el" ]; then + echo "error: el-lsp.el not found at ${LSP_DIR}" >&2 exit 1 fi -COMBINED="dist/el-lsp-combined.el" -echo "==> Combining sources..." -cat "${RUNTIME_SRCS[@]}" el-lsp.el > "${COMBINED}" -echo " ${COMBINED}" +mkdir -p "${OUT_DIR}" -# ── Compile El → C ───────────────────────────────────────────────────────────── -C_OUT="dist/el-lsp.c" +# ── Compile El → C ───────────────────────────────────────────────────────── +C_OUT="${OUT_DIR}/el-lsp.c" echo "==> Compiling El → C..." -"${ELC}" "${COMBINED}" > "${C_OUT}" -echo " ${C_OUT}" +echo " ${LSP_DIR}/el-lsp.el → ${C_OUT}" -# ── Compile C → binary ───────────────────────────────────────────────────────── -BIN="dist/el-lsp" +# el-lsp.el uses only standard El builtins (no import statements needed). +# The elc compiler emits #include "el_runtime.h" at the top of the output. +"${ELC}" "${LSP_DIR}/el-lsp.el" > "${C_OUT}" + +echo " Done ($(wc -l < "${C_OUT}") lines of C)." + +# ── Compile C → binary ───────────────────────────────────────────────────── +BIN="${OUT_DIR}/el-lsp" echo "==> Compiling C → binary..." +echo " ${C_OUT} + el_runtime.c → ${BIN}" + cc -std=c11 -O2 \ -I "${RUNTIME_DIR}" \ -o "${BIN}" \ "${C_OUT}" "${RUNTIME_DIR}/el_runtime.c" \ -lcurl -lpthread -echo " ${BIN}" +echo " Done." + +# ── Summary ──────────────────────────────────────────────────────────────── echo -echo "==> Build complete: ${BIN}" +echo "==> Build complete." echo -echo " Run as LSP server (editors connect via stdin/stdout):" -echo " ${BIN}" +echo " Binary : ${BIN}" +echo " Size : $(du -sh "${BIN}" | cut -f1)" echo -echo " Test with a synthetic LSP initialize request:" -echo " echo -e 'Content-Length: 97\\r\\n\\r\\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"capabilities\":{}}}' | ${BIN}" +echo " Install system-wide:" +echo " sudo cp ${BIN} /usr/local/bin/el-lsp" +echo +echo " Quick smoke test (initialize + shutdown):" +cat << 'SMOKETEST' + python3 - << 'PY' +import subprocess, json + +def frame(body): + b = body.encode() + return f"Content-Length: {len(b)}\r\n\r\n".encode() + b + +def send(proc, obj): + proc.stdin.write(frame(json.dumps(obj))) + proc.stdin.flush() + +def recv(proc): + hdr = b"" + while not hdr.endswith(b"\r\n\r\n"): + hdr += proc.stdout.read(1) + cl = int([l for l in hdr.decode().split("\r\n") if l.startswith("Content-Length")][0].split(": ")[1]) + return json.loads(proc.stdout.read(cl)) + +import sys, os +bin_path = sys.argv[1] if len(sys.argv) > 1 else "./dist/el-lsp" +proc = subprocess.Popen([bin_path], stdin=subprocess.PIPE, stdout=subprocess.PIPE) +send(proc, {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}) +r = recv(proc) +print("initialize:", r.get("result", {}).get("serverInfo", {}).get("name"), "OK" if "result" in r else "FAIL") +send(proc, {"jsonrpc":"2.0","id":2,"method":"shutdown","params":{}}) +r = recv(proc) +print("shutdown:", "OK" if r.get("result") is None else "FAIL") +send(proc, {"jsonrpc":"2.0","method":"exit","params":{}}) +proc.wait() +print("exit: OK") +PY +SMOKETEST + diff --git a/tools/lsp/el-lsp.el b/tools/lsp/el-lsp.el index 5da669a..340ec4c 100644 --- a/tools/lsp/el-lsp.el +++ b/tools/lsp/el-lsp.el @@ -1,809 +1,851 @@ -// tools/lsp/el-lsp.el — El Language Server Protocol (LSP) server +// tools/lsp/el-lsp.el — Full El Language Server Protocol implementation // -// Implements the Language Server Protocol over stdin/stdout (JSON-RPC). -// Editors (VSCode, Neovim, etc.) launch this binary as a child process and -// communicate via JSON-RPC messages framed with Content-Length headers. +// Implements LSP over JSON-RPC 2.0 with Content-Length framing on stdin/stdout. // -// Wire format (LSP spec §3.1): -// Content-Length: \r\n -// \r\n -// +// Features: +// - textDocument/completion — builtins (full list) + user fn names + keywords +// - textDocument/hover — signatures + descriptions for all builtins +// - textDocument/definition — go-to-def for user-defined fns +// - textDocument/didOpen/didChange/didClose/didSave — full document sync +// - textDocument/publishDiagnostics — unclosed braces/parens, unterminated strings +// - initialize / shutdown / exit — clean lifecycle // -// This skeleton handles: -// initialize — advertise server capabilities -// initialized — no-op acknowledgement -// textDocument/didOpen — store document source in state -// textDocument/didChange — update stored source -// textDocument/didClose — evict document from state -// textDocument/completion — return completions (builtins + doc fns + keywords) -// textDocument/hover — hover documentation for word at cursor -// textDocument/definition — go-to-definition (scan for fn () -// shutdown / exit — clean shutdown -// -// ── Seed primitive required ─────────────────────────────────────────────────── -// -// lsp_read_message() calls: -// __read_n(n: Int) -> String -// -// This primitive reads exactly n bytes from stdin. It is NOT yet in the El -// runtime. The seed agent must: -// 1. Add to el-compiler/runtime/el_runtime.c: -// -// el_val_t __read_n(el_val_t n) { -// int64_t count = (int64_t)n; -// if (count <= 0) return EL_STR(strdup("")); -// char *buf = malloc(count + 1); -// if (!buf) return EL_STR(strdup("")); -// size_t total = 0; -// while (total < (size_t)count) { -// size_t got = fread(buf + total, 1, (size_t)count - total, stdin); -// if (got == 0) break; -// total += got; -// } -// buf[total] = '\0'; -// return EL_STR(buf); -// } -// -// 2. Declare in el-compiler/runtime/el_runtime.h: -// el_val_t __read_n(el_val_t n); -// -// 3. Add to builtin_arity table in el-compiler/src/codegen.el: -// "arity" "1" "__read_n" -// -// 4. Rebuild the elc binary. -// -// ── Runtime modules required ────────────────────────────────────────────────── -// runtime/string.el — str_*, int_to_str, str_to_int, bool_to_str, etc. -// runtime/state.el — state_set, state_get, state_del -// runtime/json.el — json_get, json_get_raw, json_escape_string, -// json_array_get, json_build_array -// (runtime/math.el, runtime/env.el, runtime/fs.el included by build.sh -// but not directly used by the LSP itself) +// Requires __read_n(n: Int) -> String in el_runtime.c (see build.sh). +// Requires __print_raw(s: String) in el_runtime.c for framed writes. +// Build: tools/lsp/build.sh -// ── Identifier character helpers ────────────────────────────────────────────── +// ── JSON-RPC framing ────────────────────────────────────────────────────── -// _is_ident_char — true if the char code c can appear in an El identifier. -// El identifiers: [A-Za-z_][A-Za-z0-9_]* -fn _is_ident_char(c: Int) -> Bool { - if c >= 65 { if c <= 90 { return true } } // A-Z - if c >= 97 { if c <= 122 { return true } } // a-z - if c >= 48 { if c <= 57 { return true } } // 0-9 - if c == 95 { return true } // _ - return false -} - -// _ident_start_at — find the left boundary of an identifier starting at or -// before column `col` in `text`. Returns the byte index where the identifier -// starts, or `col` if no identifier extends left. -fn _ident_start_at(text: String, col: Int) -> Int { - let lo: Int = col - let searching: Bool = true - while searching { - if lo <= 0 { - let lo = 0 - let searching = false - } else { - let ch: Int = str_char_code(text, lo - 1) - if _is_ident_char(ch) { - lo = lo - 1 - } else { - let searching = false - } - } - } - return lo -} - -// _ident_end_at — find the right boundary of an identifier starting at `pos` -// in `text`. Returns the byte index one past the last identifier character. -fn _ident_end_at(text: String, pos: Int) -> Int { - let n: Int = str_len(text) - let hi: Int = pos - let searching: Bool = true - while searching { - if hi >= n { - let searching = false - } else { - let ch: Int = str_char_code(text, hi) - if _is_ident_char(ch) { - hi = hi + 1 - } else { - let searching = false - } - } - } - return hi -} - -// lsp_word_at — extract the identifier word under/at cursor column `col` -// in `line_text`. Returns "" when the cursor is not on an identifier. -fn lsp_word_at(line_text: String, col: Int) -> String { - let n: Int = str_len(line_text) - if n == 0 { return "" } - let c: Int = col - if c >= n { c = n - 1 } - if c < 0 { c = 0 } - - // If the cursor is not on an identifier character, return "". - if !_is_ident_char(str_char_code(line_text, c)) { return "" } - - let lo: Int = _ident_start_at(line_text, c) - let hi: Int = _ident_end_at(line_text, c) - return str_slice(line_text, lo, hi) -} - -// ── El builtins known to the LSP ───────────────────────────────────────────── - -fn lsp_builtin_names() -> [String] { - let names: [String] = el_list_empty() - // I/O - names = el_list_append(names, "println") - names = el_list_append(names, "print") - names = el_list_append(names, "readline") - // String - names = el_list_append(names, "str_len") - names = el_list_append(names, "str_eq") - names = el_list_append(names, "str_concat") - names = el_list_append(names, "str_slice") - names = el_list_append(names, "str_contains") - names = el_list_append(names, "str_starts_with") - names = el_list_append(names, "str_ends_with") - names = el_list_append(names, "str_replace") - names = el_list_append(names, "str_index_of") - names = el_list_append(names, "str_last_index_of") - names = el_list_append(names, "str_split") - names = el_list_append(names, "str_split_lines") - names = el_list_append(names, "str_split_n") - names = el_list_append(names, "str_join") - names = el_list_append(names, "str_trim") - names = el_list_append(names, "str_lstrip") - names = el_list_append(names, "str_rstrip") - names = el_list_append(names, "str_to_upper") - names = el_list_append(names, "str_to_lower") - names = el_list_append(names, "str_upper") - names = el_list_append(names, "str_lower") - names = el_list_append(names, "str_repeat") - names = el_list_append(names, "str_reverse") - names = el_list_append(names, "str_char_at") - names = el_list_append(names, "str_char_code") - names = el_list_append(names, "str_count") - names = el_list_append(names, "str_count_lines") - names = el_list_append(names, "str_pad_left") - names = el_list_append(names, "str_pad_right") - names = el_list_append(names, "str_strip_prefix") - names = el_list_append(names, "str_strip_suffix") - names = el_list_append(names, "str_find_chars") - // Type conversions - names = el_list_append(names, "int_to_str") - names = el_list_append(names, "str_to_int") - names = el_list_append(names, "float_to_str") - names = el_list_append(names, "str_to_float") - names = el_list_append(names, "bool_to_str") - // Math - names = el_list_append(names, "el_abs") - names = el_list_append(names, "el_max") - names = el_list_append(names, "el_min") - names = el_list_append(names, "math_sqrt") - names = el_list_append(names, "math_log") - names = el_list_append(names, "math_sin") - names = el_list_append(names, "math_cos") - names = el_list_append(names, "math_pi") - // List - names = el_list_append(names, "el_list_empty") - names = el_list_append(names, "el_list_append") - names = el_list_append(names, "el_list_len") - names = el_list_append(names, "el_list_get") - names = el_list_append(names, "el_list_clone") - names = el_list_append(names, "el_list_new") - names = el_list_append(names, "list_push") - names = el_list_append(names, "list_join") - names = el_list_append(names, "list_range") - // Map - names = el_list_append(names, "el_map_new") - names = el_list_append(names, "el_map_get") - names = el_list_append(names, "el_map_set") - // State - names = el_list_append(names, "state_set") - names = el_list_append(names, "state_get") - names = el_list_append(names, "state_del") - names = el_list_append(names, "state_keys") - names = el_list_append(names, "state_has") - names = el_list_append(names, "state_get_or") - // JSON - names = el_list_append(names, "json_get") - names = el_list_append(names, "json_get_raw") - names = el_list_append(names, "json_get_string") - names = el_list_append(names, "json_get_int") - names = el_list_append(names, "json_get_bool") - names = el_list_append(names, "json_set") - names = el_list_append(names, "json_parse") - names = el_list_append(names, "json_stringify") - names = el_list_append(names, "json_array_len") - names = el_list_append(names, "json_array_get") - names = el_list_append(names, "json_array_get_string") - names = el_list_append(names, "json_build_object") - names = el_list_append(names, "json_build_array") - names = el_list_append(names, "json_escape_string") - // Filesystem - names = el_list_append(names, "fs_read") - names = el_list_append(names, "fs_write") - names = el_list_append(names, "fs_exists") - names = el_list_append(names, "fs_mkdir") - names = el_list_append(names, "fs_list") - // HTTP - names = el_list_append(names, "http_get") - names = el_list_append(names, "http_post") - names = el_list_append(names, "http_post_json") - names = el_list_append(names, "http_serve") - // Exec - names = el_list_append(names, "exec") - names = el_list_append(names, "exec_bg") - names = el_list_append(names, "exec_capture") - // Time - names = el_list_append(names, "time_now") - names = el_list_append(names, "sleep_ms") - names = el_list_append(names, "sleep_secs") - // UUID / env - names = el_list_append(names, "uuid_new") - names = el_list_append(names, "env") - // Classification - names = el_list_append(names, "is_letter") - names = el_list_append(names, "is_digit") - names = el_list_append(names, "is_alphanumeric") - names = el_list_append(names, "is_whitespace") - names = el_list_append(names, "url_encode") - names = el_list_append(names, "url_decode") - return names -} - -fn lsp_keyword_names() -> [String] { - let kws: [String] = el_list_empty() - kws = el_list_append(kws, "fn") - kws = el_list_append(kws, "let") - kws = el_list_append(kws, "return") - kws = el_list_append(kws, "if") - kws = el_list_append(kws, "else") - kws = el_list_append(kws, "while") - kws = el_list_append(kws, "for") - kws = el_list_append(kws, "in") - kws = el_list_append(kws, "true") - kws = el_list_append(kws, "false") - kws = el_list_append(kws, "import") - kws = el_list_append(kws, "type") - kws = el_list_append(kws, "enum") - kws = el_list_append(kws, "match") - return kws -} - -// ── JSON-RPC helpers ────────────────────────────────────────────────────────── - -// lsp_write_message — frame a JSON body as an LSP Content-Length message and -// write it to stdout. -fn lsp_write_message(json: String) { - let body_len: Int = str_len(json) - __print("Content-Length: " + int_to_str(body_len) + "\r\n\r\n" + json) -} - -// lsp_read_header_line — read one header line, strip trailing \r, return it. -// Returns "" on EOF. -fn lsp_read_header_line() -> String { - let line: String = readline() - // readline() returns "" on EOF/stdin-close. - if str_eq(line, "") { return "" } - // Strip trailing \r if readline left it. - return str_rstrip(line) -} - -// lsp_parse_content_length — given a trimmed header line, return the -// Content-Length value if this is a Content-Length header, otherwise -1. -fn lsp_parse_content_length(line: String) -> Int { - let prefix: String = "Content-Length: " - if str_starts_with(line, prefix) { - let value_part: String = str_slice(line, str_len(prefix), str_len(line)) - return str_to_int(str_trim(value_part)) - } - return -1 -} - -// lsp_read_message — read one LSP message from stdin. -// -// Protocol: -// 1. Read lines until a blank line (end of headers). -// 2. Extract Content-Length from the headers. -// 3. Read exactly Content-Length bytes as the JSON body via __read_n(). -// -// Returns the JSON body, or "" on EOF / malformed message. -// -// DEPENDENCY: __read_n(n: Int) -> String — see top-of-file note. +// Read a Content-Length framed JSON-RPC message from stdin. fn lsp_read_message() -> String { - let content_length: Int = -1 - let reading_headers: Bool = true - - while reading_headers { - let line: String = lsp_read_header_line() - if str_eq(line, "") { - // EOF on the very first read means stdin was closed. - if content_length < 0 { - return "" - } - // Blank line after at least one header = end of headers. - reading_headers = false - } else { - let cl: Int = lsp_parse_content_length(line) - if cl >= 0 { - content_length = cl - } - // Blank trimmed line = end of headers (no EOF). - if str_eq(line, "") { - reading_headers = false + let header_buf: String = "" + let found_end: Bool = false + let max_header: Int = 8192 + let hi: Int = 0 + while !found_end { + if hi >= max_header { + return "" + } + let ch: String = __read_n(1) + if str_eq(ch, "") { + return "" + } + let header_buf = header_buf + ch + let hlen: Int = str_len(header_buf) + if hlen >= 4 { + let tail: String = str_slice(header_buf, hlen - 4, hlen) + if str_eq(tail, "\r\n\r\n") { + let found_end = true } } + let hi = hi + 1 } - - if content_length < 0 { + let cl_key: String = "Content-Length: " + let cl_pos: Int = str_index_of(header_buf, cl_key) + if cl_pos < 0 { return "" } - - // Read exactly content_length bytes from stdin. - // __read_n is the seed primitive described in the top-of-file note. - return __read_n(content_length) -} - -// ── Response builders ───────────────────────────────────────────────────────── - -fn lsp_make_response(id: String, result_json: String) -> String { - return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"result\":" + result_json + "}" -} - -fn lsp_make_error(id: String, code: Int, message: String) -> String { - let escaped_msg: String = json_escape_string(message) - return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"error\":{\"code\":" + int_to_str(code) + ",\"message\":\"" + escaped_msg + "\"}}" -} - -// ── Server capabilities ─────────────────────────────────────────────────────── - -fn lsp_capabilities() -> String { - return "{" + - "\"textDocumentSync\":1," + - "\"completionProvider\":{\"triggerCharacters\":[\".\",\"_\"]}," + - "\"hoverProvider\":true," + - "\"definitionProvider\":true" + - "}" -} - -// ── Document state ──────────────────────────────────────────────────────────── - -fn doc_key(uri: String) -> String { - return "doc_" + uri -} - -fn doc_store(uri: String, text: String) { - state_set(doc_key(uri), text) -} - -fn doc_load(uri: String) -> String { - return state_get(doc_key(uri)) -} - -// ── Source analysis ─────────────────────────────────────────────────────────── - -// _scan_fn_name_at — given source and a position `i` that points just past -// "fn ", skip whitespace and collect an identifier. Returns the function name -// string (possibly "") and advances `out_pos` to the character after the name. -// -// Because El cannot return multiple values, we encode the end position into -// state instead of returning it; callers who need it call -// _scan_fn_end_pos() separately. -fn _scan_fn_name_at(source: String, i: Int) -> String { - let n: Int = str_len(source) - let j: Int = i - - // Skip whitespace between "fn" and the name. - let skipping: Bool = true - while skipping { - if j >= n { let skipping = false } else { - let ch: Int = str_char_code(source, j) - if ch == 32 { j = j + 1 } - else { if ch == 9 { j = j + 1 } else { let skipping = false } } + let after: String = str_slice(header_buf, cl_pos + str_len(cl_key), str_len(header_buf)) + let cr_pos: Int = str_index_of(after, "\r") + let num_str: String = "" + if cr_pos >= 0 { + let num_str = str_slice(after, 0, cr_pos) + } else { + let nl_pos: Int = str_index_of(after, "\n") + if nl_pos >= 0 { + let num_str = str_slice(after, 0, nl_pos) } } + let body_len: Int = str_to_int(str_trim(num_str)) + if body_len <= 0 { + return "" + } + __read_n(body_len) +} - let name_start: Int = j - // Collect identifier characters. - let collecting: Bool = true - while collecting { - if j >= n { let collecting = false } else { - if _is_ident_char(str_char_code(source, j)) { - j = j + 1 +// Write a Content-Length framed JSON-RPC message to stdout. +fn lsp_write_message(body: String) -> Void { + let len: Int = str_len(body) + __print_raw("Content-Length: " + int_to_str(len) + "\r\n\r\n" + body) +} + +// ── JSON helpers ────────────────────────────────────────────────────────── + +fn lsp_json_escape(s: String) -> String { + let s = str_replace(s, "\\", "\\\\") + let s = str_replace(s, "\"", "\\\"") + let s = str_replace(s, "\n", "\\n") + let s = str_replace(s, "\r", "\\r") + let s = str_replace(s, "\t", "\\t") + s +} + +// ── JSON-RPC envelope builders ───────────────────────────────────────────── + +fn lsp_response(id: String, result_json: String) -> String { + "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"result\":" + result_json + "}" +} + +fn lsp_error_response(id: String, code: Int, message: String) -> String { + "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"error\":{\"code\":" + int_to_str(code) + ",\"message\":\"" + lsp_json_escape(message) + "\"}}" +} + +fn lsp_notification(method: String, params_json: String) -> String { + "{\"jsonrpc\":\"2.0\",\"method\":\"" + method + "\",\"params\":" + params_json + "}" +} + +// ── Document store ───────────────────────────────────────────────────────── + +fn lsp_doc_key(uri: String) -> String { + "doc:" + uri +} + +fn lsp_doc_store(uri: String, text: String) -> Void { + state_set(lsp_doc_key(uri), text) +} + +fn lsp_doc_get(uri: String) -> String { + state_get(lsp_doc_key(uri)) +} + +fn lsp_doc_remove(uri: String) -> Void { + state_del(lsp_doc_key(uri)) +} + +// ── Builtin catalog ─────────────────────────────────────────────────────── +// Format: "name|signature|description" + +fn lsp_builtin_catalog() -> [String] { + let c: [String] = native_list_empty() + let c = native_list_append(c, "println|println(s: String) -> Void|Print s followed by a newline to stdout.") + let c = native_list_append(c, "print|print(s: String) -> Void|Print s to stdout without a newline.") + let c = native_list_append(c, "readline|readline() -> String|Read one line from stdin (newline stripped).") + let c = native_list_append(c, "str_eq|str_eq(a: String, b: String) -> Bool|Return true if a equals b.") + let c = native_list_append(c, "str_len|str_len(s: String) -> Int|Return the byte length of s.") + let c = native_list_append(c, "str_concat|str_concat(a: String, b: String) -> String|Concatenate a and b.") + let c = native_list_append(c, "str_slice|str_slice(s: String, start: Int, end: Int) -> String|Substring [start, end).") + let c = native_list_append(c, "str_contains|str_contains(s: String, sub: String) -> Bool|True if s contains sub.") + let c = native_list_append(c, "str_starts_with|str_starts_with(s: String, prefix: String) -> Bool|True if s starts with prefix.") + let c = native_list_append(c, "str_ends_with|str_ends_with(s: String, suffix: String) -> Bool|True if s ends with suffix.") + let c = native_list_append(c, "str_replace|str_replace(s: String, from: String, to: String) -> String|Replace all occurrences of from with to.") + let c = native_list_append(c, "str_to_upper|str_to_upper(s: String) -> String|Convert to uppercase.") + let c = native_list_append(c, "str_to_lower|str_to_lower(s: String) -> String|Convert to lowercase.") + let c = native_list_append(c, "str_trim|str_trim(s: String) -> String|Strip leading and trailing whitespace.") + let c = native_list_append(c, "str_lstrip|str_lstrip(s: String) -> String|Strip leading whitespace.") + let c = native_list_append(c, "str_rstrip|str_rstrip(s: String) -> String|Strip trailing whitespace.") + let c = native_list_append(c, "str_index_of|str_index_of(s: String, sub: String) -> Int|First byte offset of sub, or -1.") + let c = native_list_append(c, "str_last_index_of|str_last_index_of(s: String, sub: String) -> Int|Last byte offset of sub, or -1.") + let c = native_list_append(c, "str_split|str_split(s: String, sep: String) -> [String]|Split on sep.") + let c = native_list_append(c, "str_split_lines|str_split_lines(s: String) -> [String]|Split into lines.") + let c = native_list_append(c, "str_split_n|str_split_n(s: String, sep: String, n: Int) -> [String]|Split at most n times.") + let c = native_list_append(c, "str_join|str_join(parts: [String], sep: String) -> String|Join list with sep.") + let c = native_list_append(c, "str_char_at|str_char_at(s: String, i: Int) -> String|Single character at byte index i.") + let c = native_list_append(c, "str_char_code|str_char_code(s: String, i: Int) -> Int|Byte value at index i.") + let c = native_list_append(c, "str_pad_left|str_pad_left(s: String, width: Int, pad: String) -> String|Pad on the left to width.") + let c = native_list_append(c, "str_pad_right|str_pad_right(s: String, width: Int, pad: String) -> String|Pad on the right to width.") + let c = native_list_append(c, "str_repeat|str_repeat(s: String, n: Int) -> String|Repeat s n times.") + let c = native_list_append(c, "str_reverse|str_reverse(s: String) -> String|Reverse by codepoint.") + let c = native_list_append(c, "str_strip_prefix|str_strip_prefix(s: String, prefix: String) -> String|Remove prefix if present.") + let c = native_list_append(c, "str_strip_suffix|str_strip_suffix(s: String, suffix: String) -> String|Remove suffix if present.") + let c = native_list_append(c, "str_strip_chars|str_strip_chars(s: String, chars: String) -> String|Strip chars from both ends.") + let c = native_list_append(c, "str_count|str_count(s: String, sub: String) -> Int|Count non-overlapping occurrences.") + let c = native_list_append(c, "str_count_lines|str_count_lines(s: String) -> Int|Number of lines.") + let c = native_list_append(c, "str_find_chars|str_find_chars(s: String, any_of: String) -> Int|First index of any char in any_of.") + let c = native_list_append(c, "str_index_of_all|str_index_of_all(s: String, sub: String) -> [Int]|All byte offsets of sub.") + let c = native_list_append(c, "str_upper|str_upper(s: String) -> String|Convert to uppercase (alias).") + let c = native_list_append(c, "str_lower|str_lower(s: String) -> String|Convert to lowercase (alias).") + let c = native_list_append(c, "is_letter|is_letter(s: String) -> Bool|All ASCII letters.") + let c = native_list_append(c, "is_digit|is_digit(s: String) -> Bool|All ASCII digits.") + let c = native_list_append(c, "is_alphanumeric|is_alphanumeric(s: String) -> Bool|All ASCII alphanumeric.") + let c = native_list_append(c, "is_whitespace|is_whitespace(s: String) -> Bool|All whitespace.") + let c = native_list_append(c, "is_uppercase|is_uppercase(s: String) -> Bool|All uppercase ASCII.") + let c = native_list_append(c, "is_lowercase|is_lowercase(s: String) -> Bool|All lowercase ASCII.") + let c = native_list_append(c, "int_to_str|int_to_str(n: Int) -> String|Integer to decimal string.") + let c = native_list_append(c, "str_to_int|str_to_int(s: String) -> Int|Parse decimal string to integer.") + let c = native_list_append(c, "float_to_str|float_to_str(f: Float) -> String|Float to string.") + let c = native_list_append(c, "str_to_float|str_to_float(s: String) -> Float|Parse string to float.") + let c = native_list_append(c, "int_to_float|int_to_float(n: Int) -> Float|Integer to float.") + let c = native_list_append(c, "float_to_int|float_to_int(f: Float) -> Int|Truncate float to integer.") + let c = native_list_append(c, "format_float|format_float(f: Float, decimals: Int) -> String|Fixed decimal places.") + let c = native_list_append(c, "decimal_round|decimal_round(f: Float, decimals: Int) -> Float|Round to N decimal places.") + let c = native_list_append(c, "bool_to_str|bool_to_str(b: Bool) -> String|Bool to \"true\" or \"false\".") + let c = native_list_append(c, "el_abs|el_abs(n: Int) -> Int|Absolute value.") + let c = native_list_append(c, "el_max|el_max(a: Int, b: Int) -> Int|Maximum of two integers.") + let c = native_list_append(c, "el_min|el_min(a: Int, b: Int) -> Int|Minimum of two integers.") + let c = native_list_append(c, "math_sqrt|math_sqrt(f: Float) -> Float|Square root.") + let c = native_list_append(c, "math_log|math_log(f: Float) -> Float|Base-10 logarithm.") + let c = native_list_append(c, "math_ln|math_ln(f: Float) -> Float|Natural logarithm.") + let c = native_list_append(c, "math_sin|math_sin(f: Float) -> Float|Sine (radians).") + let c = native_list_append(c, "math_cos|math_cos(f: Float) -> Float|Cosine (radians).") + let c = native_list_append(c, "math_pi|math_pi() -> Float|Value of pi.") + let c = native_list_append(c, "el_list_empty|el_list_empty() -> [Any]|Create an empty list.") + let c = native_list_append(c, "el_list_append|el_list_append(list: [Any], elem: Any) -> [Any]|Append and return updated list.") + let c = native_list_append(c, "el_list_len|el_list_len(list: [Any]) -> Int|Number of elements.") + let c = native_list_append(c, "el_list_get|el_list_get(list: [Any], index: Int) -> Any|Element at index.") + let c = native_list_append(c, "el_list_clone|el_list_clone(list: [Any]) -> [Any]|Shallow copy.") + let c = native_list_append(c, "list_push|list_push(list: [Any], elem: Any) -> [Any]|Append elem (alias).") + let c = native_list_append(c, "list_push_front|list_push_front(list: [Any], elem: Any) -> [Any]|Prepend elem.") + let c = native_list_append(c, "list_join|list_join(list: [Any], sep: String) -> String|Join list with sep.") + let c = native_list_append(c, "list_range|list_range(start: Int, end: Int) -> [Int]|Integers from start to end-1.") + let c = native_list_append(c, "el_map_get|el_map_get(map: Map, key: String) -> Any|Get by key.") + let c = native_list_append(c, "el_map_set|el_map_set(map: Map, key: String, value: Any) -> Map|Set key.") + let c = native_list_append(c, "el_get_field|el_get_field(map: Map, key: String) -> Any|Get field.") + let c = native_list_append(c, "state_set|state_set(key: String, value: String) -> Void|Store in process state.") + let c = native_list_append(c, "state_get|state_get(key: String) -> String|Retrieve from process state, empty if absent.") + let c = native_list_append(c, "state_del|state_del(key: String) -> Void|Delete from process state.") + let c = native_list_append(c, "state_keys|state_keys() -> [String]|All keys in process state.") + let c = native_list_append(c, "json_get|json_get(json: String, key: String) -> String|Get string field from JSON object.") + let c = native_list_append(c, "json_get_string|json_get_string(json: String, key: String) -> String|Get string field.") + let c = native_list_append(c, "json_get_int|json_get_int(json: String, key: String) -> Int|Get integer field.") + let c = native_list_append(c, "json_get_float|json_get_float(json: String, key: String) -> Float|Get float field.") + let c = native_list_append(c, "json_get_bool|json_get_bool(json: String, key: String) -> Bool|Get boolean field.") + let c = native_list_append(c, "json_get_raw|json_get_raw(json: String, key: String) -> String|Get raw JSON fragment.") + let c = native_list_append(c, "json_set|json_set(json: String, key: String, value: String) -> String|Set key in JSON object.") + let c = native_list_append(c, "json_parse|json_parse(s: String) -> String|Parse and validate JSON.") + let c = native_list_append(c, "json_stringify|json_stringify(v: Any) -> String|Serialize to JSON.") + let c = native_list_append(c, "json_array_len|json_array_len(json: String) -> Int|JSON array length.") + let c = native_list_append(c, "json_array_get|json_array_get(json: String, index: Int) -> String|JSON array element.") + let c = native_list_append(c, "json_array_get_string|json_array_get_string(json: String, index: Int) -> String|String element at index.") + let c = native_list_append(c, "fs_read|fs_read(path: String) -> String|Read file contents.") + let c = native_list_append(c, "fs_write|fs_write(path: String, content: String) -> Bool|Write file.") + let c = native_list_append(c, "fs_list|fs_list(path: String) -> [String]|List directory entries.") + let c = native_list_append(c, "fs_exists|fs_exists(path: String) -> Bool|True if path exists.") + let c = native_list_append(c, "fs_mkdir|fs_mkdir(path: String) -> Bool|Create directory (mkdir -p).") + let c = native_list_append(c, "http_get|http_get(url: String) -> String|HTTP GET, return response body.") + let c = native_list_append(c, "http_post|http_post(url: String, body: String) -> String|HTTP POST with plain body.") + let c = native_list_append(c, "http_post_json|http_post_json(url: String, json_body: String) -> String|HTTP POST with JSON.") + let c = native_list_append(c, "http_get_with_headers|http_get_with_headers(url: String, headers: Map) -> String|GET with headers.") + let c = native_list_append(c, "http_post_with_headers|http_post_with_headers(url: String, body: String, headers: Map) -> String|POST with headers.") + let c = native_list_append(c, "http_serve|http_serve(port: Int, handler: Any) -> Void|Start HTTP server.") + let c = native_list_append(c, "http_serve_v2|http_serve_v2(port: Int, handler: Any) -> Void|Start HTTP server v2 (method,path,headers,body).") + let c = native_list_append(c, "http_response|http_response(status: Int, headers_json: String, body: String) -> String|Build HTTP response envelope.") + let c = native_list_append(c, "url_encode|url_encode(s: String) -> String|URL-encode (RFC 3986).") + let c = native_list_append(c, "url_decode|url_decode(s: String) -> String|URL-decode.") + let c = native_list_append(c, "time_now|time_now() -> String|Current local time as ISO 8601.") + let c = native_list_append(c, "time_now_utc|time_now_utc() -> String|Current UTC time as ISO 8601.") + let c = native_list_append(c, "sleep_secs|sleep_secs(secs: Int) -> Void|Sleep N seconds.") + let c = native_list_append(c, "sleep_ms|sleep_ms(ms: Int) -> Void|Sleep N milliseconds.") + let c = native_list_append(c, "time_format|time_format(ts: String, fmt: String) -> String|Format timestamp.") + let c = native_list_append(c, "time_add|time_add(ts: String, n: Int, unit: String) -> String|Add time to timestamp.") + let c = native_list_append(c, "time_diff|time_diff(ts1: String, ts2: String, unit: String) -> Int|Diff two timestamps.") + let c = native_list_append(c, "now|now() -> Instant|Current time as Instant.") + let c = native_list_append(c, "unix_seconds|unix_seconds(n: Int) -> Instant|Instant from Unix seconds.") + let c = native_list_append(c, "unix_millis|unix_millis(n: Int) -> Instant|Instant from Unix milliseconds.") + let c = native_list_append(c, "uuid_new|uuid_new() -> String|Random UUID v4.") + let c = native_list_append(c, "uuid_v4|uuid_v4() -> String|Random UUID v4 (alias).") + let c = native_list_append(c, "env|env(key: String) -> String|Read environment variable.") + let c = native_list_append(c, "args|args() -> [String]|Command-line arguments.") + let c = native_list_append(c, "exit_program|exit_program(code: Int) -> Void|Exit with code.") + let c = native_list_append(c, "exec_command|exec_command(cmd: String) -> Int|Run shell command, return exit code.") + let c = native_list_append(c, "exec_capture|exec_capture(cmd: String) -> String|Run shell command, capture stdout.") + let c = native_list_append(c, "sha256_hex|sha256_hex(input: String) -> String|SHA-256 as lowercase hex.") + let c = native_list_append(c, "sha256_bytes|sha256_bytes(input: String) -> String|SHA-256 as raw bytes.") + let c = native_list_append(c, "hmac_sha256_hex|hmac_sha256_hex(key: String, message: String) -> String|HMAC-SHA-256 hex.") + let c = native_list_append(c, "base64_encode|base64_encode(input: String) -> String|Base64 encode.") + let c = native_list_append(c, "base64_decode|base64_decode(input: String) -> String|Base64 decode.") + let c = native_list_append(c, "base64url_encode|base64url_encode(input: String) -> String|URL-safe base64 encode.") + let c = native_list_append(c, "base64url_decode|base64url_decode(input: String) -> String|URL-safe base64 decode.") + let c = native_list_append(c, "llm_call|llm_call(model: String, prompt: String) -> String|LLM with user prompt.") + let c = native_list_append(c, "llm_call_system|llm_call_system(model: String, system: String, user: String) -> String|LLM with system + user.") + let c = native_list_append(c, "llm_call_agentic|llm_call_agentic(model: String, system: String, user: String, tools: String) -> String|Agentic LLM.") + let c = native_list_append(c, "llm_vision|llm_vision(model: String, system: String, prompt: String, image: String) -> String|LLM with image.") + let c = native_list_append(c, "llm_models|llm_models() -> String|List available LLM models.") + let c = native_list_append(c, "llm_register_tool|llm_register_tool(name: String, handler_fn: String) -> Void|Register tool.") + let c = native_list_append(c, "engram_node|engram_node(content: String, node_type: String, salience: Float) -> String|Create engram node.") + let c = native_list_append(c, "engram_get_node|engram_get_node(id: String) -> Map|Get node by ID.") + let c = native_list_append(c, "engram_strengthen|engram_strengthen(node_id: String) -> Void|Increase salience.") + let c = native_list_append(c, "engram_forget|engram_forget(node_id: String) -> Void|Remove node.") + let c = native_list_append(c, "engram_search|engram_search(query: String, limit: Int) -> [Map]|Semantic search.") + let c = native_list_append(c, "engram_connect|engram_connect(from: String, to: String, weight: Float, rel: String) -> Void|Connect nodes.") + let c = native_list_append(c, "engram_activate|engram_activate(query: String, depth: Int) -> [Map]|Activate nodes.") + let c = native_list_append(c, "engram_save|engram_save(path: String) -> Bool|Save to file.") + let c = native_list_append(c, "engram_load|engram_load(path: String) -> Bool|Load from file.") + let c = native_list_append(c, "engram_node_count|engram_node_count() -> Int|Total nodes.") + let c = native_list_append(c, "engram_edge_count|engram_edge_count() -> Int|Total edges.") + let c = native_list_append(c, "engram_neighbors|engram_neighbors(node_id: String) -> [Map]|Neighboring nodes.") + let c = native_list_append(c, "engram_search_json|engram_search_json(query: String, limit: Int) -> String|Search as JSON.") + let c = native_list_append(c, "engram_stats_json|engram_stats_json() -> String|Graph stats as JSON.") + let c = native_list_append(c, "dharma_connect|dharma_connect(cgi_id: String) -> Bool|Connect to peer CGI.") + let c = native_list_append(c, "dharma_send|dharma_send(channel: String, content: String) -> String|Send to channel.") + let c = native_list_append(c, "dharma_activate|dharma_activate(query: String) -> String|Activate across network.") + let c = native_list_append(c, "dharma_emit|dharma_emit(event_type: String, payload: String) -> Void|Emit network event.") + let c = native_list_append(c, "dharma_field|dharma_field(event_type: String) -> String|Block until event.") + let c = native_list_append(c, "dharma_peers|dharma_peers() -> [String]|Connected peer IDs.") + let c = native_list_append(c, "native_list_empty|native_list_empty() -> [Any]|Empty list (VM alias).") + let c = native_list_append(c, "native_list_append|native_list_append(list: [Any], elem: Any) -> [Any]|Append (VM alias).") + let c = native_list_append(c, "native_list_len|native_list_len(list: [Any]) -> Int|Length (VM alias).") + let c = native_list_append(c, "native_list_get|native_list_get(list: [Any], i: Int) -> Any|Get element (VM alias).") + let c = native_list_append(c, "native_list_clone|native_list_clone(list: [Any]) -> [Any]|Clone (VM alias).") + let c = native_list_append(c, "native_string_chars|native_string_chars(s: String) -> [String]|Split into chars.") + let c = native_list_append(c, "native_int_to_str|native_int_to_str(n: Int) -> String|Int to string (VM alias).") + c +} + +// Find a builtin catalog entry by name. Returns "name|sig|desc" or "". +fn lsp_builtin_find(name: String, catalog: [String]) -> String { + let n: Int = native_list_len(catalog) + let i: Int = 0 + while i < n { + let entry: String = native_list_get(catalog, i) + let bar: Int = str_index_of(entry, "|") + if bar >= 0 { + let ename: String = str_slice(entry, 0, bar) + if str_eq(ename, name) { + return entry + } + } + let i = i + 1 + } + "" +} + +// ── Source scanning ──────────────────────────────────────────────────────── + +// Extract user-defined function names from source text. +fn lsp_extract_fns(source: String) -> [String] { + let result: [String] = native_list_empty() + let lines: [String] = str_split_lines(source) + let n: Int = native_list_len(lines) + let i: Int = 0 + while i < n { + let line: String = str_trim(native_list_get(lines, i)) + if str_starts_with(line, "fn ") { + let after: String = str_slice(line, 3, str_len(line)) + let paren: Int = str_index_of(after, "(") + if paren > 0 { + let name: String = str_trim(str_slice(after, 0, paren)) + if str_len(name) > 0 { + let result = native_list_append(result, name) + } + } + } + let i = i + 1 + } + result +} + +// Find the definition of fn_name in source. +// Returns "line:char" (0-based) or "" if not found. +fn lsp_find_def(source: String, fn_name: String) -> String { + let lines: [String] = str_split_lines(source) + let n: Int = native_list_len(lines) + let target1: String = "fn " + fn_name + "(" + let target2: String = "fn " + fn_name + " (" + let i: Int = 0 + while i < n { + let line: String = native_list_get(lines, i) + let trimmed: String = str_trim(line) + if str_starts_with(trimmed, target1) { + let col: Int = str_index_of(line, target1) + if col < 0 { + let col = 0 + } + return int_to_str(i) + ":" + int_to_str(col) + } + if str_starts_with(trimmed, target2) { + let col: Int = str_index_of(line, target2) + if col < 0 { + let col = 0 + } + return int_to_str(i) + ":" + int_to_str(col) + } + let i = i + 1 + } + "" +} + +// Get the identifier word at line_no:char_no in source (0-based). +fn lsp_word_at(source: String, line_no: Int, char_no: Int) -> String { + let lines: [String] = str_split_lines(source) + if line_no >= native_list_len(lines) { + return "" + } + let line: String = native_list_get(lines, line_no) + let line_len: Int = str_len(line) + if char_no >= line_len { + return "" + } + let start: Int = char_no + let going_back: Bool = true + while going_back { + if start <= 0 { + let going_back = false + } else { + let c: Int = str_char_code(line, start - 1) + let is_word: Bool = false + if c >= 97 { if c <= 122 { let is_word = true } } + if c >= 65 { if c <= 90 { let is_word = true } } + if c >= 48 { if c <= 57 { let is_word = true } } + if c == 95 { let is_word = true } + if is_word { + let start = start - 1 } else { - let collecting = false + let going_back = false } } } - - // Store the end position so lsp_extract_fn_names can advance past it. - state_set("_fn_scan_end", int_to_str(j)) - return str_slice(source, name_start, j) + let end: Int = char_no + let going_fwd: Bool = true + while going_fwd { + if end >= line_len { + let going_fwd = false + } else { + let c: Int = str_char_code(line, end) + let is_word: Bool = false + if c >= 97 { if c <= 122 { let is_word = true } } + if c >= 65 { if c <= 90 { let is_word = true } } + if c >= 48 { if c <= 57 { let is_word = true } } + if c == 95 { let is_word = true } + if is_word { + let end = end + 1 + } else { + let going_fwd = false + } + } + } + if end <= start { + return "" + } + str_slice(line, start, end) } -// lsp_extract_fn_names — scan El source for function definitions. -// Looks for the three-character sequence "fn " and extracts the identifier -// that follows. Returns a list of function name strings. -fn lsp_extract_fn_names(source: String) -> [String] { - let result: [String] = el_list_empty() - let slen: Int = str_len(source) - let i: Int = 0 +// ── Completions ──────────────────────────────────────────────────────────── - while i < slen { - // Try to match "fn " at position i (need at least 3 chars remaining). - if i + 3 <= slen { - let window: String = str_slice(source, i, i + 3) - if str_eq(window, "fn ") { - let fn_name: String = _scan_fn_name_at(source, i + 3) - let end_pos_str: String = state_get("_fn_scan_end") - let end_pos: Int = str_to_int(end_pos_str) - if str_len(fn_name) > 0 { - result = el_list_append(result, fn_name) +fn lsp_completion_item(label: String, kind: Int, detail: String) -> String { + "{\"label\":\"" + lsp_json_escape(label) + "\",\"kind\":" + int_to_str(kind) + ",\"detail\":\"" + lsp_json_escape(detail) + "\"}" +} + +fn lsp_build_completions(uri: String, catalog: [String]) -> String { + let source: String = lsp_doc_get(uri) + let items: [String] = native_list_empty() + // User-defined fns (kind=3 Function) + let user_fns: [String] = lsp_extract_fns(source) + let fn_n: Int = native_list_len(user_fns) + let i: Int = 0 + while i < fn_n { + let name: String = native_list_get(user_fns, i) + let items = native_list_append(items, lsp_completion_item(name, 3, "user-defined fn")) + let i = i + 1 + } + // Builtins (kind=3 Function) + let bn: Int = native_list_len(catalog) + let j: Int = 0 + while j < bn { + let entry: String = native_list_get(catalog, j) + let bar1: Int = str_index_of(entry, "|") + if bar1 >= 0 { + let bname: String = str_slice(entry, 0, bar1) + let rest: String = str_slice(entry, bar1 + 1, str_len(entry)) + let bar2: Int = str_index_of(rest, "|") + let sig: String = "" + if bar2 >= 0 { + let sig = str_slice(rest, 0, bar2) + } else { + let sig = rest + } + let items = native_list_append(items, lsp_completion_item(bname, 3, sig)) + } + let j = j + 1 + } + // Keywords (kind=14) + let kws: [String] = native_list_empty() + let kws = native_list_append(kws, "fn") + let kws = native_list_append(kws, "let") + let kws = native_list_append(kws, "return") + let kws = native_list_append(kws, "if") + let kws = native_list_append(kws, "else") + let kws = native_list_append(kws, "while") + let kws = native_list_append(kws, "for") + let kws = native_list_append(kws, "in") + let kws = native_list_append(kws, "true") + let kws = native_list_append(kws, "false") + let kws = native_list_append(kws, "import") + let kws = native_list_append(kws, "match") + let kws = native_list_append(kws, "break") + let kws = native_list_append(kws, "continue") + let kw_n: Int = native_list_len(kws) + let k: Int = 0 + while k < kw_n { + let kw: String = native_list_get(kws, k) + let items = native_list_append(items, lsp_completion_item(kw, 14, "keyword")) + let k = k + 1 + } + // Types (kind=8 Class) + let types: [String] = native_list_empty() + let types = native_list_append(types, "String") + let types = native_list_append(types, "Int") + let types = native_list_append(types, "Bool") + let types = native_list_append(types, "Float") + let types = native_list_append(types, "Void") + let types = native_list_append(types, "Any") + let types = native_list_append(types, "Instant") + let types = native_list_append(types, "Duration") + let t_n: Int = native_list_len(types) + let t: Int = 0 + while t < t_n { + let ty: String = native_list_get(types, t) + let items = native_list_append(items, lsp_completion_item(ty, 8, "type")) + let t = t + 1 + } + "[" + str_join(items, ",") + "]" +} + +// ── Hover ────────────────────────────────────────────────────────────────── + +fn lsp_keyword_hover(word: String) -> String { + if str_eq(word, "fn") { return "Declare a function:\n```el\nfn name(param: Type) -> ReturnType { ... }\n```" } + if str_eq(word, "let") { return "Bind a local variable:\n```el\nlet name: Type = value\n```" } + if str_eq(word, "return") { return "Return a value from a function." } + if str_eq(word, "if") { return "Conditional:\n```el\nif condition { ... } else { ... }\n```" } + if str_eq(word, "else") { return "Else branch of an if statement." } + if str_eq(word, "while") { return "Loop while condition is true:\n```el\nwhile condition { ... }\n```" } + if str_eq(word, "for") { return "Range loop:\n```el\nfor i in 0..n { ... }\n```" } + if str_eq(word, "in") { return "Used in for-in range loops." } + if str_eq(word, "true") { return "Boolean literal true." } + if str_eq(word, "false") { return "Boolean literal false." } + if str_eq(word, "import") { return "Import a module:\n```el\nimport \"path/to/module.el\"\n```" } + if str_eq(word, "match") { return "Pattern match:\n```el\nmatch value { \"a\" => ... }\n```" } + if str_eq(word, "break") { return "Break out of the current while loop." } + if str_eq(word, "continue") { return "Skip to the next iteration of the current while loop." } + if str_eq(word, "String") { return "Built-in string type. Strings are immutable byte sequences." } + if str_eq(word, "Int") { return "Built-in 64-bit signed integer type." } + if str_eq(word, "Bool") { return "Built-in boolean type. Values: true or false." } + if str_eq(word, "Float") { return "Built-in 64-bit floating-point type (IEEE 754 double)." } + if str_eq(word, "Void") { return "Return type for functions that produce no value." } + if str_eq(word, "Any") { return "Dynamically typed value — accepts any El type." } + "" +} + +fn lsp_build_hover(uri: String, line_no: Int, char_no: Int, catalog: [String]) -> String { + let source: String = lsp_doc_get(uri) + let word: String = lsp_word_at(source, line_no, char_no) + if str_eq(word, "") { + return "null" + } + // Keywords + types + let kw_doc: String = lsp_keyword_hover(word) + if !str_eq(kw_doc, "") { + let content: String = "**" + word + "** (keyword)\n\n" + kw_doc + return "{\"contents\":{\"kind\":\"markdown\",\"value\":\"" + lsp_json_escape(content) + "\"}}" + } + // Builtins + let entry: String = lsp_builtin_find(word, catalog) + if !str_eq(entry, "") { + let bar1: Int = str_index_of(entry, "|") + let rest: String = str_slice(entry, bar1 + 1, str_len(entry)) + let bar2: Int = str_index_of(rest, "|") + let sig: String = "" + let desc: String = "" + if bar2 >= 0 { + let sig = str_slice(rest, 0, bar2) + let desc = str_slice(rest, bar2 + 1, str_len(rest)) + } else { + let sig = rest + } + let content: String = "**" + word + "** (builtin)\n\n```el\n" + sig + "\n```\n\n" + desc + return "{\"contents\":{\"kind\":\"markdown\",\"value\":\"" + lsp_json_escape(content) + "\"}}" + } + // User-defined fns — find the fn signature line + let user_fns: [String] = lsp_extract_fns(source) + let uf_n: Int = native_list_len(user_fns) + let i: Int = 0 + while i < uf_n { + let fn_name: String = native_list_get(user_fns, i) + if str_eq(fn_name, word) { + let lines: [String] = str_split_lines(source) + let ln: Int = native_list_len(lines) + let j: Int = 0 + while j < ln { + let line: String = str_trim(native_list_get(lines, j)) + if str_starts_with(line, "fn " + word + "(") { + let content: String = "**" + word + "** (user-defined)\n\n```el\n" + line + "\n```" + return "{\"contents\":{\"kind\":\"markdown\",\"value\":\"" + lsp_json_escape(content) + "\"}}" } - // Advance past the name to avoid re-matching sub-sequences. - if end_pos > i { - i = end_pos + let j = j + 1 + } + let content: String = "**" + word + "** (user-defined function)" + return "{\"contents\":{\"kind\":\"markdown\",\"value\":\"" + lsp_json_escape(content) + "\"}}" + } + let i = i + 1 + } + "null" +} + +// ── Go-to-definition ─────────────────────────────────────────────────────── + +fn lsp_build_definition(uri: String, line_no: Int, char_no: Int) -> String { + let source: String = lsp_doc_get(uri) + let word: String = lsp_word_at(source, line_no, char_no) + if str_eq(word, "") { + return "null" + } + let pos_str: String = lsp_find_def(source, word) + if str_eq(pos_str, "") { + return "null" + } + let colon: Int = str_index_of(pos_str, ":") + if colon < 0 { + return "null" + } + let def_line: Int = str_to_int(str_slice(pos_str, 0, colon)) + let def_char: Int = str_to_int(str_slice(pos_str, colon + 1, str_len(pos_str))) + "{\"uri\":\"" + lsp_json_escape(uri) + "\",\"range\":{\"start\":{\"line\":" + int_to_str(def_line) + ",\"character\":" + int_to_str(def_char) + "},\"end\":{\"line\":" + int_to_str(def_line) + ",\"character\":" + int_to_str(def_char + str_len(word)) + "}}}" +} + +// ── Diagnostics ──────────────────────────────────────────────────────────── + +fn lsp_make_diag(line_no: Int, col_start: Int, col_end: Int, message: String, severity: Int) -> String { + "{\"range\":{\"start\":{\"line\":" + int_to_str(line_no) + ",\"character\":" + int_to_str(col_start) + "},\"end\":{\"line\":" + int_to_str(line_no) + ",\"character\":" + int_to_str(col_end) + "}},\"severity\":" + int_to_str(severity) + ",\"source\":\"el-lsp\",\"message\":\"" + lsp_json_escape(message) + "\"}" +} + +fn lsp_compute_diagnostics(source: String) -> String { + let diags: [String] = native_list_empty() + let lines: [String] = str_split_lines(source) + let n: Int = native_list_len(lines) + let brace_depth: Int = 0 + let paren_depth: Int = 0 + let bracket_depth: Int = 0 + let brace_line: Int = 0 + let paren_line: Int = 0 + let bracket_line: Int = 0 + let i: Int = 0 + while i < n { + let line: String = native_list_get(lines, i) + let line_len: Int = str_len(line) + let in_str: Bool = false + let escape_next: Bool = false + let str_start: Int = 0 + let ci: Int = 0 + while ci < line_len { + let ch: Int = str_char_code(line, ci) + if escape_next { + let escape_next = false + } else { + if ch == 92 { + if in_str { let escape_next = true } } else { - i = i + 1 + if ch == 34 { + if in_str { + let in_str = false + } else { + let in_str = true + let str_start = ci + } + } else { + if !in_str { + if ch == 123 { + let brace_depth = brace_depth + 1 + let brace_line = i + } + if ch == 125 { + if brace_depth > 0 { + let brace_depth = brace_depth - 1 + } else { + let diags = native_list_append(diags, lsp_make_diag(i, ci, ci + 1, "Unexpected '}'", 1)) + } + } + if ch == 40 { + let paren_depth = paren_depth + 1 + let paren_line = i + } + if ch == 41 { + if paren_depth > 0 { + let paren_depth = paren_depth - 1 + } else { + let diags = native_list_append(diags, lsp_make_diag(i, ci, ci + 1, "Unexpected ')'", 1)) + } + } + if ch == 91 { + let bracket_depth = bracket_depth + 1 + let bracket_line = i + } + if ch == 93 { + if bracket_depth > 0 { + let bracket_depth = bracket_depth - 1 + } else { + let diags = native_list_append(diags, lsp_make_diag(i, ci, ci + 1, "Unexpected ']'", 1)) + } + } + } + } } - } else { - i = i + 1 } - } else { - i = i + 1 + let ci = ci + 1 } - } - - return result -} - -// lsp_build_completion_items — convert a list of name strings into a JSON -// array of LSP CompletionItem objects. -// LSP CompletionItemKind: 3=Function, 6=Variable, 14=Keyword. -fn lsp_build_completion_items(names: [String], kind: Int) -> [String] { - let items: [String] = el_list_empty() - let n: Int = el_list_len(names) - let i: Int = 0 - while i < n { - let name: String = el_list_get(names, i) - let escaped: String = json_escape_string(name) - let item: String = "{\"label\":\"" + escaped + "\",\"kind\":" + int_to_str(kind) + "}" - items = el_list_append(items, item) - i = i + 1 - } - return items -} - -// lsp_items_to_json — serialize a list of pre-encoded JSON item strings to a -// JSON array string. -fn lsp_items_to_json(items: [String]) -> String { - let n: Int = el_list_len(items) - let result: String = "[" - let i: Int = 0 - while i < n { - if i > 0 { result = result + "," } - result = result + el_list_get(items, i) - i = i + 1 - } - return result + "]" -} - -// ── El keyword and builtin documentation (for hover) ───────────────────────── - -fn lsp_keyword_doc(word: String) -> String { - if str_eq(word, "fn") { return "`fn name(param: Type) -> ReturnType { ... }` — declare a function" } - if str_eq(word, "let") { return "`let name: Type = value` — bind a variable" } - if str_eq(word, "return") { return "`return expr` — return a value from a function" } - if str_eq(word, "if") { return "`if condition { ... } else { ... }` — conditional" } - if str_eq(word, "else") { return "`else { ... }` — else branch of an if statement" } - if str_eq(word, "while") { return "`while condition { ... }` — loop while condition is true" } - if str_eq(word, "for") { return "`for i in 0..n { ... }` — range loop" } - if str_eq(word, "in") { return "Used in `for i in start..end` range loops" } - if str_eq(word, "true") { return "`true` — boolean literal" } - if str_eq(word, "false") { return "`false` — boolean literal" } - if str_eq(word, "import") { return "`import \"path/to/module.el\"` — import a module" } - if str_eq(word, "type") { return "`type Name { field: Type }` — define a struct" } - if str_eq(word, "enum") { return "`enum Name { Variant, ... }` — define an enum" } - if str_eq(word, "match") { return "`match expr { ... }` — pattern match" } - return "" -} - -fn lsp_builtin_doc(name: String) -> String { - // I/O - if str_eq(name, "println") { return "`println(s: String)` — print string + newline to stdout" } - if str_eq(name, "print") { return "`print(s: String)` — print string without newline" } - if str_eq(name, "readline") { return "`readline() -> String` — read one line from stdin" } - // String - if str_eq(name, "str_len") { return "`str_len(s: String) -> Int` — byte length of string" } - if str_eq(name, "str_eq") { return "`str_eq(a: String, b: String) -> Bool` — string equality" } - if str_eq(name, "str_concat") { return "`str_concat(a: String, b: String) -> String` — concatenate" } - if str_eq(name, "str_slice") { return "`str_slice(s: String, start: Int, end: Int) -> String` — substring [start, end)" } - if str_eq(name, "str_contains") { return "`str_contains(s: String, sub: String) -> Bool`" } - if str_eq(name, "str_replace") { return "`str_replace(s: String, from: String, to: String) -> String`" } - if str_eq(name, "str_split") { return "`str_split(s: String, sep: String) -> [String]`" } - if str_eq(name, "str_split_lines"){ return "`str_split_lines(s: String) -> [String]` — split on newlines" } - if str_eq(name, "str_join") { return "`str_join(parts: [String], sep: String) -> String`" } - if str_eq(name, "str_trim") { return "`str_trim(s: String) -> String` — strip leading/trailing whitespace" } - if str_eq(name, "str_starts_with"){ return "`str_starts_with(s: String, prefix: String) -> Bool`" } - if str_eq(name, "str_ends_with") { return "`str_ends_with(s: String, suffix: String) -> Bool`" } - if str_eq(name, "str_index_of") { return "`str_index_of(s: String, sub: String) -> Int` — first occurrence, or -1" } - if str_eq(name, "str_to_upper") { return "`str_to_upper(s: String) -> String` — ASCII uppercase" } - if str_eq(name, "str_to_lower") { return "`str_to_lower(s: String) -> String` — ASCII lowercase" } - if str_eq(name, "str_char_at") { return "`str_char_at(s: String, i: Int) -> String` — one-char string at byte index i" } - if str_eq(name, "str_char_code") { return "`str_char_code(s: String, i: Int) -> Int` — byte value at index i" } - if str_eq(name, "str_repeat") { return "`str_repeat(s: String, n: Int) -> String`" } - if str_eq(name, "str_reverse") { return "`str_reverse(s: String) -> String` — byte-reverse" } - if str_eq(name, "str_pad_left") { return "`str_pad_left(s: String, width: Int, pad: String) -> String`" } - if str_eq(name, "str_pad_right") { return "`str_pad_right(s: String, width: Int, pad: String) -> String`" } - // Type conversions - if str_eq(name, "int_to_str") { return "`int_to_str(n: Int) -> String` — decimal string" } - if str_eq(name, "str_to_int") { return "`str_to_int(s: String) -> Int` — parse decimal string" } - if str_eq(name, "bool_to_str") { return "`bool_to_str(b: Bool) -> String` — \"true\" or \"false\"" } - if str_eq(name, "float_to_str") { return "`float_to_str(f: Float) -> String`" } - // List - if str_eq(name, "el_list_empty") { return "`el_list_empty() -> [T]` — create empty list" } - if str_eq(name, "el_list_append") { return "`el_list_append(list: [T], elem: T) -> [T]` — append element" } - if str_eq(name, "el_list_len") { return "`el_list_len(list: [T]) -> Int` — number of elements" } - if str_eq(name, "el_list_get") { return "`el_list_get(list: [T], i: Int) -> T` — element at index" } - // State - if str_eq(name, "state_set") { return "`state_set(key: String, val: String)` — store in process state" } - if str_eq(name, "state_get") { return "`state_get(key: String) -> String` — retrieve; \"\" if absent" } - if str_eq(name, "state_del") { return "`state_del(key: String)` — remove key from state" } - if str_eq(name, "state_has") { return "`state_has(key: String) -> Bool` — true if key present" } - // JSON - if str_eq(name, "json_get") { return "`json_get(json: String, key: String) -> String` — extract field" } - if str_eq(name, "json_get_raw") { return "`json_get_raw(json: String, key: String) -> String` — raw JSON fragment" } - if str_eq(name, "json_set") { return "`json_set(json: String, key: String, val: String) -> String`" } - if str_eq(name, "json_parse") { return "`json_parse(s: String) -> Map`" } - if str_eq(name, "json_stringify") { return "`json_stringify(v: Any) -> String`" } - if str_eq(name, "json_array_len") { return "`json_array_len(arr: String) -> Int`" } - if str_eq(name, "json_array_get") { return "`json_array_get(arr: String, i: Int) -> String`" } - // Filesystem - if str_eq(name, "fs_read") { return "`fs_read(path: String) -> String` — read file" } - if str_eq(name, "fs_write") { return "`fs_write(path: String, content: String) -> Bool`" } - if str_eq(name, "fs_exists") { return "`fs_exists(path: String) -> Bool`" } - if str_eq(name, "fs_mkdir") { return "`fs_mkdir(path: String) -> Bool` — mkdir -p" } - // HTTP - if str_eq(name, "http_get") { return "`http_get(url: String) -> String` — HTTP GET, return body" } - if str_eq(name, "http_post") { return "`http_post(url: String, body: String) -> String`" } - if str_eq(name, "http_post_json") { return "`http_post_json(url: String, json: String) -> String`" } - if str_eq(name, "http_serve") { return "`http_serve(port: Int, handler: String)` — start HTTP server" } - // Exec - if str_eq(name, "exec") { return "`exec(cmd: String) -> String` — run shell command, return stdout" } - if str_eq(name, "exec_bg") { return "`exec_bg(cmd: String)` — run shell command in background" } - // Time - if str_eq(name, "sleep_ms") { return "`sleep_ms(ms: Int) -> Int` — sleep N milliseconds" } - if str_eq(name, "sleep_secs") { return "`sleep_secs(s: Int) -> Int` — sleep N seconds" } - // UUID - if str_eq(name, "uuid_new") { return "`uuid_new() -> String` — generate a UUID v4" } - if str_eq(name, "env") { return "`env(key: String) -> String` — get environment variable" } - // Math - if str_eq(name, "el_abs") { return "`el_abs(n: Int) -> Int` — absolute value" } - if str_eq(name, "el_max") { return "`el_max(a: Int, b: Int) -> Int` — maximum" } - if str_eq(name, "el_min") { return "`el_min(a: Int, b: Int) -> Int` — minimum" } - if str_eq(name, "math_sqrt") { return "`math_sqrt(f: Float) -> Float` — square root" } - return "" -} - -// ── LSP method handlers ─────────────────────────────────────────────────────── - -fn handle_initialize(id: String, params: String) -> String { - let caps: String = lsp_capabilities() - let result: String = "{\"capabilities\":" + caps + ",\"serverInfo\":{\"name\":\"el-lsp\",\"version\":\"0.1.0\"}}" - return lsp_make_response(id, result) -} - -// handle_did_open — store document text; returns "" (notification, no response). -fn handle_did_open(params: String) -> String { - let td_raw: String = json_get_raw(params, "textDocument") - let uri: String = json_get(td_raw, "uri") - let text: String = json_get(td_raw, "text") - doc_store(uri, text) - return "" -} - -// handle_did_change — update stored text; returns "" (notification). -// textDocumentSync=1 (full): contentChanges[0].text has the entire new text. -fn handle_did_change(params: String) -> String { - let td_raw: String = json_get_raw(params, "textDocument") - let uri: String = json_get(td_raw, "uri") - let changes_raw: String = json_get_raw(params, "contentChanges") - let first: String = json_array_get(changes_raw, 0) - let text: String = json_get(first, "text") - doc_store(uri, text) - return "" -} - -// handle_completion — completions: builtins + keywords + document functions. -fn handle_completion(id: String, params: String) -> String { - let td_raw: String = json_get_raw(params, "textDocument") - let uri: String = json_get(td_raw, "uri") - let source: String = doc_load(uri) - - let all_items: [String] = el_list_empty() - - // Builtins — CompletionItemKind.Variable = 6 - let builtin_items: [String] = lsp_build_completion_items(lsp_builtin_names(), 6) - let bi: Int = 0 - while bi < el_list_len(builtin_items) { - all_items = el_list_append(all_items, el_list_get(builtin_items, bi)) - bi = bi + 1 - } - - // Keywords — CompletionItemKind.Keyword = 14 - let kw_items: [String] = lsp_build_completion_items(lsp_keyword_names(), 14) - let ki: Int = 0 - while ki < el_list_len(kw_items) { - all_items = el_list_append(all_items, el_list_get(kw_items, ki)) - ki = ki + 1 - } - - // Document functions — CompletionItemKind.Function = 3 - if !str_eq(source, "") { - let doc_fns: [String] = lsp_extract_fn_names(source) - let fn_items: [String] = lsp_build_completion_items(doc_fns, 3) - let fi: Int = 0 - while fi < el_list_len(fn_items) { - all_items = el_list_append(all_items, el_list_get(fn_items, fi)) - fi = fi + 1 + if in_str { + let diags = native_list_append(diags, lsp_make_diag(i, str_start, line_len, "Unterminated string literal", 1)) } + let i = i + 1 + } + if brace_depth > 0 { + let diags = native_list_append(diags, lsp_make_diag(brace_line, 0, 1, "Unclosed '{' (opened near this line)", 1)) + } + if paren_depth > 0 { + let diags = native_list_append(diags, lsp_make_diag(paren_line, 0, 1, "Unclosed '(' (opened near this line)", 1)) + } + if bracket_depth > 0 { + let diags = native_list_append(diags, lsp_make_diag(bracket_line, 0, 1, "Unclosed '[' (opened near this line)", 1)) + } + "[" + str_join(diags, ",") + "]" +} + +fn lsp_publish_diagnostics(uri: String) -> Void { + let source: String = lsp_doc_get(uri) + let diags: String = lsp_compute_diagnostics(source) + let notif: String = lsp_notification("textDocument/publishDiagnostics", + "{\"uri\":\"" + lsp_json_escape(uri) + "\",\"diagnostics\":" + diags + "}") + lsp_write_message(notif) +} + +// ── Method handlers ──────────────────────────────────────────────────────── + +fn lsp_handle_initialize(id: String) -> Void { + let caps: String = "{\"textDocumentSync\":1,\"completionProvider\":{\"triggerCharacters\":[\".\",\"_\",\"(\"]},\"hoverProvider\":true,\"definitionProvider\":true}" + let result: String = "{\"capabilities\":" + caps + ",\"serverInfo\":{\"name\":\"el-lsp\",\"version\":\"1.0.0\"}}" + lsp_write_message(lsp_response(id, result)) +} + +fn lsp_handle_completion(id: String, params: String, catalog: [String]) -> Void { + let td: String = json_get_raw(params, "textDocument") + let uri: String = json_get_string(td, "uri") + let items: String = lsp_build_completions(uri, catalog) + let result: String = "{\"isIncomplete\":false,\"items\":" + items + "}" + lsp_write_message(lsp_response(id, result)) +} + +fn lsp_handle_hover(id: String, params: String, catalog: [String]) -> Void { + let td: String = json_get_raw(params, "textDocument") + let uri: String = json_get_string(td, "uri") + let pos: String = json_get_raw(params, "position") + let line_no: Int = json_get_int(pos, "line") + let char_no: Int = json_get_int(pos, "character") + let result: String = lsp_build_hover(uri, line_no, char_no, catalog) + lsp_write_message(lsp_response(id, result)) +} + +fn lsp_handle_definition(id: String, params: String) -> Void { + let td: String = json_get_raw(params, "textDocument") + let uri: String = json_get_string(td, "uri") + let pos: String = json_get_raw(params, "position") + let line_no: Int = json_get_int(pos, "line") + let char_no: Int = json_get_int(pos, "character") + let result: String = lsp_build_definition(uri, line_no, char_no) + lsp_write_message(lsp_response(id, result)) +} + +fn lsp_handle_did_open(params: String, catalog: [String]) -> Void { + let td: String = json_get_raw(params, "textDocument") + let uri: String = json_get_string(td, "uri") + let text: String = json_get_string(td, "text") + lsp_doc_store(uri, text) + lsp_publish_diagnostics(uri) +} + +fn lsp_handle_did_change(params: String) -> Void { + let td: String = json_get_raw(params, "textDocument") + let uri: String = json_get_string(td, "uri") + let changes: String = json_get_raw(params, "contentChanges") + let first: String = json_array_get(changes, 0) + let text: String = json_get_string(first, "text") + lsp_doc_store(uri, text) + lsp_publish_diagnostics(uri) +} + +fn lsp_handle_did_close(params: String) -> Void { + let td: String = json_get_raw(params, "textDocument") + let uri: String = json_get_string(td, "uri") + lsp_doc_remove(uri) + let notif: String = lsp_notification("textDocument/publishDiagnostics", + "{\"uri\":\"" + lsp_json_escape(uri) + "\",\"diagnostics\":[]}") + lsp_write_message(notif) +} + +fn lsp_handle_did_save(params: String) -> Void { + let td: String = json_get_raw(params, "textDocument") + let uri: String = json_get_string(td, "uri") + lsp_publish_diagnostics(uri) +} + +fn lsp_handle_shutdown(id: String) -> Void { + lsp_write_message(lsp_response(id, "null")) +} + +// ── Main dispatch ────────────────────────────────────────────────────────── + +// Returns false when the server should stop. +fn lsp_dispatch(msg: String, catalog: [String]) -> Bool { + let method: String = json_get_string(msg, "method") + let id_raw: String = json_get_raw(msg, "id") + let id: String = id_raw + if str_eq(id, "") { + let id = "null" + } + let params_raw: String = json_get_raw(msg, "params") + let params: String = params_raw + if str_eq(params, "") { + let params = "{}" } - let items_json: String = lsp_items_to_json(all_items) - let result: String = "{\"isIncomplete\":false,\"items\":" + items_json + "}" - return lsp_make_response(id, result) -} - -// handle_hover — return markdown documentation for the word at cursor. -fn handle_hover(id: String, params: String) -> String { - let td_raw: String = json_get_raw(params, "textDocument") - let uri: String = json_get(td_raw, "uri") - let pos_raw: String = json_get_raw(params, "position") - let line_num: Int = str_to_int(json_get(pos_raw, "line")) - let col_num: Int = str_to_int(json_get(pos_raw, "character")) - - let source: String = doc_load(uri) - if str_eq(source, "") { return lsp_make_response(id, "null") } - - let lines: [String] = str_split_lines(source) - if line_num >= el_list_len(lines) { return lsp_make_response(id, "null") } - - let line_text: String = el_list_get(lines, line_num) - let word: String = lsp_word_at(line_text, col_num) - if str_eq(word, "") { return lsp_make_response(id, "null") } - - let doc: String = lsp_keyword_doc(word) - if str_eq(doc, "") { doc = lsp_builtin_doc(word) } - if str_eq(doc, "") { return lsp_make_response(id, "null") } - - let escaped_doc: String = json_escape_string(doc) - let result: String = "{\"contents\":{\"kind\":\"markdown\",\"value\":\"" + escaped_doc + "\"}}" - return lsp_make_response(id, result) -} - -// _find_fn_def_line — search `lines` for a line containing "fn (". -// Returns the 0-based line index, or -1 if not found. -fn _find_fn_def_line(lines: [String], name: String) -> Int { - let target: String = "fn " + name + "(" - let num_lines: Int = el_list_len(lines) - let li: Int = 0 - let found: Int = -1 - while li < num_lines { - if found < 0 { - let l: String = el_list_get(lines, li) - if str_contains(l, target) { - found = li - } - } - li = li + 1 + if str_eq(method, "initialize") { + lsp_handle_initialize(id) + return true } - return found -} - -// handle_definition — go-to-definition: find the fn declaration for the word. -fn handle_definition(id: String, params: String) -> String { - let td_raw: String = json_get_raw(params, "textDocument") - let uri: String = json_get(td_raw, "uri") - let pos_raw: String = json_get_raw(params, "position") - let line_num: Int = str_to_int(json_get(pos_raw, "line")) - let col_num: Int = str_to_int(json_get(pos_raw, "character")) - - let source: String = doc_load(uri) - if str_eq(source, "") { return lsp_make_response(id, "null") } - - let lines: [String] = str_split_lines(source) - if line_num >= el_list_len(lines) { return lsp_make_response(id, "null") } - - let line_text: String = el_list_get(lines, line_num) - let word: String = lsp_word_at(line_text, col_num) - if str_eq(word, "") { return lsp_make_response(id, "null") } - - let def_line: Int = _find_fn_def_line(lines, word) - if def_line < 0 { return lsp_make_response(id, "null") } - - // Column of the function name: after "fn " (3 chars). - let def_text: String = el_list_get(lines, def_line) - let fn_prefix_idx: Int = str_index_of(def_text, "fn ") - let name_col: Int = fn_prefix_idx + 3 - let name_end_col: Int = name_col + str_len(word) - - let escaped_uri: String = json_escape_string(uri) - let result: String = "{\"uri\":\"" + escaped_uri + "\"," + - "\"range\":{" + - "\"start\":{\"line\":" + int_to_str(def_line) + ",\"character\":" + int_to_str(name_col) + "}," + - "\"end\":{\"line\":" + int_to_str(def_line) + ",\"character\":" + int_to_str(name_end_col) + "}" + - "}}" - return lsp_make_response(id, result) -} - -fn handle_shutdown(id: String) -> String { - return lsp_make_response(id, "null") -} - -// ── Main dispatch ───────────────────────────────────────────────────────────── - -fn lsp_dispatch(method: String, id: String, params: String) -> String { - if str_eq(method, "initialize") { return handle_initialize(id, params) } - if str_eq(method, "initialized") { return "" } - if str_eq(method, "textDocument/didOpen") { return handle_did_open(params) } - if str_eq(method, "textDocument/didChange"){ return handle_did_change(params) } - if str_eq(method, "textDocument/didClose") { - let uri: String = json_get(json_get_raw(params, "textDocument"), "uri") - state_del(doc_key(uri)) - return "" + if str_eq(method, "initialized") { + return true + } + if str_eq(method, "shutdown") { + lsp_handle_shutdown(id) + return true } - if str_eq(method, "textDocument/completion") { return handle_completion(id, params) } - if str_eq(method, "textDocument/hover") { return handle_hover(id, params) } - if str_eq(method, "textDocument/definition") { return handle_definition(id, params) } - if str_eq(method, "shutdown") { return handle_shutdown(id) } if str_eq(method, "exit") { - state_set("lsp_exit", "1") - return "" + return false } - // Unknown method with an id — return method-not-found. - if !str_eq(id, "") { - if !str_eq(id, "null") { - return lsp_make_error(id, -32601, "Method not found: " + method) - } + if str_eq(method, "textDocument/didOpen") { + lsp_handle_did_open(params, catalog) + return true } - return "" + if str_eq(method, "textDocument/didChange") { + lsp_handle_did_change(params) + return true + } + if str_eq(method, "textDocument/didClose") { + lsp_handle_did_close(params) + return true + } + if str_eq(method, "textDocument/didSave") { + lsp_handle_did_save(params) + return true + } + if str_eq(method, "textDocument/completion") { + lsp_handle_completion(id, params, catalog) + return true + } + if str_eq(method, "textDocument/hover") { + lsp_handle_hover(id, params, catalog) + return true + } + if str_eq(method, "textDocument/definition") { + lsp_handle_definition(id, params) + return true + } + // Unknown method — error for requests, ignore notifications + if !str_eq(id, "null") { + lsp_write_message(lsp_error_response(id, -32601, "Method not found: " + method)) + } + true } -// ── Entry point ─────────────────────────────────────────────────────────────── - -fn main() { - state_set("lsp_exit", "0") - state_set("_fn_scan_end", "0") - +fn main() -> Void { + let catalog: [String] = lsp_builtin_catalog() let running: Bool = true while running { let msg: String = lsp_read_message() - if str_eq(msg, "") { - // EOF — stdin closed, exit. - running = false + let running = false } else { - let id: String = json_get(msg, "id") - if str_eq(id, "") { id = "null" } - - let method: String = json_get(msg, "method") - - let params: String = json_get_raw(msg, "params") - if str_eq(params, "") { params = "{}" } - - let response: String = lsp_dispatch(method, id, params) - - if !str_eq(response, "") { - lsp_write_message(response) - } - - if str_eq(state_get("lsp_exit"), "1") { - running = false + let cont: Bool = lsp_dispatch(msg, catalog) + if !cont { + let running = false } } } diff --git a/tools/lsp/vscode-extension/.vscode/launch.json b/tools/lsp/vscode-extension/.vscode/launch.json new file mode 100644 index 0000000..dd2b3b8 --- /dev/null +++ b/tools/lsp/vscode-extension/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Extension (Extension Development Host)", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ], + "outFiles": [ + "${workspaceFolder}/**/*.js" + ], + "preLaunchTask": "npm: install", + "env": { + "EL_LSP_LOG": "1" + } + }, + { + "name": "Attach to el-lsp process", + "type": "node", + "request": "attach", + "port": 6009, + "restart": true, + "timeout": 10000, + "outFiles": [ + "${workspaceFolder}/**/*.js" + ] + } + ] +} diff --git a/tools/lsp/vscode-extension/.vscode/tasks.json b/tools/lsp/vscode-extension/.vscode/tasks.json new file mode 100644 index 0000000..070686d --- /dev/null +++ b/tools/lsp/vscode-extension/.vscode/tasks.json @@ -0,0 +1,16 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "install", + "label": "npm: install", + "detail": "Install vscode-languageclient dependency", + "group": "build", + "presentation": { + "reveal": "silent" + }, + "problemMatcher": [] + } + ] +} diff --git a/tools/lsp/vscode-extension/extension.js b/tools/lsp/vscode-extension/extension.js index 9ddc408..3d6479f 100644 --- a/tools/lsp/vscode-extension/extension.js +++ b/tools/lsp/vscode-extension/extension.js @@ -1,91 +1,137 @@ -// tools/lsp/vscode-extension/extension.js — El Language VSCode extension +// tools/lsp/vscode-extension/extension.js // -// Launches el-lsp as a child process (stdin/stdout Language Server Protocol) -// and connects VSCode's language client to it. +// El Language VSCode extension — Language Client // -// Prerequisites: -// - el-lsp binary built at ../dist/el-lsp (run tools/lsp/build.sh first) -// - npm install (installs vscode-languageclient) +// Launches the el-lsp binary as a child process and connects VSCode's +// Language Client to it over stdin/stdout (JSON-RPC 2.0 + Content-Length). // -// Install for development: -// 1. Open the vscode-extension/ folder in VSCode -// 2. Press F5 — Extension Development Host launches -// 3. Open any .el file — the LSP activates +// Capabilities provided by el-lsp: +// - textDocumentSync (full) +// - completionProvider — builtins + user fns + keywords +// - hoverProvider — signatures + descriptions +// - definitionProvider — go-to-def for user-defined fns +// - diagnostics — unclosed braces/parens, unterminated strings // -// To package as a .vsix: +// Setup (development): +// 1. cd tools/lsp && ./build.sh (builds dist/el-lsp) +// 2. cd vscode-extension && npm install (installs vscode-languageclient) +// 3. Open vscode-extension/ in VSCode +// 4. Press F5 (launches Extension Development Host) +// 5. Open any .el file +// +// Package as .vsix: // npm install -g @vscode/vsce // cd tools/lsp/vscode-extension && vsce package 'use strict'; const path = require('path'); +const fs = require('fs'); const { workspace, window, ExtensionContext } = require('vscode'); const { LanguageClient, TransportKind, } = require('vscode-languageclient/node'); +/** @type {LanguageClient | undefined} */ let client; /** - * activate — called when a .el file is first opened. - * Resolves the el-lsp binary path and starts the language client. + * activate — entry point called when any .el file is first opened. * * @param {ExtensionContext} context */ function activate(context) { - // Resolve el-lsp binary path. - // Default: ../dist/el-lsp relative to this extension root. - // Override with the "el.lspPath" workspace setting. + // ── Resolve el-lsp binary ──────────────────────────────────────────── const config = workspace.getConfiguration('el'); - const defaultLspPath = path.join(context.extensionPath, '..', 'dist', 'el-lsp'); - const serverPath = config.get('lspPath', defaultLspPath); - // ServerOptions: run el-lsp as a child process over stdin/stdout. + // Default path: ../dist/el-lsp relative to the extension root. + // Override with the "el.lspPath" workspace/user setting. + const defaultLspPath = path.join(context.extensionPath, '..', 'dist', 'el-lsp'); + const serverPath = config.get('lspPath', defaultLspPath); + + if (!fs.existsSync(serverPath)) { + window.showErrorMessage( + `El Language Server binary not found at: ${serverPath}\n` + + `Run tools/lsp/build.sh to build it, then reload VSCode.\n` + + `Or set "el.lspPath" in settings to the correct path.` + ); + return; + } + + // ── Server options (start el-lsp as child process) ─────────────────── + /** @type {import('vscode-languageclient/node').ServerOptions} */ const serverOptions = { run: { - command: serverPath, + command: serverPath, transport: TransportKind.stdio, + options: { env: { ...process.env } }, }, debug: { - command: serverPath, + command: serverPath, transport: TransportKind.stdio, - // In debug mode you could add --verbose or redirect stderr to a - // log file for inspection. For now debug == run. + options: { + env: { ...process.env }, + // Redirect el-lsp stderr to VSCode's Output panel in debug mode. + }, }, }; - // ClientOptions: handle all files with language id "el". + // ── Client options ─────────────────────────────────────────────────── + /** @type {import('vscode-languageclient/node').LanguageClientOptions} */ const clientOptions = { + // Activate for all .el files (file:// and untitled: schemes). documentSelector: [ - { scheme: 'file', language: 'el' }, + { scheme: 'file', language: 'el' }, { scheme: 'untitled', language: 'el' }, ], synchronize: { - // Re-send full document on save (matches textDocumentSync: 1). + // Fire fileEvents so the client resends on disk changes outside VSCode. fileEvents: workspace.createFileSystemWatcher('**/*.el'), }, - // Output channel for LSP messages (visible in Output → El Language Server). outputChannelName: 'El Language Server', + // Trace LSP messages to the Output panel for debugging. + // Set "el.trace.server": "verbose" in settings to enable. + traceOutputChannel: window.createOutputChannel('El LSP Trace'), }; + // ── Create and start client ────────────────────────────────────────── client = new LanguageClient( 'el-language-server', 'El Language Server', serverOptions, - clientOptions + clientOptions, ); - // Start the client (and the server as its child process). - client.start(); + // Register the client so it is disposed when the extension deactivates. + context.subscriptions.push(client); - // Notify user on activation. - window.showInformationMessage('El Language Server started.'); + client.start().then(() => { + // Show a discrete status bar item while the server is active. + const status = window.createStatusBarItem(1); + status.text = '$(symbol-misc) El LSP'; + status.tooltip = 'El Language Server is running'; + status.command = 'el.restartServer'; + status.show(); + context.subscriptions.push(status); + }).catch((err) => { + window.showErrorMessage(`El Language Server failed to start: ${err.message}`); + }); + + // ── Commands ───────────────────────────────────────────────────────── + context.subscriptions.push( + require('vscode').commands.registerCommand('el.restartServer', () => { + if (client) { + client.stop().then(() => client.start()); + window.showInformationMessage('El Language Server restarted.'); + } + }) + ); } /** * deactivate — called when the extension is unloaded. - * Stops the language client (sends LSP shutdown + exit). + * Sends LSP shutdown + exit to the server process. */ function deactivate() { if (!client) { diff --git a/tools/lsp/vscode-extension/package.json b/tools/lsp/vscode-extension/package.json index fef10e1..32f7e43 100644 --- a/tools/lsp/vscode-extension/package.json +++ b/tools/lsp/vscode-extension/package.json @@ -1,20 +1,29 @@ { "name": "el-language", "displayName": "El Language", - "description": "El language support — syntax highlighting, completions, hover, go-to-definition", - "version": "0.1.0", + "description": "El language support — syntax highlighting, completions, hover, go-to-definition, and diagnostics", + "version": "1.0.0", "publisher": "neuron-technologies", "license": "MIT", + "icon": "icon.png", + "repository": { + "type": "git", + "url": "https://github.com/neuron-technologies/foundation" + }, "engines": { "vscode": "^1.75.0" }, "categories": [ - "Programming Languages" + "Programming Languages", + "Linters", + "Other" ], "keywords": [ "el", "el-lang", - "language-server" + "language-server", + "lsp", + "neuron" ], "activationEvents": [ "onLanguage:el" @@ -23,13 +32,8 @@ "languages": [ { "id": "el", - "aliases": [ - "El", - "el" - ], - "extensions": [ - ".el" - ], + "aliases": ["El", "el-lang"], + "extensions": [".el", ".elh"], "configuration": "./language-configuration.json" } ], @@ -39,6 +43,29 @@ "scopeName": "source.el", "path": "./syntaxes/el.tmGrammar.json" } + ], + "configuration": { + "type": "object", + "title": "El Language", + "properties": { + "el.lspPath": { + "type": "string", + "default": "", + "description": "Absolute path to the el-lsp binary. Leave empty to use the binary bundled with the extension (../dist/el-lsp)." + }, + "el.trace.server": { + "type": "string", + "enum": ["off", "messages", "verbose"], + "default": "off", + "description": "Trace LSP messages between VSCode and el-lsp (visible in Output → El LSP Trace)." + } + } + }, + "commands": [ + { + "command": "el.restartServer", + "title": "El: Restart Language Server" + } ] }, "main": "./extension.js", @@ -46,10 +73,12 @@ "vscode-languageclient": "^8.1.0" }, "devDependencies": { - "@types/vscode": "^1.75.0" + "@types/vscode": "^1.75.0", + "@vscode/vsce": "^2.22.0" }, "scripts": { - "vscode:prepublish": "echo 'no build step required'", - "package": "vsce package" + "vscode:prepublish": "echo 'no transpile step required'", + "package": "vsce package", + "install-dev": "npm install && code --install-extension el-language-1.0.0.vsix 2>/dev/null || true" } } diff --git a/tools/lsp/vscode-extension/syntaxes/el.tmGrammar.json b/tools/lsp/vscode-extension/syntaxes/el.tmGrammar.json index 7031a6a..bf30903 100644 --- a/tools/lsp/vscode-extension/syntaxes/el.tmGrammar.json +++ b/tools/lsp/vscode-extension/syntaxes/el.tmGrammar.json @@ -7,11 +7,16 @@ { "include": "#comments" }, { "include": "#strings" }, { "include": "#function-definition" }, - { "include": "#keywords" }, + { "include": "#builtin-functions" }, + { "include": "#keywords-control" }, + { "include": "#keywords-declaration" }, + { "include": "#keywords-other" }, { "include": "#types" }, + { "include": "#type-annotations" }, { "include": "#constants" }, { "include": "#numbers" }, { "include": "#operators" }, + { "include": "#range-operator" }, { "include": "#function-call" }, { "include": "#identifiers" } ], @@ -29,44 +34,68 @@ "patterns": [ { "name": "constant.character.escape.el", - "match": "\\\\[nrt\\\\\"']" + "match": "\\\\[nrt\\\\\"'0]" + }, + { + "name": "constant.character.escape.hex.el", + "match": "\\\\x[0-9a-fA-F]{2}" } ] }, "function-definition": { - "comment": "Highlight 'fn name(' — the name gets entity.name.function scope", - "match": "\\bfn\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(", + "comment": "fn keyword + function name — name gets entity.name.function", + "match": "\\b(fn)\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*(?=\\()", "captures": { - "0": { "name": "meta.function.el" }, - "1": { "name": "entity.name.function.el" } + "1": { "name": "keyword.declaration.function.el" }, + "2": { "name": "entity.name.function.el" } } }, - "keywords": { - "patterns": [ - { - "comment": "Control-flow keywords", - "name": "keyword.control.el", - "match": "\\b(if|else|while|for|in|return|match)\\b" - }, - { - "comment": "Declaration keywords", - "name": "keyword.declaration.el", - "match": "\\b(fn|let|type|enum|import|from|as)\\b" - }, - { - "comment": "Reserved future keywords", - "name": "keyword.other.el", - "match": "\\b(cgi|vessel|activate|where|sealed|with|test|seed|assert|protocol|impl|retry|times|fallback|reason|parallel|trace|requires|deploy|to|via|target|manager|engine|accessor)\\b" - } - ] + "builtin-functions": { + "comment": "Well-known El builtins get support.function scope for distinct colour", + "name": "support.function.builtin.el", + "match": "\\b(println|print|readline|str_eq|str_len|str_concat|str_slice|str_contains|str_starts_with|str_ends_with|str_replace|str_to_upper|str_to_lower|str_trim|str_lstrip|str_rstrip|str_index_of|str_last_index_of|str_split|str_split_lines|str_split_n|str_join|str_char_at|str_char_code|str_pad_left|str_pad_right|str_repeat|str_reverse|str_strip_prefix|str_strip_suffix|str_strip_chars|str_count|str_count_lines|str_find_chars|str_upper|str_lower|int_to_str|str_to_int|float_to_str|str_to_float|int_to_float|float_to_int|format_float|decimal_round|bool_to_str|el_abs|el_max|el_min|math_sqrt|math_log|math_ln|math_sin|math_cos|math_pi|el_list_empty|el_list_append|el_list_len|el_list_get|el_list_clone|list_push|list_push_front|list_join|list_range|el_map_get|el_map_set|el_get_field|state_set|state_get|state_del|state_keys|json_get|json_get_string|json_get_int|json_get_float|json_get_bool|json_get_raw|json_set|json_parse|json_stringify|json_array_len|json_array_get|json_array_get_string|fs_read|fs_write|fs_list|fs_exists|fs_mkdir|http_get|http_post|http_post_json|http_get_with_headers|http_post_with_headers|http_serve|http_serve_v2|http_response|url_encode|url_decode|time_now|time_now_utc|sleep_secs|sleep_ms|time_format|time_add|time_diff|now|unix_seconds|unix_millis|uuid_new|uuid_v4|env|args|exit_program|exec_command|exec_capture|sha256_hex|hmac_sha256_hex|base64_encode|base64_decode|base64url_encode|base64url_decode|llm_call|llm_call_system|llm_call_agentic|llm_vision|llm_models|llm_register_tool|engram_node|engram_get_node|engram_strengthen|engram_forget|engram_search|engram_connect|engram_activate|engram_save|engram_load|engram_node_count|engram_edge_count|engram_neighbors|engram_search_json|engram_stats_json|dharma_connect|dharma_send|dharma_activate|dharma_emit|dharma_field|dharma_peers|native_list_empty|native_list_append|native_list_len|native_list_get|native_list_clone|native_string_chars|native_int_to_str)\\b(?=\\s*\\()" + }, + + "keywords-control": { + "name": "keyword.control.el", + "match": "\\b(if|else|while|for|in|return|match|break|continue)\\b" + }, + + "keywords-declaration": { + "name": "keyword.declaration.el", + "match": "\\b(fn|let|type|enum|import|from|as|extern)\\b" + }, + + "keywords-other": { + "name": "keyword.other.el", + "match": "\\b(cgi|vessel|activate|where|sealed|with|test|seed|assert|protocol|impl|retry|times|fallback|reason|parallel|trace|requires|deploy|to|via|target|manager|engine|accessor)\\b" }, "types": { - "comment": "Built-in El types", - "name": "support.type.el", - "match": "\\b(String|Int|Float|Bool|Void|Any|Map|List)\\b" + "comment": "Built-in El primitive types", + "name": "support.type.primitive.el", + "match": "\\b(String|Int|Float|Bool|Void|Any|Instant|Duration|Map|List)\\b" + }, + + "type-annotations": { + "comment": "Highlight type annotations after : in let and fn params", + "patterns": [ + { + "match": ":\\s*([A-Z][a-zA-Z0-9_<>, ]*)(?=\\s*[,)={\\]]|\\s*->)", + "captures": { + "1": { "name": "support.type.el" } + } + }, + { + "match": "->\\s*([A-Z][a-zA-Z0-9_<>, ]*)", + "captures": { + "0": { "name": "keyword.operator.arrow.el" }, + "1": { "name": "support.type.return.el" } + } + } + ] }, "constants": { @@ -74,6 +103,10 @@ { "name": "constant.language.boolean.el", "match": "\\b(true|false)\\b" + }, + { + "name": "constant.language.null.el", + "match": "\\bnull\\b" } ] }, @@ -82,24 +115,33 @@ "patterns": [ { "name": "constant.numeric.float.el", - "match": "\\b[0-9]+\\.[0-9]+\\b" + "match": "-?\\b[0-9]+\\.[0-9]+\\b" }, { "name": "constant.numeric.integer.el", - "match": "\\b[0-9]+\\b" + "match": "-?\\b[0-9]+\\b" } ] }, + "range-operator": { + "name": "keyword.operator.range.el", + "match": "\\.\\." + }, + "operators": { "patterns": [ { "name": "keyword.operator.comparison.el", - "match": "(==|!=|<=|>=|<|>)" + "match": "(==|!=|<=|>=)" + }, + { + "name": "keyword.operator.comparison.el", + "match": "(?-])([<>])(?![>=])" }, { "name": "keyword.operator.logical.el", - "match": "(&&|\\|\\||!)" + "match": "(&&|\\|\\||!(?!=))" }, { "name": "keyword.operator.assignment.el", @@ -117,7 +159,7 @@ }, "function-call": { - "comment": "Highlight function calls: name( — name gets entity.name.function scope", + "comment": "Function calls: identifier immediately followed by (", "match": "\\b([a-zA-Z_][a-zA-Z0-9_]*)\\s*(?=\\()", "captures": { "1": { "name": "entity.name.function.call.el" } @@ -125,7 +167,7 @@ }, "identifiers": { - "comment": "General identifiers — lower priority than other rules", + "comment": "Catch-all for remaining identifiers", "name": "variable.other.el", "match": "\\b[a-zA-Z_][a-zA-Z0-9_]*\\b" }