Skip to main content

noyalib_lsp/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Library surface for `noyalib-lsp`.
5//!
6//! Hosts the JSON-RPC 2.0 dispatch logic, the document store that
7//! tracks open buffers, and the LSP capability handlers
8//! (`textDocument/formatting`, `textDocument/publishDiagnostics`,
9//! `textDocument/hover`). The `noyalib-lsp` binary in `main.rs` is
10//! a thin stdio shim that drives [`Server::handle_message`]; tests
11//! reach the same handlers directly so coverage does not depend on
12//! standing up a real LSP client.
13//!
14//! # Cargo features
15//!
16//! This crate exposes no optional features; the LSP capability
17//! set is fixed at `textDocumentSync` (full), formatting, and
18//! hover. Optional `noyalib` features (`schema`, `parallel`, …)
19//! pulled in by a downstream binary do not change this crate's
20//! wire surface — they only affect what `noyalib::Error` messages
21//! are produced inside diagnostics. The canonical `noyalib`
22//! feature matrix lives in
23//! [`crates/noyalib/src/lib.rs`](https://docs.rs/noyalib).
24//!
25//! # MSRV
26//!
27//! **Rust 1.85.0** stable. The `tower-lsp` and async deps floor
28//! at 1.85; the core `noyalib` library still builds on **1.75**.
29//! CI verifies both floors via the `Per-crate MSRV` workflow
30//! job. See workspace
31//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md#1-msrv-minimum-supported-rust-version).
32//!
33//! # Panics
34//!
35//! Public functions in this crate do not panic on well-formed
36//! input. The LSP binary's stdin/stdout handling propagates
37//! I/O errors back to the host as JSON-RPC error envelopes.
38//!
39//! # Errors
40//!
41//! All handlers return JSON-RPC error envelopes per the
42//! [LSP specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/).
43//! Parse / format errors come from `noyalib::Error` and surface
44//! through the `textDocument/publishDiagnostics` channel as
45//! span-aware `Diagnostic` records.
46//!
47//! # Concurrency
48//!
49//! Each LSP request is processed sequentially on the binary's
50//! stdio loop. The internal document store is reentrant:
51//! handlers `&mut`-borrow it serialised by the request
52//! loop. No internal threading; rayon is opt-in via
53//! the `parallel` feature on `noyalib`.
54//!
55//! # Platform support
56//!
57//! Tier-1 (CI-verified each PR): `aarch64-apple-darwin`,
58//! `x86_64-unknown-linux-gnu`, `x86_64-pc-windows-msvc`.
59//! Editor-specific notes for VS Code, Neovim, Helix, Emacs,
60//! Zed, Sublime, IntelliJ live under
61//! [`crates/noyalib-lsp/examples/`](https://github.com/sebastienrousseau/noyalib/tree/main/crates/noyalib-lsp/examples).
62//!
63//! # Performance
64//!
65//! Each `didOpen` / `didChange` event re-parses the entire
66//! buffer in a single pass — `O(n)` in document bytes. A 1 MB
67//! YAML document typically reports diagnostics in under 5 ms
68//! on commodity hardware. The CST is cached per document so
69//! `textDocument/formatting` and `textDocument/hover` reuse
70//! the same parse on subsequent requests.
71//!
72//! # Security
73//!
74//! `#![forbid(unsafe_code)]`. No FFI. No network I/O — LSP is
75//! stdio-only. The server reads file contents only from the
76//! editor's `didOpen` notifications; it does not autoload
77//! arbitrary paths from the filesystem. Resource-limit gates
78//! are inherited from `noyalib`'s `ParserConfig` defaults.
79//! Full posture:
80//! [`SECURITY.md`](https://github.com/sebastienrousseau/noyalib/blob/main/SECURITY.md).
81//!
82//! # API stability and SemVer
83//!
84//! Pre-1.0 (`0.0.x`): the LSP wire contract (method names,
85//! capability flags, JSON-RPC error code ranges, document store
86//! semantics) is **stable** within a 0.0.x line — bug fixes
87//! only. Adding a new LSP capability is allowed within a 0.0.x
88//! bump; removing or repurposing one is held to a 0.x bump
89//! (e.g. 0.0.x → 0.1.0). The Rust library surface (`Server`,
90//! `HandleOutcome`, `Request`, `Response`, `ErrorResponse`) is
91//! covered by the workspace SemVer policy in
92//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md#2-semver--api-stability).
93//! `cargo-semver-checks` runs in CI on every PR.
94//!
95//! # Documentation
96//!
97//! - **Engineering policies** — workspace
98//!   [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md).
99//! - **LSP specification**: <https://microsoft.github.io/language-server-protocol/>.
100//! - **Editor configurations** (VS Code / Neovim / Helix /
101//!   Emacs / Zed / Sublime / IntelliJ):
102//!   [`examples/`](https://github.com/sebastienrousseau/noyalib/tree/main/crates/noyalib-lsp/examples).
103//! - **Protocol-method coverage matrix**:
104//!   [`doc/protocol-coverage.md`](https://github.com/sebastienrousseau/noyalib/blob/main/crates/noyalib-lsp/doc/protocol-coverage.md).
105
106#![forbid(unsafe_code)]
107#![warn(missing_docs)]
108
109use serde::{Deserialize, Serialize};
110use serde_json::{Value as JsonValue, json};
111use std::collections::HashMap;
112
113pub mod diagnostics;
114pub mod format;
115pub mod hover;
116
117/// JSON-RPC 2.0 request envelope (LSP wire format).
118#[derive(Debug, Deserialize)]
119pub struct Request {
120    /// Always `"2.0"`.
121    pub jsonrpc: String,
122    /// Method name, e.g. `"textDocument/didOpen"`.
123    pub method: String,
124    /// Method parameters; LSP shape varies by method.
125    #[serde(default)]
126    pub params: JsonValue,
127    /// Request id; absent on notifications.
128    pub id: Option<JsonValue>,
129}
130
131/// JSON-RPC 2.0 success response envelope.
132#[derive(Debug, Serialize)]
133pub struct Response {
134    /// Always `"2.0"`.
135    pub jsonrpc: &'static str,
136    /// The result payload.
137    pub result: JsonValue,
138    /// Echo of the corresponding request's id.
139    pub id: JsonValue,
140}
141
142/// JSON-RPC 2.0 error envelope.
143#[derive(Debug, Serialize)]
144pub struct ErrorResponse {
145    /// Always `"2.0"`.
146    pub jsonrpc: &'static str,
147    /// Error payload.
148    pub error: ErrorObject,
149    /// Echo of the corresponding request's id.
150    pub id: JsonValue,
151}
152
153/// JSON-RPC 2.0 error object.
154#[derive(Debug, Serialize)]
155pub struct ErrorObject {
156    /// Numeric error code per JSON-RPC convention.
157    pub code: i32,
158    /// Human-readable message.
159    pub message: String,
160}
161
162/// LSP server-side notification envelope (e.g. for
163/// `textDocument/publishDiagnostics`).
164#[derive(Debug, Serialize)]
165pub struct Notification {
166    /// Always `"2.0"`.
167    pub jsonrpc: &'static str,
168    /// Method name being invoked on the client.
169    pub method: &'static str,
170    /// Method parameters.
171    pub params: JsonValue,
172}
173
174/// What the stdio loop should do with a parsed message — write a
175/// reply and / or zero or more server-initiated notifications, or
176/// stay silent.
177#[derive(Debug, Default)]
178pub struct HandleOutcome {
179    /// Reply payload, when the request had an `id`.
180    pub reply: Option<String>,
181    /// Notifications the server emits as a side-effect (e.g.
182    /// diagnostics published after a `didChange`).
183    pub notifications: Vec<String>,
184}
185
186impl HandleOutcome {
187    /// Build a reply-only outcome with no notifications.
188    ///
189    /// # Examples
190    ///
191    /// ```
192    /// use noyalib_lsp::HandleOutcome;
193    /// let o = HandleOutcome::reply("{}".into());
194    /// assert!(o.reply.is_some());
195    /// assert!(o.notifications.is_empty());
196    /// ```
197    pub fn reply(payload: String) -> Self {
198        HandleOutcome {
199            reply: Some(payload),
200            notifications: Vec::new(),
201        }
202    }
203
204    /// Notification-only outcome (no reply expected by the client).
205    ///
206    /// # Examples
207    ///
208    /// ```
209    /// use noyalib_lsp::HandleOutcome;
210    /// let o = HandleOutcome::notify("{}".into());
211    /// assert!(o.reply.is_none());
212    /// assert_eq!(o.notifications.len(), 1);
213    /// ```
214    pub fn notify(payload: String) -> Self {
215        HandleOutcome {
216            reply: None,
217            notifications: vec![payload],
218        }
219    }
220
221    /// Empty / no-op outcome.
222    ///
223    /// # Examples
224    ///
225    /// ```
226    /// use noyalib_lsp::HandleOutcome;
227    /// let o = HandleOutcome::silent();
228    /// assert!(o.reply.is_none());
229    /// assert!(o.notifications.is_empty());
230    /// ```
231    pub fn silent() -> Self {
232        HandleOutcome::default()
233    }
234}
235
236/// Stateful LSP server. One instance per stdio session; the document
237/// store owns the in-memory snapshot of every open buffer.
238///
239/// # Examples
240///
241/// ```
242/// use noyalib_lsp::Server;
243/// let server = Server::new();
244/// assert_eq!(server.open_document_count(), 0);
245/// ```
246#[derive(Debug, Default)]
247pub struct Server {
248    /// Documents the client has opened, keyed by URI.
249    documents: HashMap<String, String>,
250    /// Whether the client has issued `initialize` / `initialized`.
251    initialized: bool,
252    /// Whether the client has issued `shutdown`. After shutdown the
253    /// server only honours `exit`.
254    shutting_down: bool,
255}
256
257impl Server {
258    /// Construct a fresh server with an empty document store.
259    ///
260    /// # Examples
261    ///
262    /// ```
263    /// use noyalib_lsp::Server;
264    /// let server = Server::new();
265    /// assert_eq!(server.open_document_count(), 0);
266    /// ```
267    #[must_use]
268    pub fn new() -> Self {
269        Server::default()
270    }
271
272    /// Number of currently-open documents. Useful for tests that
273    /// assert the server's internal state.
274    ///
275    /// # Examples
276    ///
277    /// ```
278    /// use noyalib_lsp::Server;
279    /// assert_eq!(Server::new().open_document_count(), 0);
280    /// ```
281    #[must_use]
282    pub fn open_document_count(&self) -> usize {
283        self.documents.len()
284    }
285
286    /// Snapshot of an open document, or `None` if the URI is not
287    /// known.
288    ///
289    /// # Examples
290    ///
291    /// ```
292    /// use noyalib_lsp::Server;
293    /// let server = Server::new();
294    /// assert_eq!(server.document("file:///nope.yaml"), None);
295    /// ```
296    #[must_use]
297    pub fn document(&self, uri: &str) -> Option<&str> {
298        self.documents.get(uri).map(String::as_str)
299    }
300
301    /// Process one parsed JSON-RPC line and return the resulting
302    /// reply / notifications. The stdio loop in `main` calls this
303    /// per LSP message; tests call it with crafted strings.
304    ///
305    /// # Examples
306    ///
307    /// ```
308    /// use noyalib_lsp::Server;
309    /// let mut server = Server::new();
310    /// let req = r#"{"jsonrpc":"2.0","method":"initialize","id":1,"params":{}}"#;
311    /// let outcome = server.handle_message(req);
312    /// assert!(outcome.reply.is_some());
313    /// ```
314    pub fn handle_message(&mut self, raw: &str) -> HandleOutcome {
315        let req: Request = match serde_json::from_str(raw) {
316            Ok(r) => r,
317            Err(e) => {
318                return HandleOutcome::reply(error_str(
319                    JsonValue::Null,
320                    -32700,
321                    format!("parse error: {e}"),
322                ));
323            }
324        };
325        if req.jsonrpc != "2.0" {
326            return HandleOutcome::reply(error_str(
327                req.id.unwrap_or(JsonValue::Null),
328                -32600,
329                "invalid request: jsonrpc must be \"2.0\"".into(),
330            ));
331        }
332        let id = req.id.clone();
333        let result = self.dispatch(&req.method, req.params);
334
335        let mut outcome = HandleOutcome::default();
336        match (id, result) {
337            (None, Ok(side)) => {
338                outcome.notifications = side.notifications;
339            }
340            (None, Err(_)) => {
341                // Notifications swallow errors per JSON-RPC.
342            }
343            (Some(id), Ok(side)) => {
344                outcome.reply = Some(
345                    serde_json::to_string(&Response {
346                        jsonrpc: "2.0",
347                        result: side.value,
348                        id,
349                    })
350                    .expect("infallible serialise"),
351                );
352                outcome.notifications = side.notifications;
353            }
354            (Some(id), Err((code, msg))) => {
355                outcome.reply = Some(error_str(id, code, msg));
356            }
357        }
358        outcome
359    }
360
361    fn dispatch(&mut self, method: &str, params: JsonValue) -> Result<DispatchOk, (i32, String)> {
362        if self.shutting_down && method != "exit" {
363            return Err((-32600, format!("server is shutting down; refused {method}")));
364        }
365        match method {
366            "initialize" => {
367                self.initialized = true;
368                Ok(DispatchOk::value(json!({
369                    "capabilities": {
370                        "textDocumentSync": 1,
371                        "documentFormattingProvider": true,
372                        "hoverProvider": true,
373                    },
374                    "serverInfo": {
375                        "name": "noyalib-lsp",
376                        "version": env!("CARGO_PKG_VERSION"),
377                    }
378                })))
379            }
380            "initialized" => Ok(DispatchOk::value(JsonValue::Null)),
381            "shutdown" => {
382                self.shutting_down = true;
383                Ok(DispatchOk::value(JsonValue::Null))
384            }
385            "exit" => Ok(DispatchOk::value(JsonValue::Null)),
386            "textDocument/didOpen" => self.did_open(params),
387            "textDocument/didChange" => self.did_change(params),
388            "textDocument/didClose" => self.did_close(params),
389            "textDocument/formatting" => self.formatting(params),
390            "textDocument/hover" => self.hover(params),
391            other => Err((-32601, format!("method not found: {other}"))),
392        }
393    }
394
395    fn did_open(&mut self, params: JsonValue) -> Result<DispatchOk, (i32, String)> {
396        let uri = uri_from_params(&params).ok_or((-32602, "missing textDocument.uri".into()))?;
397        let text = params
398            .pointer("/textDocument/text")
399            .and_then(|v| v.as_str())
400            .unwrap_or_default()
401            .to_owned();
402        let _ = self.documents.insert(uri.clone(), text.clone());
403        let mut ok = DispatchOk::value(JsonValue::Null);
404        if let Some(note) = diagnostics::publish_diagnostics(&uri, &text) {
405            ok.notifications.push(note);
406        }
407        Ok(ok)
408    }
409
410    fn did_change(&mut self, params: JsonValue) -> Result<DispatchOk, (i32, String)> {
411        let uri = uri_from_params(&params).ok_or((-32602, "missing textDocument.uri".into()))?;
412        // LSP TextDocumentSyncKind::Full — the client sends the
413        // entire new text in `contentChanges[0].text`.
414        let text = params
415            .pointer("/contentChanges/0/text")
416            .and_then(|v| v.as_str())
417            .ok_or((-32602, "missing contentChanges[0].text".into()))?
418            .to_owned();
419        let _ = self.documents.insert(uri.clone(), text.clone());
420        let mut ok = DispatchOk::value(JsonValue::Null);
421        if let Some(note) = diagnostics::publish_diagnostics(&uri, &text) {
422            ok.notifications.push(note);
423        }
424        Ok(ok)
425    }
426
427    fn did_close(&mut self, params: JsonValue) -> Result<DispatchOk, (i32, String)> {
428        let uri = uri_from_params(&params).ok_or((-32602, "missing textDocument.uri".into()))?;
429        let _ = self.documents.remove(&uri);
430        Ok(DispatchOk::value(JsonValue::Null))
431    }
432
433    fn formatting(&self, params: JsonValue) -> Result<DispatchOk, (i32, String)> {
434        let uri = uri_from_params(&params).ok_or((-32602, "missing textDocument.uri".into()))?;
435        let text = self
436            .documents
437            .get(&uri)
438            .ok_or((-32602, format!("document not open: {uri}")))?;
439        let edits = format::full_document_edits(text)
440            .map_err(|e| (-32603, format!("format failed: {e}")))?;
441        Ok(DispatchOk::value(serde_json::to_value(edits).unwrap()))
442    }
443
444    fn hover(&self, params: JsonValue) -> Result<DispatchOk, (i32, String)> {
445        let uri = uri_from_params(&params).ok_or((-32602, "missing textDocument.uri".into()))?;
446        let line = params
447            .pointer("/position/line")
448            .and_then(|v| v.as_u64())
449            .ok_or((-32602, "missing position.line".into()))? as usize;
450        let column = params
451            .pointer("/position/character")
452            .and_then(|v| v.as_u64())
453            .ok_or((-32602, "missing position.character".into()))? as usize;
454        let text = self
455            .documents
456            .get(&uri)
457            .ok_or((-32602, format!("document not open: {uri}")))?;
458        Ok(DispatchOk::value(hover::hover_at(text, line, column)))
459    }
460}
461
462struct DispatchOk {
463    value: JsonValue,
464    notifications: Vec<String>,
465}
466
467impl DispatchOk {
468    fn value(v: JsonValue) -> Self {
469        DispatchOk {
470            value: v,
471            notifications: Vec::new(),
472        }
473    }
474}
475
476fn uri_from_params(params: &JsonValue) -> Option<String> {
477    params
478        .pointer("/textDocument/uri")
479        .and_then(|v| v.as_str())
480        .map(str::to_owned)
481}
482
483/// Render a JSON-RPC error envelope to a single-line string.
484///
485/// # Examples
486///
487/// ```
488/// use noyalib_lsp::error_str;
489/// use serde_json::json;
490/// let s = error_str(json!(1), -32601, "method not found".into());
491/// assert!(s.contains("\"code\":-32601"));
492/// ```
493pub fn error_str(id: JsonValue, code: i32, message: String) -> String {
494    serde_json::to_string(&ErrorResponse {
495        jsonrpc: "2.0",
496        error: ErrorObject { code, message },
497        id,
498    })
499    .expect("infallible serialise")
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    fn parse_reply(out: &HandleOutcome) -> JsonValue {
507        let s = out.reply.as_deref().expect("expected reply");
508        serde_json::from_str(s).unwrap()
509    }
510
511    #[test]
512    fn handle_message_returns_parse_error_on_bad_json() {
513        let mut s = Server::new();
514        let out = s.handle_message("{not json");
515        let v = parse_reply(&out);
516        assert_eq!(v["error"]["code"].as_i64().unwrap(), -32700);
517        assert!(v["id"].is_null());
518    }
519
520    #[test]
521    fn handle_message_rejects_non_2_0_jsonrpc() {
522        let mut s = Server::new();
523        let req = json!({"jsonrpc": "1.0", "method": "initialize", "id": 1});
524        let out = s.handle_message(&req.to_string());
525        let v = parse_reply(&out);
526        assert_eq!(v["error"]["code"].as_i64().unwrap(), -32600);
527    }
528
529    #[test]
530    fn initialize_returns_capabilities_and_server_info() {
531        let mut s = Server::new();
532        let req = json!({"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}});
533        let out = s.handle_message(&req.to_string());
534        let v = parse_reply(&out);
535        assert_eq!(
536            v["result"]["serverInfo"]["name"].as_str(),
537            Some("noyalib-lsp")
538        );
539        assert_eq!(
540            v["result"]["capabilities"]["documentFormattingProvider"].as_bool(),
541            Some(true),
542        );
543        assert_eq!(
544            v["result"]["capabilities"]["hoverProvider"].as_bool(),
545            Some(true),
546        );
547        assert_eq!(
548            v["result"]["capabilities"]["textDocumentSync"].as_i64(),
549            Some(1),
550        );
551    }
552
553    #[test]
554    fn unknown_method_returns_method_not_found() {
555        let mut s = Server::new();
556        let req = json!({"jsonrpc": "2.0", "method": "frobnicate", "id": 7});
557        let out = s.handle_message(&req.to_string());
558        let v = parse_reply(&out);
559        assert_eq!(v["error"]["code"].as_i64().unwrap(), -32601);
560    }
561
562    #[test]
563    fn shutdown_then_non_exit_method_is_rejected() {
564        let mut s = Server::new();
565        let _ =
566            s.handle_message(&json!({"jsonrpc": "2.0", "method": "shutdown", "id": 1}).to_string());
567        let out = s.handle_message(
568            &json!({"jsonrpc": "2.0", "method": "textDocument/hover", "id": 2,
569                "params": {"textDocument": {"uri": "f"}, "position": {"line": 0, "character": 0}}})
570            .to_string(),
571        );
572        let v = parse_reply(&out);
573        assert_eq!(v["error"]["code"].as_i64().unwrap(), -32600);
574    }
575
576    #[test]
577    fn exit_after_shutdown_succeeds() {
578        let mut s = Server::new();
579        let _ =
580            s.handle_message(&json!({"jsonrpc": "2.0", "method": "shutdown", "id": 1}).to_string());
581        let out = s.handle_message(&json!({"jsonrpc": "2.0", "method": "exit"}).to_string());
582        // Notifications swallow no reply; outcome should be silent.
583        assert!(out.reply.is_none());
584    }
585
586    #[test]
587    fn did_open_records_document_and_publishes_diagnostics() {
588        let mut s = Server::new();
589        let req = json!({
590            "jsonrpc": "2.0",
591            "method": "textDocument/didOpen",
592            "params": {
593                "textDocument": {
594                    "uri": "file:///tmp/a.yaml",
595                    "languageId": "yaml",
596                    "version": 1,
597                    "text": "name: noyalib\n"
598                }
599            }
600        });
601        let out = s.handle_message(&req.to_string());
602        // didOpen is a notification — no reply.
603        assert!(out.reply.is_none());
604        // Diagnostics are published as a server-initiated notification.
605        assert_eq!(out.notifications.len(), 1);
606        let note: JsonValue = serde_json::from_str(&out.notifications[0]).unwrap();
607        assert_eq!(
608            note["method"].as_str(),
609            Some("textDocument/publishDiagnostics"),
610        );
611        assert_eq!(s.open_document_count(), 1);
612        assert_eq!(s.document("file:///tmp/a.yaml"), Some("name: noyalib\n"));
613    }
614
615    #[test]
616    fn did_change_overwrites_text_and_re_publishes() {
617        let mut s = Server::new();
618        let _ = s.handle_message(
619            &json!({
620                "jsonrpc": "2.0",
621                "method": "textDocument/didOpen",
622                "params": {
623                    "textDocument": {
624                        "uri": "file:///tmp/b.yaml", "languageId": "yaml",
625                        "version": 1, "text": "a: 1\n"
626                    }
627                }
628            })
629            .to_string(),
630        );
631        let out = s.handle_message(
632            &json!({
633                "jsonrpc": "2.0",
634                "method": "textDocument/didChange",
635                "params": {
636                    "textDocument": {"uri": "file:///tmp/b.yaml", "version": 2},
637                    "contentChanges": [{"text": "a: 2\n"}]
638                }
639            })
640            .to_string(),
641        );
642        assert_eq!(out.notifications.len(), 1);
643        assert_eq!(s.document("file:///tmp/b.yaml"), Some("a: 2\n"));
644    }
645
646    #[test]
647    fn did_close_drops_document() {
648        let mut s = Server::new();
649        let _ = s.handle_message(
650            &json!({
651                "jsonrpc": "2.0",
652                "method": "textDocument/didOpen",
653                "params": {"textDocument": {
654                    "uri": "f", "languageId": "yaml", "version": 1, "text": "x: 1\n"
655                }}
656            })
657            .to_string(),
658        );
659        let _ = s.handle_message(
660            &json!({
661                "jsonrpc": "2.0",
662                "method": "textDocument/didClose",
663                "params": {"textDocument": {"uri": "f"}}
664            })
665            .to_string(),
666        );
667        assert_eq!(s.open_document_count(), 0);
668    }
669
670    #[test]
671    fn formatting_returns_text_edits() {
672        let mut s = Server::new();
673        let _ = s.handle_message(
674            &json!({
675                "jsonrpc": "2.0",
676                "method": "textDocument/didOpen",
677                "params": {"textDocument": {
678                    "uri": "f", "languageId": "yaml", "version": 1, "text": "a: 1\n"
679                }}
680            })
681            .to_string(),
682        );
683        let out = s.handle_message(
684            &json!({
685                "jsonrpc": "2.0",
686                "method": "textDocument/formatting",
687                "id": 5,
688                "params": {"textDocument": {"uri": "f"}, "options": {"tabSize": 2, "insertSpaces": true}}
689            })
690            .to_string(),
691        );
692        let v = parse_reply(&out);
693        assert!(v["result"].is_array());
694    }
695
696    #[test]
697    fn formatting_unknown_uri_errors() {
698        let mut s = Server::new();
699        let out = s.handle_message(
700            &json!({
701                "jsonrpc": "2.0",
702                "method": "textDocument/formatting",
703                "id": 6,
704                "params": {"textDocument": {"uri": "missing"}, "options": {}}
705            })
706            .to_string(),
707        );
708        let v = parse_reply(&out);
709        assert_eq!(v["error"]["code"].as_i64().unwrap(), -32602);
710    }
711
712    #[test]
713    fn hover_unknown_uri_errors() {
714        let mut s = Server::new();
715        let out = s.handle_message(
716            &json!({
717                "jsonrpc": "2.0",
718                "method": "textDocument/hover",
719                "id": 7,
720                "params": {
721                    "textDocument": {"uri": "missing"},
722                    "position": {"line": 0, "character": 0}
723                }
724            })
725            .to_string(),
726        );
727        let v = parse_reply(&out);
728        assert_eq!(v["error"]["code"].as_i64().unwrap(), -32602);
729    }
730
731    #[test]
732    fn error_str_renders_canonical_envelope() {
733        let s = error_str(json!(42), -32000, "boom".into());
734        let v: JsonValue = serde_json::from_str(&s).unwrap();
735        assert_eq!(v["jsonrpc"].as_str(), Some("2.0"));
736        assert_eq!(v["id"].as_i64(), Some(42));
737        assert_eq!(v["error"]["code"].as_i64(), Some(-32000));
738    }
739
740    #[test]
741    fn handle_outcome_helpers_construct_correctly() {
742        let r = HandleOutcome::reply("hi".into());
743        assert!(r.reply.is_some());
744        assert!(r.notifications.is_empty());
745        let n = HandleOutcome::notify("x".into());
746        assert!(n.reply.is_none());
747        assert_eq!(n.notifications.len(), 1);
748        let s = HandleOutcome::silent();
749        assert!(s.reply.is_none());
750        assert!(s.notifications.is_empty());
751    }
752}