Home Posts Custom LSP Extensions in Rust for DSLs [Deep Dive]
Developer Tools

Custom LSP Extensions in Rust for DSLs [Deep Dive]

Custom LSP Extensions in Rust for DSLs [Deep Dive]
Dillip Chowdary
Dillip Chowdary
Tech Entrepreneur & Innovator · May 03, 2026 · 9 min read

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.
Pro tip: Treat every custom request as a public API. Version it in your docs, and avoid leaking internal parser structs into the wire format.

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.

  1. 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-lsp
  2. Step 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"
  3. 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 rule block exists, and exposes a custom request called acmeDsl/explainRule that 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(&params.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;
    }
Watch out: A custom request is not a substitute for standard LSP features. If the editor can already express the workflow as hover, code action, completion, or execute-command, prefer the standard shape first.

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
  1. Launch the language server through your editor client.
  2. Open the DSL file above and confirm that no warning diagnostic appears.
  3. Hover over the first line and confirm the standard hover text appears.
  4. 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? +
Add a custom request only when the workflow is genuinely domain-specific and does not map cleanly to standard LSP features like 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? +
Yes, as long as your client explicitly sends the request and your server registers the same method name. The safest pattern is a namespaced string such as acmeDsl/explainRule with small request and response payloads.
Why should a new DSL server start with full-text sync? +
TextDocumentSyncKind::FULL is slower than incremental sync, but it reduces early-state bugs while you are still proving the parser, diagnostics, and custom requests. Once the protocol contract is stable, you can optimize toward incremental updates.
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.

Found this useful? Share it.