Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions implants/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ members = [
"lib/eldritch/stdlib/eldritch-libsys",
"lib/eldritch/stdlib/eldritch-libtime",
"lib/eldritch/eldritch",
"lib/eldritch/eldritch-lsp",
"lib/portals/portal-stream",
]
exclude = [
Expand Down
15 changes: 15 additions & 0 deletions implants/lib/eldritch/eldritch-lsp/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "eldritch-lsp"
version = "0.1.0"
edition = "2021"

[dependencies]
eldritch-core = { path = "../eldritch-core" }
tower-lsp = "0.20.0"
tokio = { version = "1.36", features = ["rt-multi-thread", "macros", "io-std", "fs"] }
walkdir = "2.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
log = "0.4"
env_logger = "0.11"
108 changes: 108 additions & 0 deletions implants/lib/eldritch/eldritch-lsp/src/linter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use eldritch_core::Stmt;
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};

/// A trait defining a single linting rule.
///
/// Rules follow a two-phase check for performance:
/// 1. `should_lint`: A lightweight string check.
/// 2. `check`: A deeper AST or full-text analysis.
pub trait LintRule: Send + Sync {
/// Returns the unique name of the rule (e.g., "no-forbidden-runes").
fn name(&self) -> &'static str;

/// Quickly determines if this rule is relevant for the given source code.
/// This optimization prevents expensive AST traversals for rules that definitely won't match.
fn should_lint(&self, _source: &str) -> bool {
// Default to true for safety, but implementations should override this
// with fast checks (e.g., source.contains("forbidden"))
true
}

/// Runs the linting logic.
///
/// * `ast`: The parsed AST, if available. Some lints might work purely on text.
/// * `source`: The raw source code.
fn check(&self, ast: Option<&[Stmt]>, source: &str) -> Vec<Diagnostic>;
}

/// Registry to hold and manage all active lint rules.
pub struct LintRegistry {
rules: Vec<Box<dyn LintRule>>,
}

impl LintRegistry {
pub fn new() -> Self {
Self { rules: Vec::new() }
}

pub fn register<R: LintRule + 'static>(&mut self, rule: R) {
self.rules.push(Box::new(rule));
}

/// Runs all registered rules against the source/AST.
pub fn run(&self, ast: Option<&[Stmt]>, source: &str) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();

for rule in &self.rules {
if rule.should_lint(source) {
diagnostics.extend(rule.check(ast, source));
}
}

diagnostics
}
}

impl Default for LintRegistry {
fn default() -> Self {
let mut registry = Self::new();
registry.register(NoForbiddenRunes);
registry
}
}

// --- Example Rule: NoForbiddenRunes ---

struct NoForbiddenRunes;

impl LintRule for NoForbiddenRunes {
fn name(&self) -> &'static str {
"no-forbidden-runes"
}

fn should_lint(&self, source: &str) -> bool {
source.contains("vecna")
}

fn check(&self, _ast: Option<&[Stmt]>, source: &str) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();

for (line_idx, line) in source.lines().enumerate() {
if let Some(col_idx) = line.find("vecna") {
let range = Range {
start: Position {
line: line_idx as u32,
character: col_idx as u32,
},
end: Position {
line: line_idx as u32,
character: (col_idx + 5) as u32, // "vecna".len()
},
};

diagnostics.push(Diagnostic {
range,
severity: Some(DiagnosticSeverity::WARNING),
code: Some(tower_lsp::lsp_types::NumberOrString::String(
self.name().to_string(),
)),
source: Some("eldritch-lint".to_string()),
message: "The use of 'vecna' is forbidden. It summons unwanted attention.".to_string(),
..Default::default()
});
}
}

diagnostics
}
}
188 changes: 188 additions & 0 deletions implants/lib/eldritch/eldritch-lsp/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
use tower_lsp::jsonrpc::Result;
use tower_lsp::lsp_types::*;
use tower_lsp::{Client, LanguageServer, LspService, Server};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;

mod linter;
mod stdlib;

use linter::LintRegistry;
use stdlib::StdlibIndex;
use eldritch_core::{Parser, Lexer};

struct Backend {
client: Client,
/// Registry of lint rules.
linter: LintRegistry,
/// Index of the standard library.
stdlib: Arc<RwLock<StdlibIndex>>,
/// In-memory cache of document contents.
documents: Arc<RwLock<HashMap<Url, String>>>,
}

impl Backend {
async fn validate_document(&self, uri: Url, text: &str, version: Option<i32>) {
let mut diagnostics = Vec::new();

// 1. Parsing Phase (using eldritch-core)
let mut lexer = Lexer::new(text.to_string());
let tokens = lexer.scan_tokens();

let mut parser = Parser::new(tokens);
let (ast_stmts, parse_errors) = parser.parse();

// 2. Syntax Error Diagnostics
for err in parse_errors {
// Convert Span to LSP Range
// eldritch_core::token::Span has {start, end, line}.
// This is slightly tricky because Span in `token.rs` only has `line` and byte indices.
// LSP needs line/character.
// Simplified mapping: we trust the `line` from Span, but we need to calculate `character`.
// Ideally, we'd use a line indexer. For "Best Effort", we can estimate or just highlight the line.

// Note: `line` in Span is 1-based. LSP is 0-based.
let line_idx = err.span.line.saturating_sub(1) as u32;

let range = Range {
start: Position { line: line_idx, character: 0 },
end: Position { line: line_idx, character: u32::MAX }, // Highlight full line if column unknown
};

diagnostics.push(Diagnostic {
range,
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("syntax-error".to_string())),
source: Some("eldritch-parser".to_string()),
message: err.message,
..Default::default()
});
}

// 3. Linting Phase (Best Effort)
// Pass the AST if parsing succeeded partially (ast_stmts might contain valid stmts even with errors).
let lint_diags = self.linter.run(Some(&ast_stmts), text);
diagnostics.extend(lint_diags);

// 4. Publish Diagnostics
self.client.publish_diagnostics(uri, diagnostics, version).await;
}
}

#[tower_lsp::async_trait]
impl LanguageServer for Backend {
async fn initialize(&self, params: InitializeParams) -> Result<InitializeResult> {
let root_uri = params.root_uri.or_else(|| {
params.root_path.map(|path| Url::from_file_path(path).unwrap())
});

if let Some(uri) = root_uri {
if let Ok(path) = uri.to_file_path() {
// Heuristic: stdlib is at implants/lib/eldritch/stdlib
// We assume the workspace root is the repo root.
// Or we look for the stdlib relative to workspace.
let stdlib_path = path.join("implants/lib/eldritch/stdlib");

let stdlib_ref = self.stdlib.clone();
tokio::spawn(async move {
let mut index = stdlib_ref.write().await;
index.scan(stdlib_path);
});
}
}

Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
completion_provider: Some(CompletionOptions {
trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
..Default::default()
}),
..Default::default()
},
..Default::default()
})
}

async fn initialized(&self, _: InitializedParams) {
self.client
.log_message(MessageType::INFO, "Eldritch LSP initialized!")
.await;
}

async fn shutdown(&self) -> Result<()> {
Ok(())
}

async fn did_open(&self, params: DidOpenTextDocumentParams) {
let uri = params.text_document.uri.clone();
let text = params.text_document.text.clone();
let version = Some(params.text_document.version);

self.documents.write().await.insert(uri.clone(), text.clone());
self.validate_document(uri, &text, version).await;
}

async fn did_change(&self, params: DidChangeTextDocumentParams) {
let uri = params.text_document.uri;
let version = Some(params.text_document.version);
// We use Full sync, so the first change event has the full text
if let Some(change) = params.content_changes.into_iter().next() {
self.documents.write().await.insert(uri.clone(), change.text.clone());
self.validate_document(uri, &change.text, version).await;
}
}

async fn completion(&self, _params: CompletionParams) -> Result<Option<CompletionResponse>> {
// Context-aware completion logic
// For now, we mix in:
// 1. Stdlib modules
// 2. Keywords (Safe Mode fallback)

let stdlib = self.stdlib.read().await;
let mut items = Vec::new();

// Add Stdlib modules
for module in stdlib.get_completions() {
items.push(CompletionItem {
label: module,
kind: Some(CompletionItemKind::MODULE),
detail: Some("Standard Library".to_string()),
..Default::default()
});
}

// Add Keywords
let keywords = vec![
"def", "if", "else", "for", "while", "return", "import", "true", "false", "none"
];
for kw in keywords {
items.push(CompletionItem {
label: kw.to_string(),
kind: Some(CompletionItemKind::KEYWORD),
..Default::default()
});
}

Ok(Some(CompletionResponse::Array(items)))
}
}

#[tokio::main]
async fn main() {
env_logger::init();

let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();

let (service, socket) = LspService::new(|client| Backend {
client,
linter: LintRegistry::default(),
stdlib: Arc::new(RwLock::new(StdlibIndex::new())),
documents: Arc::new(RwLock::new(HashMap::new())),
});
Server::new(stdin, stdout, socket).serve(service).await;
}
68 changes: 68 additions & 0 deletions implants/lib/eldritch/eldritch-lsp/src/stdlib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use std::collections::HashSet;
use std::path::PathBuf;
use walkdir::WalkDir;

/// Manages an index of the Eldritch Standard Library.
///
/// This index allows the LSP to be aware of available standard library modules
/// and functions, enabling context-aware autocompletion even when files are
/// not explicitly imported in the current workspace.
#[derive(Debug)]
pub struct StdlibIndex {
/// A set of known module names (e.g., "http", "file", "sys").
/// In a real implementation, this might map names to function signatures.
pub modules: HashSet<String>,
}

impl StdlibIndex {
pub fn new() -> Self {
Self {
modules: HashSet::new(),
}
}

/// Recursively scans the given root path for Eldritch standard library modules.
///
/// It looks for files with `.eldritch` extension or directories that imply module structure.
/// For this simplified implementation, we assume directories in `stdlib/` represent modules
/// (e.g. `stdlib/eldritch-libhttp` -> "http").
pub fn scan(&mut self, root_path: PathBuf) {
log::info!("Scanning stdlib at {:?}", root_path);

for entry in WalkDir::new(&root_path)
.min_depth(1)
.max_depth(2) // optimize: stdlib structure is shallow
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_dir() {
if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) {
if dir_name.starts_with("eldritch-lib") {
let module_name = dir_name.trim_start_matches("eldritch-lib");
if !module_name.is_empty() {
self.modules.insert(module_name.to_string());
log::debug!("Found stdlib module: {}", module_name);
}
}
}
}
}

// Add hardcoded core modules if scanning fails or structure is different
if self.modules.is_empty() {
log::warn!("Stdlib scan yielded no results. Using fallback core modules.");
let defaults = vec!["agent", "assets", "crypto", "file", "http", "pivot", "process", "random", "regex", "report", "sys", "time"];
for m in defaults {
self.modules.insert(m.to_string());
}
}
}

/// Returns a list of all known module names for autocompletion.
pub fn get_completions(&self) -> Vec<String> {
let mut completions: Vec<String> = self.modules.iter().cloned().collect();
completions.sort();
completions
}
}
5 changes: 5 additions & 0 deletions vscode/vscode-tome-builder/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
out/
dist/
node_modules/
.vscode-test/
*.vsix
Loading
Loading