rename crates/ → vessels/ — El's word for buildable units
Per the consolidation onto El: 'crates' is the Rust word, 'vessel' is El's (per spec/language.md §15). The directory rename is the structural marker that this slot holds an El buildable unit, even if its current contents are still Rust pending port. Mechanical: git mv crates vessels, sed workspace members and any path dependencies, update CI workflow paths, update README references. Cross-repo path dependencies (`../foo/crates/bar`) updated workspace- wide so cargo metadata still resolves where the Rust still builds.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "el-lsp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
el-lexer = { workspace = true }
|
||||
el-parser = { workspace = true }
|
||||
el-types = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Completion engine — prefix-based + semantic scoring.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use el_types::{TypeDef, TypeEnv};
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Completion {
|
||||
pub label: String,
|
||||
pub kind: CompletionKind,
|
||||
pub detail: String,
|
||||
pub documentation: Option<String>,
|
||||
/// Activation strength: higher = more relevant, completions sorted desc.
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CompletionKind {
|
||||
Variable,
|
||||
Function,
|
||||
Type,
|
||||
Keyword,
|
||||
}
|
||||
|
||||
// ── Keywords ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const KEYWORDS: &[&str] = &[
|
||||
"let", "fn", "type", "enum", "match", "return", "activate", "where",
|
||||
"sealed", "if", "else", "for", "in", "while", "true", "false",
|
||||
"test", "seed", "assert", "target", "protocol", "impl",
|
||||
"import", "from", "as", "with", "retry", "times", "fallback",
|
||||
"reason", "parallel", "trace", "requires", "deploy", "to", "via",
|
||||
];
|
||||
|
||||
// ── Builtin functions ─────────────────────────────────────────────────────────
|
||||
|
||||
const BUILTIN_FUNCTIONS: &[(&str, &str)] = &[
|
||||
("println", "fn(value: String) -> Void"),
|
||||
("print", "fn(value: String) -> Void"),
|
||||
("int_to_str", "fn(n: Int) -> String"),
|
||||
("str_to_int", "fn(s: String) -> Int"),
|
||||
("string_len", "fn(s: String) -> Int"),
|
||||
("str_slice", "fn(s: String, start: Int, end: Int) -> String"),
|
||||
("str_concat", "fn(a: String, b: String) -> String"),
|
||||
("str_contains", "fn(s: String, sub: String) -> Bool"),
|
||||
("str_starts_with", "fn(s: String, prefix: String) -> Bool"),
|
||||
("str_ends_with", "fn(s: String, suffix: String) -> Bool"),
|
||||
("str_to_upper", "fn(s: String) -> String"),
|
||||
("str_to_lower", "fn(s: String) -> String"),
|
||||
("str_split", "fn(s: String, sep: String) -> [String]"),
|
||||
("str_trim", "fn(s: String) -> String"),
|
||||
("str_replace", "fn(s: String, from: String, to: String) -> String"),
|
||||
("str_index_of", "fn(s: String, sub: String) -> Int"),
|
||||
("float_to_str", "fn(f: Float) -> String"),
|
||||
("str_to_float", "fn(s: String) -> Float"),
|
||||
("array_push", "fn(arr: [T], val: T) -> [T]"),
|
||||
("array_len", "fn(arr: [T]) -> Int"),
|
||||
("array_get", "fn(arr: [T], i: Int) -> T"),
|
||||
("list_len", "fn(list: [T]) -> Int"),
|
||||
("list_get", "fn(list: [T], i: Int) -> T"),
|
||||
("list_map", "fn(list: [T], f: fn(T) -> U) -> [U]"),
|
||||
("list_filter", "fn(list: [T], f: fn(T) -> Bool) -> [T]"),
|
||||
("list_reduce", "fn(list: [T], init: U, f: fn(U, T) -> U) -> U"),
|
||||
("map_create", "fn() -> Map"),
|
||||
("map_set", "fn(m: Map, key: String, val: T) -> Map"),
|
||||
("map_get", "fn(m: Map, key: String) -> T"),
|
||||
("map_has", "fn(m: Map, key: String) -> Bool"),
|
||||
("math_abs", "fn(n: Float) -> Float"),
|
||||
("math_sqrt", "fn(n: Float) -> Float"),
|
||||
("math_floor", "fn(n: Float) -> Int"),
|
||||
("math_ceil", "fn(n: Float) -> Int"),
|
||||
("math_round", "fn(n: Float) -> Int"),
|
||||
("math_min", "fn(a: Float, b: Float) -> Float"),
|
||||
("math_max", "fn(a: Float, b: Float) -> Float"),
|
||||
("math_pow", "fn(base: Float, exp: Float) -> Float"),
|
||||
("now_millis", "fn() -> Int"),
|
||||
("time_now_utc", "fn() -> String"),
|
||||
("time_to_parts", "fn(ts: String) -> Map"),
|
||||
("time_format", "fn(ts: String, fmt: String) -> String"),
|
||||
("llm_call", "fn(prompt: String) -> String"),
|
||||
("llm_parallel", "fn(prompts: [String]) -> [String]"),
|
||||
("random_int", "fn(min: Int, max: Int) -> Int"),
|
||||
("random_float", "fn() -> Float"),
|
||||
("parse_json", "fn(s: String) -> Map"),
|
||||
("to_json", "fn(v: T) -> String"),
|
||||
("http_get", "fn(url: String) -> String"),
|
||||
("http_post", "fn(url: String, body: String) -> String"),
|
||||
("read_file", "fn(path: String) -> String"),
|
||||
("write_file", "fn(path: String, content: String) -> Void"),
|
||||
("env_get", "fn(key: String) -> String"),
|
||||
("sleep_ms", "fn(ms: Int) -> Void"),
|
||||
("uuid_new", "fn() -> String"),
|
||||
("hash_sha256", "fn(s: String) -> String"),
|
||||
];
|
||||
|
||||
const BUILTIN_TYPES: &[(&str, &str)] = &[
|
||||
("Int", "64-bit signed integer"),
|
||||
("Float", "64-bit IEEE 754 double"),
|
||||
("String", "UTF-8 string"),
|
||||
("Bool", "Boolean value"),
|
||||
("Uuid", "RFC 4122 UUID"),
|
||||
("Void", "Unit type — no value"),
|
||||
];
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Produce completions at `cursor_pos` in `source`.
|
||||
///
|
||||
/// Extracts the identifier prefix before the cursor and returns all
|
||||
/// completions whose label starts with that prefix, sorted by score
|
||||
/// (prefix match score + semantic boost).
|
||||
pub fn completions_at(env: &TypeEnv, source: &str, cursor_pos: usize) -> Vec<Completion> {
|
||||
let prefix = extract_prefix(source, cursor_pos);
|
||||
let mut results: Vec<Completion> = Vec::new();
|
||||
|
||||
// Keywords
|
||||
for &kw in KEYWORDS {
|
||||
if kw.starts_with(&prefix) {
|
||||
let score = prefix_score(kw, &prefix)
|
||||
+ if matches!(kw, "activate" | "sealed") { 0.2 } else { 0.0 };
|
||||
results.push(Completion {
|
||||
label: kw.to_string(),
|
||||
kind: CompletionKind::Keyword,
|
||||
detail: "keyword".into(),
|
||||
documentation: keyword_doc(kw),
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in types
|
||||
for &(name, desc) in BUILTIN_TYPES {
|
||||
if name.to_lowercase().starts_with(&prefix.to_lowercase()) || prefix.is_empty() {
|
||||
results.push(Completion {
|
||||
label: name.to_string(),
|
||||
kind: CompletionKind::Type,
|
||||
detail: desc.into(),
|
||||
documentation: Some(format!("Built-in type: {desc}")),
|
||||
score: prefix_score(name, &prefix) + 0.1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// User-defined types from env
|
||||
for (type_name, def) in &env.types {
|
||||
// Skip built-ins already listed above
|
||||
if BUILTIN_TYPES.iter().any(|(n, _)| *n == type_name) {
|
||||
continue;
|
||||
}
|
||||
if type_name.to_lowercase().starts_with(&prefix.to_lowercase()) || prefix.is_empty() {
|
||||
let (detail, doc, extra_score) = match def {
|
||||
TypeDef::Struct { fields, .. } => {
|
||||
let field_list = fields
|
||||
.iter()
|
||||
.map(|(f, t)| format!("{f}: {t}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
(format!("struct {{ {field_list} }}"),
|
||||
Some(format!("User-defined struct with fields: {field_list}")),
|
||||
0.15)
|
||||
}
|
||||
TypeDef::Enum { variants, .. } => {
|
||||
let v_list = variants.iter().map(|v| v.name.clone()).collect::<Vec<_>>().join(", ");
|
||||
(format!("enum {{ {v_list} }}"),
|
||||
Some(format!("Enum variants: {v_list}")),
|
||||
0.15)
|
||||
}
|
||||
TypeDef::Primitive(_) => {
|
||||
("primitive type".into(), None, 0.05)
|
||||
}
|
||||
TypeDef::Protocol { methods, .. } => {
|
||||
let m_list = methods.iter().map(|m| m.name.clone()).collect::<Vec<_>>().join(", ");
|
||||
(format!("protocol {{ {m_list} }}"),
|
||||
Some(format!("Protocol methods: {m_list}")),
|
||||
0.15)
|
||||
}
|
||||
};
|
||||
results.push(Completion {
|
||||
label: type_name.clone(),
|
||||
kind: CompletionKind::Type,
|
||||
detail,
|
||||
documentation: doc,
|
||||
score: prefix_score(type_name, &prefix) + extra_score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Builtin functions
|
||||
for &(name, sig) in BUILTIN_FUNCTIONS {
|
||||
if name.to_lowercase().starts_with(&prefix.to_lowercase()) || prefix.is_empty() {
|
||||
results.push(Completion {
|
||||
label: name.to_string(),
|
||||
kind: CompletionKind::Function,
|
||||
detail: sig.into(),
|
||||
documentation: Some(format!("Built-in function: {name}\n{sig}")),
|
||||
score: prefix_score(name, &prefix) + 0.11,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Functions from env (user-defined)
|
||||
for (fn_name, fn_type) in &env.functions {
|
||||
if fn_name.to_lowercase().starts_with(&prefix.to_lowercase()) || prefix.is_empty() {
|
||||
results.push(Completion {
|
||||
label: fn_name.clone(),
|
||||
kind: CompletionKind::Function,
|
||||
detail: fn_type.to_string(),
|
||||
documentation: Some(format!("Function: {fn_name} :: {fn_type}")),
|
||||
score: prefix_score(fn_name, &prefix) + 0.12,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending, deduplicate by label
|
||||
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
results.dedup_by(|a, b| a.label == b.label);
|
||||
results
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the identifier fragment immediately before `cursor_pos`.
|
||||
fn extract_prefix(source: &str, cursor_pos: usize) -> String {
|
||||
let end = cursor_pos.min(source.len());
|
||||
let bytes = source.as_bytes();
|
||||
let mut start = end;
|
||||
while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') {
|
||||
start -= 1;
|
||||
}
|
||||
source[start..end].to_string()
|
||||
}
|
||||
|
||||
/// Score based on how much of the label is matched by the prefix.
|
||||
/// Returns a value in [0, 1].
|
||||
fn prefix_score(label: &str, prefix: &str) -> f32 {
|
||||
if prefix.is_empty() {
|
||||
return 0.5;
|
||||
}
|
||||
let lower_label = label.to_lowercase();
|
||||
let lower_prefix = prefix.to_lowercase();
|
||||
if lower_label.starts_with(&lower_prefix) {
|
||||
// Exact prefix match — score by how complete the prefix is
|
||||
(lower_prefix.len() as f32 / lower_label.len() as f32).min(1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn keyword_doc(kw: &str) -> Option<String> {
|
||||
let doc = match kw {
|
||||
"activate" => "Spreading-activation query: `activate TypeName where \"query\"`\nReturns `[TypeName]` from the Engram knowledge graph.",
|
||||
"sealed" => "Quantum-sealed block: marks sensitive code for runtime protection.",
|
||||
"let" => "Declare an immutable binding: `let name: Type = expr`",
|
||||
"fn" => "Define a function: `fn name(params) -> ReturnType { body }`",
|
||||
"type" => "Define a struct type: `type Name { field: Type }`",
|
||||
"enum" => "Define an enum: `enum Name { Variant1, Variant2(Type) }`",
|
||||
"match" => "Pattern match: `match expr { Pattern => result }`",
|
||||
"return" => "Return a value from a function.",
|
||||
"where" => "Used in `activate T where \"query\"`",
|
||||
"if" => "Conditional: `if cond { then } else { else }`",
|
||||
"else" => "Else branch of an if expression.",
|
||||
"for" => "For loop: `for item in collection { body }`",
|
||||
"while" => "While loop: `while cond { body }`",
|
||||
"in" => "Used in `for item in collection`",
|
||||
"true" | "false" => "Boolean literal",
|
||||
"protocol" => "Define a protocol (trait): `protocol Name { fn method(self) -> Ret; }`",
|
||||
"impl" => "Implement a protocol: `impl Protocol for Type { fn method(self) -> Ret { ... } }`",
|
||||
"import" => "Import from a module: `import { Name } from \"module\"`",
|
||||
"from" => "Used in import: `import { Name } from \"module\"`",
|
||||
"as" => "Alias in import: `import { Name as Alias } from \"module\"`",
|
||||
"with" => "With clause for retry/fallback: `with retry times 3`",
|
||||
"retry" => "Retry policy: `retry times N`",
|
||||
"times" => "Used in retry: `retry times N`",
|
||||
"fallback" => "Fallback value on failure: `fallback { default_expr }`",
|
||||
"reason" => "Reason clause: provides context to Engram reasoning engine.",
|
||||
"parallel" => "Parallel execution: `parallel { task1; task2 }`",
|
||||
"trace" => "Emit a trace event: `trace \"message\"`",
|
||||
"requires" => "Dependency declaration: `requires Module`",
|
||||
"deploy" => "Deploy declaration: `deploy service to target via method`",
|
||||
"to" => "Used in deploy: `deploy X to Y`",
|
||||
"via" => "Used in deploy: `deploy X via method`",
|
||||
"test" => "Test declaration: `test \"name\" { assertions }`",
|
||||
"seed" => "Seed data block: `seed { ... }`",
|
||||
"assert" => "Assertion: `assert condition, \"message\"`",
|
||||
"target" => "Target annotation: `target { ... }`",
|
||||
_ => return None,
|
||||
};
|
||||
Some(doc.to_string())
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Diagnostic computation — wraps el-types TypeChecker output.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Diagnostic {
|
||||
/// Human-readable message.
|
||||
pub message: String,
|
||||
/// "error" | "warning" | "info"
|
||||
pub severity: String,
|
||||
/// 1-based line number (if known).
|
||||
pub line: Option<u32>,
|
||||
/// 1-based column (if known).
|
||||
pub col: Option<u32>,
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Self { message: message.into(), severity: "error".into(), line: None, col: None }
|
||||
}
|
||||
|
||||
pub fn warning(message: impl Into<String>) -> Self {
|
||||
Self { message: message.into(), severity: "warning".into(), line: None, col: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and type-check `source`, returning all diagnostics.
|
||||
pub fn check(source: &str) -> Vec<Diagnostic> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
let tokens = match el_lexer::tokenize(source) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
out.push(Diagnostic {
|
||||
message: format!("Lex error: {}", e.kind),
|
||||
severity: "error".into(),
|
||||
line: Some(e.span.line),
|
||||
col: Some(e.span.col),
|
||||
});
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
let program = match el_parser::parse(tokens, source.to_string()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
out.push(Diagnostic {
|
||||
message: format!("Parse error: {}", e.kind),
|
||||
severity: "error".into(),
|
||||
line: Some(e.span.line),
|
||||
col: Some(e.span.col),
|
||||
});
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
let mut checker = el_types::TypeChecker::with_builtins();
|
||||
checker.check(&program);
|
||||
|
||||
for d in &checker.diagnostics {
|
||||
if d.is_error {
|
||||
out.push(Diagnostic::error(&d.message));
|
||||
} else {
|
||||
out.push(Diagnostic::warning(&d.message));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Hover information — identify the token under the cursor and return type docs.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use el_types::{TypeDef, TypeEnv};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HoverInfo {
|
||||
pub type_name: String,
|
||||
pub documentation: String,
|
||||
pub engram_node_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Return hover info for the token at `cursor_pos`.
|
||||
pub fn hover_at(env: &TypeEnv, source: &str, cursor_pos: usize) -> Option<HoverInfo> {
|
||||
let token = extract_token(source, cursor_pos);
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check built-in primitives
|
||||
let primitive_doc = match token.as_str() {
|
||||
"Int" => Some(("Int", "64-bit signed integer")),
|
||||
"Float" => Some(("Float", "64-bit IEEE 754 double")),
|
||||
"String" => Some(("String", "UTF-8 string")),
|
||||
"Bool" => Some(("Bool", "Boolean — true or false")),
|
||||
"Uuid" => Some(("Uuid", "RFC 4122 UUID")),
|
||||
"Void" => Some(("Void", "Unit type — no value")),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some((name, doc)) = primitive_doc {
|
||||
return Some(HoverInfo {
|
||||
type_name: name.to_string(),
|
||||
documentation: doc.to_string(),
|
||||
engram_node_type: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Check user-defined types
|
||||
if let Some(def) = env.types.get(&token) {
|
||||
let engram_node_type = env.engram_mappings.get(&token).cloned();
|
||||
let doc = format_typedef_doc(&token, def);
|
||||
return Some(HoverInfo {
|
||||
type_name: token.clone(),
|
||||
documentation: doc,
|
||||
engram_node_type,
|
||||
});
|
||||
}
|
||||
|
||||
// Check functions
|
||||
if let Some(fn_type) = env.functions.get(&token) {
|
||||
return Some(HoverInfo {
|
||||
type_name: token.clone(),
|
||||
documentation: format!("fn {token} :: {fn_type}"),
|
||||
engram_node_type: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Check keywords
|
||||
let kw_doc = match token.as_str() {
|
||||
"activate" => Some("activate TypeName where \"query\"\nReturns [TypeName] via spreading activation over the Engram knowledge graph."),
|
||||
"sealed" => Some("sealed { ... }\nMarks the block as sensitive — values are redacted from debuggers in debug builds."),
|
||||
"let" => Some("let name: Type = expr\nDeclare an immutable binding."),
|
||||
"fn" => Some("fn name(params) -> ReturnType { body }\nDefine a function."),
|
||||
"type" => Some("type Name { field: Type }\nDefine a struct type."),
|
||||
"enum" => Some("enum Name { Variant1, Variant2(Type) }\nDefine an enum."),
|
||||
"match" => Some("match expr { Pattern => result }\nPattern match an expression."),
|
||||
"return" => Some("return expr\nReturn a value from the enclosing function."),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(doc) = kw_doc {
|
||||
return Some(HoverInfo {
|
||||
type_name: token.clone(),
|
||||
documentation: doc.to_string(),
|
||||
engram_node_type: None,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn extract_token(source: &str, cursor_pos: usize) -> String {
|
||||
let len = source.len();
|
||||
if cursor_pos > len {
|
||||
return String::new();
|
||||
}
|
||||
let bytes = source.as_bytes();
|
||||
|
||||
// Expand left
|
||||
let mut start = cursor_pos;
|
||||
while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') {
|
||||
start -= 1;
|
||||
}
|
||||
// Expand right
|
||||
let mut end = cursor_pos;
|
||||
while end < len && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
|
||||
end += 1;
|
||||
}
|
||||
source[start..end].to_string()
|
||||
}
|
||||
|
||||
fn format_typedef_doc(name: &str, def: &TypeDef) -> String {
|
||||
match def {
|
||||
TypeDef::Struct { fields, .. } => {
|
||||
let fields_str = fields
|
||||
.iter()
|
||||
.map(|(f, t)| format!(" {f}: {t}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("type {name} {{\n{fields_str}\n}}")
|
||||
}
|
||||
TypeDef::Enum { variants, .. } => {
|
||||
let variants_str = variants
|
||||
.iter()
|
||||
.map(|v| {
|
||||
if let Some(payload) = &v.payload {
|
||||
format!(" {}({})", v.name, payload)
|
||||
} else {
|
||||
format!(" {}", v.name)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("enum {name} {{\n{variants_str}\n}}")
|
||||
}
|
||||
TypeDef::Primitive(t) => format!("primitive type {name} = {t}"),
|
||||
TypeDef::Protocol { methods, .. } => {
|
||||
let methods_str = methods
|
||||
.iter()
|
||||
.map(|m| format!(" fn {}()", m.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("protocol {name} {{\n{methods_str}\n}}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
//! el-lsp — Minimal Language Server for the Engram language.
|
||||
//!
|
||||
//! Provides completions, hover info, diagnostics, and a type graph.
|
||||
//! Driven by the el-types type checker and el-parser AST.
|
||||
|
||||
mod completion;
|
||||
mod diagnostic;
|
||||
mod hover;
|
||||
mod type_graph;
|
||||
|
||||
pub use completion::{Completion, CompletionKind};
|
||||
pub use diagnostic::Diagnostic;
|
||||
pub use hover::HoverInfo;
|
||||
pub use type_graph::{TypeEdge, TypeGraph, TypeNode};
|
||||
|
||||
use el_types::{TypeChecker, TypeEnv};
|
||||
|
||||
// ── LanguageServer ────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct LanguageServer;
|
||||
|
||||
impl LanguageServer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Compute completions at the given cursor byte position.
|
||||
pub fn complete(&self, source: &str, cursor_pos: usize) -> Vec<Completion> {
|
||||
let env = build_type_env(source);
|
||||
completion::completions_at(&env, source, cursor_pos)
|
||||
}
|
||||
|
||||
/// Return hover information for the token at the given byte position.
|
||||
pub fn hover(&self, source: &str, cursor_pos: usize) -> Option<HoverInfo> {
|
||||
let env = build_type_env(source);
|
||||
hover::hover_at(&env, source, cursor_pos)
|
||||
}
|
||||
|
||||
/// Run the type checker and return diagnostics.
|
||||
pub fn diagnostics(&self, source: &str) -> Vec<Diagnostic> {
|
||||
diagnostic::check(source)
|
||||
}
|
||||
|
||||
/// Build a type graph from the source.
|
||||
pub fn type_graph(&self, source: &str) -> TypeGraph {
|
||||
let env = build_type_env(source);
|
||||
type_graph::build(&env)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LanguageServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse source and run the type checker, returning the final TypeEnv.
|
||||
fn build_type_env(source: &str) -> TypeEnv {
|
||||
let tokens = match el_lexer::tokenize(source) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return TypeEnv::with_builtins(),
|
||||
};
|
||||
let program = match el_parser::parse(tokens, source.to_string()) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return TypeEnv::with_builtins(),
|
||||
};
|
||||
let mut checker = TypeChecker::with_builtins();
|
||||
checker.check(&program);
|
||||
checker.env
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"
|
||||
type Point {
|
||||
x: Float
|
||||
y: Float
|
||||
}
|
||||
|
||||
type Circle {
|
||||
center: Point
|
||||
radius: Float
|
||||
}
|
||||
|
||||
enum Color {
|
||||
Red
|
||||
Green
|
||||
Blue
|
||||
Custom(String)
|
||||
}
|
||||
|
||||
fn distance(a: Point, b: Point) -> Float {
|
||||
let dx: Float = a.x - b.x
|
||||
let dy: Float = a.y - b.y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
fn main() -> Void {
|
||||
let greeting: String = "Hello from Engram"
|
||||
let count: Int = 42
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn test_diagnostics_clean_source() {
|
||||
let lsp = LanguageServer::new();
|
||||
let diags = lsp.diagnostics(SAMPLE);
|
||||
// Should have no errors for valid source
|
||||
let errors: Vec<_> = diags.iter().filter(|d| d.severity == "error").collect();
|
||||
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagnostics_invalid_source() {
|
||||
let lsp = LanguageServer::new();
|
||||
let diags = lsp.diagnostics("let x: UnknownType = 42");
|
||||
// Should surface at least one diagnostic for unknown type
|
||||
assert!(!diags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_graph_has_nodes() {
|
||||
let lsp = LanguageServer::new();
|
||||
let graph = lsp.type_graph(SAMPLE);
|
||||
// Should have at least the built-in types plus Point, Circle, Color
|
||||
assert!(!graph.nodes.is_empty());
|
||||
let names: Vec<_> = graph.nodes.iter().map(|n| n.name.as_str()).collect();
|
||||
assert!(names.contains(&"Point"), "expected Point in type graph");
|
||||
assert!(names.contains(&"Circle"), "expected Circle in type graph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_graph_has_edges() {
|
||||
let lsp = LanguageServer::new();
|
||||
let graph = lsp.type_graph(SAMPLE);
|
||||
// Circle has a field `center: Point` → should produce an edge
|
||||
let has_circle_edge = graph
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.from == "Circle" && e.to == "Point");
|
||||
assert!(has_circle_edge, "expected Circle->Point edge; edges: {:?}", graph.edges);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completions_return_keywords() {
|
||||
let lsp = LanguageServer::new();
|
||||
let completions = lsp.complete("", 0);
|
||||
let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect();
|
||||
assert!(labels.contains(&"let"), "expected 'let' keyword completion");
|
||||
assert!(labels.contains(&"fn"), "expected 'fn' keyword completion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completions_include_types() {
|
||||
let lsp = LanguageServer::new();
|
||||
let completions = lsp.complete(SAMPLE, 0);
|
||||
let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect();
|
||||
// User-defined types should appear
|
||||
assert!(labels.contains(&"Point"), "expected Point in completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hover_on_builtin_type() {
|
||||
let lsp = LanguageServer::new();
|
||||
// Position at the "Float" token in the sample
|
||||
let source = "let x: Float = 3.14";
|
||||
// Find the byte offset of "Float"
|
||||
let pos = source.find("Float").unwrap();
|
||||
let info = lsp.hover(source, pos);
|
||||
assert!(info.is_some(), "expected hover info for Float");
|
||||
let info = info.unwrap();
|
||||
assert_eq!(info.type_name, "Float");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completions_sorted_by_score() {
|
||||
let lsp = LanguageServer::new();
|
||||
let completions = lsp.complete(SAMPLE, 0);
|
||||
// Completions should be sorted descending by score
|
||||
for window in completions.windows(2) {
|
||||
assert!(
|
||||
window[0].score >= window[1].score,
|
||||
"completions not sorted by score: {:?} before {:?}",
|
||||
window[0],
|
||||
window[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Type graph construction — nodes are types, edges are field/return/param relationships.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use el_types::{Type, TypeDef, TypeEnv};
|
||||
|
||||
// ── Data types ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TypeGraph {
|
||||
pub nodes: Vec<TypeNode>,
|
||||
pub edges: Vec<TypeEdge>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TypeNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// "builtin" | "struct" | "enum" | "primitive"
|
||||
pub kind: String,
|
||||
pub fields: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TypeEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// "field" | "returns" | "param" | "extends"
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
// ── Builder ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const BUILTINS: &[&str] = &["Int", "Float", "String", "Bool", "Uuid", "Void"];
|
||||
|
||||
pub fn build(env: &TypeEnv) -> TypeGraph {
|
||||
let mut nodes: Vec<TypeNode> = Vec::new();
|
||||
let mut edges: Vec<TypeEdge> = Vec::new();
|
||||
|
||||
// Add all built-in types as nodes
|
||||
for &name in BUILTINS {
|
||||
nodes.push(TypeNode {
|
||||
id: name.to_string(),
|
||||
name: name.to_string(),
|
||||
kind: "builtin".into(),
|
||||
fields: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Add user-defined types
|
||||
for (type_name, def) in &env.types {
|
||||
// Skip built-ins — already added
|
||||
if BUILTINS.contains(&type_name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
match def {
|
||||
TypeDef::Struct { fields, .. } => {
|
||||
let field_strs: Vec<String> = fields
|
||||
.iter()
|
||||
.map(|(f, t)| format!("{f}: {t}"))
|
||||
.collect();
|
||||
|
||||
nodes.push(TypeNode {
|
||||
id: type_name.clone(),
|
||||
name: type_name.clone(),
|
||||
kind: "struct".into(),
|
||||
fields: field_strs,
|
||||
});
|
||||
|
||||
// Add field edges
|
||||
for (field_name, field_type) in fields {
|
||||
if let Some(target) = named_type(field_type) {
|
||||
edges.push(TypeEdge {
|
||||
from: type_name.clone(),
|
||||
to: target,
|
||||
label: format!("field:{field_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
TypeDef::Enum { variants, .. } => {
|
||||
let field_strs: Vec<String> = variants
|
||||
.iter()
|
||||
.map(|v| {
|
||||
if let Some(payload) = &v.payload {
|
||||
format!("{}({})", v.name, payload)
|
||||
} else {
|
||||
v.name.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
nodes.push(TypeNode {
|
||||
id: type_name.clone(),
|
||||
name: type_name.clone(),
|
||||
kind: "enum".into(),
|
||||
fields: field_strs,
|
||||
});
|
||||
|
||||
// Add variant payload edges
|
||||
for v in variants {
|
||||
if let Some(payload) = &v.payload {
|
||||
if let Some(target) = named_type(payload) {
|
||||
edges.push(TypeEdge {
|
||||
from: type_name.clone(),
|
||||
to: target,
|
||||
label: format!("variant:{}", v.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TypeDef::Primitive(_) => {
|
||||
// Register as primitive node (e.g. user-registered aliases)
|
||||
nodes.push(TypeNode {
|
||||
id: type_name.clone(),
|
||||
name: type_name.clone(),
|
||||
kind: "primitive".into(),
|
||||
fields: vec![],
|
||||
});
|
||||
}
|
||||
TypeDef::Protocol { methods, .. } => {
|
||||
let method_strs: Vec<String> = methods
|
||||
.iter()
|
||||
.map(|m| m.name.clone())
|
||||
.collect();
|
||||
nodes.push(TypeNode {
|
||||
id: type_name.clone(),
|
||||
name: type_name.clone(),
|
||||
kind: "protocol".into(),
|
||||
fields: method_strs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add function signature edges
|
||||
for (fn_name, fn_type) in &env.functions {
|
||||
if let Type::Fn { params, return_type } = fn_type {
|
||||
// Return edge: fn -> return type
|
||||
if let Some(ret) = named_type(return_type) {
|
||||
edges.push(TypeEdge {
|
||||
from: fn_name.clone(),
|
||||
to: ret,
|
||||
label: "returns".into(),
|
||||
});
|
||||
}
|
||||
// Param edges: fn -> param types
|
||||
for param_type in params {
|
||||
if let Some(param) = named_type(param_type) {
|
||||
edges.push(TypeEdge {
|
||||
from: fn_name.clone(),
|
||||
to: param,
|
||||
label: "param".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TypeGraph { nodes, edges }
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the name from a Named type, or the inner type for Array/Optional.
|
||||
fn named_type(ty: &Type) -> Option<String> {
|
||||
match ty {
|
||||
Type::Named(n) => Some(n.clone()),
|
||||
Type::Array(inner) => named_type(inner),
|
||||
Type::Optional(inner) => named_type(inner),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user