merge tools/lsp — El LSP skeleton: JSON-RPC, completions, hover, go-to-def, VSCode extension

This commit is contained in:
Will Anderson
2026-05-03 15:53:39 -05:00
6 changed files with 1245 additions and 0 deletions
+124
View File
@@ -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}"
+810
View File
@@ -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: <n>\r\n
// \r\n
// <n bytes of UTF-8 JSON>
//
// 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 <word>()
// 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<String, Any>`" }
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 <name>(".
// 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
}
}
}
}
+97
View File
@@ -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 };
@@ -0,0 +1,26 @@
{
"comments": {
"lineComment": "//"
},
"brackets": [
["{", "}"],
["[", "]"],
["(", ")"]
],
"autoClosingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"", "notIn": ["string"] }
],
"surroundingPairs": [
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""]
],
"indentationRules": {
"increaseIndentPattern": "\\{[^}]*$",
"decreaseIndentPattern": "^\\s*}"
}
}
+55
View File
@@ -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"
}
}
@@ -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"
}
}
}