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:
Will Anderson
2026-04-27 19:12:42 -05:00
commit 1172ab6351
27 changed files with 5944 additions and 0 deletions
+33
View File
@@ -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"
+158
View File
@@ -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()
}
+180
View File
@@ -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
}
+51
View File
@@ -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))
}
+6
View File
@@ -0,0 +1,6 @@
pub mod build;
pub mod files;
pub mod lsp;
pub mod plugins;
pub mod reason;
pub mod type_graph;
+56
View File
@@ -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()))
}
+82
View File
@@ -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))
}
+32
View File
@@ -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()
}
}
+50
View File
@@ -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(),
}
}
}
}
+87
View File
@@ -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)
}
+20
View File
@@ -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())
}
+235
View File
@@ -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");
}