Custom LSP Extensions in Rust for DSLs [Deep Dive]
Bottom Line
Use standard LSP features for the parts every editor already understands, then add a small, namespaced custom request for domain-only behavior. In Rust, tower-lsp already provides the extension hook, so the real engineering work is defining a stable DSL contract.
Key Takeaways
- ›Keep portable features on standard LSP; reserve custom requests for DSL-only workflows.
- ›tower-lsp 0.20.0 exposes custom_method for JSON-RPC extensions.
- ›Use namespaced methods like acmeDsl/explainRule and never start with $/.
- ›Start with full-text sync first, then optimize parsing and incremental updates later.
If your language is a narrow DSL instead of a general-purpose language, standard LSP features usually get you only halfway. Hover, diagnostics, and completion cover the common editor contract, but business-specific workflows often need more. This tutorial shows how to build a Rust language server, keep the portable pieces on standard LSP, and add one carefully scoped custom JSON-RPC method for domain-aware behavior without turning your protocol surface into a mess.
- Keep portable features on standard LSP; reserve custom requests for DSL-only workflows.
- tower-lsp 0.20.0 exposes custom_method for JSON-RPC extensions.
- Use namespaced methods like acmeDsl/explainRule and never start with $/.
- Start with TextDocumentSyncKind::FULL first, then optimize later.
Prerequisites
Bottom Line
The safest pattern is simple: implement standard LSP features first, then add one namespaced custom request for DSL-only semantics. That keeps your server usable across editors while still unlocking domain-specific power.
Prerequisites box
- A stable Rust toolchain with cargo available.
- Basic familiarity with JSON-RPC and the Language Server Protocol.
- A client host such as VS Code, Neovim, or another editor that can launch an LSP over stdio.
- A small DSL sample file you can open during testing.
- Optional: use TechBytes' Code Formatter to clean up JSON request and response samples while debugging protocol traffic.
Design the Extension Contract
Before writing Rust, decide what belongs on the standard protocol and what deserves an extension. This is where most LSP projects either stay maintainable or become editor-specific glue code.
- Put hover, diagnostics, completion, symbols, and formatting on standard LSP methods whenever possible.
- Use a custom method only when the behavior is truly domain-specific, such as explaining a rule, simulating a policy, or expanding a business macro.
- Name custom methods with a stable namespace like
acmeDsl/explainRule. - Do not use method names beginning with $/; the LSP spec reserves that prefix for protocol-implementation messages.
- Keep custom payloads narrow. Small request and response types are easier to version and easier to support across clients.
Build the Rust Server
The example below uses tower-lsp 0.20.0 because it provides the standard LanguageServer trait plus the custom_method hook we need.
Step 1: Create the binary project
Start with cargo new and the --bin flag so the server can run over stdio.
cargo new acme-dsl-lsp --bin cd acme-dsl-lspStep 2: Add dependencies
You need async runtime support, JSON serialization, and the LSP server crate.
[dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } tower-lsp = "0.20.0"Step 3: Implement standard features and one custom request
This server does three useful things. It stores open documents, publishes a simple diagnostic when no
ruleblock exists, and exposes a custom request calledacmeDsl/explainRulethat returns a DSL-specific explanation for a rule declaration.use std::collections::HashMap; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tower_lsp::jsonrpc::{Error, Result}; use tower_lsp::lsp_types::*; use tower_lsp::{Client, LanguageServer, LspService, Server}; struct Backend { client: Client, docs: RwLock<HashMap<Url, String>>, } #[derive(Debug, Deserialize)] struct ExplainRuleParams { uri: Url, line: u32, } #[derive(Debug, Serialize)] struct ExplainRuleResult { rule_name: String, summary: String, } #[tower_lsp::async_trait] impl LanguageServer for Backend { async fn initialize(&self, _: InitializeParams) -> Result<InitializeResult> { Ok(InitializeResult { capabilities: ServerCapabilities { text_document_sync: Some(TextDocumentSyncCapability::Kind( TextDocumentSyncKind::FULL, )), hover_provider: Some(HoverProviderCapability::Simple(true)), ..Default::default() }, server_info: Some(ServerInfo { name: "Acme DSL LSP".into(), version: None, }), ..Default::default() }) } async fn initialized(&self, _: InitializedParams) { self.client .log_message(MessageType::INFO, "Acme DSL server initialized") .await; } async fn shutdown(&self) -> Result<()> { Ok(()) } async fn did_open(&self, params: DidOpenTextDocumentParams) { let uri = params.text_document.uri; let text = params.text_document.text; self.docs.write().await.insert(uri.clone(), text.clone()); self.publish_rule_diagnostics(uri, &text).await; } async fn did_change(&self, params: DidChangeTextDocumentParams) { if let Some(change) = params.content_changes.into_iter().next() { let uri = params.text_document.uri; self.docs.write().await.insert(uri.clone(), change.text.clone()); self.publish_rule_diagnostics(uri, &change.text).await; } } async fn hover(&self, params: HoverParams) -> Result<Option<Hover>> { let pos = params.text_document_position_params.position; let uri = params.text_document_position_params.text_document.uri; let docs = self.docs.read().await; let Some(text) = docs.get(&uri) else { return Ok(None); }; let line = text.lines().nth(pos.line as usize).unwrap_or("").trim(); if let Some(name) = line.strip_prefix("rule ").and_then(|s| s.strip_suffix(':')) { return Ok(Some(Hover { contents: HoverContents::Scalar(MarkedString::String(format!( "Rule `{}` starts here. Use the custom request for a richer explanation.", name ))), range: None, })); } Ok(None) } } impl Backend { async fn explain_rule(&self, params: ExplainRuleParams) -> Result<ExplainRuleResult> { let docs = self.docs.read().await; let text = docs .get(¶ms.uri) .ok_or_else(|| Error::invalid_params("document is not open"))?; let line = text .lines() .nth(params.line as usize) .ok_or_else(|| Error::invalid_params("line is out of range"))? .trim(); let Some(rule_name) = line .strip_prefix("rule ") .and_then(|s| s.strip_suffix(':')) else { return Err(Error::invalid_params("expected a rule declaration")); }; Ok(ExplainRuleResult { rule_name: rule_name.to_string(), summary: format!( "Rule `{}` declares a domain rule entrypoint for downstream validation.", rule_name ), }) } async fn publish_rule_diagnostics(&self, uri: Url, text: &str) { let has_rule = text.lines().any(|line| line.trim_start().starts_with("rule ")); let diagnostics = if has_rule { vec![] } else { vec![Diagnostic { range: Range::new(Position::new(0, 0), Position::new(0, 1)), severity: Some(DiagnosticSeverity::WARNING), message: "File does not declare any `rule` blocks".into(), ..Default::default() }] }; self.client.publish_diagnostics(uri, diagnostics, None).await; } } #[tokio::main] async fn main() { let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); let (service, socket) = LspService::build(|client| Backend { client, docs: RwLock::new(HashMap::new()), }) .custom_method("acmeDsl/explainRule", Backend::explain_rule) .finish(); Server::new(stdin, stdout, socket).serve(service).await; }
Wire the Client Request
On the client side, you only need to send a normal JSON-RPC request once the server is running. In a VS Code client, that typically means calling sendRequest on the language client after the active document and cursor position are known.
Step 4: Call the custom request from the editor
const result = await client.sendRequest("acmeDsl/explainRule", {
uri: editor.document.uri.toString(),
line: editor.selection.active.line
});
console.log(result.rule_name, result.summary);The design rule here is important:
- The client sends only stable, editor-neutral data such as URI and line number.
- The server owns parsing, semantic interpretation, and error messages.
- The response is small enough to use in a hover, webview, command palette, or custom sidebar later.
Verification and Expected Output
Use a tiny DSL file so the first end-to-end run is obvious.
rule checkout_total:
when order.total > 0
then emit invoice- Launch the language server through your editor client.
- Open the DSL file above and confirm that no warning diagnostic appears.
- Hover over the first line and confirm the standard hover text appears.
- Trigger the custom request at line
0.
A valid JSON-RPC request body looks like this:
{
"jsonrpc": "2.0",
"id": 7,
"method": "acmeDsl/explainRule",
"params": {
"uri": "file:///workspace/sample.acme",
"line": 0
}
}The expected response is:
{
"jsonrpc": "2.0",
"id": 7,
"result": {
"rule_name": "checkout_total",
"summary": "Rule `checkout_total` declares a domain rule entrypoint for downstream validation."
}
}If you instead open a file with no rule declaration, expect one warning diagnostic with the message File does not declare any `rule` blocks.
Troubleshooting and What's Next
Troubleshooting top 3
- The custom request never arrives: verify the client and server use the exact same method string. A one-character namespace mismatch is enough to produce
MethodNotFound. - You get invalid params errors: confirm the document is open before calling the custom request, and make sure the line number exists in the current buffer state.
- Diagnostics look stale: if you start with TextDocumentSyncKind::FULL, always replace the stored document text on each change event. Mixing full-text assumptions with incremental logic is a common early bug.
What's next
- Replace the line-based rule extraction with your real lexer and parser.
- Add completion items for DSL keywords and domain entities.
- Introduce semantic tokens only after the parse tree and symbol model are stable.
- Version your custom request if multiple clients will depend on it.
- Add protocol logging in your editor client so request and response traces are visible during debugging.
The strategic takeaway is that custom LSP extensions should stay small and explicit. A DSL server becomes much easier to evolve when the base editor experience rides on standard LSP and only the domain-specific intelligence crosses the wire through well-named custom requests.
Frequently Asked Questions
When should I add a custom LSP request instead of using a standard method? +
hover, completion, or codeAction. If the editor already understands the behavior through the spec, stick to the standard method for better portability.Can editors call custom LSP methods safely? +
acmeDsl/explainRule with small request and response payloads.Why should a new DSL server start with full-text sync? +
Why not put every DSL action behind executeCommand? +
workspace/executeCommand is useful, but it is a poor dumping ground for every server capability. A dedicated custom request gives you a typed payload, a clearer purpose, and a cleaner contract for results that are not really editor commands.Get Engineering Deep-Dives in Your Inbox
Weekly breakdowns of architecture, security, and developer tooling — no fluff.
Related Deep-Dives
Rust Codegen for Domain-Specific Languages
A practical guide to generating fast, typed Rust from small internal DSLs.
System ArchitectureLanguage Server Architecture for Editor Tooling
How to split client, protocol, parser, and analysis layers without painting yourself into a corner.
Developer ReferenceJSON-RPC Patterns for Developer Platforms
Protocol design patterns that keep internal tooling APIs small, stable, and debuggable.