feat: el-ide — native IDE for engram-lang, LSP, type graph, plugin ecosystem
Axum HTTP server (port 7771) serving a single-page IDE with CodeMirror 6 syntax highlighting for engram-lang, a force-directed type graph visualizer, LSP (completions, hover, diagnostics), SSE-streamed build/run output, a plugin host with five first-party plugins, and a reasoning panel that proxies to engram-server. 28 tests across three crates, zero warnings.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
Cargo.lock
|
||||
Generated
+2350
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/el-ide-server",
|
||||
"crates/el-lsp",
|
||||
"crates/el-plugin-host",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Neuron Technologies"]
|
||||
|
||||
[workspace.dependencies]
|
||||
# Internal crates (el-ide workspace)
|
||||
el-lsp = { path = "crates/el-lsp" }
|
||||
el-plugin-host = { path = "crates/el-plugin-host" }
|
||||
|
||||
# Engram lang crates (path deps)
|
||||
el-lexer = { path = "../engram-lang/crates/el-lexer" }
|
||||
el-parser = { path = "../engram-lang/crates/el-parser" }
|
||||
el-types = { path = "../engram-lang/crates/el-types" }
|
||||
el-compiler = { path = "../engram-lang/crates/el-compiler" }
|
||||
|
||||
# External
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
rust-embed = { version = "8", features = ["axum"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
async-stream = "0.3"
|
||||
tokio-stream = "0.1"
|
||||
which = "6"
|
||||
mime_guess = "2"
|
||||
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "el-ide-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "el-ide"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
el-lsp = { workspace = true }
|
||||
el-plugin-host = { workspace = true }
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
rust-embed = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
which = { workspace = true }
|
||||
urlencoding = "2"
|
||||
mime_guess = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
tower = "0.5"
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Build and run API — streams output via SSE.
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
Json,
|
||||
response::{
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
IntoResponse,
|
||||
},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
use crate::{sse::classify_line, AppState};
|
||||
|
||||
// ── Request types ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BuildRequest {
|
||||
/// Target: "debug" | "release" | "prod"
|
||||
#[serde(default = "default_target")]
|
||||
pub target: String,
|
||||
/// Optional specific file to build; otherwise builds whole project.
|
||||
pub file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RunRequest {
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
fn default_target() -> String {
|
||||
"debug".into()
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/build — streams build output as SSE.
|
||||
pub async fn build_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BuildRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let project_path = state.config.project_path.clone();
|
||||
let stream = build_stream(project_path, req);
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// POST /api/run — compiles and runs a file, streams output as SSE.
|
||||
pub async fn run_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RunRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let project_path = state.config.project_path.clone();
|
||||
let stream = run_stream(project_path, req);
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
// ── Stream builders ───────────────────────────────────────────────────────────
|
||||
|
||||
fn build_stream(
|
||||
project_path: String,
|
||||
req: BuildRequest,
|
||||
) -> impl tokio_stream::Stream<Item = Result<Event, Infallible>> {
|
||||
async_stream::stream! {
|
||||
let el_bin = el_binary();
|
||||
let mut cmd = tokio::process::Command::new(&el_bin);
|
||||
|
||||
if let Some(ref file) = req.file {
|
||||
let file_path = format!("{project_path}/{file}");
|
||||
cmd.arg("build").arg(&file_path).arg("--target").arg(&req.target);
|
||||
} else {
|
||||
let main = format!("{project_path}/src/main.el");
|
||||
cmd.arg("build").arg(&main).arg("--target").arg(&req.target);
|
||||
}
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.current_dir(&project_path);
|
||||
|
||||
yield Ok(Event::default().event("info").data(format!("Running: el build --target {}", req.target)));
|
||||
|
||||
match cmd.spawn() {
|
||||
Err(e) => {
|
||||
yield Ok(Event::default().event("error").data(format!("Failed to start el: {e}")));
|
||||
yield Ok(Event::default().event("done").data("1"));
|
||||
}
|
||||
Ok(mut child) => {
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let mut reader = tokio::io::BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
yield Ok(classify_line(&line));
|
||||
}
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let mut reader = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
yield Ok(classify_line(&line));
|
||||
}
|
||||
}
|
||||
let code = child.wait().await.map(|s| s.code().unwrap_or(0)).unwrap_or(1);
|
||||
yield Ok(Event::default().event("done").data(code.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_stream(
|
||||
project_path: String,
|
||||
req: RunRequest,
|
||||
) -> impl tokio_stream::Stream<Item = Result<Event, Infallible>> {
|
||||
async_stream::stream! {
|
||||
let el_bin = el_binary();
|
||||
let file_path = format!("{project_path}/{}", req.file);
|
||||
|
||||
yield Ok(Event::default().event("info").data(format!("Running: el run {}", req.file)));
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&el_bin);
|
||||
cmd.arg("run")
|
||||
.arg(&file_path)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.current_dir(&project_path);
|
||||
|
||||
match cmd.spawn() {
|
||||
Err(e) => {
|
||||
yield Ok(Event::default().event("error").data(format!("Failed to start el: {e}")));
|
||||
yield Ok(Event::default().event("done").data("1"));
|
||||
}
|
||||
Ok(mut child) => {
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let mut reader = tokio::io::BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
yield Ok(Event::default().event("output").data(line));
|
||||
}
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let mut reader = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
yield Ok(classify_line(&line));
|
||||
}
|
||||
}
|
||||
let code = child.wait().await.map(|s| s.code().unwrap_or(0)).unwrap_or(1);
|
||||
yield Ok(Event::default().event("done").data(code.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn el_binary() -> String {
|
||||
if let Ok(path) = which::which("el") {
|
||||
return path.to_string_lossy().to_string();
|
||||
}
|
||||
"el".to_string()
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! File system API — list, read, and write files within the project root.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PathQuery {
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
pub children: Option<Vec<FileEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileContent {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WriteRequest {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WriteResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||
|
||||
fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_json::Value>) {
|
||||
(code, Json(serde_json::json!({ "error": msg.into() })))
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/files?path={dir}
|
||||
///
|
||||
/// Returns a recursive directory listing for the given path
|
||||
/// (relative to EL_IDE_PROJECT_PATH).
|
||||
pub async fn list_files(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<PathQuery>,
|
||||
) -> ApiResult<Vec<FileEntry>> {
|
||||
let root = PathBuf::from(&state.config.project_path);
|
||||
let rel = q.path.unwrap_or_else(|| ".".into());
|
||||
let target = root.join(&rel);
|
||||
let target = target
|
||||
.canonicalize()
|
||||
.map_err(|e| api_err(StatusCode::BAD_REQUEST, format!("invalid path: {e}")))?;
|
||||
|
||||
// Security: ensure the resolved path is within the project root
|
||||
let root_canonical = root
|
||||
.canonicalize()
|
||||
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
if !target.starts_with(&root_canonical) {
|
||||
return Err(api_err(StatusCode::FORBIDDEN, "path escapes project root"));
|
||||
}
|
||||
|
||||
let entries = read_dir_recursive(&target, &root_canonical, 0)
|
||||
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
Ok(Json(entries))
|
||||
}
|
||||
|
||||
fn read_dir_recursive(
|
||||
dir: &Path,
|
||||
root: &Path,
|
||||
depth: usize,
|
||||
) -> Result<Vec<FileEntry>, String> {
|
||||
if depth > 8 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let rd = std::fs::read_dir(dir).map_err(|e| e.to_string())?;
|
||||
let mut items: Vec<_> = rd.filter_map(|e| e.ok()).collect();
|
||||
items.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in items {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
// Skip hidden files/dirs
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let rel_path = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().to_string();
|
||||
let is_dir = path.is_dir();
|
||||
|
||||
let children = if is_dir {
|
||||
Some(read_dir_recursive(&path, root, depth + 1).unwrap_or_default())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
entries.push(FileEntry { name, path: rel_path, is_dir, children });
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// GET /api/file?path={file}
|
||||
pub async fn read_file(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<PathQuery>,
|
||||
) -> ApiResult<FileContent> {
|
||||
let rel = q.path.ok_or_else(|| api_err(StatusCode::BAD_REQUEST, "missing path parameter"))?;
|
||||
let file_path = safe_path(&state.config.project_path, &rel)
|
||||
.map_err(|e| api_err(StatusCode::FORBIDDEN, e))?;
|
||||
|
||||
let content = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| api_err(StatusCode::NOT_FOUND, format!("cannot read file: {e}")))?;
|
||||
|
||||
Ok(Json(FileContent { path: rel, content }))
|
||||
}
|
||||
|
||||
/// POST /api/file — body: { path, content }
|
||||
pub async fn write_file(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<WriteRequest>,
|
||||
) -> ApiResult<WriteResponse> {
|
||||
let file_path = safe_path(&state.config.project_path, &req.path)
|
||||
.map_err(|e| api_err(StatusCode::FORBIDDEN, e))?;
|
||||
|
||||
// Create parent dirs if needed
|
||||
if let Some(parent) = file_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
std::fs::write(&file_path, &req.content)
|
||||
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot write: {e}")))?;
|
||||
|
||||
Ok(Json(WriteResponse { ok: true }))
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn safe_path(project_root: &str, rel: &str) -> Result<PathBuf, String> {
|
||||
let root = PathBuf::from(project_root)
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("invalid project root: {e}"))?;
|
||||
// Prevent path traversal
|
||||
let joined = root.join(rel);
|
||||
// We can't canonicalize non-existent files, so check manually
|
||||
let normalized = normalize_path(&joined);
|
||||
if !normalized.starts_with(&root) {
|
||||
return Err(format!("path '{rel}' escapes project root"));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Normalize a path without requiring the file to exist.
|
||||
fn normalize_path(path: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for component in path.components() {
|
||||
use std::path::Component;
|
||||
match component {
|
||||
Component::ParentDir => { out.pop(); }
|
||||
Component::CurDir => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! LSP API endpoints — completions, hover, errors.
|
||||
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use el_lsp::{Completion, Diagnostic, HoverInfo, LanguageServer};
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||
|
||||
// ── Query types ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SourceQuery {
|
||||
pub source: String,
|
||||
pub pos: Option<usize>,
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/lsp/complete?source=...&pos=...
|
||||
pub async fn complete(
|
||||
Query(q): Query<SourceQuery>,
|
||||
) -> ApiResult<Vec<Completion>> {
|
||||
let lsp = LanguageServer::new();
|
||||
let pos = q.pos.unwrap_or(0);
|
||||
let completions = lsp.complete(&q.source, pos);
|
||||
Ok(Json(completions))
|
||||
}
|
||||
|
||||
/// GET /api/lsp/hover?source=...&pos=...
|
||||
pub async fn hover(
|
||||
Query(q): Query<SourceQuery>,
|
||||
) -> ApiResult<Option<HoverInfo>> {
|
||||
let lsp = LanguageServer::new();
|
||||
let pos = q.pos.unwrap_or(0);
|
||||
let info = lsp.hover(&q.source, pos);
|
||||
Ok(Json(info))
|
||||
}
|
||||
|
||||
/// GET /api/lsp/errors?source=...
|
||||
pub async fn errors(
|
||||
Query(q): Query<SourceQuery>,
|
||||
) -> ApiResult<Vec<Diagnostic>> {
|
||||
let lsp = LanguageServer::new();
|
||||
let diags = lsp.diagnostics(&q.source);
|
||||
Ok(Json(diags))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod build;
|
||||
pub mod files;
|
||||
pub mod lsp;
|
||||
pub mod plugins;
|
||||
pub mod reason;
|
||||
pub mod type_graph;
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Plugins API — list, install, remove.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use el_plugin_host::Plugin;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||
|
||||
fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_json::Value>) {
|
||||
(code, Json(serde_json::json!({ "error": msg.into() })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InstallRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OkResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// GET /api/plugins
|
||||
pub async fn list_plugins(State(state): State<AppState>) -> ApiResult<Vec<Plugin>> {
|
||||
let host = state.plugins.lock().await;
|
||||
Ok(Json(host.list()))
|
||||
}
|
||||
|
||||
/// POST /api/plugins/install — body: { name }
|
||||
pub async fn install_plugin(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<InstallRequest>,
|
||||
) -> ApiResult<Plugin> {
|
||||
let mut host = state.plugins.lock().await;
|
||||
host.install(&req.name)
|
||||
.map(Json)
|
||||
.map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string()))
|
||||
}
|
||||
|
||||
/// DELETE /api/plugins/{name}
|
||||
pub async fn remove_plugin(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> ApiResult<OkResponse> {
|
||||
let mut host = state.plugins.lock().await;
|
||||
host.remove(&name)
|
||||
.map(|_| Json(OkResponse { ok: true }))
|
||||
.map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Reasoning API — proxy to engram-server for hypothesis evaluation.
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||
|
||||
fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_json::Value>) {
|
||||
(code, Json(serde_json::json!({ "error": msg.into() })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReasonRequest {
|
||||
pub hypothesis: String,
|
||||
pub context: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReasonResponse {
|
||||
pub verdict: String,
|
||||
pub confidence: f64,
|
||||
pub evidence: Vec<EvidenceItem>,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EvidenceItem {
|
||||
pub text: String,
|
||||
pub weight: f64,
|
||||
}
|
||||
|
||||
/// POST /api/reason — proxy to engram-server reasoning endpoint.
|
||||
pub async fn reason(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ReasonRequest>,
|
||||
) -> ApiResult<ReasonResponse> {
|
||||
let engram_url = &state.config.engram_url;
|
||||
let url = format!("{engram_url}/api/reason");
|
||||
|
||||
// Attempt to proxy to engram-server; fall back to stub if unavailable.
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"hypothesis": req.hypothesis,
|
||||
"context": req.context,
|
||||
});
|
||||
|
||||
match client.post(&url).json(&body).timeout(std::time::Duration::from_secs(10)).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let json: serde_json::Value = resp.json().await
|
||||
.map_err(|e| api_err(StatusCode::BAD_GATEWAY, format!("invalid response: {e}")))?;
|
||||
Ok(Json(ReasonResponse {
|
||||
verdict: json["verdict"].as_str().unwrap_or("unknown").to_string(),
|
||||
confidence: json["confidence"].as_f64().unwrap_or(0.0),
|
||||
evidence: json["evidence"].as_array().map(|arr| {
|
||||
arr.iter().map(|e| EvidenceItem {
|
||||
text: e["text"].as_str().unwrap_or("").to_string(),
|
||||
weight: e["weight"].as_f64().unwrap_or(0.0),
|
||||
}).collect()
|
||||
}).unwrap_or_default(),
|
||||
source: "engram-server".into(),
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
// Stub response when engram-server is unavailable
|
||||
Ok(Json(ReasonResponse {
|
||||
verdict: "unresolved".into(),
|
||||
confidence: 0.0,
|
||||
evidence: vec![EvidenceItem {
|
||||
text: "engram-server is not reachable; connect an Engram instance for live reasoning.".into(),
|
||||
weight: 0.0,
|
||||
}],
|
||||
source: "stub".into(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Type graph API — returns nodes and edges for the current project.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use el_lsp::{LanguageServer, TypeGraph};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TypeGraphQuery {
|
||||
/// Optional source to use directly (e.g. current editor content).
|
||||
pub source: Option<String>,
|
||||
/// Or load from a file path within the project.
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/type-graph?source=... or ?path=...
|
||||
///
|
||||
/// If neither is provided, reads src/main.el from the project root.
|
||||
pub async fn type_graph(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<TypeGraphQuery>,
|
||||
) -> ApiResult<TypeGraph> {
|
||||
let source = if let Some(src) = q.source {
|
||||
src
|
||||
} else {
|
||||
let rel = q.path.unwrap_or_else(|| "src/main.el".into());
|
||||
let file_path = std::path::PathBuf::from(&state.config.project_path).join(&rel);
|
||||
std::fs::read_to_string(&file_path).unwrap_or_default()
|
||||
};
|
||||
|
||||
let lsp = LanguageServer::new();
|
||||
let graph = lsp.type_graph(&source);
|
||||
Ok(Json(graph))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Server configuration loaded from environment variables.
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// HTTP port (EL_IDE_PORT, default 7771)
|
||||
pub port: u16,
|
||||
/// Root path of the project to open (EL_IDE_PROJECT_PATH, default ".")
|
||||
pub project_path: String,
|
||||
/// Engram server URL for reasoning (EL_ENGRAM_URL, default http://localhost:8742)
|
||||
pub engram_url: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
port: std::env::var("EL_IDE_PORT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(7771),
|
||||
project_path: std::env::var("EL_IDE_PROJECT_PATH")
|
||||
.unwrap_or_else(|_| ".".into()),
|
||||
engram_url: std::env::var("EL_ENGRAM_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8742".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self::from_env()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Embedded static assets via rust-embed.
|
||||
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../ide/"]
|
||||
pub struct Assets;
|
||||
|
||||
/// Serve `index.html` at the root path.
|
||||
pub async fn serve_index() -> impl IntoResponse {
|
||||
serve_file("index.html")
|
||||
}
|
||||
|
||||
/// Serve any embedded asset by path.
|
||||
pub async fn serve_asset(Path(path): Path<String>) -> impl IntoResponse {
|
||||
serve_file(&path)
|
||||
}
|
||||
|
||||
fn serve_file(path: &str) -> Response {
|
||||
match Assets::get(path) {
|
||||
Some(content) => {
|
||||
let mime = mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, mime)],
|
||||
content.data.to_vec(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
None => {
|
||||
// For SPA routing, fall back to index.html
|
||||
match Assets::get("index.html") {
|
||||
Some(content) => (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())],
|
||||
content.data.to_vec(),
|
||||
)
|
||||
.into_response(),
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! el-ide-server — Axum HTTP server for the Engram Language IDE.
|
||||
//!
|
||||
//! Serves the IDE HTML at GET / and provides API endpoints for file operations,
|
||||
//! build/run (SSE streaming), LSP, plugins, and type graph visualization.
|
||||
|
||||
mod api;
|
||||
mod config;
|
||||
mod embed;
|
||||
mod sse;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{Router, routing::{delete, get, post}};
|
||||
use tokio::sync::Mutex;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use el_plugin_host::PluginHost;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
// ── App state ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub plugins: Arc<Mutex<PluginHost>>,
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env().add_directive("el_ide=info".parse().unwrap()))
|
||||
.init();
|
||||
|
||||
let config = Config::from_env();
|
||||
let port = config.port;
|
||||
let state = AppState {
|
||||
config: Arc::new(config),
|
||||
plugins: Arc::new(Mutex::new(PluginHost::new())),
|
||||
};
|
||||
|
||||
let app = build_router(state);
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
|
||||
info!("el-ide listening on http://{addr}");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Router::new()
|
||||
// IDE HTML
|
||||
.route("/", get(embed::serve_index))
|
||||
.route("/*path", get(embed::serve_asset))
|
||||
// File API
|
||||
.route("/api/files", get(api::files::list_files))
|
||||
.route("/api/file", get(api::files::read_file).post(api::files::write_file))
|
||||
// Build/run (SSE)
|
||||
.route("/api/build", post(api::build::build_handler))
|
||||
.route("/api/run", post(api::build::run_handler))
|
||||
// LSP
|
||||
.route("/api/lsp/complete", get(api::lsp::complete))
|
||||
.route("/api/lsp/hover", get(api::lsp::hover))
|
||||
.route("/api/lsp/errors", get(api::lsp::errors))
|
||||
// Type graph
|
||||
.route("/api/type-graph", get(api::type_graph::type_graph))
|
||||
// Plugins
|
||||
.route("/api/plugins", get(api::plugins::list_plugins))
|
||||
.route("/api/plugins/install", post(api::plugins::install_plugin))
|
||||
.route("/api/plugins/{name}", delete(api::plugins::remove_plugin))
|
||||
// Reasoning (proxy to engram-server)
|
||||
.route("/api/reason", post(api::reason::reason))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! SSE helpers for streaming build/run output.
|
||||
|
||||
use axum::response::sse::Event;
|
||||
|
||||
/// Build a line-output SSE event, classifying the line as error/warning/success/info.
|
||||
pub fn classify_line(line: &str) -> Event {
|
||||
let event_type = if line.to_lowercase().contains("error") {
|
||||
"error"
|
||||
} else if line.to_lowercase().contains("warning") || line.to_lowercase().contains("warn") {
|
||||
"warning"
|
||||
} else if line.to_lowercase().contains("compiled")
|
||||
|| line.to_lowercase().contains("ok")
|
||||
|| line.to_lowercase().contains("success")
|
||||
{
|
||||
"success"
|
||||
} else {
|
||||
"info"
|
||||
};
|
||||
Event::default().event(event_type).data(line.to_string())
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Integration tests for el-ide-server API endpoints.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use el_plugin_host::PluginHost;
|
||||
|
||||
use crate::{build_router, config::Config, AppState};
|
||||
|
||||
fn test_state(project_path: &str) -> AppState {
|
||||
AppState {
|
||||
config: Arc::new(Config {
|
||||
port: 7771,
|
||||
project_path: project_path.to_string(),
|
||||
engram_url: "http://localhost:8742".into(),
|
||||
}),
|
||||
plugins: Arc::new(Mutex::new(PluginHost::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_project_path() -> String {
|
||||
// Use the examples/hello-project as the test project.
|
||||
// CARGO_MANIFEST_DIR = el-ide/crates/el-ide-server
|
||||
let manifest = env!("CARGO_MANIFEST_DIR");
|
||||
// Normalize: go up three directories from the crate to workspace root, then into examples
|
||||
let ws_root = std::path::Path::new(manifest)
|
||||
.parent().unwrap() // crates/
|
||||
.parent().unwrap() // el-ide/
|
||||
.to_path_buf();
|
||||
ws_root.join("examples/hello-project")
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| ws_root.join("examples/hello-project"))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn get_json(app: axum::Router, uri: &str) -> (StatusCode, serde_json::Value) {
|
||||
let resp = app
|
||||
.oneshot(Request::get(uri).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
|
||||
(status, json)
|
||||
}
|
||||
|
||||
// ── GET / ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_root_returns_html() {
|
||||
let state = test_state(&test_project_path());
|
||||
let app = build_router(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(Request::get("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The embedded HTML should return 200
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ── GET /api/files ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_files_returns_entries() {
|
||||
let project = test_project_path();
|
||||
let state = test_state(&project);
|
||||
let app = build_router(state);
|
||||
|
||||
let (status, json) = get_json(app, "/api/files?path=.").await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {json}");
|
||||
assert!(json.is_array(), "expected array, got {json}");
|
||||
let arr = json.as_array().unwrap();
|
||||
assert!(!arr.is_empty(), "expected non-empty file listing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_files_contains_src_dir() {
|
||||
let project = test_project_path();
|
||||
let state = test_state(&project);
|
||||
let app = build_router(state);
|
||||
|
||||
let (status, json) = get_json(app, "/api/files?path=.").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let arr = json.as_array().unwrap();
|
||||
let has_src = arr.iter().any(|e| e["name"] == "src" || e["name"] == "el.toml");
|
||||
assert!(has_src, "expected src or el.toml in listing; got {arr:?}");
|
||||
}
|
||||
|
||||
// ── GET /api/file ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_file_main_el() {
|
||||
let project = test_project_path();
|
||||
let state = test_state(&project);
|
||||
let app = build_router(state);
|
||||
|
||||
let (status, json) = get_json(app, "/api/file?path=src/main.el").await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {json}");
|
||||
assert!(json["content"].is_string(), "expected content field");
|
||||
let content = json["content"].as_str().unwrap();
|
||||
assert!(content.contains("fn main"), "expected fn main in content");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_read_missing_file_returns_404() {
|
||||
let project = test_project_path();
|
||||
let state = test_state(&project);
|
||||
let app = build_router(state);
|
||||
|
||||
let (status, _) = get_json(app, "/api/file?path=nonexistent.el").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── GET /api/lsp/errors ───────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_lsp_errors_clean_source() {
|
||||
let state = test_state(".");
|
||||
let app = build_router(state);
|
||||
|
||||
let source = "fn main() -> Void { let x: Int = 42 }";
|
||||
let uri = format!("/api/lsp/errors?source={}", urlencoding::encode(source));
|
||||
|
||||
let (status, json) = get_json(app, &uri).await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {json}");
|
||||
assert!(json.is_array());
|
||||
let errors: Vec<_> = json.as_array().unwrap().iter()
|
||||
.filter(|d| d["severity"] == "error")
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_lsp_errors_bad_type() {
|
||||
let state = test_state(".");
|
||||
let app = build_router(state);
|
||||
|
||||
let source = "let x: NonExistentType = 42";
|
||||
let uri = format!("/api/lsp/errors?source={}", urlencoding::encode(source));
|
||||
|
||||
let (status, json) = get_json(app, &uri).await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {json}");
|
||||
assert!(json.is_array());
|
||||
// Should have at least one diagnostic
|
||||
assert!(!json.as_array().unwrap().is_empty(), "expected diagnostic for unknown type");
|
||||
}
|
||||
|
||||
// ── GET /api/type-graph ───────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_type_graph_returns_nodes_and_edges() {
|
||||
let state = test_state(".");
|
||||
let app = build_router(state);
|
||||
|
||||
let source = "type Point { x: Float y: Float } type Circle { center: Point radius: Float }";
|
||||
let uri = format!("/api/type-graph?source={}", urlencoding::encode(source));
|
||||
|
||||
let (status, json) = get_json(app, &uri).await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {json}");
|
||||
assert!(json["nodes"].is_array());
|
||||
assert!(json["edges"].is_array());
|
||||
|
||||
let node_names: Vec<_> = json["nodes"].as_array().unwrap()
|
||||
.iter().map(|n| n["name"].as_str().unwrap_or("")).collect();
|
||||
assert!(node_names.contains(&"Point"), "expected Point node");
|
||||
assert!(node_names.contains(&"Circle"), "expected Circle node");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_type_graph_has_field_edge() {
|
||||
let state = test_state(".");
|
||||
let app = build_router(state);
|
||||
|
||||
let source = "type Point { x: Float y: Float } type Circle { center: Point radius: Float }";
|
||||
let uri = format!("/api/type-graph?source={}", urlencoding::encode(source));
|
||||
|
||||
let (status, json) = get_json(app, &uri).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let edges = json["edges"].as_array().unwrap();
|
||||
let has_edge = edges.iter().any(|e| e["from"] == "Circle" && e["to"] == "Point");
|
||||
assert!(has_edge, "expected Circle->Point edge, edges: {edges:?}");
|
||||
}
|
||||
|
||||
// ── GET /api/plugins ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_plugins_returns_five() {
|
||||
let state = test_state(".");
|
||||
let app = build_router(state);
|
||||
|
||||
let (status, json) = get_json(app, "/api/plugins").await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {json}");
|
||||
let plugins = json.as_array().unwrap();
|
||||
assert_eq!(plugins.len(), 5, "expected 5 first-party plugins");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dark_theme_installed() {
|
||||
let state = test_state(".");
|
||||
let app = build_router(state);
|
||||
|
||||
let (status, json) = get_json(app, "/api/plugins").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let plugins = json.as_array().unwrap();
|
||||
let dark = plugins.iter().find(|p| p["name"] == "el-theme-dark").unwrap();
|
||||
assert_eq!(dark["installed"], true);
|
||||
}
|
||||
|
||||
// ── GET /api/lsp/complete ─────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_completions_return_keywords() {
|
||||
let state = test_state(".");
|
||||
let app = build_router(state);
|
||||
|
||||
let uri = "/api/lsp/complete?source=&pos=0";
|
||||
let (status, json) = get_json(app, uri).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(json.is_array());
|
||||
let labels: Vec<_> = json.as_array().unwrap().iter()
|
||||
.map(|c| c["label"].as_str().unwrap_or(""))
|
||||
.collect();
|
||||
assert!(labels.contains(&"let"), "expected 'let' in completions");
|
||||
assert!(labels.contains(&"fn"), "expected 'fn' in completions");
|
||||
}
|
||||
@@ -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,187 @@
|
||||
//! 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", "true", "false",
|
||||
];
|
||||
|
||||
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)
|
||||
}
|
||||
};
|
||||
results.push(Completion {
|
||||
label: type_name.clone(),
|
||||
kind: CompletionKind::Type,
|
||||
detail,
|
||||
documentation: doc,
|
||||
score: prefix_score(type_name, &prefix) + extra_score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Functions from env
|
||||
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
|
||||
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
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 }`",
|
||||
"in" => "Used in `for item in collection`",
|
||||
"true" | "false" => "Boolean literal",
|
||||
_ => return None,
|
||||
};
|
||||
Some(doc.to_string())
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! 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::error(format!("Lex error: {e}")));
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
let program = match el_parser::parse(tokens, source.to_string()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
out.push(Diagnostic::error(format!("Parse error: {e}")));
|
||||
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,131 @@
|
||||
//! 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}"),
|
||||
}
|
||||
}
|
||||
@@ -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,162 @@
|
||||
//! 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![],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "el-plugin-host"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,253 @@
|
||||
//! el-plugin-host — Plugin lifecycle management for el-ide.
|
||||
//!
|
||||
//! Manages installation, removal, and querying of IDE plugins.
|
||||
//! First-party plugins are pre-registered; a plugin registry server
|
||||
//! is required for third-party installation (not yet live).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginHook {
|
||||
OnSave,
|
||||
OnBuild,
|
||||
OnBuildComplete,
|
||||
CustomPanel,
|
||||
CustomTool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Plugin {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub installed: bool,
|
||||
pub enabled: bool,
|
||||
pub hooks: Vec<PluginHook>,
|
||||
/// Whether this is a first-party Neuron Technologies plugin.
|
||||
pub first_party: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PluginError {
|
||||
#[error("plugin '{0}' not found")]
|
||||
NotFound(String),
|
||||
#[error("plugin '{0}' is already installed")]
|
||||
AlreadyInstalled(String),
|
||||
#[error("plugin registry is not available (registry URL not configured)")]
|
||||
RegistryUnavailable,
|
||||
#[error("plugin '{0}' cannot be removed: it is a required built-in")]
|
||||
BuiltIn(String),
|
||||
}
|
||||
|
||||
// ── PluginHost ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct PluginHost {
|
||||
plugins: HashMap<String, Plugin>,
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Create a PluginHost pre-populated with first-party plugins.
|
||||
pub fn new() -> Self {
|
||||
let mut host = Self { plugins: HashMap::new() };
|
||||
host.register_first_party_plugins();
|
||||
host
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn list(&self) -> Vec<Plugin> {
|
||||
let mut plugins: Vec<Plugin> = self.plugins.values().cloned().collect();
|
||||
plugins.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
plugins
|
||||
}
|
||||
|
||||
/// Install a plugin by name.
|
||||
///
|
||||
/// For first-party plugins, this marks them as installed.
|
||||
/// For third-party, registry access is required (not yet implemented).
|
||||
pub fn install(&mut self, name: &str) -> Result<Plugin, PluginError> {
|
||||
if let Some(plugin) = self.plugins.get_mut(name) {
|
||||
if plugin.installed {
|
||||
return Err(PluginError::AlreadyInstalled(name.to_string()));
|
||||
}
|
||||
plugin.installed = true;
|
||||
plugin.enabled = true;
|
||||
Ok(plugin.clone())
|
||||
} else {
|
||||
Err(PluginError::RegistryUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a plugin.
|
||||
pub fn remove(&mut self, name: &str) -> Result<(), PluginError> {
|
||||
match self.plugins.get_mut(name) {
|
||||
None => Err(PluginError::NotFound(name.to_string())),
|
||||
Some(p) => {
|
||||
if p.name == "el-theme-dark" {
|
||||
return Err(PluginError::BuiltIn(name.to_string()));
|
||||
}
|
||||
p.installed = false;
|
||||
p.enabled = false;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enable(&mut self, name: &str) -> Result<(), PluginError> {
|
||||
self.plugins
|
||||
.get_mut(name)
|
||||
.ok_or_else(|| PluginError::NotFound(name.to_string()))
|
||||
.map(|p| { p.enabled = true; })
|
||||
}
|
||||
|
||||
pub fn disable(&mut self, name: &str) -> Result<(), PluginError> {
|
||||
self.plugins
|
||||
.get_mut(name)
|
||||
.ok_or_else(|| PluginError::NotFound(name.to_string()))
|
||||
.map(|p| { p.enabled = false; })
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&Plugin> {
|
||||
self.plugins.get(name)
|
||||
}
|
||||
|
||||
// ── First-party plugins ───────────────────────────────────────────────────
|
||||
|
||||
fn register_first_party_plugins(&mut self) {
|
||||
let plugins = vec![
|
||||
Plugin {
|
||||
name: "el-theme-dark".into(),
|
||||
version: "1.0.0".into(),
|
||||
description: "Dark theme — the default Engram IDE theme.".into(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
hooks: vec![],
|
||||
first_party: true,
|
||||
},
|
||||
Plugin {
|
||||
name: "el-theme-light".into(),
|
||||
version: "1.0.0".into(),
|
||||
description: "Light theme for high-ambient-light environments.".into(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
hooks: vec![],
|
||||
first_party: true,
|
||||
},
|
||||
Plugin {
|
||||
name: "el-fmt".into(),
|
||||
version: "0.1.0".into(),
|
||||
description: "Code formatter — auto-formats .el files on save.".into(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
hooks: vec![PluginHook::OnSave],
|
||||
first_party: true,
|
||||
},
|
||||
Plugin {
|
||||
name: "el-doc".into(),
|
||||
version: "0.1.0".into(),
|
||||
description: "Documentation generator — produces HTML docs from type definitions.".into(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
hooks: vec![PluginHook::CustomTool],
|
||||
first_party: true,
|
||||
},
|
||||
Plugin {
|
||||
name: "el-test".into(),
|
||||
version: "0.1.0".into(),
|
||||
description: "Test runner with inline results displayed in the editor gutter.".into(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
hooks: vec![PluginHook::OnBuildComplete, PluginHook::CustomPanel],
|
||||
first_party: true,
|
||||
},
|
||||
];
|
||||
|
||||
for plugin in plugins {
|
||||
self.plugins.insert(plugin.name.clone(), plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginHost {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_list_returns_all_first_party() {
|
||||
let host = PluginHost::new();
|
||||
let plugins = host.list();
|
||||
assert_eq!(plugins.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dark_theme_installed_by_default() {
|
||||
let host = PluginHost::new();
|
||||
let plugin = host.get("el-theme-dark").unwrap();
|
||||
assert!(plugin.installed);
|
||||
assert!(plugin.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_uninstalled_plugin() {
|
||||
let mut host = PluginHost::new();
|
||||
let result = host.install("el-doc");
|
||||
assert!(result.is_ok());
|
||||
let plugin = host.get("el-doc").unwrap();
|
||||
assert!(plugin.installed);
|
||||
assert!(plugin.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_already_installed_errors() {
|
||||
let mut host = PluginHost::new();
|
||||
let result = host.install("el-theme-dark");
|
||||
assert!(matches!(result, Err(PluginError::AlreadyInstalled(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_plugin() {
|
||||
let mut host = PluginHost::new();
|
||||
host.install("el-fmt").ok(); // already installed
|
||||
let result = host.remove("el-fmt");
|
||||
assert!(result.is_ok());
|
||||
let plugin = host.get("el-fmt").unwrap();
|
||||
assert!(!plugin.installed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_builtin_errors() {
|
||||
let mut host = PluginHost::new();
|
||||
let result = host.remove("el-theme-dark");
|
||||
assert!(matches!(result, Err(PluginError::BuiltIn(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_nonexistent_errors() {
|
||||
let mut host = PluginHost::new();
|
||||
let result = host.remove("el-nonexistent");
|
||||
assert!(matches!(result, Err(PluginError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_disable() {
|
||||
let mut host = PluginHost::new();
|
||||
host.disable("el-theme-dark").unwrap();
|
||||
assert!(!host.get("el-theme-dark").unwrap().enabled);
|
||||
host.enable("el-theme-dark").unwrap();
|
||||
assert!(host.get("el-theme-dark").unwrap().enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[package]
|
||||
name = "hello"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,41 @@
|
||||
fn main() -> Void {
|
||||
let greeting: String = "Hello from Engram"
|
||||
let count: Int = 42
|
||||
}
|
||||
|
||||
type Point {
|
||||
x: Float
|
||||
y: Float
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type Circle {
|
||||
center: Point
|
||||
radius: Float
|
||||
}
|
||||
|
||||
enum Shape {
|
||||
Dot
|
||||
Line(String)
|
||||
Polygon(Int)
|
||||
}
|
||||
|
||||
fn classify(s: Shape) -> String {
|
||||
match s {
|
||||
Shape::Dot => "point"
|
||||
Shape::Line(name) => name
|
||||
Shape::Polygon(sides) => "polygon"
|
||||
}
|
||||
}
|
||||
|
||||
// activate example — spreading activation query
|
||||
// let circles: [Circle] = activate Circle where "large circles near origin"
|
||||
|
||||
sealed {
|
||||
let api_key: String = "secret-key"
|
||||
}
|
||||
+1466
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user