Archived
feat: el-ide — native IDE for engram-lang, LSP, type graph, plugin ecosystem
Axum HTTP server (port 7771) serving a single-page IDE with CodeMirror 6 syntax highlighting for engram-lang, a force-directed type graph visualizer, LSP (completions, hover, diagnostics), SSE-streamed build/run output, a plugin host with five first-party plugins, and a reasoning panel that proxies to engram-server. 28 tests across three crates, zero warnings.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
//! Build and run API — streams output via SSE.
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
Json,
|
||||
response::{
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
IntoResponse,
|
||||
},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
use crate::{sse::classify_line, AppState};
|
||||
|
||||
// ── Request types ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BuildRequest {
|
||||
/// Target: "debug" | "release" | "prod"
|
||||
#[serde(default = "default_target")]
|
||||
pub target: String,
|
||||
/// Optional specific file to build; otherwise builds whole project.
|
||||
pub file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RunRequest {
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
fn default_target() -> String {
|
||||
"debug".into()
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /api/build — streams build output as SSE.
|
||||
pub async fn build_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<BuildRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let project_path = state.config.project_path.clone();
|
||||
let stream = build_stream(project_path, req);
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// POST /api/run — compiles and runs a file, streams output as SSE.
|
||||
pub async fn run_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RunRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let project_path = state.config.project_path.clone();
|
||||
let stream = run_stream(project_path, req);
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
// ── Stream builders ───────────────────────────────────────────────────────────
|
||||
|
||||
fn build_stream(
|
||||
project_path: String,
|
||||
req: BuildRequest,
|
||||
) -> impl tokio_stream::Stream<Item = Result<Event, Infallible>> {
|
||||
async_stream::stream! {
|
||||
let el_bin = el_binary();
|
||||
let mut cmd = tokio::process::Command::new(&el_bin);
|
||||
|
||||
if let Some(ref file) = req.file {
|
||||
let file_path = format!("{project_path}/{file}");
|
||||
cmd.arg("build").arg(&file_path).arg("--target").arg(&req.target);
|
||||
} else {
|
||||
let main = format!("{project_path}/src/main.el");
|
||||
cmd.arg("build").arg(&main).arg("--target").arg(&req.target);
|
||||
}
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.current_dir(&project_path);
|
||||
|
||||
yield Ok(Event::default().event("info").data(format!("Running: el build --target {}", req.target)));
|
||||
|
||||
match cmd.spawn() {
|
||||
Err(e) => {
|
||||
yield Ok(Event::default().event("error").data(format!("Failed to start el: {e}")));
|
||||
yield Ok(Event::default().event("done").data("1"));
|
||||
}
|
||||
Ok(mut child) => {
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let mut reader = tokio::io::BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
yield Ok(classify_line(&line));
|
||||
}
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let mut reader = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
yield Ok(classify_line(&line));
|
||||
}
|
||||
}
|
||||
let code = child.wait().await.map(|s| s.code().unwrap_or(0)).unwrap_or(1);
|
||||
yield Ok(Event::default().event("done").data(code.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_stream(
|
||||
project_path: String,
|
||||
req: RunRequest,
|
||||
) -> impl tokio_stream::Stream<Item = Result<Event, Infallible>> {
|
||||
async_stream::stream! {
|
||||
let el_bin = el_binary();
|
||||
let file_path = format!("{project_path}/{}", req.file);
|
||||
|
||||
yield Ok(Event::default().event("info").data(format!("Running: el run {}", req.file)));
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&el_bin);
|
||||
cmd.arg("run")
|
||||
.arg(&file_path)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.current_dir(&project_path);
|
||||
|
||||
match cmd.spawn() {
|
||||
Err(e) => {
|
||||
yield Ok(Event::default().event("error").data(format!("Failed to start el: {e}")));
|
||||
yield Ok(Event::default().event("done").data("1"));
|
||||
}
|
||||
Ok(mut child) => {
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let mut reader = tokio::io::BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
yield Ok(Event::default().event("output").data(line));
|
||||
}
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let mut reader = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
yield Ok(classify_line(&line));
|
||||
}
|
||||
}
|
||||
let code = child.wait().await.map(|s| s.code().unwrap_or(0)).unwrap_or(1);
|
||||
yield Ok(Event::default().event("done").data(code.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn el_binary() -> String {
|
||||
if let Ok(path) = which::which("el") {
|
||||
return path.to_string_lossy().to_string();
|
||||
}
|
||||
"el".to_string()
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! File system API — list, read, and write files within the project root.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PathQuery {
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
pub children: Option<Vec<FileEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileContent {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WriteRequest {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WriteResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||
|
||||
fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_json::Value>) {
|
||||
(code, Json(serde_json::json!({ "error": msg.into() })))
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/files?path={dir}
|
||||
///
|
||||
/// Returns a recursive directory listing for the given path
|
||||
/// (relative to EL_IDE_PROJECT_PATH).
|
||||
pub async fn list_files(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<PathQuery>,
|
||||
) -> ApiResult<Vec<FileEntry>> {
|
||||
let root = PathBuf::from(&state.config.project_path);
|
||||
let rel = q.path.unwrap_or_else(|| ".".into());
|
||||
let target = root.join(&rel);
|
||||
let target = target
|
||||
.canonicalize()
|
||||
.map_err(|e| api_err(StatusCode::BAD_REQUEST, format!("invalid path: {e}")))?;
|
||||
|
||||
// Security: ensure the resolved path is within the project root
|
||||
let root_canonical = root
|
||||
.canonicalize()
|
||||
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
if !target.starts_with(&root_canonical) {
|
||||
return Err(api_err(StatusCode::FORBIDDEN, "path escapes project root"));
|
||||
}
|
||||
|
||||
let entries = read_dir_recursive(&target, &root_canonical, 0)
|
||||
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
Ok(Json(entries))
|
||||
}
|
||||
|
||||
fn read_dir_recursive(
|
||||
dir: &Path,
|
||||
root: &Path,
|
||||
depth: usize,
|
||||
) -> Result<Vec<FileEntry>, String> {
|
||||
if depth > 8 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let rd = std::fs::read_dir(dir).map_err(|e| e.to_string())?;
|
||||
let mut items: Vec<_> = rd.filter_map(|e| e.ok()).collect();
|
||||
items.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in items {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
// Skip hidden files/dirs
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let rel_path = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().to_string();
|
||||
let is_dir = path.is_dir();
|
||||
|
||||
let children = if is_dir {
|
||||
Some(read_dir_recursive(&path, root, depth + 1).unwrap_or_default())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
entries.push(FileEntry { name, path: rel_path, is_dir, children });
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// GET /api/file?path={file}
|
||||
pub async fn read_file(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<PathQuery>,
|
||||
) -> ApiResult<FileContent> {
|
||||
let rel = q.path.ok_or_else(|| api_err(StatusCode::BAD_REQUEST, "missing path parameter"))?;
|
||||
let file_path = safe_path(&state.config.project_path, &rel)
|
||||
.map_err(|e| api_err(StatusCode::FORBIDDEN, e))?;
|
||||
|
||||
let content = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| api_err(StatusCode::NOT_FOUND, format!("cannot read file: {e}")))?;
|
||||
|
||||
Ok(Json(FileContent { path: rel, content }))
|
||||
}
|
||||
|
||||
/// POST /api/file — body: { path, content }
|
||||
pub async fn write_file(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<WriteRequest>,
|
||||
) -> ApiResult<WriteResponse> {
|
||||
let file_path = safe_path(&state.config.project_path, &req.path)
|
||||
.map_err(|e| api_err(StatusCode::FORBIDDEN, e))?;
|
||||
|
||||
// Create parent dirs if needed
|
||||
if let Some(parent) = file_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
std::fs::write(&file_path, &req.content)
|
||||
.map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot write: {e}")))?;
|
||||
|
||||
Ok(Json(WriteResponse { ok: true }))
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn safe_path(project_root: &str, rel: &str) -> Result<PathBuf, String> {
|
||||
let root = PathBuf::from(project_root)
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("invalid project root: {e}"))?;
|
||||
// Prevent path traversal
|
||||
let joined = root.join(rel);
|
||||
// We can't canonicalize non-existent files, so check manually
|
||||
let normalized = normalize_path(&joined);
|
||||
if !normalized.starts_with(&root) {
|
||||
return Err(format!("path '{rel}' escapes project root"));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Normalize a path without requiring the file to exist.
|
||||
fn normalize_path(path: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for component in path.components() {
|
||||
use std::path::Component;
|
||||
match component {
|
||||
Component::ParentDir => { out.pop(); }
|
||||
Component::CurDir => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! LSP API endpoints — completions, hover, errors.
|
||||
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use el_lsp::{Completion, Diagnostic, HoverInfo, LanguageServer};
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||
|
||||
// ── Query types ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SourceQuery {
|
||||
pub source: String,
|
||||
pub pos: Option<usize>,
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /api/lsp/complete?source=...&pos=...
|
||||
pub async fn complete(
|
||||
Query(q): Query<SourceQuery>,
|
||||
) -> ApiResult<Vec<Completion>> {
|
||||
let lsp = LanguageServer::new();
|
||||
let pos = q.pos.unwrap_or(0);
|
||||
let completions = lsp.complete(&q.source, pos);
|
||||
Ok(Json(completions))
|
||||
}
|
||||
|
||||
/// GET /api/lsp/hover?source=...&pos=...
|
||||
pub async fn hover(
|
||||
Query(q): Query<SourceQuery>,
|
||||
) -> ApiResult<Option<HoverInfo>> {
|
||||
let lsp = LanguageServer::new();
|
||||
let pos = q.pos.unwrap_or(0);
|
||||
let info = lsp.hover(&q.source, pos);
|
||||
Ok(Json(info))
|
||||
}
|
||||
|
||||
/// GET /api/lsp/errors?source=...
|
||||
pub async fn errors(
|
||||
Query(q): Query<SourceQuery>,
|
||||
) -> ApiResult<Vec<Diagnostic>> {
|
||||
let lsp = LanguageServer::new();
|
||||
let diags = lsp.diagnostics(&q.source);
|
||||
Ok(Json(diags))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod build;
|
||||
pub mod files;
|
||||
pub mod lsp;
|
||||
pub mod plugins;
|
||||
pub mod reason;
|
||||
pub mod type_graph;
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Plugins API — list, install, remove.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use el_plugin_host::Plugin;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||
|
||||
fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_json::Value>) {
|
||||
(code, Json(serde_json::json!({ "error": msg.into() })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct InstallRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OkResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// GET /api/plugins
|
||||
pub async fn list_plugins(State(state): State<AppState>) -> ApiResult<Vec<Plugin>> {
|
||||
let host = state.plugins.lock().await;
|
||||
Ok(Json(host.list()))
|
||||
}
|
||||
|
||||
/// POST /api/plugins/install — body: { name }
|
||||
pub async fn install_plugin(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<InstallRequest>,
|
||||
) -> ApiResult<Plugin> {
|
||||
let mut host = state.plugins.lock().await;
|
||||
host.install(&req.name)
|
||||
.map(Json)
|
||||
.map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string()))
|
||||
}
|
||||
|
||||
/// DELETE /api/plugins/{name}
|
||||
pub async fn remove_plugin(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> ApiResult<OkResponse> {
|
||||
let mut host = state.plugins.lock().await;
|
||||
host.remove(&name)
|
||||
.map(|_| Json(OkResponse { ok: true }))
|
||||
.map_err(|e| api_err(StatusCode::BAD_REQUEST, e.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Reasoning API — proxy to engram-server for hypothesis evaluation.
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||
|
||||
fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_json::Value>) {
|
||||
(code, Json(serde_json::json!({ "error": msg.into() })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReasonRequest {
|
||||
pub hypothesis: String,
|
||||
pub context: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReasonResponse {
|
||||
pub verdict: String,
|
||||
pub confidence: f64,
|
||||
pub evidence: Vec<EvidenceItem>,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EvidenceItem {
|
||||
pub text: String,
|
||||
pub weight: f64,
|
||||
}
|
||||
|
||||
/// POST /api/reason — proxy to engram-server reasoning endpoint.
|
||||
pub async fn reason(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ReasonRequest>,
|
||||
) -> ApiResult<ReasonResponse> {
|
||||
let engram_url = &state.config.engram_url;
|
||||
let url = format!("{engram_url}/api/reason");
|
||||
|
||||
// Attempt to proxy to engram-server; fall back to stub if unavailable.
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"hypothesis": req.hypothesis,
|
||||
"context": req.context,
|
||||
});
|
||||
|
||||
match client.post(&url).json(&body).timeout(std::time::Duration::from_secs(10)).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let json: serde_json::Value = resp.json().await
|
||||
.map_err(|e| api_err(StatusCode::BAD_GATEWAY, format!("invalid response: {e}")))?;
|
||||
Ok(Json(ReasonResponse {
|
||||
verdict: json["verdict"].as_str().unwrap_or("unknown").to_string(),
|
||||
confidence: json["confidence"].as_f64().unwrap_or(0.0),
|
||||
evidence: json["evidence"].as_array().map(|arr| {
|
||||
arr.iter().map(|e| EvidenceItem {
|
||||
text: e["text"].as_str().unwrap_or("").to_string(),
|
||||
weight: e["weight"].as_f64().unwrap_or(0.0),
|
||||
}).collect()
|
||||
}).unwrap_or_default(),
|
||||
source: "engram-server".into(),
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
// Stub response when engram-server is unavailable
|
||||
Ok(Json(ReasonResponse {
|
||||
verdict: "unresolved".into(),
|
||||
confidence: 0.0,
|
||||
evidence: vec![EvidenceItem {
|
||||
text: "engram-server is not reachable; connect an Engram instance for live reasoning.".into(),
|
||||
weight: 0.0,
|
||||
}],
|
||||
source: "stub".into(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Type graph API — returns nodes and edges for the current project.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use el_lsp::{LanguageServer, TypeGraph};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TypeGraphQuery {
|
||||
/// Optional source to use directly (e.g. current editor content).
|
||||
pub source: Option<String>,
|
||||
/// Or load from a file path within the project.
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/type-graph?source=... or ?path=...
|
||||
///
|
||||
/// If neither is provided, reads src/main.el from the project root.
|
||||
pub async fn type_graph(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<TypeGraphQuery>,
|
||||
) -> ApiResult<TypeGraph> {
|
||||
let source = if let Some(src) = q.source {
|
||||
src
|
||||
} else {
|
||||
let rel = q.path.unwrap_or_else(|| "src/main.el".into());
|
||||
let file_path = std::path::PathBuf::from(&state.config.project_path).join(&rel);
|
||||
std::fs::read_to_string(&file_path).unwrap_or_default()
|
||||
};
|
||||
|
||||
let lsp = LanguageServer::new();
|
||||
let graph = lsp.type_graph(&source);
|
||||
Ok(Json(graph))
|
||||
}
|
||||
Reference in New Issue
Block a user