Archived
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8b5807e4c |
Generated
-2505
File diff suppressed because it is too large
Load Diff
-41
@@ -1,41 +0,0 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"vessels/el-ide-server",
|
||||
"vessels/el-lsp",
|
||||
"vessels/el-plugin-host",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Neuron Technologies"]
|
||||
|
||||
[workspace.dependencies]
|
||||
# Internal crates (el-ide workspace)
|
||||
el-lsp = { path = "vessels/el-lsp" }
|
||||
el-plugin-host = { path = "vessels/el-plugin-host" }
|
||||
|
||||
# Engram lang crates (path deps)
|
||||
el-lexer = { path = "../el/engrams/el-lexer" }
|
||||
el-parser = { path = "../el/engrams/el-parser" }
|
||||
el-types = { path = "../el/engrams/el-types" }
|
||||
el-compiler = { path = "../el/engrams/el-compiler" }
|
||||
|
||||
# External
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = { version = "0.7", features = ["macros", "ws"] }
|
||||
futures-util = "0.3"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
rust-embed = { version = "8", features = ["axum"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
async-stream = "0.3"
|
||||
tokio-stream = "0.1"
|
||||
which = "6"
|
||||
mime_guess = "2"
|
||||
@@ -1,34 +0,0 @@
|
||||
[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 }
|
||||
futures-util = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
tower = "0.5"
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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![],
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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®ex=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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "el-lsp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
el-lexer = { workspace = true }
|
||||
el-parser = { workspace = true }
|
||||
el-types = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -1,292 +0,0 @@
|
||||
//! Completion engine — prefix-based + semantic scoring.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use el_types::{TypeDef, TypeEnv};
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Completion {
|
||||
pub label: String,
|
||||
pub kind: CompletionKind,
|
||||
pub detail: String,
|
||||
pub documentation: Option<String>,
|
||||
/// Activation strength: higher = more relevant, completions sorted desc.
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CompletionKind {
|
||||
Variable,
|
||||
Function,
|
||||
Type,
|
||||
Keyword,
|
||||
}
|
||||
|
||||
// ── Keywords ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const KEYWORDS: &[&str] = &[
|
||||
"let", "fn", "type", "enum", "match", "return", "activate", "where",
|
||||
"sealed", "if", "else", "for", "in", "while", "true", "false",
|
||||
"test", "seed", "assert", "target", "protocol", "impl",
|
||||
"import", "from", "as", "with", "retry", "times", "fallback",
|
||||
"reason", "parallel", "trace", "requires", "deploy", "to", "via",
|
||||
];
|
||||
|
||||
// ── Builtin functions ─────────────────────────────────────────────────────────
|
||||
|
||||
const BUILTIN_FUNCTIONS: &[(&str, &str)] = &[
|
||||
("println", "fn(value: String) -> Void"),
|
||||
("print", "fn(value: String) -> Void"),
|
||||
("int_to_str", "fn(n: Int) -> String"),
|
||||
("str_to_int", "fn(s: String) -> Int"),
|
||||
("string_len", "fn(s: String) -> Int"),
|
||||
("str_slice", "fn(s: String, start: Int, end: Int) -> String"),
|
||||
("str_concat", "fn(a: String, b: String) -> String"),
|
||||
("str_contains", "fn(s: String, sub: String) -> Bool"),
|
||||
("str_starts_with", "fn(s: String, prefix: String) -> Bool"),
|
||||
("str_ends_with", "fn(s: String, suffix: String) -> Bool"),
|
||||
("str_to_upper", "fn(s: String) -> String"),
|
||||
("str_to_lower", "fn(s: String) -> String"),
|
||||
("str_split", "fn(s: String, sep: String) -> [String]"),
|
||||
("str_trim", "fn(s: String) -> String"),
|
||||
("str_replace", "fn(s: String, from: String, to: String) -> String"),
|
||||
("str_index_of", "fn(s: String, sub: String) -> Int"),
|
||||
("float_to_str", "fn(f: Float) -> String"),
|
||||
("str_to_float", "fn(s: String) -> Float"),
|
||||
("array_push", "fn(arr: [T], val: T) -> [T]"),
|
||||
("array_len", "fn(arr: [T]) -> Int"),
|
||||
("array_get", "fn(arr: [T], i: Int) -> T"),
|
||||
("list_len", "fn(list: [T]) -> Int"),
|
||||
("list_get", "fn(list: [T], i: Int) -> T"),
|
||||
("list_map", "fn(list: [T], f: fn(T) -> U) -> [U]"),
|
||||
("list_filter", "fn(list: [T], f: fn(T) -> Bool) -> [T]"),
|
||||
("list_reduce", "fn(list: [T], init: U, f: fn(U, T) -> U) -> U"),
|
||||
("map_create", "fn() -> Map"),
|
||||
("map_set", "fn(m: Map, key: String, val: T) -> Map"),
|
||||
("map_get", "fn(m: Map, key: String) -> T"),
|
||||
("map_has", "fn(m: Map, key: String) -> Bool"),
|
||||
("math_abs", "fn(n: Float) -> Float"),
|
||||
("math_sqrt", "fn(n: Float) -> Float"),
|
||||
("math_floor", "fn(n: Float) -> Int"),
|
||||
("math_ceil", "fn(n: Float) -> Int"),
|
||||
("math_round", "fn(n: Float) -> Int"),
|
||||
("math_min", "fn(a: Float, b: Float) -> Float"),
|
||||
("math_max", "fn(a: Float, b: Float) -> Float"),
|
||||
("math_pow", "fn(base: Float, exp: Float) -> Float"),
|
||||
("now_millis", "fn() -> Int"),
|
||||
("time_now_utc", "fn() -> String"),
|
||||
("time_to_parts", "fn(ts: String) -> Map"),
|
||||
("time_format", "fn(ts: String, fmt: String) -> String"),
|
||||
("llm_call", "fn(prompt: String) -> String"),
|
||||
("llm_parallel", "fn(prompts: [String]) -> [String]"),
|
||||
("random_int", "fn(min: Int, max: Int) -> Int"),
|
||||
("random_float", "fn() -> Float"),
|
||||
("parse_json", "fn(s: String) -> Map"),
|
||||
("to_json", "fn(v: T) -> String"),
|
||||
("http_get", "fn(url: String) -> String"),
|
||||
("http_post", "fn(url: String, body: String) -> String"),
|
||||
("read_file", "fn(path: String) -> String"),
|
||||
("write_file", "fn(path: String, content: String) -> Void"),
|
||||
("env_get", "fn(key: String) -> String"),
|
||||
("sleep_ms", "fn(ms: Int) -> Void"),
|
||||
("uuid_new", "fn() -> String"),
|
||||
("hash_sha256", "fn(s: String) -> String"),
|
||||
];
|
||||
|
||||
const BUILTIN_TYPES: &[(&str, &str)] = &[
|
||||
("Int", "64-bit signed integer"),
|
||||
("Float", "64-bit IEEE 754 double"),
|
||||
("String", "UTF-8 string"),
|
||||
("Bool", "Boolean value"),
|
||||
("Uuid", "RFC 4122 UUID"),
|
||||
("Void", "Unit type — no value"),
|
||||
];
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Produce completions at `cursor_pos` in `source`.
|
||||
///
|
||||
/// Extracts the identifier prefix before the cursor and returns all
|
||||
/// completions whose label starts with that prefix, sorted by score
|
||||
/// (prefix match score + semantic boost).
|
||||
pub fn completions_at(env: &TypeEnv, source: &str, cursor_pos: usize) -> Vec<Completion> {
|
||||
let prefix = extract_prefix(source, cursor_pos);
|
||||
let mut results: Vec<Completion> = Vec::new();
|
||||
|
||||
// Keywords
|
||||
for &kw in KEYWORDS {
|
||||
if kw.starts_with(&prefix) {
|
||||
let score = prefix_score(kw, &prefix)
|
||||
+ if matches!(kw, "activate" | "sealed") { 0.2 } else { 0.0 };
|
||||
results.push(Completion {
|
||||
label: kw.to_string(),
|
||||
kind: CompletionKind::Keyword,
|
||||
detail: "keyword".into(),
|
||||
documentation: keyword_doc(kw),
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in types
|
||||
for &(name, desc) in BUILTIN_TYPES {
|
||||
if name.to_lowercase().starts_with(&prefix.to_lowercase()) || prefix.is_empty() {
|
||||
results.push(Completion {
|
||||
label: name.to_string(),
|
||||
kind: CompletionKind::Type,
|
||||
detail: desc.into(),
|
||||
documentation: Some(format!("Built-in type: {desc}")),
|
||||
score: prefix_score(name, &prefix) + 0.1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// User-defined types from env
|
||||
for (type_name, def) in &env.types {
|
||||
// Skip built-ins already listed above
|
||||
if BUILTIN_TYPES.iter().any(|(n, _)| *n == type_name) {
|
||||
continue;
|
||||
}
|
||||
if type_name.to_lowercase().starts_with(&prefix.to_lowercase()) || prefix.is_empty() {
|
||||
let (detail, doc, extra_score) = match def {
|
||||
TypeDef::Struct { fields, .. } => {
|
||||
let field_list = fields
|
||||
.iter()
|
||||
.map(|(f, t)| format!("{f}: {t}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
(format!("struct {{ {field_list} }}"),
|
||||
Some(format!("User-defined struct with fields: {field_list}")),
|
||||
0.15)
|
||||
}
|
||||
TypeDef::Enum { variants, .. } => {
|
||||
let v_list = variants.iter().map(|v| v.name.clone()).collect::<Vec<_>>().join(", ");
|
||||
(format!("enum {{ {v_list} }}"),
|
||||
Some(format!("Enum variants: {v_list}")),
|
||||
0.15)
|
||||
}
|
||||
TypeDef::Primitive(_) => {
|
||||
("primitive type".into(), None, 0.05)
|
||||
}
|
||||
TypeDef::Protocol { methods, .. } => {
|
||||
let m_list = methods.iter().map(|m| m.name.clone()).collect::<Vec<_>>().join(", ");
|
||||
(format!("protocol {{ {m_list} }}"),
|
||||
Some(format!("Protocol methods: {m_list}")),
|
||||
0.15)
|
||||
}
|
||||
};
|
||||
results.push(Completion {
|
||||
label: type_name.clone(),
|
||||
kind: CompletionKind::Type,
|
||||
detail,
|
||||
documentation: doc,
|
||||
score: prefix_score(type_name, &prefix) + extra_score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Builtin functions
|
||||
for &(name, sig) in BUILTIN_FUNCTIONS {
|
||||
if name.to_lowercase().starts_with(&prefix.to_lowercase()) || prefix.is_empty() {
|
||||
results.push(Completion {
|
||||
label: name.to_string(),
|
||||
kind: CompletionKind::Function,
|
||||
detail: sig.into(),
|
||||
documentation: Some(format!("Built-in function: {name}\n{sig}")),
|
||||
score: prefix_score(name, &prefix) + 0.11,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Functions from env (user-defined)
|
||||
for (fn_name, fn_type) in &env.functions {
|
||||
if fn_name.to_lowercase().starts_with(&prefix.to_lowercase()) || prefix.is_empty() {
|
||||
results.push(Completion {
|
||||
label: fn_name.clone(),
|
||||
kind: CompletionKind::Function,
|
||||
detail: fn_type.to_string(),
|
||||
documentation: Some(format!("Function: {fn_name} :: {fn_type}")),
|
||||
score: prefix_score(fn_name, &prefix) + 0.12,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending, deduplicate by label
|
||||
results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
results.dedup_by(|a, b| a.label == b.label);
|
||||
results
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the identifier fragment immediately before `cursor_pos`.
|
||||
fn extract_prefix(source: &str, cursor_pos: usize) -> String {
|
||||
let end = cursor_pos.min(source.len());
|
||||
let bytes = source.as_bytes();
|
||||
let mut start = end;
|
||||
while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') {
|
||||
start -= 1;
|
||||
}
|
||||
source[start..end].to_string()
|
||||
}
|
||||
|
||||
/// Score based on how much of the label is matched by the prefix.
|
||||
/// Returns a value in [0, 1].
|
||||
fn prefix_score(label: &str, prefix: &str) -> f32 {
|
||||
if prefix.is_empty() {
|
||||
return 0.5;
|
||||
}
|
||||
let lower_label = label.to_lowercase();
|
||||
let lower_prefix = prefix.to_lowercase();
|
||||
if lower_label.starts_with(&lower_prefix) {
|
||||
// Exact prefix match — score by how complete the prefix is
|
||||
(lower_prefix.len() as f32 / lower_label.len() as f32).min(1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn keyword_doc(kw: &str) -> Option<String> {
|
||||
let doc = match kw {
|
||||
"activate" => "Spreading-activation query: `activate TypeName where \"query\"`\nReturns `[TypeName]` from the Engram knowledge graph.",
|
||||
"sealed" => "Quantum-sealed block: marks sensitive code for runtime protection.",
|
||||
"let" => "Declare an immutable binding: `let name: Type = expr`",
|
||||
"fn" => "Define a function: `fn name(params) -> ReturnType { body }`",
|
||||
"type" => "Define a struct type: `type Name { field: Type }`",
|
||||
"enum" => "Define an enum: `enum Name { Variant1, Variant2(Type) }`",
|
||||
"match" => "Pattern match: `match expr { Pattern => result }`",
|
||||
"return" => "Return a value from a function.",
|
||||
"where" => "Used in `activate T where \"query\"`",
|
||||
"if" => "Conditional: `if cond { then } else { else }`",
|
||||
"else" => "Else branch of an if expression.",
|
||||
"for" => "For loop: `for item in collection { body }`",
|
||||
"while" => "While loop: `while cond { body }`",
|
||||
"in" => "Used in `for item in collection`",
|
||||
"true" | "false" => "Boolean literal",
|
||||
"protocol" => "Define a protocol (trait): `protocol Name { fn method(self) -> Ret; }`",
|
||||
"impl" => "Implement a protocol: `impl Protocol for Type { fn method(self) -> Ret { ... } }`",
|
||||
"import" => "Import from a module: `import { Name } from \"module\"`",
|
||||
"from" => "Used in import: `import { Name } from \"module\"`",
|
||||
"as" => "Alias in import: `import { Name as Alias } from \"module\"`",
|
||||
"with" => "With clause for retry/fallback: `with retry times 3`",
|
||||
"retry" => "Retry policy: `retry times N`",
|
||||
"times" => "Used in retry: `retry times N`",
|
||||
"fallback" => "Fallback value on failure: `fallback { default_expr }`",
|
||||
"reason" => "Reason clause: provides context to Engram reasoning engine.",
|
||||
"parallel" => "Parallel execution: `parallel { task1; task2 }`",
|
||||
"trace" => "Emit a trace event: `trace \"message\"`",
|
||||
"requires" => "Dependency declaration: `requires Module`",
|
||||
"deploy" => "Deploy declaration: `deploy service to target via method`",
|
||||
"to" => "Used in deploy: `deploy X to Y`",
|
||||
"via" => "Used in deploy: `deploy X via method`",
|
||||
"test" => "Test declaration: `test \"name\" { assertions }`",
|
||||
"seed" => "Seed data block: `seed { ... }`",
|
||||
"assert" => "Assertion: `assert condition, \"message\"`",
|
||||
"target" => "Target annotation: `target { ... }`",
|
||||
_ => return None,
|
||||
};
|
||||
Some(doc.to_string())
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
//! Diagnostic computation — wraps el-types TypeChecker output.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Diagnostic {
|
||||
/// Human-readable message.
|
||||
pub message: String,
|
||||
/// "error" | "warning" | "info"
|
||||
pub severity: String,
|
||||
/// 1-based line number (if known).
|
||||
pub line: Option<u32>,
|
||||
/// 1-based column (if known).
|
||||
pub col: Option<u32>,
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Self { message: message.into(), severity: "error".into(), line: None, col: None }
|
||||
}
|
||||
|
||||
pub fn warning(message: impl Into<String>) -> Self {
|
||||
Self { message: message.into(), severity: "warning".into(), line: None, col: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and type-check `source`, returning all diagnostics.
|
||||
pub fn check(source: &str) -> Vec<Diagnostic> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
let tokens = match el_lexer::tokenize(source) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
out.push(Diagnostic {
|
||||
message: format!("Lex error: {}", e.kind),
|
||||
severity: "error".into(),
|
||||
line: Some(e.span.line),
|
||||
col: Some(e.span.col),
|
||||
});
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
let program = match el_parser::parse(tokens, source.to_string()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
out.push(Diagnostic {
|
||||
message: format!("Parse error: {}", e.kind),
|
||||
severity: "error".into(),
|
||||
line: Some(e.span.line),
|
||||
col: Some(e.span.col),
|
||||
});
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
let mut checker = el_types::TypeChecker::with_builtins();
|
||||
checker.check(&program);
|
||||
|
||||
for d in &checker.diagnostics {
|
||||
if d.is_error {
|
||||
out.push(Diagnostic::error(&d.message));
|
||||
} else {
|
||||
out.push(Diagnostic::warning(&d.message));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
//! Hover information — identify the token under the cursor and return type docs.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use el_types::{TypeDef, TypeEnv};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HoverInfo {
|
||||
pub type_name: String,
|
||||
pub documentation: String,
|
||||
pub engram_node_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Return hover info for the token at `cursor_pos`.
|
||||
pub fn hover_at(env: &TypeEnv, source: &str, cursor_pos: usize) -> Option<HoverInfo> {
|
||||
let token = extract_token(source, cursor_pos);
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check built-in primitives
|
||||
let primitive_doc = match token.as_str() {
|
||||
"Int" => Some(("Int", "64-bit signed integer")),
|
||||
"Float" => Some(("Float", "64-bit IEEE 754 double")),
|
||||
"String" => Some(("String", "UTF-8 string")),
|
||||
"Bool" => Some(("Bool", "Boolean — true or false")),
|
||||
"Uuid" => Some(("Uuid", "RFC 4122 UUID")),
|
||||
"Void" => Some(("Void", "Unit type — no value")),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some((name, doc)) = primitive_doc {
|
||||
return Some(HoverInfo {
|
||||
type_name: name.to_string(),
|
||||
documentation: doc.to_string(),
|
||||
engram_node_type: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Check user-defined types
|
||||
if let Some(def) = env.types.get(&token) {
|
||||
let engram_node_type = env.engram_mappings.get(&token).cloned();
|
||||
let doc = format_typedef_doc(&token, def);
|
||||
return Some(HoverInfo {
|
||||
type_name: token.clone(),
|
||||
documentation: doc,
|
||||
engram_node_type,
|
||||
});
|
||||
}
|
||||
|
||||
// Check functions
|
||||
if let Some(fn_type) = env.functions.get(&token) {
|
||||
return Some(HoverInfo {
|
||||
type_name: token.clone(),
|
||||
documentation: format!("fn {token} :: {fn_type}"),
|
||||
engram_node_type: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Check keywords
|
||||
let kw_doc = match token.as_str() {
|
||||
"activate" => Some("activate TypeName where \"query\"\nReturns [TypeName] via spreading activation over the Engram knowledge graph."),
|
||||
"sealed" => Some("sealed { ... }\nMarks the block as sensitive — values are redacted from debuggers in debug builds."),
|
||||
"let" => Some("let name: Type = expr\nDeclare an immutable binding."),
|
||||
"fn" => Some("fn name(params) -> ReturnType { body }\nDefine a function."),
|
||||
"type" => Some("type Name { field: Type }\nDefine a struct type."),
|
||||
"enum" => Some("enum Name { Variant1, Variant2(Type) }\nDefine an enum."),
|
||||
"match" => Some("match expr { Pattern => result }\nPattern match an expression."),
|
||||
"return" => Some("return expr\nReturn a value from the enclosing function."),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(doc) = kw_doc {
|
||||
return Some(HoverInfo {
|
||||
type_name: token.clone(),
|
||||
documentation: doc.to_string(),
|
||||
engram_node_type: None,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn extract_token(source: &str, cursor_pos: usize) -> String {
|
||||
let len = source.len();
|
||||
if cursor_pos > len {
|
||||
return String::new();
|
||||
}
|
||||
let bytes = source.as_bytes();
|
||||
|
||||
// Expand left
|
||||
let mut start = cursor_pos;
|
||||
while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') {
|
||||
start -= 1;
|
||||
}
|
||||
// Expand right
|
||||
let mut end = cursor_pos;
|
||||
while end < len && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
|
||||
end += 1;
|
||||
}
|
||||
source[start..end].to_string()
|
||||
}
|
||||
|
||||
fn format_typedef_doc(name: &str, def: &TypeDef) -> String {
|
||||
match def {
|
||||
TypeDef::Struct { fields, .. } => {
|
||||
let fields_str = fields
|
||||
.iter()
|
||||
.map(|(f, t)| format!(" {f}: {t}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("type {name} {{\n{fields_str}\n}}")
|
||||
}
|
||||
TypeDef::Enum { variants, .. } => {
|
||||
let variants_str = variants
|
||||
.iter()
|
||||
.map(|v| {
|
||||
if let Some(payload) = &v.payload {
|
||||
format!(" {}({})", v.name, payload)
|
||||
} else {
|
||||
format!(" {}", v.name)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("enum {name} {{\n{variants_str}\n}}")
|
||||
}
|
||||
TypeDef::Primitive(t) => format!("primitive type {name} = {t}"),
|
||||
TypeDef::Protocol { methods, .. } => {
|
||||
let methods_str = methods
|
||||
.iter()
|
||||
.map(|m| format!(" fn {}()", m.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("protocol {name} {{\n{methods_str}\n}}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
//! el-lsp — Minimal Language Server for the Engram language.
|
||||
//!
|
||||
//! Provides completions, hover info, diagnostics, and a type graph.
|
||||
//! Driven by the el-types type checker and el-parser AST.
|
||||
|
||||
mod completion;
|
||||
mod diagnostic;
|
||||
mod hover;
|
||||
mod type_graph;
|
||||
|
||||
pub use completion::{Completion, CompletionKind};
|
||||
pub use diagnostic::Diagnostic;
|
||||
pub use hover::HoverInfo;
|
||||
pub use type_graph::{TypeEdge, TypeGraph, TypeNode};
|
||||
|
||||
use el_types::{TypeChecker, TypeEnv};
|
||||
|
||||
// ── LanguageServer ────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct LanguageServer;
|
||||
|
||||
impl LanguageServer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Compute completions at the given cursor byte position.
|
||||
pub fn complete(&self, source: &str, cursor_pos: usize) -> Vec<Completion> {
|
||||
let env = build_type_env(source);
|
||||
completion::completions_at(&env, source, cursor_pos)
|
||||
}
|
||||
|
||||
/// Return hover information for the token at the given byte position.
|
||||
pub fn hover(&self, source: &str, cursor_pos: usize) -> Option<HoverInfo> {
|
||||
let env = build_type_env(source);
|
||||
hover::hover_at(&env, source, cursor_pos)
|
||||
}
|
||||
|
||||
/// Run the type checker and return diagnostics.
|
||||
pub fn diagnostics(&self, source: &str) -> Vec<Diagnostic> {
|
||||
diagnostic::check(source)
|
||||
}
|
||||
|
||||
/// Build a type graph from the source.
|
||||
pub fn type_graph(&self, source: &str) -> TypeGraph {
|
||||
let env = build_type_env(source);
|
||||
type_graph::build(&env)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LanguageServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse source and run the type checker, returning the final TypeEnv.
|
||||
fn build_type_env(source: &str) -> TypeEnv {
|
||||
let tokens = match el_lexer::tokenize(source) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return TypeEnv::with_builtins(),
|
||||
};
|
||||
let program = match el_parser::parse(tokens, source.to_string()) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return TypeEnv::with_builtins(),
|
||||
};
|
||||
let mut checker = TypeChecker::with_builtins();
|
||||
checker.check(&program);
|
||||
checker.env
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"
|
||||
type Point {
|
||||
x: Float
|
||||
y: Float
|
||||
}
|
||||
|
||||
type Circle {
|
||||
center: Point
|
||||
radius: Float
|
||||
}
|
||||
|
||||
enum Color {
|
||||
Red
|
||||
Green
|
||||
Blue
|
||||
Custom(String)
|
||||
}
|
||||
|
||||
fn distance(a: Point, b: Point) -> Float {
|
||||
let dx: Float = a.x - b.x
|
||||
let dy: Float = a.y - b.y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
fn main() -> Void {
|
||||
let greeting: String = "Hello from Engram"
|
||||
let count: Int = 42
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn test_diagnostics_clean_source() {
|
||||
let lsp = LanguageServer::new();
|
||||
let diags = lsp.diagnostics(SAMPLE);
|
||||
// Should have no errors for valid source
|
||||
let errors: Vec<_> = diags.iter().filter(|d| d.severity == "error").collect();
|
||||
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagnostics_invalid_source() {
|
||||
let lsp = LanguageServer::new();
|
||||
let diags = lsp.diagnostics("let x: UnknownType = 42");
|
||||
// Should surface at least one diagnostic for unknown type
|
||||
assert!(!diags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_graph_has_nodes() {
|
||||
let lsp = LanguageServer::new();
|
||||
let graph = lsp.type_graph(SAMPLE);
|
||||
// Should have at least the built-in types plus Point, Circle, Color
|
||||
assert!(!graph.nodes.is_empty());
|
||||
let names: Vec<_> = graph.nodes.iter().map(|n| n.name.as_str()).collect();
|
||||
assert!(names.contains(&"Point"), "expected Point in type graph");
|
||||
assert!(names.contains(&"Circle"), "expected Circle in type graph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_graph_has_edges() {
|
||||
let lsp = LanguageServer::new();
|
||||
let graph = lsp.type_graph(SAMPLE);
|
||||
// Circle has a field `center: Point` → should produce an edge
|
||||
let has_circle_edge = graph
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.from == "Circle" && e.to == "Point");
|
||||
assert!(has_circle_edge, "expected Circle->Point edge; edges: {:?}", graph.edges);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completions_return_keywords() {
|
||||
let lsp = LanguageServer::new();
|
||||
let completions = lsp.complete("", 0);
|
||||
let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect();
|
||||
assert!(labels.contains(&"let"), "expected 'let' keyword completion");
|
||||
assert!(labels.contains(&"fn"), "expected 'fn' keyword completion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completions_include_types() {
|
||||
let lsp = LanguageServer::new();
|
||||
let completions = lsp.complete(SAMPLE, 0);
|
||||
let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect();
|
||||
// User-defined types should appear
|
||||
assert!(labels.contains(&"Point"), "expected Point in completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hover_on_builtin_type() {
|
||||
let lsp = LanguageServer::new();
|
||||
// Position at the "Float" token in the sample
|
||||
let source = "let x: Float = 3.14";
|
||||
// Find the byte offset of "Float"
|
||||
let pos = source.find("Float").unwrap();
|
||||
let info = lsp.hover(source, pos);
|
||||
assert!(info.is_some(), "expected hover info for Float");
|
||||
let info = info.unwrap();
|
||||
assert_eq!(info.type_name, "Float");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_completions_sorted_by_score() {
|
||||
let lsp = LanguageServer::new();
|
||||
let completions = lsp.complete(SAMPLE, 0);
|
||||
// Completions should be sorted descending by score
|
||||
for window in completions.windows(2) {
|
||||
assert!(
|
||||
window[0].score >= window[1].score,
|
||||
"completions not sorted by score: {:?} before {:?}",
|
||||
window[0],
|
||||
window[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
//! Type graph construction — nodes are types, edges are field/return/param relationships.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use el_types::{Type, TypeDef, TypeEnv};
|
||||
|
||||
// ── Data types ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TypeGraph {
|
||||
pub nodes: Vec<TypeNode>,
|
||||
pub edges: Vec<TypeEdge>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TypeNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// "builtin" | "struct" | "enum" | "primitive"
|
||||
pub kind: String,
|
||||
pub fields: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TypeEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// "field" | "returns" | "param" | "extends"
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
// ── Builder ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const BUILTINS: &[&str] = &["Int", "Float", "String", "Bool", "Uuid", "Void"];
|
||||
|
||||
pub fn build(env: &TypeEnv) -> TypeGraph {
|
||||
let mut nodes: Vec<TypeNode> = Vec::new();
|
||||
let mut edges: Vec<TypeEdge> = Vec::new();
|
||||
|
||||
// Add all built-in types as nodes
|
||||
for &name in BUILTINS {
|
||||
nodes.push(TypeNode {
|
||||
id: name.to_string(),
|
||||
name: name.to_string(),
|
||||
kind: "builtin".into(),
|
||||
fields: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Add user-defined types
|
||||
for (type_name, def) in &env.types {
|
||||
// Skip built-ins — already added
|
||||
if BUILTINS.contains(&type_name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
match def {
|
||||
TypeDef::Struct { fields, .. } => {
|
||||
let field_strs: Vec<String> = fields
|
||||
.iter()
|
||||
.map(|(f, t)| format!("{f}: {t}"))
|
||||
.collect();
|
||||
|
||||
nodes.push(TypeNode {
|
||||
id: type_name.clone(),
|
||||
name: type_name.clone(),
|
||||
kind: "struct".into(),
|
||||
fields: field_strs,
|
||||
});
|
||||
|
||||
// Add field edges
|
||||
for (field_name, field_type) in fields {
|
||||
if let Some(target) = named_type(field_type) {
|
||||
edges.push(TypeEdge {
|
||||
from: type_name.clone(),
|
||||
to: target,
|
||||
label: format!("field:{field_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
TypeDef::Enum { variants, .. } => {
|
||||
let field_strs: Vec<String> = variants
|
||||
.iter()
|
||||
.map(|v| {
|
||||
if let Some(payload) = &v.payload {
|
||||
format!("{}({})", v.name, payload)
|
||||
} else {
|
||||
v.name.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
nodes.push(TypeNode {
|
||||
id: type_name.clone(),
|
||||
name: type_name.clone(),
|
||||
kind: "enum".into(),
|
||||
fields: field_strs,
|
||||
});
|
||||
|
||||
// Add variant payload edges
|
||||
for v in variants {
|
||||
if let Some(payload) = &v.payload {
|
||||
if let Some(target) = named_type(payload) {
|
||||
edges.push(TypeEdge {
|
||||
from: type_name.clone(),
|
||||
to: target,
|
||||
label: format!("variant:{}", v.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TypeDef::Primitive(_) => {
|
||||
// Register as primitive node (e.g. user-registered aliases)
|
||||
nodes.push(TypeNode {
|
||||
id: type_name.clone(),
|
||||
name: type_name.clone(),
|
||||
kind: "primitive".into(),
|
||||
fields: vec![],
|
||||
});
|
||||
}
|
||||
TypeDef::Protocol { methods, .. } => {
|
||||
let method_strs: Vec<String> = methods
|
||||
.iter()
|
||||
.map(|m| m.name.clone())
|
||||
.collect();
|
||||
nodes.push(TypeNode {
|
||||
id: type_name.clone(),
|
||||
name: type_name.clone(),
|
||||
kind: "protocol".into(),
|
||||
fields: method_strs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add function signature edges
|
||||
for (fn_name, fn_type) in &env.functions {
|
||||
if let Type::Fn { params, return_type } = fn_type {
|
||||
// Return edge: fn -> return type
|
||||
if let Some(ret) = named_type(return_type) {
|
||||
edges.push(TypeEdge {
|
||||
from: fn_name.clone(),
|
||||
to: ret,
|
||||
label: "returns".into(),
|
||||
});
|
||||
}
|
||||
// Param edges: fn -> param types
|
||||
for param_type in params {
|
||||
if let Some(param) = named_type(param_type) {
|
||||
edges.push(TypeEdge {
|
||||
from: fn_name.clone(),
|
||||
to: param,
|
||||
label: "param".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TypeGraph { nodes, edges }
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the name from a Named type, or the inner type for Array/Optional.
|
||||
fn named_type(ty: &Type) -> Option<String> {
|
||||
match ty {
|
||||
Type::Named(n) => Some(n.clone()),
|
||||
Type::Array(inner) => named_type(inner),
|
||||
Type::Optional(inner) => named_type(inner),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "el-plugin-host"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -1,253 +0,0 @@
|
||||
//! el-plugin-host — Plugin lifecycle management for el-ide.
|
||||
//!
|
||||
//! Manages installation, removal, and querying of IDE plugins.
|
||||
//! First-party plugins are pre-registered; a plugin registry server
|
||||
//! is required for third-party installation (not yet live).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginHook {
|
||||
OnSave,
|
||||
OnBuild,
|
||||
OnBuildComplete,
|
||||
CustomPanel,
|
||||
CustomTool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Plugin {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub installed: bool,
|
||||
pub enabled: bool,
|
||||
pub hooks: Vec<PluginHook>,
|
||||
/// Whether this is a first-party Neuron Technologies plugin.
|
||||
pub first_party: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PluginError {
|
||||
#[error("plugin '{0}' not found")]
|
||||
NotFound(String),
|
||||
#[error("plugin '{0}' is already installed")]
|
||||
AlreadyInstalled(String),
|
||||
#[error("plugin registry is not available (registry URL not configured)")]
|
||||
RegistryUnavailable,
|
||||
#[error("plugin '{0}' cannot be removed: it is a required built-in")]
|
||||
BuiltIn(String),
|
||||
}
|
||||
|
||||
// ── PluginHost ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct PluginHost {
|
||||
plugins: HashMap<String, Plugin>,
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Create a PluginHost pre-populated with first-party plugins.
|
||||
pub fn new() -> Self {
|
||||
let mut host = Self { plugins: HashMap::new() };
|
||||
host.register_first_party_plugins();
|
||||
host
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn list(&self) -> Vec<Plugin> {
|
||||
let mut plugins: Vec<Plugin> = self.plugins.values().cloned().collect();
|
||||
plugins.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
plugins
|
||||
}
|
||||
|
||||
/// Install a plugin by name.
|
||||
///
|
||||
/// For first-party plugins, this marks them as installed.
|
||||
/// For third-party, registry access is required (not yet implemented).
|
||||
pub fn install(&mut self, name: &str) -> Result<Plugin, PluginError> {
|
||||
if let Some(plugin) = self.plugins.get_mut(name) {
|
||||
if plugin.installed {
|
||||
return Err(PluginError::AlreadyInstalled(name.to_string()));
|
||||
}
|
||||
plugin.installed = true;
|
||||
plugin.enabled = true;
|
||||
Ok(plugin.clone())
|
||||
} else {
|
||||
Err(PluginError::RegistryUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a plugin.
|
||||
pub fn remove(&mut self, name: &str) -> Result<(), PluginError> {
|
||||
match self.plugins.get_mut(name) {
|
||||
None => Err(PluginError::NotFound(name.to_string())),
|
||||
Some(p) => {
|
||||
if p.name == "el-theme-dark" {
|
||||
return Err(PluginError::BuiltIn(name.to_string()));
|
||||
}
|
||||
p.installed = false;
|
||||
p.enabled = false;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enable(&mut self, name: &str) -> Result<(), PluginError> {
|
||||
self.plugins
|
||||
.get_mut(name)
|
||||
.ok_or_else(|| PluginError::NotFound(name.to_string()))
|
||||
.map(|p| { p.enabled = true; })
|
||||
}
|
||||
|
||||
pub fn disable(&mut self, name: &str) -> Result<(), PluginError> {
|
||||
self.plugins
|
||||
.get_mut(name)
|
||||
.ok_or_else(|| PluginError::NotFound(name.to_string()))
|
||||
.map(|p| { p.enabled = false; })
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&Plugin> {
|
||||
self.plugins.get(name)
|
||||
}
|
||||
|
||||
// ── First-party plugins ───────────────────────────────────────────────────
|
||||
|
||||
fn register_first_party_plugins(&mut self) {
|
||||
let plugins = vec![
|
||||
Plugin {
|
||||
name: "el-theme-dark".into(),
|
||||
version: "1.0.0".into(),
|
||||
description: "Dark theme — the default Engram IDE theme.".into(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
hooks: vec![],
|
||||
first_party: true,
|
||||
},
|
||||
Plugin {
|
||||
name: "el-theme-light".into(),
|
||||
version: "1.0.0".into(),
|
||||
description: "Light theme for high-ambient-light environments.".into(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
hooks: vec![],
|
||||
first_party: true,
|
||||
},
|
||||
Plugin {
|
||||
name: "el-fmt".into(),
|
||||
version: "0.1.0".into(),
|
||||
description: "Code formatter — auto-formats .el files on save.".into(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
hooks: vec![PluginHook::OnSave],
|
||||
first_party: true,
|
||||
},
|
||||
Plugin {
|
||||
name: "el-doc".into(),
|
||||
version: "0.1.0".into(),
|
||||
description: "Documentation generator — produces HTML docs from type definitions.".into(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
hooks: vec![PluginHook::CustomTool],
|
||||
first_party: true,
|
||||
},
|
||||
Plugin {
|
||||
name: "el-test".into(),
|
||||
version: "0.1.0".into(),
|
||||
description: "Test runner with inline results displayed in the editor gutter.".into(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
hooks: vec![PluginHook::OnBuildComplete, PluginHook::CustomPanel],
|
||||
first_party: true,
|
||||
},
|
||||
];
|
||||
|
||||
for plugin in plugins {
|
||||
self.plugins.insert(plugin.name.clone(), plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginHost {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_list_returns_all_first_party() {
|
||||
let host = PluginHost::new();
|
||||
let plugins = host.list();
|
||||
assert_eq!(plugins.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dark_theme_installed_by_default() {
|
||||
let host = PluginHost::new();
|
||||
let plugin = host.get("el-theme-dark").unwrap();
|
||||
assert!(plugin.installed);
|
||||
assert!(plugin.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_uninstalled_plugin() {
|
||||
let mut host = PluginHost::new();
|
||||
let result = host.install("el-doc");
|
||||
assert!(result.is_ok());
|
||||
let plugin = host.get("el-doc").unwrap();
|
||||
assert!(plugin.installed);
|
||||
assert!(plugin.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_install_already_installed_errors() {
|
||||
let mut host = PluginHost::new();
|
||||
let result = host.install("el-theme-dark");
|
||||
assert!(matches!(result, Err(PluginError::AlreadyInstalled(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_plugin() {
|
||||
let mut host = PluginHost::new();
|
||||
host.install("el-fmt").ok(); // already installed
|
||||
let result = host.remove("el-fmt");
|
||||
assert!(result.is_ok());
|
||||
let plugin = host.get("el-fmt").unwrap();
|
||||
assert!(!plugin.installed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_builtin_errors() {
|
||||
let mut host = PluginHost::new();
|
||||
let result = host.remove("el-theme-dark");
|
||||
assert!(matches!(result, Err(PluginError::BuiltIn(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_nonexistent_errors() {
|
||||
let mut host = PluginHost::new();
|
||||
let result = host.remove("el-nonexistent");
|
||||
assert!(matches!(result, Err(PluginError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enable_disable() {
|
||||
let mut host = PluginHost::new();
|
||||
host.disable("el-theme-dark").unwrap();
|
||||
assert!(!host.get("el-theme-dark").unwrap().enabled);
|
||||
host.enable("el-theme-dark").unwrap();
|
||||
assert!(host.get("el-theme-dark").unwrap().enabled);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user