remove rust scaffolding — el is the implementation

This commit is contained in:
Will Anderson
2026-05-03 04:10:38 -05:00
parent f3834267bf
commit c8b5807e4c
35 changed files with 0 additions and 6429 deletions
-158
View File
@@ -1,158 +0,0 @@
//! 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()
}
-29
View File
@@ -1,29 +0,0 @@
//! Config API — exposes project metadata to the frontend.
use axum::{extract::State, Json};
use serde::Serialize;
use std::path::Path;
use crate::AppState;
#[derive(Serialize)]
pub struct ConfigResponse {
pub project_name: String,
pub project_path: String,
pub engram_url: String,
}
pub async fn get_config(State(state): State<AppState>) -> Json<ConfigResponse> {
let project_path = &state.config.project_path;
let project_name = Path::new(project_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(project_path)
.to_string();
Json(ConfigResponse {
project_name,
project_path: project_path.clone(),
engram_url: state.config.engram_url.clone(),
})
}
-255
View File
@@ -1,255 +0,0 @@
//! 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 and build/vendor noise
if name.starts_with('.') {
continue;
}
// Skip common non-source directories that bloat the tree
if matches!(name.as_str(), "target" | "node_modules" | ".git" | "dist" | ".cargo") {
continue;
}
// Skip lock files and large generated files
if matches!(name.as_str(), "Cargo.lock" | "package-lock.json" | "yarn.lock" | "bun.lockb") {
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 }))
}
/// DELETE /api/file?path={file}
pub async fn delete_file(
State(state): State<AppState>,
Query(q): Query<PathQuery>,
) -> ApiResult<WriteResponse> {
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))?;
if file_path.is_dir() {
std::fs::remove_dir_all(&file_path)
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot delete directory: {e}")))?;
} else {
std::fs::remove_file(&file_path)
.map_err(|e| api_err(StatusCode::NOT_FOUND, format!("cannot delete file: {e}")))?;
}
Ok(Json(WriteResponse { ok: true }))
}
#[derive(Debug, Deserialize)]
pub struct MkdirRequest {
pub path: String,
}
/// POST /api/files/mkdir — body: { path }
pub async fn mkdir(
State(state): State<AppState>,
Json(req): Json<MkdirRequest>,
) -> ApiResult<WriteResponse> {
let dir_path = safe_path(&state.config.project_path, &req.path)
.map_err(|e| api_err(StatusCode::FORBIDDEN, e))?;
std::fs::create_dir_all(&dir_path)
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot create directory: {e}")))?;
Ok(Json(WriteResponse { ok: true }))
}
#[derive(Debug, Deserialize)]
pub struct RenameRequest {
pub from: String,
pub to: String,
}
/// POST /api/files/rename — body: { from, to }
pub async fn rename_file(
State(state): State<AppState>,
Json(req): Json<RenameRequest>,
) -> ApiResult<WriteResponse> {
let from_path = safe_path(&state.config.project_path, &req.from)
.map_err(|e| api_err(StatusCode::FORBIDDEN, e))?;
let to_path = safe_path(&state.config.project_path, &req.to)
.map_err(|e| api_err(StatusCode::FORBIDDEN, e))?;
// Ensure parent exists
if let Some(parent) = to_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
std::fs::rename(&from_path, &to_path)
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot rename: {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
}
-84
View File
@@ -1,84 +0,0 @@
//! Format API — run `el fmt` on source content.
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(msg: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": msg.to_string() })),
)
}
#[derive(Debug, Deserialize)]
pub struct FormatRequest {
pub content: String,
/// Optional filename hint for language detection
pub path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct FormatResponse {
pub content: String,
pub changed: bool,
}
/// POST /api/format — format content via `el fmt`, falls back to identity.
pub async fn format(
State(state): State<AppState>,
Json(req): Json<FormatRequest>,
) -> ApiResult<FormatResponse> {
let project_path = &state.config.project_path;
// Write content to a temp file, run `el fmt`, read back
let tmp_path = format!("{project_path}/.el-ide-fmt-tmp.el");
tokio::fs::write(&tmp_path, &req.content)
.await
.map_err(|e| api_err(format!("write tmp: {e}")))?;
// Try to find `el` binary
let el_bin = if let Ok(path) = which::which("el") {
path.to_string_lossy().to_string()
} else {
// el not installed — return unchanged
tokio::fs::remove_file(&tmp_path).await.ok();
return Ok(Json(FormatResponse {
content: req.content.clone(),
changed: false,
}));
};
let output = tokio::process::Command::new(&el_bin)
.arg("fmt")
.arg(&tmp_path)
.current_dir(project_path)
.output()
.await;
match output {
Ok(out) if out.status.success() => {
let formatted = tokio::fs::read_to_string(&tmp_path)
.await
.unwrap_or_else(|_| req.content.clone());
tokio::fs::remove_file(&tmp_path).await.ok();
let changed = formatted != req.content;
Ok(Json(FormatResponse { content: formatted, changed }))
}
_ => {
tokio::fs::remove_file(&tmp_path).await.ok();
Ok(Json(FormatResponse {
content: req.content.clone(),
changed: false,
}))
}
}
}
-108
View File
@@ -1,108 +0,0 @@
//! Git API endpoints — status and diff.
use axum::{
extract::{Query, 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(msg: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": msg.to_string() })),
)
}
// ── Types ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct GitFileStatus {
pub path: String,
pub status: String, // "M", "A", "D", "?", "R", etc.
}
#[derive(Debug, Deserialize)]
pub struct DiffQuery {
pub path: Option<String>,
}
// ── Handlers ──────────────────────────────────────────────────────────────────
/// GET /api/git/status — returns list of changed files.
pub async fn git_status(
State(state): State<AppState>,
) -> ApiResult<Vec<GitFileStatus>> {
let project_path = &state.config.project_path;
let output = tokio::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(project_path)
.output()
.await
.map_err(|e| api_err(format!("git status failed: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut files = Vec::new();
for line in stdout.lines() {
if line.len() < 4 {
continue;
}
let xy = &line[..2];
let path = line[3..].trim();
// Handle renames: "old -> new"
let file_path = if let Some(arrow) = path.find(" -> ") {
&path[arrow + 4..]
} else {
path
};
// Determine status badge: prefer index status (first char)
let status = if xy.starts_with('M') || xy.ends_with('M') {
"M"
} else if xy.starts_with('A') || xy.starts_with('?') {
"A"
} else if xy.starts_with('D') || xy.ends_with('D') {
"D"
} else if xy.starts_with('R') {
"R"
} else {
"M"
};
files.push(GitFileStatus {
path: file_path.to_string(),
status: status.to_string(),
});
}
Ok(Json(files))
}
/// GET /api/git/diff?path=<file> — returns unified diff.
pub async fn git_diff(
State(state): State<AppState>,
Query(q): Query<DiffQuery>,
) -> ApiResult<String> {
let project_path = &state.config.project_path;
let mut cmd = tokio::process::Command::new("git");
cmd.arg("diff").current_dir(project_path);
if let Some(ref path) = q.path {
cmd.arg("--").arg(path);
}
let output = cmd
.output()
.await
.map_err(|e| api_err(format!("git diff failed: {e}")))?;
let diff = String::from_utf8_lossy(&output.stdout).to_string();
Ok(Json(diff))
}
-135
View File
@@ -1,135 +0,0 @@
//! LSP API endpoints — completions, hover, errors, activate-preview.
use axum::{
extract::{Query, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use el_lsp::{Completion, Diagnostic, HoverInfo, LanguageServer};
use crate::AppState;
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>,
}
#[derive(Debug, Deserialize)]
pub struct ActivatePreviewQuery {
pub type_name: String,
pub query: String,
pub source: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ActivatePreviewResponse {
pub count: usize,
pub nodes: Vec<ActivateNode>,
pub connected: bool,
}
#[derive(Debug, Serialize)]
pub struct ActivateNode {
pub id: String,
pub label: String,
pub score: f64,
}
// ── 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))
}
/// GET /api/lsp/activate-preview?type_name=...&query=...&source=...
///
/// Returns a live preview of what nodes would activate for the given
/// `activate TypeName where "query"` expression.
pub async fn activate_preview(
State(state): State<AppState>,
Query(q): Query<ActivatePreviewQuery>,
) -> ApiResult<ActivatePreviewResponse> {
let engram_url = &state.config.engram_url;
// Try to query the Engram DB for matching nodes
let client = reqwest::Client::new();
let url = format!("{engram_url}/api/activate-preview");
let body = serde_json::json!({
"type_name": q.type_name,
"query": q.query,
"limit": 5,
});
match client
.post(&url)
.json(&body)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let json: serde_json::Value = resp.json().await.unwrap_or_default();
let nodes: Vec<ActivateNode> = json["nodes"]
.as_array()
.map(|arr| {
arr.iter()
.map(|n| ActivateNode {
id: n["id"].as_str().unwrap_or("").to_string(),
label: n["label"].as_str().unwrap_or("").to_string(),
score: n["score"].as_f64().unwrap_or(0.0),
})
.collect()
})
.unwrap_or_default();
let count = json["count"].as_u64().unwrap_or(nodes.len() as u64) as usize;
Ok(Json(ActivatePreviewResponse {
count,
nodes,
connected: true,
}))
}
_ => {
// Engram not connected — return stub indicating disconnected state
Ok(Json(ActivatePreviewResponse {
count: 0,
nodes: vec![],
connected: false,
}))
}
}
}
-18
View File
@@ -1,18 +0,0 @@
pub mod build;
pub mod config;
pub mod files;
pub mod format;
pub mod git;
pub mod lsp;
pub mod outline;
pub mod plugins;
pub mod reason;
pub mod references;
pub mod run_state;
pub mod search;
pub mod settings;
pub mod snippets;
pub mod status;
pub mod terminal;
pub mod themes;
pub mod type_graph;
-91
View File
@@ -1,91 +0,0 @@
//! Document outline API — returns functions, types, enums from AST.
use axum::{
extract::Query,
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
#[derive(Debug, Deserialize)]
pub struct SourceQuery {
pub source: String,
}
#[derive(Debug, Serialize)]
pub struct OutlineItem {
pub kind: String, // "fn" | "type" | "enum" | "protocol" | "impl"
pub name: String,
pub line: usize,
}
/// GET /api/outline?source=... — extract document symbols.
pub async fn outline(
Query(q): Query<SourceQuery>,
) -> ApiResult<Vec<OutlineItem>> {
let items = extract_outline(&q.source);
Ok(Json(items))
}
fn extract_outline(source: &str) -> Vec<OutlineItem> {
let mut items = Vec::new();
// Simple regex-like line scan for top-level declarations.
// Pattern: keyword followed by an identifier.
for (i, line) in source.lines().enumerate() {
let trimmed = line.trim();
let line_num = i + 1;
// fn name(
if let Some(rest) = trimmed.strip_prefix("fn ") {
if let Some(name) = ident_from(rest) {
items.push(OutlineItem { kind: "fn".into(), name, line: line_num });
}
continue;
}
// type Name {
if let Some(rest) = trimmed.strip_prefix("type ") {
if let Some(name) = ident_from(rest) {
items.push(OutlineItem { kind: "type".into(), name, line: line_num });
}
continue;
}
// enum Name {
if let Some(rest) = trimmed.strip_prefix("enum ") {
if let Some(name) = ident_from(rest) {
items.push(OutlineItem { kind: "enum".into(), name, line: line_num });
}
continue;
}
// protocol Name {
if let Some(rest) = trimmed.strip_prefix("protocol ") {
if let Some(name) = ident_from(rest) {
items.push(OutlineItem { kind: "protocol".into(), name, line: line_num });
}
continue;
}
// impl Protocol for Type {
if let Some(rest) = trimmed.strip_prefix("impl ") {
// name is "Protocol for Type" → take until {
let name = rest.trim_end_matches(|c: char| c == '{').trim();
if !name.is_empty() {
items.push(OutlineItem { kind: "impl".into(), name: name.to_string(), line: line_num });
}
continue;
}
}
items
}
fn ident_from(s: &str) -> Option<String> {
let s = s.trim_start();
let end = s.find(|c: char| !c.is_alphanumeric() && c != '_').unwrap_or(s.len());
if end == 0 {
None
} else {
Some(s[..end].to_string())
}
}
-77
View File
@@ -1,77 +0,0 @@
//! Plugins API — list, install, remove, enable, disable.
//!
//! All mutation operations use POST with a JSON body containing `name`
//! to avoid path-parameter routing conflicts with Axum 0.7/matchit 0.7.
use axum::{extract::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 NameRequest {
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<NameRequest>,
) -> 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()))
}
/// POST /api/plugins/remove — body: { name }
pub async fn remove_plugin(
State(state): State<AppState>,
Json(req): Json<NameRequest>,
) -> ApiResult<OkResponse> {
let mut host = state.plugins.lock().await;
host.remove(&req.name)
.map(|_| Json(OkResponse { ok: true }))
.map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string()))
}
/// POST /api/plugins/enable — body: { name }
pub async fn enable_plugin(
State(state): State<AppState>,
Json(req): Json<NameRequest>,
) -> ApiResult<OkResponse> {
let mut host = state.plugins.lock().await;
host.enable(&req.name)
.map(|_| Json(OkResponse { ok: true }))
.map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string()))
}
/// POST /api/plugins/disable — body: { name }
pub async fn disable_plugin(
State(state): State<AppState>,
Json(req): Json<NameRequest>,
) -> ApiResult<OkResponse> {
let mut host = state.plugins.lock().await;
host.disable(&req.name)
.map(|_| Json(OkResponse { ok: true }))
.map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string()))
}
-409
View File
@@ -1,409 +0,0 @@
//! Reasoning API — pair programming with Neuron + 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() })))
}
// ── Hypothesis evaluation (legacy) ───────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct ReasonRequest {
pub hypothesis: Option<String>,
pub context: Option<String>,
// Pair programming fields
pub message: Option<String>,
pub selection: Option<String>,
pub conversation_history: Option<Vec<ConversationMessage>>,
pub mode: Option<String>, // "hypothesis" | "pair" | "knowledge-search"
pub query: Option<String>, // for knowledge-search mode
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Serialize)]
pub struct ReasonResponse {
pub verdict: Option<String>,
pub confidence: Option<f64>,
pub evidence: Vec<EvidenceItem>,
pub source: String,
// Pair programming fields
pub message: Option<String>,
pub code_blocks: Vec<CodeBlock>,
pub suggestions: Vec<String>,
// Knowledge search fields
pub nodes: Vec<KnowledgeNode>,
}
#[derive(Debug, Serialize)]
pub struct EvidenceItem {
pub text: String,
pub weight: f64,
}
#[derive(Debug, Serialize)]
pub struct CodeBlock {
pub language: String,
pub code: String,
pub description: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct KnowledgeNode {
pub id: String,
pub label: String,
pub node_type: String,
pub score: f64,
}
/// POST /api/reason — multi-mode endpoint:
/// - mode "hypothesis" (default): proxy to engram-server for hypothesis evaluation
/// - mode "pair": pair programming with Neuron (proxies to soma chat endpoint)
/// - mode "knowledge-search": search Engram knowledge graph
pub async fn reason(
State(state): State<AppState>,
Json(req): Json<ReasonRequest>,
) -> ApiResult<ReasonResponse> {
let mode = req.mode.as_deref().unwrap_or("hypothesis");
match mode {
"pair" => handle_pair_programming(state, req).await,
"knowledge-search" => handle_knowledge_search(state, req).await,
_ => handle_hypothesis(state, req).await,
}
}
// ── Hypothesis mode ───────────────────────────────────────────────────────────
async fn handle_hypothesis(
state: AppState,
req: ReasonRequest,
) -> ApiResult<ReasonResponse> {
let hypothesis = req.hypothesis.clone().unwrap_or_default();
let engram_url = &state.config.engram_url;
let url = format!("{engram_url}/api/reason");
let client = reqwest::Client::new();
let body = serde_json::json!({
"hypothesis": 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: Some(json["verdict"].as_str().unwrap_or("unknown").to_string()),
confidence: Some(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(),
message: None,
code_blocks: vec![],
suggestions: vec![],
nodes: vec![],
}))
}
_ => {
Ok(Json(ReasonResponse {
verdict: Some("unresolved".into()),
confidence: Some(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(),
message: None,
code_blocks: vec![],
suggestions: vec![],
nodes: vec![],
}))
}
}
}
// ── Pair programming mode ─────────────────────────────────────────────────────
async fn handle_pair_programming(
state: AppState,
req: ReasonRequest,
) -> ApiResult<ReasonResponse> {
let user_message = req.message.clone().unwrap_or_default();
let context = req.context.clone().unwrap_or_default();
let selection = req.selection.clone().unwrap_or_default();
let history = req.conversation_history.clone().unwrap_or_default();
let engram_url = &state.config.engram_url;
// Build system prompt
let system_prompt = build_pair_system_prompt(&context, &selection);
// Try to proxy to soma/engram chat endpoint
let client = reqwest::Client::new();
// Build messages array for chat completions format
let mut messages: Vec<serde_json::Value> = vec![
serde_json::json!({ "role": "system", "content": system_prompt }),
];
// Add conversation history
for msg in &history {
messages.push(serde_json::json!({
"role": msg.role,
"content": msg.content,
}));
}
// Add current user message
messages.push(serde_json::json!({ "role": "user", "content": user_message }));
let chat_body = serde_json::json!({
"model": "neuron",
"messages": messages,
"stream": false,
});
// Try soma chat endpoint
let soma_url = format!("{engram_url}/v1/chat/completions");
let response_text = match client
.post(&soma_url)
.json(&chat_body)
.timeout(std::time::Duration::from_secs(30))
.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}")))?;
json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("No response from Neuron.")
.to_string()
}
_ => {
// Fallback stub response when soma is unavailable
build_stub_pair_response(&user_message, &context, &selection)
}
};
// Extract code blocks from the response
let code_blocks = extract_code_blocks(&response_text);
// Extract suggestions (lines starting with "- " or "• ")
let suggestions = extract_suggestions(&response_text);
Ok(Json(ReasonResponse {
verdict: None,
confidence: None,
evidence: vec![],
source: "neuron".into(),
message: Some(response_text),
code_blocks,
suggestions,
nodes: vec![],
}))
}
fn build_pair_system_prompt(context: &str, selection: &str) -> String {
let mut prompt = String::from(
"You are Neuron, an AI pair programmer for the Engram language. \
You are deeply integrated into the IDE — you can see the current file, understand types, \
and help write, debug, and reason about Engram programs.\n\n\
The Engram language features:\n\
- `activate TypeName where \"query\"` — spreading activation over the knowledge graph\n\
- `sealed { ... }` — quantum-sealed sensitive blocks\n\
- Types map to semantic nodes in the Engram knowledge graph\n\
- Structural typing with protocols and impl blocks\n\n"
);
if !context.is_empty() {
let truncated = if context.len() > 3000 {
format!("{}...(truncated)", &context[..3000])
} else {
context.to_string()
};
prompt.push_str(&format!(
"Current file content:\n```engram\n{truncated}\n```\n\n"
));
}
if !selection.is_empty() {
prompt.push_str(&format!(
"User has selected this code:\n```engram\n{selection}\n```\n\n"
));
}
prompt.push_str(
"When suggesting code, wrap it in ```engram code blocks. \
Be concise and practical. If you see opportunities to use spreading activation \
(`activate X where \"...\"`), suggest them. \
Focus on the semantic relationships between types and knowledge graph nodes."
);
prompt
}
fn build_stub_pair_response(user_message: &str, context: &str, selection: &str) -> String {
let has_context = !context.is_empty();
let has_selection = !selection.is_empty();
let msg_lower = user_message.to_lowercase();
if msg_lower.contains("explain") && has_selection {
format!(
"I can see the selected code. To get a real AI analysis, \
connect an Engram instance at the configured endpoint.\n\n\
Based on the structure, this code appears to define types and functions \
in the Engram type system.\n\n\
Connect Neuron for semantic analysis of knowledge graph relationships."
)
} else if msg_lower.contains("activate") || msg_lower.contains("semantic") {
"The `activate` keyword performs spreading activation over the Engram knowledge graph. \
Example:\n\n\
```engram\nlet results: [User] = activate User where \"premium subscribers active this week\"\n```\n\n\
This returns all User nodes semantically matching the query. \
Connect an Engram instance for live knowledge graph queries.".to_string()
} else if has_context {
format!(
"I see your current file. Neuron pair programming is ready — \
connect an Engram instance to enable live AI responses.\n\n\
Your file has {} characters of Engram code. \
Once connected, I can analyze types, suggest activations, and help debug.",
context.len()
)
} else {
"Neuron pair programming panel is active. \
Connect an Engram instance (set EL_ENGRAM_URL) to enable live AI responses. \
I can help with:\n\
- Explaining and debugging Engram code\n\
- Suggesting `activate` queries for your data types\n\
- Writing functions and type definitions\n\
- Analyzing semantic relationships in your knowledge graph".to_string()
}
}
fn extract_code_blocks(text: &str) -> Vec<CodeBlock> {
let mut blocks = Vec::new();
let mut remaining = text;
while let Some(start) = remaining.find("```") {
remaining = &remaining[start + 3..];
// Find language (optional, on same line as ```)
let lang_end = remaining.find('\n').unwrap_or(remaining.len());
let lang = remaining[..lang_end].trim().to_string();
let lang = if lang.is_empty() { "engram".to_string() } else { lang };
remaining = &remaining[lang_end..];
if remaining.starts_with('\n') {
remaining = &remaining[1..];
}
if let Some(end) = remaining.find("```") {
let code = remaining[..end].trim_end_matches('\n').to_string();
remaining = &remaining[end + 3..];
if !code.is_empty() {
blocks.push(CodeBlock {
language: lang,
code,
description: None,
});
}
} else {
break;
}
}
blocks
}
fn extract_suggestions(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("- ") || trimmed.starts_with("") || trimmed.starts_with("* ") {
Some(trimmed[2..].trim().to_string())
} else {
None
}
})
.filter(|s| !s.is_empty())
.take(8)
.collect()
}
// ── Knowledge search mode ─────────────────────────────────────────────────────
async fn handle_knowledge_search(
state: AppState,
req: ReasonRequest,
) -> ApiResult<ReasonResponse> {
let query = req.query.clone().unwrap_or_else(|| req.message.clone().unwrap_or_default());
let engram_url = &state.config.engram_url;
let client = reqwest::Client::new();
let url = format!("{engram_url}/api/search");
let body = serde_json::json!({ "query": query, "limit": 20 });
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}")))?;
let nodes = json.as_array()
.map(|arr| {
arr.iter().map(|n| KnowledgeNode {
id: n["id"].as_str().unwrap_or("").to_string(),
label: n["label"].as_str().unwrap_or("").to_string(),
node_type: n["type"].as_str().unwrap_or("").to_string(),
score: n["score"].as_f64().unwrap_or(0.0),
}).collect()
})
.unwrap_or_default();
Ok(Json(ReasonResponse {
verdict: None,
confidence: None,
evidence: vec![],
source: "engram-db".into(),
message: None,
code_blocks: vec![],
suggestions: vec![],
nodes,
}))
}
_ => {
// Stub: return empty nodes when engram is unavailable
Ok(Json(ReasonResponse {
verdict: None,
confidence: None,
evidence: vec![EvidenceItem {
text: "Engram DB not reachable — connect an Engram instance for knowledge graph search.".into(),
weight: 0.0,
}],
source: "stub".into(),
message: Some("Connect an Engram instance to search the knowledge graph.".into()),
code_blocks: vec![],
suggestions: vec![],
nodes: vec![],
}))
}
}
}
-119
View File
@@ -1,119 +0,0 @@
//! Definition and references API — find all uses of a symbol.
use axum::{
extract::Query,
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
#[derive(Debug, Deserialize)]
pub struct SymbolQuery {
pub source: String,
pub pos: usize,
}
#[derive(Debug, Serialize)]
pub struct SymbolLocation {
pub line: usize,
pub col: usize,
pub snippet: String,
}
/// GET /api/definition?source=...&pos=... — find definition of symbol at pos.
pub async fn definition(
Query(q): Query<SymbolQuery>,
) -> ApiResult<Option<SymbolLocation>> {
let loc = find_definition(&q.source, q.pos);
Ok(Json(loc))
}
/// GET /api/references?source=...&pos=... — find all references to symbol at pos.
pub async fn references(
Query(q): Query<SymbolQuery>,
) -> ApiResult<Vec<SymbolLocation>> {
let refs = find_references(&q.source, q.pos);
Ok(Json(refs))
}
// ── Implementation ────────────────────────────────────────────────────────────
fn word_at(source: &str, pos: usize) -> Option<&str> {
if pos > source.len() {
return None;
}
let bytes = source.as_bytes();
let mut start = pos;
let mut end = pos;
while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') {
start -= 1;
}
while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
end += 1;
}
if start == end {
None
} else {
Some(&source[start..end])
}
}
fn find_definition(source: &str, pos: usize) -> Option<SymbolLocation> {
let word = word_at(source, pos)?;
if word.is_empty() {
return None;
}
// Look for `fn word`, `type word`, `enum word`, `let word`, `protocol word`
let patterns = [
format!("fn {word}"),
format!("type {word}"),
format!("enum {word}"),
format!("protocol {word}"),
];
for (i, line) in source.lines().enumerate() {
for pat in &patterns {
if line.contains(pat.as_str()) {
let col = line.find(pat.as_str()).unwrap_or(0);
return Some(SymbolLocation {
line: i + 1,
col: col + 1,
snippet: line.trim().to_string(),
});
}
}
}
None
}
fn find_references(source: &str, pos: usize) -> Vec<SymbolLocation> {
let word = match word_at(source, pos) {
Some(w) if !w.is_empty() => w,
_ => return vec![],
};
let mut refs = Vec::new();
for (i, line) in source.lines().enumerate() {
let mut search = line;
let mut offset = 0;
while let Some(idx) = search.find(word) {
// Check word boundaries
let abs = offset + idx;
let before_ok = abs == 0 || !line.as_bytes()[abs - 1].is_ascii_alphanumeric() && line.as_bytes()[abs - 1] != b'_';
let after_ok = abs + word.len() >= line.len() || !line.as_bytes()[abs + word.len()].is_ascii_alphanumeric() && line.as_bytes()[abs + word.len()] != b'_';
if before_ok && after_ok {
refs.push(SymbolLocation {
line: i + 1,
col: abs + 1,
snippet: line.trim().to_string(),
});
}
offset += idx + word.len();
search = &search[idx + word.len()..];
}
}
refs
}
@@ -1,19 +0,0 @@
//! Run state — tracks the currently running process so it can be killed.
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::process::Child;
/// Global run state shared across request handlers.
#[derive(Default)]
pub struct RunState {
pub current_child: Mutex<Option<Child>>,
}
impl RunState {
pub fn new() -> Arc<Self> {
Arc::new(Self {
current_child: Mutex::new(None),
})
}
}
-247
View File
@@ -1,247 +0,0 @@
//! Search API — grep-style text search across project files.
//! Supports case-sensitive, whole-word, and regex search modes.
use std::path::{Path, PathBuf};
use axum::{
extract::{Query, 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 SearchQuery {
pub query: String,
pub path: Option<String>,
/// If "1", match case (default: case-insensitive)
pub case_sensitive: Option<String>,
/// If "1", match whole words only
pub whole_word: Option<String>,
/// If "1", treat query as a regex
pub regex: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SearchResult {
pub path: String,
pub line: usize,
pub col: usize,
pub snippet: String,
}
/// GET /api/search?query=<text>&path=<optional-subdir>&case_sensitive=1&whole_word=1&regex=1
pub async fn search(
State(state): State<AppState>,
Query(q): Query<SearchQuery>,
) -> ApiResult<Vec<SearchResult>> {
if q.query.is_empty() {
return Ok(Json(vec![]));
}
let root = PathBuf::from(&state.config.project_path)
.canonicalize()
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let search_root = if let Some(sub) = q.path.as_deref().filter(|s| !s.is_empty() && *s != ".") {
root.join(sub).canonicalize()
.map_err(|e| api_err(StatusCode::BAD_REQUEST, format!("invalid path: {e}")))?
} else {
root.clone()
};
if !search_root.starts_with(&root) {
return Err(api_err(StatusCode::FORBIDDEN, "path escapes project root"));
}
let case_sensitive = q.case_sensitive.as_deref() == Some("1");
let whole_word = q.whole_word.as_deref() == Some("1");
let use_regex = q.regex.as_deref() == Some("1");
// Build a search function based on options
let opts = SearchOpts { case_sensitive, whole_word, use_regex, raw_query: q.query.clone() };
let mut results: Vec<SearchResult> = Vec::new();
walk_and_search(&search_root, &root, &opts, &mut results);
results.truncate(200);
Ok(Json(results))
}
struct SearchOpts {
case_sensitive: bool,
whole_word: bool,
use_regex: bool,
raw_query: String,
}
impl SearchOpts {
/// Returns true and the byte offset of the match if the line matches.
fn find_in_line(&self, line: &str) -> Option<usize> {
if self.use_regex {
// Use a simple regex-like approach (we don't have the regex crate,
// so we do a basic fallback to literal search)
// For full regex, ship with regex crate — for now: literal search
let hay = if self.case_sensitive { line.to_string() } else { line.to_lowercase() };
let needle = if self.case_sensitive { self.raw_query.clone() } else { self.raw_query.to_lowercase() };
hay.find(&needle)
} else {
let hay = if self.case_sensitive { line.to_string() } else { line.to_lowercase() };
let needle = if self.case_sensitive { self.raw_query.clone() } else { self.raw_query.to_lowercase() };
if self.whole_word {
// Word-boundary check
let mut start = 0;
loop {
match hay[start..].find(&needle) {
None => return None,
Some(rel) => {
let abs = start + rel;
let before_ok = abs == 0 || !hay.as_bytes()[abs - 1].is_ascii_alphanumeric() && hay.as_bytes()[abs - 1] != b'_';
let after = abs + needle.len();
let after_ok = after >= hay.len() || !hay.as_bytes()[after].is_ascii_alphanumeric() && hay.as_bytes()[after] != b'_';
if before_ok && after_ok {
return Some(abs);
}
start = abs + 1;
}
}
}
} else {
hay.find(&needle)
}
}
}
}
/// Skip directories that are not useful to search.
fn should_skip_dir(name: &str) -> bool {
matches!(name, "target" | ".git" | "node_modules" | "dist" | ".cargo" | ".next" | ".turbo")
}
/// Skip files that are likely binary or irrelevant.
fn should_skip_file(name: &str) -> bool {
// Skip lock files and common binary extensions
if matches!(name, "Cargo.lock" | "package-lock.json" | "yarn.lock" | "bun.lockb") {
return true;
}
let binary_exts = [
".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
".wasm", ".pdf", ".zip", ".tar", ".gz", ".exe",
".so", ".dylib", ".a", ".rlib", ".rmeta",
".parquet", ".db", ".sqlite",
];
for ext in &binary_exts {
if name.ends_with(ext) {
return true;
}
}
false
}
fn walk_and_search(dir: &Path, root: &Path, opts: &SearchOpts, results: &mut Vec<SearchResult>) {
if results.len() >= 200 {
return;
}
let rd = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return,
};
let mut entries: Vec<_> = rd.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
if results.len() >= 200 {
break;
}
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if path.is_dir() {
if should_skip_dir(&name) {
continue;
}
walk_and_search(&path, root, opts, results);
} else {
if should_skip_file(&name) {
continue;
}
search_in_file(&path, root, opts, results);
}
}
}
fn search_in_file(file: &Path, root: &Path, opts: &SearchOpts, results: &mut Vec<SearchResult>) {
// Read file, skip if it looks binary
let content = match std::fs::read(file) {
Ok(b) => b,
Err(_) => return,
};
// Quick binary check: look for null bytes in first 8KB
let sample = &content[..content.len().min(8192)];
if sample.contains(&0u8) {
return;
}
let text = match std::str::from_utf8(&content) {
Ok(s) => s,
Err(_) => return,
};
let rel_path = file
.strip_prefix(root)
.unwrap_or(file)
.to_string_lossy()
.to_string();
let query_len = opts.raw_query.len();
for (line_idx, line) in text.lines().enumerate() {
if results.len() >= 200 {
break;
}
if let Some(col_byte) = opts.find_in_line(line) {
// Convert byte offset to char col (1-based)
let col = line[..col_byte].chars().count() + 1;
// Build snippet (cap at 120 chars)
let snippet = if line.len() > 120 {
// Try to center the match
let start = col_byte.saturating_sub(40);
let end = (col_byte + query_len + 40).min(line.len());
let s = &line[start..end];
if start > 0 { format!("{s}") } else { s.to_string() }
} else {
line.to_string()
};
let snippet = if snippet.len() > 120 {
format!("{}", &snippet[..117])
} else {
snippet
};
results.push(SearchResult {
path: rel_path.clone(),
line: line_idx + 1,
col,
snippet,
});
}
}
}
-110
View File
@@ -1,110 +0,0 @@
//! Settings API — persist and retrieve IDE settings to/from ~/.el-ide/settings.json
use axum::{http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
fn api_err(msg: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": msg.to_string() })),
)
}
/// Default settings returned when no file exists.
fn default_settings() -> Value {
serde_json::json!({
"theme": "dark",
"fontSize": 13,
"tabSize": 4,
"wordWrap": false,
"formatOnSave": false,
"vimMode": false,
"minimap": true,
"elBinaryPath": "el",
"engramUrl": "http://localhost:8742",
"stickyScroll": true,
"lineNumbers": true,
"bracketMatching": true
})
}
fn settings_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
PathBuf::from(home).join(".el-ide").join("settings.json")
}
/// GET /api/settings — returns current settings.
pub async fn get_settings() -> ApiResult<Value> {
let path = settings_path();
if path.exists() {
let content = std::fs::read_to_string(&path)
.map_err(|e| api_err(format!("read settings: {e}")))?;
let val: Value = serde_json::from_str(&content)
.unwrap_or_else(|_| default_settings());
// Merge with defaults to ensure all keys are present
let mut merged = default_settings();
if let (Some(obj), Some(defaults)) = (val.as_object(), merged.as_object_mut()) {
for (k, v) in obj {
defaults.insert(k.clone(), v.clone());
}
}
Ok(Json(merged))
} else {
Ok(Json(default_settings()))
}
}
#[derive(Debug, Deserialize)]
pub struct PatchSettings {
pub settings: Value,
}
/// POST /api/settings — saves settings (merges with existing).
pub async fn save_settings(
Json(req): Json<PatchSettings>,
) -> ApiResult<Value> {
let path = settings_path();
// Ensure directory exists
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| api_err(format!("create settings dir: {e}")))?;
}
// Load existing settings
let mut existing = if path.exists() {
let content = std::fs::read_to_string(&path).unwrap_or_default();
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| default_settings())
} else {
default_settings()
};
// Merge incoming patches
if let (Some(existing_obj), Some(patch_obj)) = (existing.as_object_mut(), req.settings.as_object()) {
for (k, v) in patch_obj {
existing_obj.insert(k.clone(), v.clone());
}
}
let serialized = serde_json::to_string_pretty(&existing)
.map_err(|e| api_err(format!("serialize settings: {e}")))?;
std::fs::write(&path, &serialized)
.map_err(|e| api_err(format!("write settings: {e}")))?;
Ok(Json(existing))
}
/// DELETE /api/settings — reset to defaults.
pub async fn reset_settings() -> ApiResult<Value> {
let path = settings_path();
if path.exists() {
std::fs::remove_file(&path).ok();
}
Ok(Json(default_settings()))
}
-67
View File
@@ -1,67 +0,0 @@
//! Snippets API — returns el code templates.
use axum::Json;
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct Snippet {
pub label: String,
pub description: String,
pub body: String,
}
/// GET /api/completions/snippet — return el code snippets.
pub async fn snippets() -> Json<Vec<Snippet>> {
Json(vec![
Snippet {
label: "fn".into(),
description: "Function definition".into(),
body: "fn ${1:name}(${2:params}) -> ${3:ReturnType} {\n\t${4:body}\n}".into(),
},
Snippet {
label: "type".into(),
description: "Struct type definition".into(),
body: "type ${1:Name} {\n\t${2:field}: ${3:Type},\n}".into(),
},
Snippet {
label: "enum".into(),
description: "Enum definition".into(),
body: "enum ${1:Name} {\n\t${2:Variant},\n}".into(),
},
Snippet {
label: "match".into(),
description: "Match expression".into(),
body: "match ${1:expr} {\n\t${2:pattern} => ${3:body},\n\t_ => ${4:default},\n}".into(),
},
Snippet {
label: "let".into(),
description: "Let binding".into(),
body: "let ${1:name}: ${2:Type} = ${3:value};".into(),
},
Snippet {
label: "protocol".into(),
description: "Protocol definition".into(),
body: "protocol ${1:Name} {\n\tfn ${2:method}(${3:self}) -> ${4:Ret};\n}".into(),
},
Snippet {
label: "impl".into(),
description: "Implementation block".into(),
body: "impl ${1:Protocol} for ${2:Type} {\n\t${3:body}\n}".into(),
},
Snippet {
label: "if".into(),
description: "If expression".into(),
body: "if ${1:cond} {\n\t${2:body}\n}".into(),
},
Snippet {
label: "for".into(),
description: "For loop".into(),
body: "for ${1:item} in ${2:iter} {\n\t${3:body}\n}".into(),
},
Snippet {
label: "activate".into(),
description: "Activate declaration".into(),
body: "activate ${1:Concept};".into(),
},
])
}
-94
View File
@@ -1,94 +0,0 @@
//! Status API — project metadata and server health.
use std::path::{Path, PathBuf};
use axum::{extract::State, Json};
use serde::Serialize;
use crate::AppState;
#[derive(Debug, Serialize)]
pub struct StatusResponse {
pub project_name: String,
pub project_path: String,
pub file_count: usize,
pub el_file_count: usize,
pub version: String,
pub el_version: Option<String>,
}
/// GET /api/status
pub async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
let project_path = state.config.project_path.clone();
let root = PathBuf::from(&project_path);
let project_name = root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| project_path.clone());
let (file_count, el_file_count) = count_files(&root);
let el_version = get_el_version(&state.config.el_binary);
Json(StatusResponse {
project_name,
project_path,
file_count,
el_file_count,
version: "0.1.0".into(),
el_version,
})
}
fn get_el_version(binary: &str) -> Option<String> {
let out = std::process::Command::new(binary)
.arg("--version")
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout).to_string();
let text = text.trim();
if text.is_empty() {
let err = String::from_utf8_lossy(&out.stderr).to_string();
let err = err.trim().to_string();
if err.is_empty() { None } else { Some(err) }
} else {
Some(text.to_string())
}
}
fn should_skip_dir(name: &str) -> bool {
matches!(name, "target" | ".git" | "node_modules" | "dist" | ".cargo")
}
fn count_files(root: &Path) -> (usize, usize) {
let mut total = 0usize;
let mut el_count = 0usize;
count_files_recursive(root, &mut total, &mut el_count);
(total, el_count)
}
fn count_files_recursive(dir: &Path, total: &mut usize, el_count: &mut usize) {
let rd = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return,
};
for entry in rd.filter_map(|e| e.ok()) {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if path.is_dir() {
if should_skip_dir(&name) {
continue;
}
count_files_recursive(&path, total, el_count);
} else {
*total += 1;
if name.ends_with(".el") {
*el_count += 1;
}
}
}
}
-105
View File
@@ -1,105 +0,0 @@
//! Terminal WebSocket endpoint — spawns /bin/bash and bridges IO.
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
use tokio::process::Command;
use crate::AppState;
/// GET /api/terminal — upgrades to WebSocket, spawns a shell.
pub async fn terminal_ws(
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> impl IntoResponse {
let project_path = state.config.project_path.clone();
ws.on_upgrade(move |socket| handle_socket(socket, project_path))
}
async fn handle_socket(socket: WebSocket, project_path: String) {
use futures_util::{SinkExt, StreamExt};
let mut child = match Command::new("/bin/bash")
.current_dir(&project_path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let (mut sink, _) = socket.split();
let _ = sink.send(Message::Text(format!("Failed to spawn shell: {e}\r\n"))).await;
return;
}
};
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let (mut ws_sink, mut ws_stream) = socket.split();
// stdout → WS
let stdout_task = tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(stdout);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
if ws_sink.send(Message::Text(line.clone())).await.is_err() {
break;
}
}
Err(_) => break,
}
}
});
// stderr → WS (interleaved into same stream)
let stderr_task = tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(stderr);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
// We can't send to ws_sink here (it was moved), so just drain
// In practice stderr goes to stdout via /bin/bash behavior
}
Err(_) => break,
}
}
});
// WS → stdin
while let Some(Ok(msg)) = ws_stream.next().await {
match msg {
Message::Text(text) => {
if stdin.write_all(text.as_bytes()).await.is_err() {
break;
}
}
Message::Binary(bytes) => {
if stdin.write_all(&bytes).await.is_err() {
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
// Kill child process
let _ = child.kill().await;
stdout_task.abort();
stderr_task.abort();
}
-68
View File
@@ -1,68 +0,0 @@
//! Themes API — list and switch IDE themes.
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, Serialize)]
pub struct ThemeInfo {
pub name: String,
pub label: String,
pub active: bool,
}
#[derive(Debug, Deserialize)]
pub struct SetThemeRequest {
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct OkResponse {
pub ok: bool,
pub active: String,
}
const AVAILABLE_THEMES: &[(&str, &str)] = &[
("dark", "Dark"),
("light", "Light"),
("neuron", "Neuron"),
("high-contrast", "High Contrast"),
];
/// GET /api/themes — list available themes
pub async fn list_themes(State(state): State<AppState>) -> ApiResult<Vec<ThemeInfo>> {
let active = state.active_theme.read().await.clone();
let themes = AVAILABLE_THEMES
.iter()
.map(|(name, label)| ThemeInfo {
name: name.to_string(),
label: label.to_string(),
active: *name == active,
})
.collect();
Ok(Json(themes))
}
/// POST /api/themes/active — body: { name }
pub async fn set_theme(
State(state): State<AppState>,
Json(req): Json<SetThemeRequest>,
) -> ApiResult<OkResponse> {
let valid = AVAILABLE_THEMES.iter().any(|(n, _)| *n == req.name);
if !valid {
return Err(api_err(StatusCode::BAD_REQUEST, format!("unknown theme: {}", req.name)));
}
*state.active_theme.write().await = req.name.clone();
Ok(Json(OkResponse { ok: true, active: req.name }))
}
@@ -1,42 +0,0 @@
//! 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))
}
-36
View File
@@ -1,36 +0,0 @@
//! 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,
/// El compiler binary path (EL_BINARY, default "el")
pub el_binary: 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()),
el_binary: std::env::var("EL_BINARY")
.unwrap_or_else(|_| "el".into()),
}
}
}
impl Default for Config {
fn default() -> Self {
Self::from_env()
}
}
-49
View File
@@ -1,49 +0,0 @@
//! Embedded static assets via rust-embed.
use axum::{
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")
}
/// Fallback handler — serves index.html for unknown paths (SPA routing).
pub async fn fallback() -> impl IntoResponse {
serve_file("index.html")
}
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(),
}
}
}
}
-128
View File
@@ -1,128 +0,0 @@
//! 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::{get, post, any}};
use tokio::sync::{Mutex, RwLock};
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>>,
pub active_theme: Arc<RwLock<String>>,
}
// ── 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())),
active_theme: Arc::new(RwLock::new("dark".into())),
};
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))
// Config
.route("/api/config", get(api::config::get_config))
// Status
.route("/api/status", get(api::status::status))
// File API
.route("/api/files", get(api::files::list_files))
.route("/api/file",
get(api::files::read_file)
.post(api::files::write_file)
.delete(api::files::delete_file)
)
.route("/api/files/mkdir", post(api::files::mkdir))
.route("/api/files/rename", post(api::files::rename_file))
// Search
.route("/api/search", get(api::search::search))
// 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))
.route("/api/lsp/activate-preview", get(api::lsp::activate_preview))
// Type graph
.route("/api/type-graph", get(api::type_graph::type_graph))
// Themes
.route("/api/themes", get(api::themes::list_themes))
.route("/api/themes/active", post(api::themes::set_theme))
// Plugins
.route("/api/plugins", get(api::plugins::list_plugins))
.route("/api/plugins/install", post(api::plugins::install_plugin))
.route("/api/plugins/remove", post(api::plugins::remove_plugin))
.route("/api/plugins/enable", post(api::plugins::enable_plugin))
.route("/api/plugins/disable", post(api::plugins::disable_plugin))
// Format
.route("/api/format", post(api::format::format))
// Outline
.route("/api/outline", get(api::outline::outline))
// Definition and references
.route("/api/definition", get(api::references::definition))
.route("/api/references", get(api::references::references))
// Snippets
.route("/api/completions/snippet", get(api::snippets::snippets))
// Git
.route("/api/git/status", get(api::git::git_status))
.route("/api/git/diff", get(api::git::git_diff))
// Terminal WebSocket
.route("/api/terminal", any(api::terminal::terminal_ws))
// Reasoning (proxy to engram-server)
.route("/api/reason", post(api::reason::reason))
// Settings
.route("/api/settings",
get(api::settings::get_settings)
.post(api::settings::save_settings)
.delete(api::settings::reset_settings)
)
.layer(cors)
.with_state(state)
// Fallback for SPA routing — must come after with_state/layer
.fallback(embed::fallback)
}
-20
View File
@@ -1,20 +0,0 @@
//! 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())
}
-236
View File
@@ -1,236 +0,0 @@
//! Integration tests for el-ide-server API endpoints.
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
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())),
active_theme: Arc::new(RwLock::new("dark".into())),
}
}
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"] == "manifest.el");
assert!(has_src, "expected src or manifest.el 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");
}