diff --git a/tools/lsp/build.sh b/tools/lsp/build.sh new file mode 100755 index 0000000..3a15063 --- /dev/null +++ b/tools/lsp/build.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# tools/lsp/build.sh — Build the El LSP server binary. +# +# 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 +# - 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. +# +# 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); +# } + +set -euo pipefail +cd "$(dirname "$0")" + +# ── Locate el root ───────────────────────────────────────────────────────────── +EL_HOME="${EL_HOME:-$(cd ../.. && pwd)}" +ELC="${ELC:-${EL_HOME}/dist/platform/elc}" +RUNTIME_DIR="${EL_HOME}/el-compiler/runtime" + +# ── 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 + 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 + 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 + exit 1 +fi + +COMBINED="dist/el-lsp-combined.el" +echo "==> Combining sources..." +cat "${RUNTIME_SRCS[@]}" el-lsp.el > "${COMBINED}" +echo " ${COMBINED}" + +# ── Compile El → C ───────────────────────────────────────────────────────────── +C_OUT="dist/el-lsp.c" +echo "==> Compiling El → C..." +"${ELC}" "${COMBINED}" > "${C_OUT}" +echo " ${C_OUT}" + +# ── Compile C → binary ───────────────────────────────────────────────────────── +BIN="dist/el-lsp" +echo "==> Compiling C → binary..." +cc -std=c11 -O2 \ + -I "${RUNTIME_DIR}" \ + -o "${BIN}" \ + "${C_OUT}" "${RUNTIME_DIR}/el_runtime.c" \ + -lcurl -lpthread +echo " ${BIN}" + +echo +echo "==> Build complete: ${BIN}" +echo +echo " Run as LSP server (editors connect via stdin/stdout):" +echo " ${BIN}" +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}" diff --git a/tools/lsp/el-lsp.el b/tools/lsp/el-lsp.el new file mode 100644 index 0000000..5da669a --- /dev/null +++ b/tools/lsp/el-lsp.el @@ -0,0 +1,810 @@ +// tools/lsp/el-lsp.el — El Language Server Protocol (LSP) server +// +// 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. +// +// Wire format (LSP spec §3.1): +// Content-Length: \r\n +// \r\n +// +// +// 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) + +// ── Identifier character helpers ────────────────────────────────────────────── + +// _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. +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 + } + } + } + + if content_length < 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 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 + } else { + let collecting = 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) +} + +// 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 + + 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) + } + // Advance past the name to avoid re-matching sub-sequences. + if end_pos > i { + i = end_pos + } else { + i = i + 1 + } + } else { + i = i + 1 + } + } else { + i = i + 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 + } + } + + 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 + } + 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, "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 "" + } + // 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) + } + } + return "" +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +fn main() { + state_set("lsp_exit", "0") + state_set("_fn_scan_end", "0") + + let running: Bool = true + while running { + let msg: String = lsp_read_message() + + if str_eq(msg, "") { + // EOF — stdin closed, exit. + 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 + } + } + } +} diff --git a/tools/lsp/vscode-extension/extension.js b/tools/lsp/vscode-extension/extension.js new file mode 100644 index 0000000..9ddc408 --- /dev/null +++ b/tools/lsp/vscode-extension/extension.js @@ -0,0 +1,97 @@ +// tools/lsp/vscode-extension/extension.js — El Language VSCode extension +// +// Launches el-lsp as a child process (stdin/stdout Language Server Protocol) +// and connects VSCode's language client to it. +// +// Prerequisites: +// - el-lsp binary built at ../dist/el-lsp (run tools/lsp/build.sh first) +// - npm install (installs vscode-languageclient) +// +// 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 +// +// To package as a .vsix: +// npm install -g @vscode/vsce +// cd tools/lsp/vscode-extension && vsce package + +'use strict'; + +const path = require('path'); +const { workspace, window, ExtensionContext } = require('vscode'); +const { + LanguageClient, + TransportKind, +} = require('vscode-languageclient/node'); + +let client; + +/** + * activate — called when a .el file is first opened. + * Resolves the el-lsp binary path and starts the language client. + * + * @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. + 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. + const serverOptions = { + run: { + command: serverPath, + transport: TransportKind.stdio, + }, + debug: { + 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. + }, + }; + + // ClientOptions: handle all files with language id "el". + const clientOptions = { + documentSelector: [ + { scheme: 'file', language: 'el' }, + { scheme: 'untitled', language: 'el' }, + ], + synchronize: { + // Re-send full document on save (matches textDocumentSync: 1). + fileEvents: workspace.createFileSystemWatcher('**/*.el'), + }, + // Output channel for LSP messages (visible in Output → El Language Server). + outputChannelName: 'El Language Server', + }; + + client = new LanguageClient( + 'el-language-server', + 'El Language Server', + serverOptions, + clientOptions + ); + + // Start the client (and the server as its child process). + client.start(); + + // Notify user on activation. + window.showInformationMessage('El Language Server started.'); +} + +/** + * deactivate — called when the extension is unloaded. + * Stops the language client (sends LSP shutdown + exit). + */ +function deactivate() { + if (!client) { + return undefined; + } + return client.stop(); +} + +module.exports = { activate, deactivate }; diff --git a/tools/lsp/vscode-extension/language-configuration.json b/tools/lsp/vscode-extension/language-configuration.json new file mode 100644 index 0000000..1da2c11 --- /dev/null +++ b/tools/lsp/vscode-extension/language-configuration.json @@ -0,0 +1,26 @@ +{ + "comments": { + "lineComment": "//" + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"", "notIn": ["string"] } + ], + "surroundingPairs": [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["\"", "\""] + ], + "indentationRules": { + "increaseIndentPattern": "\\{[^}]*$", + "decreaseIndentPattern": "^\\s*}" + } +} diff --git a/tools/lsp/vscode-extension/package.json b/tools/lsp/vscode-extension/package.json new file mode 100644 index 0000000..fef10e1 --- /dev/null +++ b/tools/lsp/vscode-extension/package.json @@ -0,0 +1,55 @@ +{ + "name": "el-language", + "displayName": "El Language", + "description": "El language support — syntax highlighting, completions, hover, go-to-definition", + "version": "0.1.0", + "publisher": "neuron-technologies", + "license": "MIT", + "engines": { + "vscode": "^1.75.0" + }, + "categories": [ + "Programming Languages" + ], + "keywords": [ + "el", + "el-lang", + "language-server" + ], + "activationEvents": [ + "onLanguage:el" + ], + "contributes": { + "languages": [ + { + "id": "el", + "aliases": [ + "El", + "el" + ], + "extensions": [ + ".el" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "el", + "scopeName": "source.el", + "path": "./syntaxes/el.tmGrammar.json" + } + ] + }, + "main": "./extension.js", + "dependencies": { + "vscode-languageclient": "^8.1.0" + }, + "devDependencies": { + "@types/vscode": "^1.75.0" + }, + "scripts": { + "vscode:prepublish": "echo 'no build step required'", + "package": "vsce package" + } +} diff --git a/tools/lsp/vscode-extension/syntaxes/el.tmGrammar.json b/tools/lsp/vscode-extension/syntaxes/el.tmGrammar.json new file mode 100644 index 0000000..7031a6a --- /dev/null +++ b/tools/lsp/vscode-extension/syntaxes/el.tmGrammar.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "El", + "scopeName": "source.el", + "fileTypes": ["el"], + "patterns": [ + { "include": "#comments" }, + { "include": "#strings" }, + { "include": "#function-definition" }, + { "include": "#keywords" }, + { "include": "#types" }, + { "include": "#constants" }, + { "include": "#numbers" }, + { "include": "#operators" }, + { "include": "#function-call" }, + { "include": "#identifiers" } + ], + "repository": { + + "comments": { + "name": "comment.line.double-slash.el", + "match": "//.*$" + }, + + "strings": { + "name": "string.quoted.double.el", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.el", + "match": "\\\\[nrt\\\\\"']" + } + ] + }, + + "function-definition": { + "comment": "Highlight 'fn name(' — the name gets entity.name.function scope", + "match": "\\bfn\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(", + "captures": { + "0": { "name": "meta.function.el" }, + "1": { "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" + } + ] + }, + + "types": { + "comment": "Built-in El types", + "name": "support.type.el", + "match": "\\b(String|Int|Float|Bool|Void|Any|Map|List)\\b" + }, + + "constants": { + "patterns": [ + { + "name": "constant.language.boolean.el", + "match": "\\b(true|false)\\b" + } + ] + }, + + "numbers": { + "patterns": [ + { + "name": "constant.numeric.float.el", + "match": "\\b[0-9]+\\.[0-9]+\\b" + }, + { + "name": "constant.numeric.integer.el", + "match": "\\b[0-9]+\\b" + } + ] + }, + + "operators": { + "patterns": [ + { + "name": "keyword.operator.comparison.el", + "match": "(==|!=|<=|>=|<|>)" + }, + { + "name": "keyword.operator.logical.el", + "match": "(&&|\\|\\||!)" + }, + { + "name": "keyword.operator.assignment.el", + "match": "(?])=(?!=)" + }, + { + "name": "keyword.operator.arithmetic.el", + "match": "[+\\-*/%]" + }, + { + "name": "keyword.operator.arrow.el", + "match": "->" + } + ] + }, + + "function-call": { + "comment": "Highlight function calls: name( — name gets entity.name.function scope", + "match": "\\b([a-zA-Z_][a-zA-Z0-9_]*)\\s*(?=\\()", + "captures": { + "1": { "name": "entity.name.function.call.el" } + } + }, + + "identifiers": { + "comment": "General identifiers — lower priority than other rules", + "name": "variable.other.el", + "match": "\\b[a-zA-Z_][a-zA-Z0-9_]*\\b" + } + } +}