rust-analyzer/lib/lsp-server/examples/minimal_lsp.rs
Shashi Shankar 4541495037 examples: add minimal_lsp.rs and FIFO test script.
* `examples/minimal_lsp.rs` – compact LSP server showing definition,
  completion, hover, rustfmt-based formatting, and dummy diagnostics.
  Advertises UTF-8 offset encoding.

* `examples/manual_test.sh` – FIFO script that streams the canonical
  nine LSP packets so anyone can validate the server from two terminals.

No new runtime deps; `anyhow` stays under [dev-dependencies].
2025-07-17 11:04:14 +02:00

335 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Minimal LanguageServerProtocol example: **`minimal_lsp.rs`**
//! =============================================================
//!
//! | ↔ / ← | LSP method | What the implementation does |
//! |-------|------------|------------------------------|
//! | ↔ | `initialize` / `initialized` | capability handshake |
//! | ← | `textDocument/publishDiagnostics` | pushes a dummy info diagnostic whenever the buffer changes |
//! | ← | `textDocument/definition` | echoes an empty location array so the jump works |
//! | ← | `textDocument/completion` | offers one hardcoded item `HelloFromLSP` |
//! | ← | `textDocument/hover` | shows *Hello from minimal_lsp* markdown |
//! | ← | `textDocument/formatting` | pipes the doc through **rustfmt** and returns a fullfile edit |
//!
//! ### Quick start
//! ```bash
//! cd rust-analyzer/lib/lsp-server
//! cargo run --example minimal_lsp
//! ```
//!
//! ### Minimal manual session (all nine packets)
//! ```no_run
//! # 1. initialize - server replies with capabilities
//! Content-Length: 85
//! {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}
//!
//! # 2. initialized - no response expected
//! Content-Length: 59
//! {"jsonrpc":"2.0","method":"initialized","params":{}}
//!
//! # 3. didOpen - provide initial buffer text
//! Content-Length: 173
//! {"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///tmp/foo.rs","languageId":"rust","version":1,"text":"fn main( ){println!(\"hi\") }"}}}
//!
//! # 4. completion - expect HelloFromLSP
//! Content-Length: 139
//! {"jsonrpc":"2.0","id":2,"method":"textDocument/completion","params":{"textDocument":{"uri":"file:///tmp/foo.rs"},"position":{"line":0,"character":0}}}
//!
//! # 5. hover - expect markdown greeting
//! Content-Length: 135
//! {"jsonrpc":"2.0","id":3,"method":"textDocument/hover","params":{"textDocument":{"uri":"file:///tmp/foo.rs"},"position":{"line":0,"character":0}}}
//!
//! # 6. goto-definition - dummy empty array
//! Content-Length: 139
//! {"jsonrpc":"2.0","id":4,"method":"textDocument/definition","params":{"textDocument":{"uri":"file:///tmp/foo.rs"},"position":{"line":0,"character":0}}}
//!
//! # 7. formatting - rustfmt full document
//! Content-Length: 157
//! {"jsonrpc":"2.0","id":5,"method":"textDocument/formatting","params":{"textDocument":{"uri":"file:///tmp/foo.rs"},"options":{"tabSize":4,"insertSpaces":true}}}
//!
//! # 8. shutdown request - server acks and prepares to exit
//! Content-Length: 67
//! {"jsonrpc":"2.0","id":6,"method":"shutdown","params":null}
//!
//! # 9. exit notification - terminates the server
//! Content-Length: 54
//! {"jsonrpc":"2.0","method":"exit","params":null}
//! ```
//!
use std::{error::Error, io::Write};
use rustc_hash::FxHashMap; // fast hash map
use std::process::Stdio;
use toolchain::command; // clippy-approved wrapper
#[allow(clippy::print_stderr, clippy::disallowed_types, clippy::disallowed_methods)]
use anyhow::{Context, Result, anyhow, bail};
use lsp_server::{Connection, Message, Request as ServerRequest, RequestId, Response};
use lsp_types::notification::Notification as _; // for METHOD consts
use lsp_types::request::Request as _;
use lsp_types::{
CompletionItem,
CompletionItemKind,
// capability helpers
CompletionOptions,
CompletionResponse,
Diagnostic,
DiagnosticSeverity,
DidChangeTextDocumentParams,
DidOpenTextDocumentParams,
DocumentFormattingParams,
Hover,
HoverContents,
HoverProviderCapability,
// core
InitializeParams,
MarkedString,
OneOf,
Position,
PublishDiagnosticsParams,
Range,
ServerCapabilities,
TextDocumentSyncCapability,
TextDocumentSyncKind,
TextEdit,
Url,
// notifications
notification::{DidChangeTextDocument, DidOpenTextDocument, PublishDiagnostics},
// requests
request::{Completion, Formatting, GotoDefinition, HoverRequest},
}; // for METHOD consts
// =====================================================================
// main
// =====================================================================
#[allow(clippy::print_stderr)]
fn main() -> std::result::Result<(), Box<dyn Error + Sync + Send>> {
log::error!("starting minimal_lsp");
// transport
let (connection, io_thread) = Connection::stdio();
// advertised capabilities
let caps = ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)),
completion_provider: Some(CompletionOptions::default()),
definition_provider: Some(OneOf::Left(true)),
hover_provider: Some(HoverProviderCapability::Simple(true)),
document_formatting_provider: Some(OneOf::Left(true)),
..Default::default()
};
let init_value = serde_json::json!({
"capabilities": caps,
"offsetEncoding": ["utf-8"],
});
let init_params = connection.initialize(init_value)?;
main_loop(connection, init_params)?;
io_thread.join()?;
log::error!("shutting down server");
Ok(())
}
// =====================================================================
// event loop
// =====================================================================
fn main_loop(
connection: Connection,
params: serde_json::Value,
) -> std::result::Result<(), Box<dyn Error + Sync + Send>> {
let _init: InitializeParams = serde_json::from_value(params)?;
let mut docs: FxHashMap<Url, String> = FxHashMap::default();
for msg in &connection.receiver {
match msg {
Message::Request(req) => {
if connection.handle_shutdown(&req)? {
break;
}
if let Err(err) = handle_request(&connection, &req, &mut docs) {
log::error!("[lsp] request {} failed: {err}", &req.method);
}
}
Message::Notification(note) => {
if let Err(err) = handle_notification(&connection, &note, &mut docs) {
log::error!("[lsp] notification {} failed: {err}", note.method);
}
}
Message::Response(resp) => log::error!("[lsp] response: {resp:?}"),
}
}
Ok(())
}
// =====================================================================
// notifications
// =====================================================================
fn handle_notification(
conn: &Connection,
note: &lsp_server::Notification,
docs: &mut FxHashMap<Url, String>,
) -> Result<()> {
match note.method.as_str() {
DidOpenTextDocument::METHOD => {
let p: DidOpenTextDocumentParams = serde_json::from_value(note.params.clone())?;
let uri = p.text_document.uri;
docs.insert(uri.clone(), p.text_document.text);
publish_dummy_diag(conn, &uri)?;
}
DidChangeTextDocument::METHOD => {
let p: DidChangeTextDocumentParams = serde_json::from_value(note.params.clone())?;
if let Some(change) = p.content_changes.into_iter().next() {
let uri = p.text_document.uri;
docs.insert(uri.clone(), change.text);
publish_dummy_diag(conn, &uri)?;
}
}
_ => {}
}
Ok(())
}
// =====================================================================
// requests
// =====================================================================
fn handle_request(
conn: &Connection,
req: &ServerRequest,
docs: &mut FxHashMap<Url, String>,
) -> Result<()> {
match req.method.as_str() {
GotoDefinition::METHOD => {
send_ok(conn, req.id.clone(), &lsp_types::GotoDefinitionResponse::Array(Vec::new()))?;
}
Completion::METHOD => {
let item = CompletionItem {
label: "HelloFromLSP".into(),
kind: Some(CompletionItemKind::FUNCTION),
detail: Some("dummy completion".into()),
..Default::default()
};
send_ok(conn, req.id.clone(), &CompletionResponse::Array(vec![item]))?;
}
HoverRequest::METHOD => {
let hover = Hover {
contents: HoverContents::Scalar(MarkedString::String(
"Hello from *minimal_lsp*".into(),
)),
range: None,
};
send_ok(conn, req.id.clone(), &hover)?;
}
Formatting::METHOD => {
let p: DocumentFormattingParams = serde_json::from_value(req.params.clone())?;
let uri = p.text_document.uri;
let text = docs
.get(&uri)
.ok_or_else(|| anyhow!("document not in cache did you send DidOpen?"))?;
let formatted = run_rustfmt(text)?;
let edit = TextEdit { range: full_range(text), new_text: formatted };
send_ok(conn, req.id.clone(), &vec![edit])?;
}
_ => send_err(
conn,
req.id.clone(),
lsp_server::ErrorCode::MethodNotFound,
"unhandled method",
)?,
}
Ok(())
}
// =====================================================================
// diagnostics
// =====================================================================
fn publish_dummy_diag(conn: &Connection, uri: &Url) -> Result<()> {
let diag = Diagnostic {
range: Range::new(Position::new(0, 0), Position::new(0, 1)),
severity: Some(DiagnosticSeverity::INFORMATION),
code: None,
code_description: None,
source: Some("minimal_lsp".into()),
message: "dummy diagnostic".into(),
related_information: None,
tags: None,
data: None,
};
let params =
PublishDiagnosticsParams { uri: uri.clone(), diagnostics: vec![diag], version: None };
conn.sender.send(Message::Notification(lsp_server::Notification::new(
PublishDiagnostics::METHOD.to_owned(),
params,
)))?;
Ok(())
}
// =====================================================================
// helpers
// =====================================================================
fn run_rustfmt(input: &str) -> Result<String> {
let cwd = std::env::current_dir().expect("can't determine CWD");
let mut child = command("rustfmt", &cwd, &FxHashMap::default())
.arg("--emit")
.arg("stdout")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn rustfmt is it installed?")?;
let Some(stdin) = child.stdin.as_mut() else {
bail!("stdin unavailable");
};
stdin.write_all(input.as_bytes())?;
let output = child.wait_with_output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("rustfmt failed: {stderr}");
}
Ok(String::from_utf8(output.stdout)?)
}
fn full_range(text: &str) -> Range {
let last_line_idx = text.lines().count().saturating_sub(1) as u32;
let last_col = text.lines().last().map_or(0, |l| l.chars().count()) as u32;
Range::new(Position::new(0, 0), Position::new(last_line_idx, last_col))
}
fn send_ok<T: serde::Serialize>(conn: &Connection, id: RequestId, result: &T) -> Result<()> {
let resp = Response { id, result: Some(serde_json::to_value(result)?), error: None };
conn.sender.send(Message::Response(resp))?;
Ok(())
}
fn send_err(
conn: &Connection,
id: RequestId,
code: lsp_server::ErrorCode,
msg: &str,
) -> Result<()> {
let resp = Response {
id,
result: None,
error: Some(lsp_server::ResponseError {
code: code as i32,
message: msg.into(),
data: None,
}),
};
conn.sender.send(Message::Response(resp))?;
Ok(())
}