test rust lsp

This commit is contained in:
2025-09-07 07:18:18 +02:00
parent a8bf55882e
commit 7a3e3ae9f7
330 changed files with 1714 additions and 70094 deletions
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "analysis"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
lsp-types = { workspace = true }
tree-sitter = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+99
View File
@@ -0,0 +1,99 @@
use serde::{ Deserialize, Serialize };
use std::collections::HashMap;
use tree_sitter::{ Node, Tree };
use lsp_types as lsp;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcDef {
pub name: String,
pub params: Vec<String>,
pub byte_start: usize,
pub byte_end: usize,
}
#[derive(Default)]
pub struct ProcIndex {
by_uri: HashMap<lsp::Uri, Vec<ProcDef>>,
}
impl ProcIndex {
pub fn insert(&mut self, uri: lsp::Uri, procs: Vec<ProcDef>) {
self.by_uri.insert(uri, procs);
}
pub fn get(&self, uri: &lsp::Uri) -> Option<&[ProcDef]> {
self.by_uri.get(uri).map(|v| v.as_slice())
}
pub fn remove(&mut self, uri: &lsp::Uri) {
self.by_uri.remove(uri);
}
}
pub fn collect_procs(tree: &Tree, source: &str) -> Vec<ProcDef> {
let root = tree.root_node();
let mut out = Vec::new();
collect_from_node(root, source.as_bytes(), &mut out);
out
}
fn collect_from_node(node: Node, src: &[u8], out: &mut Vec<ProcDef>) {
let mut stack = vec![node];
let mut cursor = node.walk();
while let Some(n) = stack.pop() {
if n.kind() == "command" {
if let Some(p) = try_extract_proc(n, src) {
out.push(p);
}
}
let mut it = n.children(&mut cursor);
while let Some(c) = it.next() {
stack.push(c);
}
}
}
fn try_extract_proc(cmd: Node, src: &[u8]) -> Option<ProcDef> {
// Gather word-like named children
let mut cursor = cmd.walk();
let mut words: Vec<(Node, String)> = Vec::new();
for ch in cmd.named_children(&mut cursor) {
match ch.kind() {
"word" | "braced_word" | "quoted_word" => {
if let Ok(t) = ch.utf8_text(src) {
words.push((ch, t.to_string()));
}
}
_ => {}
}
}
if words.len() < 2 {
return None;
}
if words[0].1 != "proc" {
return None;
}
let name = words[1].1.trim().to_string();
let params = if words.len() >= 3 { parse_params(&words[2].0, &words[2].1) } else { vec![] };
Some(ProcDef {
name,
params,
byte_start: cmd.start_byte(),
byte_end: cmd.end_byte(),
})
}
fn parse_params(node: &Node, text: &str) -> Vec<String> {
// naive split; good enough for a starter
let is_braced =
node.kind() == "braced_word" &&
text.starts_with('{') &&
text.ends_with('}') &&
text.len() >= 2;
let inner = if is_braced { &text[1..text.len() - 1] } else { text };
inner
.split_whitespace()
.map(|s| s.to_string())
.collect()
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "features"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
lsp-types = { workspace = true }
analysis = { path = "../analysis" }
text = { path = "../text" }
url = { workspace = true }
+31
View File
@@ -0,0 +1,31 @@
use anyhow::Result;
use analysis::ProcIndex;
use lsp_types as lsp;
use text::DocumentStore;
pub fn completion(
docs: &DocumentStore,
index: &ProcIndex,
params: &lsp::CompletionParams
) -> Result<Option<lsp::CompletionResponse>> {
let uri = &params.text_document_position.text_document.uri;
let Some(_doc) = docs.get(uri) else {
return Ok(None);
};
let Some(procs) = index.get(uri) else {
return Ok(None);
};
let items: Vec<lsp::CompletionItem> = procs
.iter()
.map(|p| lsp::CompletionItem {
label: p.name.clone(),
kind: Some(lsp::CompletionItemKind::FUNCTION),
detail: Some(format!("proc {}", p.params.join(" "))),
insert_text: Some(p.name.clone()),
..Default::default()
})
.collect();
Ok(Some(lsp::CompletionResponse::Array(items)))
}
+33
View File
@@ -0,0 +1,33 @@
use anyhow::Result;
use analysis::ProcIndex;
use lsp_types as lsp;
use text::DocumentStore;
pub fn definition(
docs: &DocumentStore,
index: &ProcIndex,
params: &lsp::GotoDefinitionParams
) -> Result<Option<lsp::GotoDefinitionResponse>> {
let uri = &params.text_document_position_params.text_document.uri;
let pos = params.text_document_position_params.position;
let Some(doc) = docs.get(uri) else {
return Ok(None);
};
let Some((word, _range)) = doc.word_under_position(pos) else {
return Ok(None);
};
let Some(procs) = index.get(uri) else {
return Ok(None);
};
if let Some(p) = procs.iter().find(|p| p.name == word) {
let loc = lsp::Location {
uri: uri.clone(),
range: doc.byte_range_to_lsp_range(p.byte_start, p.byte_end),
};
return Ok(Some(lsp::GotoDefinitionResponse::Scalar(loc)));
}
Ok(None)
}
@@ -0,0 +1,34 @@
use anyhow::Result;
use analysis::ProcIndex;
use lsp_types as lsp;
use text::DocumentStore;
pub fn document_symbol(
docs: &DocumentStore,
index: &ProcIndex,
uri: &lsp::Uri
) -> Result<Option<lsp::DocumentSymbolResponse>> {
let Some(doc) = docs.get(uri) else {
return Ok(None);
};
let Some(procs) = index.get(uri) else {
return Ok(None);
};
let mut symbols = Vec::new();
for p in procs {
let range = doc.byte_range_to_lsp_range(p.byte_start, p.byte_end);
symbols.push(lsp::DocumentSymbol {
name: p.name.clone(),
detail: Some(format!("proc {}", p.params.join(" "))),
kind: lsp::SymbolKind::FUNCTION,
range,
selection_range: range,
children: None,
tags: None,
deprecated: None,
});
}
Ok(Some(lsp::DocumentSymbolResponse::Nested(symbols)))
}
+7
View File
@@ -0,0 +1,7 @@
mod document_symbol;
mod definition;
mod completion;
pub use document_symbol::document_symbol;
pub use definition::definition;
pub use completion::completion;
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "lsp-main"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
env_logger = { workspace = true }
log = { workspace = true }
lsp-server = { workspace = true }
lsp-types = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
analysis = { path = "../analysis" }
features = { path = "../features" }
text = { path = "../text" }
+13
View File
@@ -0,0 +1,13 @@
use anyhow::Result;
use env_logger::Env;
use lsp_server::Connection;
mod server;
fn main() -> Result<()> {
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let (connection, io_threads) = Connection::stdio();
server::Server::new(connection).run()?;
io_threads.join()?;
Ok(())
}
+177
View File
@@ -0,0 +1,177 @@
use anyhow::Result;
use log;
use lsp_server::{ Connection, Message, Response };
use lsp_server::Notification as ServerNotification;
use lsp_server::Request as ServerRequest;
use lsp_types as lsp;
use lsp_types::notification::{
DidChangeTextDocument,
DidCloseTextDocument,
DidOpenTextDocument,
LogMessage,
Notification as LspNotification,
};
use lsp_types::request::{
Completion,
DocumentSymbolRequest,
GotoDefinition,
Request as LspRequest,
};
use serde_json::Value;
use analysis::{ collect_procs, ProcIndex };
use features::{ completion, definition, document_symbol };
use text::DocumentStore;
pub struct Server {
conn: Connection,
docs: DocumentStore,
procs: ProcIndex,
}
impl Server {
pub fn new(conn: Connection) -> Self {
Self {
conn,
docs: DocumentStore::default(),
procs: ProcIndex::default(),
}
}
pub fn run(mut self) -> Result<()> {
// Initialize handshake
let (id, _params) = self.conn.initialize_start()?;
let result = lsp::InitializeResult {
capabilities: server_capabilities(),
server_info: Some(lsp::ServerInfo {
name: "tcl-lsp".into(),
version: Some(env!("CARGO_PKG_VERSION").into()),
}),
};
self.conn.initialize_finish(id, serde_json::to_value(result)?)?;
let _ = self.log("tcl-lsp ready");
let receiver = self.conn.receiver.clone();
// main loop
for msg in receiver.iter() {
match msg {
lsp_server::Message::Request(req) => {
if self.conn.handle_shutdown(&req)? {
break;
}
if let Err(e) = self.on_request(req) {
log::error!("request error: {e:#}");
}
}
lsp_server::Message::Notification(n) => {
if let Err(e) = self.on_notification(n) {
log::error!("notification error: {e:#}");
}
}
lsp_server::Message::Response(_) => {}
}
}
let _ = self.log("tcl-lsp stopped");
Ok(())
}
fn on_notification(&mut self, n: ServerNotification) -> Result<()> {
match n.method.as_str() {
DidOpenTextDocument::METHOD => {
let params: lsp::DidOpenTextDocumentParams = serde_json::from_value(n.params)?;
let uri = params.text_document.uri.clone();
self.docs.open(params.text_document)?;
self.reindex(&uri)?;
self.log(&format!("Opened {:?}", uri))?;
}
DidChangeTextDocument::METHOD => {
let params: lsp::DidChangeTextDocumentParams = serde_json::from_value(n.params)?;
let uri = params.text_document.uri.clone();
// FULL sync: we expect one change with whole text
self.docs.apply_full_change(&uri, &params.content_changes)?;
self.reindex(&uri)?;
}
DidCloseTextDocument::METHOD => {
let params: lsp::DidCloseTextDocumentParams = serde_json::from_value(n.params)?;
self.procs.remove(&params.text_document.uri);
self.docs.close(&params.text_document.uri);
}
_ => {}
}
Ok(())
}
fn on_request(&mut self, req: ServerRequest) -> Result<()> {
match req.method.as_str() {
DocumentSymbolRequest::METHOD => {
let params: lsp::DocumentSymbolParams = serde_json::from_value(req.params)?;
let uri = params.text_document.uri;
let result = document_symbol(&self.docs, &self.procs, &uri)?;
send_ok(&self.conn, req.id, result)?;
}
GotoDefinition::METHOD => {
let params: lsp::GotoDefinitionParams = serde_json::from_value(req.params)?;
let result = definition(&self.docs, &self.procs, &params)?;
send_ok(&self.conn, req.id, result)?;
}
Completion::METHOD => {
let params: lsp::CompletionParams = serde_json::from_value(req.params)?;
let _ = self.log("Completion");
let result = completion(&self.docs, &self.procs, &params)?;
send_ok(&self.conn, req.id, result)?;
}
other => {
let resp = Response::new_err(
req.id,
lsp::error_codes::REQUEST_FAILED as i32,
format!("Unknown method: {other}")
);
self.conn.sender.send(Message::Response(resp))?;
}
}
Ok(())
}
fn reindex(&mut self, uri: &lsp::Uri) -> Result<()> {
if let Some(doc) = self.docs.get(uri) {
if let Some(tree) = doc.tree() {
let text = doc.text();
let procs = collect_procs(tree, &text);
self.procs.insert(uri.clone(), procs);
}
}
Ok(())
}
fn log(&self, message: &str) -> Result<()> {
let notif = LogMessage::METHOD.to_string();
let params = lsp::LogMessageParams {
typ: lsp::MessageType::INFO,
message: message.to_string(),
};
self.conn.sender.send(Message::Notification(ServerNotification::new(notif, params)))?;
Ok(())
}
}
fn send_ok<T: serde::Serialize>(
conn: &Connection,
id: lsp_server::RequestId,
result: T
) -> Result<()> {
let v: Value = serde_json::to_value(result)?;
conn.sender.send(Message::Response(Response::new_ok(id, v)))?;
Ok(())
}
fn server_capabilities() -> lsp::ServerCapabilities {
lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
..Default::default()
}),
..Default::default()
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "text"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = { workspace = true }
lsp-types = { workspace = true }
tree-sitter = { workspace = true }
tree-sitter-tcl = { workspace = true }
url = { workspace = true }
+179
View File
@@ -0,0 +1,179 @@
use anyhow::{ bail, Context, Result };
use lsp_types as lsp;
use std::collections::HashMap;
use tree_sitter::{ Parser, Tree };
#[derive(Default)]
pub struct DocumentStore {
docs: HashMap<lsp::Uri, Document>,
}
impl DocumentStore {
pub fn open(&mut self, item: lsp::TextDocumentItem) -> Result<()> {
let uri = item.uri.clone();
let doc = Document::open(item.text)?;
self.docs.insert(uri, doc);
Ok(())
}
pub fn apply_full_change(
&mut self,
uri: &lsp::Uri,
changes: &[lsp::TextDocumentContentChangeEvent]
) -> Result<()> {
let Some(doc) = self.docs.get_mut(uri) else {
bail!("no such document: {:?}", uri);
};
// FULL sync: assume single change with whole text
let new_text = changes.last().context("empty changes for full sync")?.text.clone();
doc.reparse(&new_text)?;
Ok(())
}
pub fn close(&mut self, uri: &lsp::Uri) {
self.docs.remove(uri);
}
pub fn get(&self, uri: &lsp::Uri) -> Option<&Document> {
self.docs.get(uri)
}
}
pub struct Document {
text: String,
parser: Parser,
tree: Option<Tree>,
}
impl Document {
pub fn open(text: String) -> Result<Self> {
let mut parser = Parser::new();
parser.set_language(&tree_sitter_tcl::LANGUAGE.into()).context("load tcl grammar")?;
let tree = parser.parse(&text, None);
Ok(Self { text, parser, tree })
}
pub fn reparse(&mut self, new_text: &str) -> Result<()> {
self.text.clear();
self.text.push_str(new_text);
self.tree = self.parser.parse(&self.text, None);
Ok(())
}
pub fn text(&self) -> String {
self.text.clone()
}
pub fn tree(&self) -> Option<&Tree> {
self.tree.as_ref()
}
/// Convert a byte range (UTF-8) into an LSP Range (UTF-16 columns).
pub fn byte_range_to_lsp_range(&self, start: usize, end: usize) -> lsp::Range {
fn byte_to_line_col_utf16(s: &str, byte: usize) -> (u32, u32) {
let clamped = byte.min(s.len());
let slice = &s[..clamped];
let mut line = 0u32;
let mut col_u16 = 0u32;
let mut last_nl = 0usize;
for (i, b) in slice.bytes().enumerate() {
if b == b'\n' {
line += 1;
last_nl = i + 1;
}
}
// compute utf16 col from last newline to clamped
let seg = &slice[last_nl..];
col_u16 = seg.encode_utf16().count() as u32;
(line, col_u16)
}
let (sl, sc) = byte_to_line_col_utf16(&self.text, start);
let (el, ec) = byte_to_line_col_utf16(&self.text, end);
lsp::Range {
start: lsp::Position {
line: sl,
character: sc,
},
end: lsp::Position {
line: el,
character: ec,
},
}
}
/// Get the UTF-8 byte offset of the "word" under a UTF-16 position (very naive).
pub fn word_under_position(
&self,
pos: lsp::Position
) -> Option<(String, std::ops::Range<usize>)> {
// Convert UTF-16 (LSP) position -> UTF-8 byte offset in self.text
fn line_col_utf16_to_byte(s: &str, line: u32, col_u16: u32) -> usize {
// Find start byte of the requested line
let mut cur_line: u32 = 0;
let mut line_start_byte: usize = 0;
if line == 0 {
line_start_byte = 0;
} else {
for (i, b) in s.bytes().enumerate() {
if b == b'\n' {
cur_line += 1;
if cur_line == line {
line_start_byte = i + 1;
break;
}
}
}
// If line beyond EOF, clamp to end
if cur_line < line {
return s.len();
}
}
// Advance by UTF-16 code units from line_start_byte
let mut remaining = col_u16 as isize;
let tail = &s[line_start_byte..];
for (off, ch) in tail.char_indices() {
if remaining <= 0 {
return line_start_byte + off;
}
// subtract the number of UTF-16 code units this char occupies (1 for BMP, 2 for surrogates)
let mut buf = [0u16; 2];
remaining -= ch.encode_utf16(&mut buf).len() as isize;
}
// If column beyond EOL, clamp to end
s.len()
}
let bytes = self.text.as_bytes();
let byte = line_col_utf16_to_byte(&self.text, pos.line, pos.character);
if byte > self.text.len() {
return None;
}
// Expand to simple Tcl-ish identifier characters: letters/digits/_ : / . -
fn is_ident(c: u8) -> bool {
c.is_ascii_alphanumeric() || matches!(c, b'_' | b':' | b'/' | b'.' | b'-')
}
// Walk backward to start
let mut start = byte;
while start > 0 && is_ident(bytes[start - 1]) {
start -= 1;
}
// Walk forward to end
let mut end = byte;
while end < bytes.len() && is_ident(bytes[end]) {
end += 1;
}
if start >= end {
return None;
}
let text = self.text[start..end].to_string();
Some((text, start..end)) // <-- no semicolon here, this is the tail expression
}
}