1243 lines
55 KiB
Rust
1243 lines
55 KiB
Rust
// pmacs_fake_lsp.rs --- Test helper. Tiny LSP-protocol echo peer.
|
|
|
|
//! Test helper binary, used only by `tests/m4_acceptance.rs` to
|
|
//! exercise the M4.5 LSP transport without depending on a real
|
|
//! language server being installed.
|
|
//!
|
|
//! Behaviour:
|
|
//!
|
|
//! * Reads `Content-Length`-framed JSON-RPC bodies from stdin.
|
|
//! * On `initialize`: replies with a minimal capabilities object.
|
|
//! * On `initialized`: silent.
|
|
//! * On `textDocument/didOpen` or `textDocument/didChange`: echoes
|
|
//! the document URI back as a `pmacs/echo` notification so the
|
|
//! test can verify the wire is alive, and pushes a synthetic
|
|
//! `publishDiagnostics` so M4.6 tests have content to read.
|
|
//! * On `textDocument/completion`: returns a deterministic
|
|
//! `CompletionList` with three items so M4.7 tests can read it.
|
|
//! * On `textDocument/hover`: returns a `MarkupContent`-shaped
|
|
//! markdown payload.
|
|
//! * On `textDocument/signatureHelp`: returns a one-signature
|
|
//! payload with two parameters and the second one active.
|
|
//! * On any other request: replies with `result: {"echo": params}`.
|
|
//! * On `shutdown`: replies with `null` and waits for `exit`.
|
|
//! * On `exit`: exits 0.
|
|
//! * If launched with `PMACS_FAKE_LSP_MODE=garbage`: writes
|
|
//! intentionally malformed framing once and exits 0, so the
|
|
//! client can verify protocol-violation handling.
|
|
//! * If launched with `PMACS_FAKE_LSP_MODE=crash`: replies to
|
|
//! `initialize`, then exits with code 7 immediately, so the
|
|
//! client can verify crash + restart handling.
|
|
//! * If launched with `PMACS_FAKE_LSP_MODE=rooturi`: writes the
|
|
//! `rootUri` received in `initialize` to the file named by
|
|
//! `PMACS_FAKE_LSP_ROOT_SINK`, so a test can assert the
|
|
//! auto-attach path derives the project root from the opened file.
|
|
//! * If launched with `PMACS_FAKE_LSP_MODE=fullonly`: advertises a
|
|
//! full-only `semanticTokensProvider` (`"full": true`, no delta
|
|
//! member) and rejects `semanticTokens/full/delta` with a JSON-RPC
|
|
//! error — a conforming full-only server, for testing that the
|
|
//! client never requests delta without the negotiated capability.
|
|
//! * If launched with `PMACS_FAKE_LSP_MODE=rangeonly`: advertises a
|
|
//! range-only `semanticTokensProvider` (`"range": true`, no `full`)
|
|
//! and rejects `semanticTokens/full` — per LSP, `full` and `range`
|
|
//! are optional, independent capabilities.
|
|
//! * If launched with `PMACS_FAKE_LSP_MODE=rangeonly16`: `rangeonly`
|
|
//! plus UTF-16 position encoding, with strict UTF-16 bounds
|
|
//! validation on `/range` — rejects a client that sent raw byte
|
|
//! columns for non-ASCII text.
|
|
//! * If launched with `PMACS_FAKE_LSP_MODE=sighelp`: additionally
|
|
//! advertises `signatureHelpProvider` with `(` / `,` triggers, so a
|
|
//! test can drive the Arc 1d auto-trigger. Every other mode omits the
|
|
//! capability and therefore never auto-triggers.
|
|
//! * If `PMACS_FAKE_LSP_CHANGE_SINK` names a file (any mode): appends
|
|
//! one `{"method", "text"}` JSON line per received didOpen /
|
|
//! didChange, so a test can replay the exact document-sync sequence
|
|
//! the server saw — the auto-pairing Q#AP7 ordering observable
|
|
//! ("the first didChange after `(` carries `()`").
|
|
|
|
use std::collections::HashMap;
|
|
use std::io::{self, Read, Write};
|
|
|
|
#[allow(
|
|
clippy::too_many_lines,
|
|
reason = "linear LSP-method dispatch; splitting a test helper this much fragments the read"
|
|
)]
|
|
fn main() {
|
|
let mode = std::env::var("PMACS_FAKE_LSP_MODE").unwrap_or_default();
|
|
if mode == "garbage" {
|
|
write_garbage();
|
|
return;
|
|
}
|
|
let mut stdin = io::stdin().lock();
|
|
let mut stdout = io::stdout().lock();
|
|
let mut crashed_after_init = false;
|
|
let mut open_docs: HashMap<String, String> = HashMap::new();
|
|
// `fullonly` observability: counts /full responses (rid-1, rid-2…).
|
|
let mut full_count: u32 = 0;
|
|
loop {
|
|
let body = match read_frame(&mut stdin) {
|
|
Ok(Some(b)) => b,
|
|
Ok(None) => return, // EOF
|
|
Err(e) => {
|
|
eprintln!("frame read error: {e}");
|
|
return;
|
|
}
|
|
};
|
|
let msg: serde_json::Value = match serde_json::from_slice(&body) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("bad json: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
let method = msg
|
|
.get("method")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_owned();
|
|
let id = msg.get("id").cloned();
|
|
let params = msg
|
|
.get("params")
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null);
|
|
// T M4.5 `wsconfig`: the client's reply to the
|
|
// `workspace/configuration` request we sent at `initialized`
|
|
// arrives here as a response (id 9001, has `result`, no
|
|
// method). Echo its result array back as a notification so
|
|
// the test can assert what pmacs answered.
|
|
if mode == "wsconfig"
|
|
&& method.is_empty()
|
|
&& msg.get("result").is_some()
|
|
&& id.as_ref().and_then(serde_json::Value::as_u64) == Some(9001)
|
|
{
|
|
let echo = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "pmacs/wsconfig",
|
|
"params": { "answer": msg.get("result").cloned() }
|
|
});
|
|
write_frame(&mut stdout, &echo);
|
|
continue;
|
|
}
|
|
// T M4.5 async-bridge failure-path test modes:
|
|
// * `error` — answer every `textDocument/*` request with a
|
|
// JSON-RPC error object (drives `Handle:await()` -> failed).
|
|
// * `silent` — accept the request but never answer it while
|
|
// staying alive (drives the request-timeout sweep, and
|
|
// makes supersede deterministic: a superseded handle can
|
|
// only settle via cancellation, never racing a response).
|
|
if method.starts_with("textDocument/") {
|
|
if mode == "error" && id.is_some() {
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id.clone(),
|
|
"error": { "code": -32603, "message": "synthetic error" }
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
continue;
|
|
}
|
|
if mode == "silent" {
|
|
continue;
|
|
}
|
|
}
|
|
match (method.as_str(), id) {
|
|
("initialize", Some(idv)) => {
|
|
let mut resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": {
|
|
"capabilities": {
|
|
"textDocumentSync": 1,
|
|
"hoverProvider": true,
|
|
"completionProvider": { "triggerCharacters": ["."] },
|
|
"definitionProvider": true,
|
|
"inlayHintProvider": true,
|
|
"documentFormattingProvider": true,
|
|
"diagnosticProvider": { "interFileDependencies": false, "workspaceDiagnostics": false },
|
|
"semanticTokensProvider": {
|
|
"legend": {
|
|
"tokenTypes": ["namespace", "function", "variable"],
|
|
"tokenModifiers": ["declaration", "readonly"]
|
|
},
|
|
// The default mode implements /full/delta, so
|
|
// it truthfully NEGOTIATES delta. Clients may
|
|
// only send /full/delta when `full` is
|
|
// `{ "delta": true }`; a bare `true` (the
|
|
// `fullonly` override below) is full-only.
|
|
"full": { "delta": true }
|
|
}
|
|
},
|
|
"serverInfo": { "name": "pmacs-fake-lsp", "version": "0.1.0" }
|
|
}
|
|
});
|
|
// T M4.5 Option B: `posecho` negotiates UTF-16 and
|
|
// echoes request positions back (see definition arm)
|
|
// so a test can prove the byte↔UTF-16 round-trip.
|
|
if mode == "posecho" {
|
|
resp["result"]["capabilities"]["positionEncoding"] =
|
|
serde_json::Value::from("utf-16");
|
|
}
|
|
// T M4.5: advertise prepareRename only in the
|
|
// prepare-* modes, so the default `rename` mode keeps
|
|
// exercising the no-prepare path.
|
|
if mode == "prepare" || mode == "preprefuse" {
|
|
resp["result"]["capabilities"]["renameProvider"] =
|
|
serde_json::json!({ "prepareProvider": true });
|
|
}
|
|
// `fullonly`: a conforming FULL-ONLY semantic-token
|
|
// server — advertises `"full": true` (no delta member)
|
|
// and REJECTS /full/delta below. Exercises the client
|
|
// rule that a stored resultId alone must never cause a
|
|
// delta request.
|
|
if mode == "fullonly" {
|
|
resp["result"]["capabilities"]["semanticTokensProvider"]["full"] =
|
|
serde_json::Value::from(true);
|
|
}
|
|
// `rangeonly`: LSP allows a provider to advertise
|
|
// `range` WITHOUT `full` — the /full arm below rejects
|
|
// in this mode, so a client that ignores the split gets
|
|
// a visible failure instead of silent staleness.
|
|
if mode.starts_with("rangeonly") {
|
|
let p = &mut resp["result"]["capabilities"]["semanticTokensProvider"];
|
|
if let Some(obj) = p.as_object_mut() {
|
|
obj.remove("full");
|
|
obj.insert("range".into(), serde_json::Value::from(true));
|
|
}
|
|
}
|
|
// `rangeonly16` additionally negotiates UTF-16, so the
|
|
// /range arm can validate that the client converted its
|
|
// byte columns to UTF-16 code units.
|
|
if mode == "rangeonly16" {
|
|
resp["result"]["capabilities"]["positionEncoding"] =
|
|
serde_json::Value::from("utf-16");
|
|
}
|
|
// Arc 1d: advertise signature help only in `sighelp`, so
|
|
// every other mode keeps the no-auto-trigger path (the
|
|
// `textDocument/signatureHelp` arm below still answers
|
|
// the manual `M-x lsp.signature-help` in any mode).
|
|
if mode == "sighelp" {
|
|
// "«" (U+00AB, 2 UTF-8 bytes) exercises the rule
|
|
// that LSP trigger characters are strings, not
|
|
// ASCII bytes.
|
|
resp["result"]["capabilities"]["signatureHelpProvider"] = serde_json::json!({
|
|
"triggerCharacters": ["(", "\u{ab}"],
|
|
"retriggerCharacters": [","]
|
|
});
|
|
}
|
|
// T M4.5 hardening `rooturi`: record the `rootUri` the
|
|
// client sent in `initialize` to a side-channel file
|
|
// (env `PMACS_FAKE_LSP_ROOT_SINK`). Lets a test prove
|
|
// the auto-attach path derives the project root from
|
|
// the opened file, not the editor's cwd. Mirrors the
|
|
// `filewatch` mode's `.received` disk side-channel.
|
|
if mode == "rooturi"
|
|
&& let Ok(sink) = std::env::var("PMACS_FAKE_LSP_ROOT_SINK")
|
|
{
|
|
let recorded = params
|
|
.get("rootUri")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("<null>");
|
|
let _ = std::fs::write(&sink, recorded);
|
|
}
|
|
write_frame(&mut stdout, &resp);
|
|
if mode == "crash" {
|
|
crashed_after_init = true;
|
|
}
|
|
}
|
|
// T M4.5 `wsconfig`: pull config the way gopls / pyright
|
|
// / clangd do right after initialize.
|
|
("initialized", _) if mode == "wsconfig" => {
|
|
let req = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 9001,
|
|
"method": "workspace/configuration",
|
|
"params": { "items": [
|
|
{ "section": "pmacs.probe" },
|
|
{ "section": "does.not.exist" }
|
|
] }
|
|
});
|
|
write_frame(&mut stdout, &req);
|
|
}
|
|
// T M4.5 `inlayrefresh` / `semantictokensrefresh`: right
|
|
// after initialize, signal that cached inlay hints /
|
|
// semantic tokens are stale via the matching server→client
|
|
// refresh request. The client must answer (null) and
|
|
// re-pull the corresponding `textDocument/*`.
|
|
("initialized", _) if mode == "inlayrefresh" => {
|
|
let req = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 9200,
|
|
"method": "workspace/inlayHint/refresh",
|
|
"params": serde_json::Value::Null
|
|
});
|
|
write_frame(&mut stdout, &req);
|
|
}
|
|
("initialized", _) if mode == "semantictokensrefresh" => {
|
|
let req = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 9201,
|
|
"method": "workspace/semanticTokens/refresh",
|
|
"params": serde_json::Value::Null
|
|
});
|
|
write_frame(&mut stdout, &req);
|
|
}
|
|
// T M4.5 `filewatch`: dynamically register a
|
|
// `workspace/didChangeWatchedFiles` watcher (RelativePattern
|
|
// rooted at PMACS_FAKE_LSP_WATCH_BASE, `**/*.txt`, all
|
|
// kinds). The client must reply null and start watching.
|
|
("initialized", _) if mode == "filewatch" => {
|
|
let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
|
|
let req = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 9300,
|
|
"method": "client/registerCapability",
|
|
"params": { "registrations": [{
|
|
"id": "watch-1",
|
|
"method": "workspace/didChangeWatchedFiles",
|
|
"registerOptions": { "watchers": [{
|
|
"globPattern": {
|
|
"baseUri": format!("file://{base}"),
|
|
"pattern": "**/*.txt"
|
|
},
|
|
"kind": 7
|
|
}] }
|
|
}] }
|
|
});
|
|
write_frame(&mut stdout, &req);
|
|
}
|
|
("initialized", _) => {}
|
|
// T M4.5: the client's file-watch notifications. Append
|
|
// `type uri` lines to `<base>/.received` as a test
|
|
// side-channel (the protocol stream is drained by the Lua
|
|
// server-request pump, so a disk channel is observable).
|
|
("workspace/didChangeWatchedFiles", _) => {
|
|
let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
|
|
if !base.is_empty()
|
|
&& let Some(changes) = params.get("changes").and_then(|c| c.as_array())
|
|
&& let Ok(mut f) = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(format!("{base}/.received"))
|
|
{
|
|
use std::io::Write as _;
|
|
for ch in changes {
|
|
let t = ch
|
|
.get("type")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.unwrap_or(0);
|
|
let u = ch
|
|
.get("uri")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or("");
|
|
let _ = writeln!(f, "{t} {u}");
|
|
}
|
|
}
|
|
}
|
|
("shutdown", Some(idv)) => {
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": serde_json::Value::Null
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("exit", _) => return,
|
|
("textDocument/completion", Some(idv)) => {
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": {
|
|
"isIncomplete": false,
|
|
"items": [
|
|
{
|
|
"label": "println",
|
|
"kind": 3,
|
|
"detail": "macro println!",
|
|
"documentation": {
|
|
"kind": "markdown",
|
|
"value": "Prints to stdout with a newline."
|
|
},
|
|
"insertText": "println!"
|
|
},
|
|
{
|
|
"label": "print",
|
|
"kind": 3,
|
|
"detail": "macro print!",
|
|
"insertText": "print!"
|
|
},
|
|
{
|
|
"label": "panic",
|
|
"kind": 3,
|
|
"detail": "macro panic!",
|
|
"insertText": "panic!"
|
|
}
|
|
]
|
|
}
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/hover", Some(idv)) => {
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": {
|
|
"contents": {
|
|
"kind": "markdown",
|
|
"value": "# pmacs-fake-lsp\n\nSynthetic hover content for the symbol under cursor."
|
|
},
|
|
"range": {
|
|
"start": { "line": 0, "character": 0 },
|
|
"end": { "line": 0, "character": 4 }
|
|
}
|
|
}
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/signatureHelp", Some(idv)) => {
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": {
|
|
"signatures": [
|
|
{
|
|
"label": "fn echo(name: &str, count: usize) -> String",
|
|
"documentation": "Echoes `name` `count` times.",
|
|
"parameters": [
|
|
{ "label": "name: &str" },
|
|
{ "label": "count: usize" }
|
|
],
|
|
"activeParameter": 1
|
|
}
|
|
],
|
|
"activeSignature": 0,
|
|
"activeParameter": 1
|
|
}
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/didOpen" | "textDocument/didChange", _) => {
|
|
let uri = params
|
|
.get("textDocument")
|
|
.and_then(|t| t.get("uri"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null);
|
|
let text = if method == "textDocument/didOpen" {
|
|
params
|
|
.get("textDocument")
|
|
.and_then(|t| t.get("text"))
|
|
.and_then(serde_json::Value::as_str)
|
|
} else {
|
|
params
|
|
.get("contentChanges")
|
|
.and_then(serde_json::Value::as_array)
|
|
.and_then(|a| a.first())
|
|
.and_then(|c| c.get("text"))
|
|
.and_then(serde_json::Value::as_str)
|
|
};
|
|
if let (Some(uri_s), Some(text)) = (uri.as_str(), text) {
|
|
open_docs.insert(uri_s.to_owned(), text.to_owned());
|
|
}
|
|
// Auto-pairing Q#AP7: the ordering observable is "the
|
|
// FIRST didChange after `(` carries `()`" — provable
|
|
// only from what the server actually received, in
|
|
// order. Mirror of `PMACS_FAKE_LSP_ROOT_SINK`: append
|
|
// one JSON line per didOpen/didChange to the sink
|
|
// file so a test can replay the exact sequence.
|
|
if let (Ok(sink), Some(text)) = (std::env::var("PMACS_FAKE_LSP_CHANGE_SINK"), text)
|
|
{
|
|
use std::io::Write as _;
|
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&sink)
|
|
{
|
|
let line = serde_json::json!({ "method": method, "text": text });
|
|
let _ = writeln!(f, "{line}");
|
|
}
|
|
}
|
|
let echo = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "pmacs/echo",
|
|
"params": {
|
|
"method": method,
|
|
"uri": uri,
|
|
}
|
|
});
|
|
write_frame(&mut stdout, &echo);
|
|
// Also push a synthetic `publishDiagnostics`
|
|
// notification with two entries (one Error, one
|
|
// Warning) so M4.6 tests can exercise the store.
|
|
if uri.is_string() {
|
|
let diags = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "textDocument/publishDiagnostics",
|
|
"params": {
|
|
"uri": uri,
|
|
"diagnostics": [
|
|
{
|
|
"range": {
|
|
"start": { "line": 0, "character": 4 },
|
|
"end": { "line": 0, "character": 8 },
|
|
},
|
|
"severity": 1,
|
|
"message": "synthetic error",
|
|
"source": "pmacs-fake-lsp",
|
|
"code": "E0001"
|
|
},
|
|
{
|
|
"range": {
|
|
"start": { "line": 2, "character": 0 },
|
|
"end": { "line": 2, "character": 5 },
|
|
},
|
|
"severity": 2,
|
|
"message": "synthetic warning",
|
|
"source": "pmacs-fake-lsp",
|
|
"code": "W0001"
|
|
}
|
|
]
|
|
}
|
|
});
|
|
write_frame(&mut stdout, &diags);
|
|
}
|
|
}
|
|
("textDocument/definition", Some(idv)) => {
|
|
let uri = params
|
|
.get("textDocument")
|
|
.and_then(|t| t.get("uri"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null);
|
|
// `posecho`: echo the request's own position back as
|
|
// the range (so the stored byte offset round-trips iff
|
|
// encode∘decode is correct) AND stamp the *wire*
|
|
// `character` the client sent into the result `uri` as
|
|
// `pos:N` (so the test can see the intermediate UTF-16
|
|
// value and prove the outbound encode was non-identity;
|
|
// the `uri` string is not a Position so the client's
|
|
// inbound rewrite leaves it untouched).
|
|
let (uri, range) = if mode == "posecho" {
|
|
let pos = params
|
|
.get("position")
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null);
|
|
let ch = pos
|
|
.get("character")
|
|
.and_then(serde_json::Value::as_u64)
|
|
.unwrap_or(0);
|
|
(
|
|
serde_json::Value::from(format!("pos:{ch}")),
|
|
serde_json::json!({ "start": pos, "end": pos }),
|
|
)
|
|
} else if mode == "defenv" {
|
|
// T M4.5 L1 cross-file: point the definition at a
|
|
// *different* file URI supplied via env, so the
|
|
// client must decode the URI, open-or-reuse that
|
|
// buffer, and reposition (SP-4 Gap A path).
|
|
let target = std::env::var("PMACS_FAKE_LSP_DEF_URI").unwrap_or_default();
|
|
(
|
|
serde_json::Value::from(target),
|
|
serde_json::json!({
|
|
"start": { "line": 2, "character": 0 },
|
|
"end": { "line": 2, "character": 3 }
|
|
}),
|
|
)
|
|
} else {
|
|
(
|
|
uri,
|
|
serde_json::json!({
|
|
"start": { "line": 7, "character": 4 },
|
|
"end": { "line": 7, "character": 9 }
|
|
}),
|
|
)
|
|
};
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": [{ "uri": uri, "range": range }]
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
// T M4.5 Location-shaped nav. Distinct line per method so
|
|
// a test can confirm each routes into its own kind slot.
|
|
(
|
|
m @ ("textDocument/references"
|
|
| "textDocument/declaration"
|
|
| "textDocument/typeDefinition"
|
|
| "textDocument/implementation"),
|
|
Some(idv),
|
|
) => {
|
|
let uri = params
|
|
.get("textDocument")
|
|
.and_then(|t| t.get("uri"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null);
|
|
let line = match m {
|
|
"textDocument/references" => 11,
|
|
"textDocument/declaration" => 21,
|
|
"textDocument/typeDefinition" => 31,
|
|
_ => 41, // implementation
|
|
};
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": [{
|
|
"uri": uri,
|
|
"range": {
|
|
"start": { "line": line, "character": 2 },
|
|
"end": { "line": line, "character": 6 }
|
|
}
|
|
}]
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/formatting", Some(idv)) => {
|
|
// Synthetic two-edit reply: trim leading whitespace on
|
|
// line 0 and append a semicolon at line 3, col 7.
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": [
|
|
{
|
|
"range": {
|
|
"start": { "line": 0, "character": 0 },
|
|
"end": { "line": 0, "character": 4 }
|
|
},
|
|
"newText": ""
|
|
},
|
|
{
|
|
"range": {
|
|
"start": { "line": 3, "character": 7 },
|
|
"end": { "line": 3, "character": 7 }
|
|
},
|
|
"newText": ";"
|
|
}
|
|
]
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/prepareRename", Some(idv)) => {
|
|
// `posecho` negotiates UTF-16. Validate the request
|
|
// position before replying so the position-codec test
|
|
// below catches byte-column regressions in this builder.
|
|
if mode == "posecho"
|
|
&& let Some(message) = utf16_position_error(¶ms, &open_docs)
|
|
{
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"error": { "code": -32602, "message": message }
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
continue;
|
|
}
|
|
// T M4.5: `preprefuse` → null (not renameable here);
|
|
// otherwise the `{ range, placeholder }` shape over
|
|
// the line-0 cols 3..6 span ("foo").
|
|
let result = if mode == "preprefuse" {
|
|
serde_json::Value::Null
|
|
} else {
|
|
serde_json::json!({
|
|
"range": {
|
|
"start": { "line": 0, "character": 3 },
|
|
"end": { "line": 0, "character": 6 }
|
|
},
|
|
"placeholder": "foo"
|
|
})
|
|
};
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": result
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/rename", Some(idv)) => {
|
|
// Same UTF-16 validation as prepareRename: rename and
|
|
// prepareRename both carry a single Position.
|
|
if mode == "posecho"
|
|
&& let Some(message) = utf16_position_error(¶ms, &open_docs)
|
|
{
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"error": { "code": -32602, "message": message }
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
continue;
|
|
}
|
|
// T M4.5 L2: reply with a `WorkspaceEdit`. The edit
|
|
// replaces the 3-char span at line 0, cols 3..6 with
|
|
// the requested `newName` (so the test can assert the
|
|
// buffer text changed). In `rename` mode a *second*
|
|
// file URI is taken from `PMACS_FAKE_LSP_RENAME_URI`
|
|
// and given the same edit, plus a `create` resource
|
|
// op — exercising the cross-file applier and the
|
|
// unsupported-op count. Otherwise the edit is
|
|
// single-file (the request's own document).
|
|
let uri = params
|
|
.get("textDocument")
|
|
.and_then(|t| t.get("uri"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null);
|
|
let new_name = params
|
|
.get("newName")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or("renamed")
|
|
.to_owned();
|
|
let edit = serde_json::json!([{
|
|
"range": {
|
|
"start": { "line": 0, "character": 3 },
|
|
"end": { "line": 0, "character": 6 }
|
|
},
|
|
"newText": new_name
|
|
}]);
|
|
let workspace_edit = if mode == "rename" {
|
|
let second = std::env::var("PMACS_FAKE_LSP_RENAME_URI").unwrap_or_default();
|
|
serde_json::json!({
|
|
"documentChanges": [
|
|
{
|
|
"textDocument": { "uri": uri, "version": 1 },
|
|
"edits": edit.clone()
|
|
},
|
|
{
|
|
"textDocument": { "uri": second, "version": 1 },
|
|
"edits": edit.clone()
|
|
}
|
|
]
|
|
})
|
|
} else {
|
|
let mut changes = serde_json::Map::new();
|
|
changes.insert(uri.as_str().unwrap_or("").to_owned(), edit);
|
|
serde_json::json!({ "changes": changes })
|
|
};
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": workspace_edit
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/codeAction", Some(idv)) => {
|
|
// T M4.5 L3: two actions — one with an inline edit
|
|
// (replace line-0 cols 3..6 with "ED1"), one that is
|
|
// command-only (the client must `executeCommand` it,
|
|
// and we then drive the change via a server→client
|
|
// `applyEdit`). The command carries the document URI
|
|
// as its argument so the executeCommand arm knows
|
|
// what to edit.
|
|
let uri = params
|
|
.get("textDocument")
|
|
.and_then(|t| t.get("uri"))
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null);
|
|
let mut changes = serde_json::Map::new();
|
|
changes.insert(
|
|
uri.as_str().unwrap_or("").to_owned(),
|
|
serde_json::json!([{
|
|
"range": {
|
|
"start": { "line": 0, "character": 3 },
|
|
"end": { "line": 0, "character": 6 }
|
|
},
|
|
"newText": "ED1"
|
|
}]),
|
|
);
|
|
// Command action first so a "apply the first action"
|
|
// client drives the executeCommand→applyEdit path;
|
|
// the inline-edit action second still exercises the
|
|
// CodeAction.edit normalisation in the store.
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": [
|
|
{
|
|
"title": "Run server command",
|
|
"kind": "refactor",
|
|
"command": {
|
|
"title": "Run",
|
|
"command": "pmacs.fake.applyEdit",
|
|
"arguments": [uri]
|
|
}
|
|
},
|
|
{
|
|
"title": "Inline fix",
|
|
"kind": "quickfix",
|
|
"edit": { "changes": changes }
|
|
}
|
|
]
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("workspace/executeCommand", Some(idv)) => {
|
|
// T M4.5 L3: the real edit is delivered out of band
|
|
// via a server→client `workspace/applyEdit` request
|
|
// (id 9100), exactly as rust-analyzer et al. do.
|
|
// Replace line-1 cols 0..3 with "ED2". Then answer
|
|
// the original executeCommand with a null result; the
|
|
// client's reply to 9100 lands in the default arm and
|
|
// is ignored.
|
|
let cmd = params
|
|
.get("command")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or("");
|
|
if cmd == "pmacs.fake.applyEdit" {
|
|
let target = params
|
|
.get("arguments")
|
|
.and_then(serde_json::Value::as_array)
|
|
.and_then(|a| a.first())
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Null);
|
|
// T M4.5 L4 `resourceops`: deliver an ordered
|
|
// documentChanges that creates a file, fills it
|
|
// (create-before-edit ordering), renames a
|
|
// sibling, and deletes another — paths derived
|
|
// from the request URI's directory so the test
|
|
// doesn't have to thread them through env.
|
|
let we = if mode == "resourceops" {
|
|
let s = target.as_str().unwrap_or("");
|
|
let base = match s.rfind('/') {
|
|
Some(i) => &s[..=i],
|
|
None => "",
|
|
};
|
|
let created = format!("{base}created.rs");
|
|
let b = format!("{base}b.rs");
|
|
let b2 = format!("{base}b2.rs");
|
|
let c = format!("{base}c.rs");
|
|
serde_json::json!({
|
|
"documentChanges": [
|
|
{ "kind": "create", "uri": created },
|
|
{
|
|
"textDocument": { "uri": created, "version": 1 },
|
|
"edits": [{
|
|
"range": {
|
|
"start": { "line": 0, "character": 0 },
|
|
"end": { "line": 0, "character": 0 }
|
|
},
|
|
"newText": "NEW"
|
|
}]
|
|
},
|
|
{ "kind": "rename", "oldUri": b, "newUri": b2 },
|
|
{ "kind": "delete", "uri": c }
|
|
]
|
|
})
|
|
} else {
|
|
serde_json::json!({
|
|
"documentChanges": [{
|
|
"textDocument": { "uri": target, "version": 1 },
|
|
"edits": [{
|
|
"range": {
|
|
"start": { "line": 1, "character": 0 },
|
|
"end": { "line": 1, "character": 3 }
|
|
},
|
|
"newText": "ED2"
|
|
}]
|
|
}]
|
|
})
|
|
};
|
|
let apply = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": 9100,
|
|
"method": "workspace/applyEdit",
|
|
"params": { "label": "fake refactor", "edit": we }
|
|
});
|
|
write_frame(&mut stdout, &apply);
|
|
}
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": serde_json::Value::Null
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/semanticTokens/full", Some(idv)) => {
|
|
// `rangeonly`: a range-only provider rejects /full —
|
|
// the client should have sent a range request.
|
|
if mode.starts_with("rangeonly") {
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"error": {
|
|
"code": -32601,
|
|
"message": "semanticTokens/full not supported"
|
|
}
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
continue;
|
|
}
|
|
// `fullonly`: bump the resultId per request so a test
|
|
// can observe WHICH pull refreshed the store — a
|
|
// repull that wrongly went to /full/delta is rejected
|
|
// and leaves the previous rid in place.
|
|
if mode == "fullonly" {
|
|
full_count += 1;
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": {
|
|
"resultId": format!("rid-{full_count}"),
|
|
"data": [0, 0, 4, 1, 1, 0, 5, 3, 2, 0, 2, 2, 7, 0, 2]
|
|
}
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
continue;
|
|
}
|
|
// T M4.5: relative-encoded `data`. Three tokens:
|
|
// [0,0,4,1,1] line 0 col 0 len 4, function, decl
|
|
// [0,5,3,2,0] same line col 5 len 3, variable
|
|
// [2,2,7,0,2] +2 lines col 2 len 7, namespace, ro
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": {
|
|
"resultId": "rid-1",
|
|
"data": [0, 0, 4, 1, 1, 0, 5, 3, 2, 0, 2, 2, 7, 0, 2]
|
|
}
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/semanticTokens/range", Some(idv)) => {
|
|
// `rangeonly16`: strict bounds validation in UTF-16
|
|
// units. A client that sent raw byte columns for
|
|
// non-ASCII text overshoots the last line's UTF-16
|
|
// length and is rejected — the fixture for the
|
|
// outbound-position conversion.
|
|
if mode == "rangeonly16"
|
|
&& let Ok(sink) = std::env::var("PMACS_FAKE_RANGE_SINK")
|
|
{
|
|
let _ = std::fs::write(&sink, format!("{params}"));
|
|
}
|
|
if mode == "rangeonly16"
|
|
&& let Some(message) = utf16_range_error(¶ms, &open_docs)
|
|
{
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"error": { "code": -32602, "message": message }
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
continue;
|
|
}
|
|
// T M4.5: same shape as /full, scoped to a range.
|
|
// One token: line 1 col 0 len 3, variable.
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": { "resultId": "rid-range", "data": [1, 0, 3, 2, 0] }
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/semanticTokens/full/delta", Some(idv)) => {
|
|
// `fullonly`: a conforming full-only server rejects a
|
|
// delta request outright — the client should never have
|
|
// sent it (capabilities advertised `"full": true` with
|
|
// no delta member).
|
|
if mode == "fullonly" {
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"error": {
|
|
"code": -32601,
|
|
"message": "semanticTokens/full/delta not supported"
|
|
}
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
continue;
|
|
}
|
|
// T M4.5: a `SemanticTokensDelta` over the /full data
|
|
// `[0,0,4,1,1, 0,5,3,2,0, 2,2,7,0,2]` — replace the
|
|
// last 5-int group (idx 10..15) with [3,0,9,1,0], so
|
|
// token 3 becomes line 3 col 0 len 9, function.
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": {
|
|
"resultId": "rid-2",
|
|
"edits": [
|
|
{ "start": 10, "deleteCount": 5, "data": [3, 0, 9, 1, 0] }
|
|
]
|
|
}
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/inlayHint", Some(idv)) => {
|
|
if mode == "inlaybounds"
|
|
&& let Some(message) = inlay_range_error(¶ms, &open_docs)
|
|
{
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"error": { "code": -32603, "message": message }
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
continue;
|
|
}
|
|
// T M4.5: a type hint (string label, kind 1) and a
|
|
// parameter hint (label *parts*, kind 2) so both
|
|
// label shapes are exercised.
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": [
|
|
{
|
|
"position": { "line": 0, "character": 9 },
|
|
"label": ": i32",
|
|
"kind": 1,
|
|
"paddingLeft": false,
|
|
"paddingRight": false,
|
|
"tooltip": "inferred type"
|
|
},
|
|
{
|
|
"position": { "line": 1, "character": 4 },
|
|
"label": [ { "value": "count" }, { "value": ":" } ],
|
|
"kind": 2,
|
|
"paddingLeft": false,
|
|
"paddingRight": true
|
|
}
|
|
]
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
// T M4.5 symbols/highlight. documentSymbol returns the
|
|
// *hierarchical* DocumentSymbol shape (exercises tree
|
|
// flatten + depth + parent); workspace/symbol the flat
|
|
// SymbolInformation shape (exercises location.uri);
|
|
// documentHighlight a two-occurrence list.
|
|
("textDocument/documentSymbol", Some(idv)) => {
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": [{
|
|
"name": "Outer", "kind": 5,
|
|
"range": { "start": { "line": 1, "character": 0 }, "end": { "line": 9, "character": 0 } },
|
|
"selectionRange": { "start": { "line": 1, "character": 6 }, "end": { "line": 1, "character": 11 } },
|
|
"children": [{
|
|
"name": "inner", "kind": 6,
|
|
"range": { "start": { "line": 3, "character": 2 }, "end": { "line": 5, "character": 2 } },
|
|
"selectionRange": { "start": { "line": 3, "character": 7 }, "end": { "line": 3, "character": 12 } }
|
|
}]
|
|
}]
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("workspace/symbol", Some(idv)) => {
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": [{
|
|
"name": "WsThing", "kind": 12,
|
|
"location": {
|
|
"uri": "file:///ws.rs",
|
|
"range": { "start": { "line": 7, "character": 3 }, "end": { "line": 7, "character": 10 } }
|
|
},
|
|
"containerName": "modw"
|
|
}]
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
("textDocument/documentHighlight", Some(idv)) => {
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": [
|
|
{ "range": { "start": { "line": 2, "character": 4 }, "end": { "line": 2, "character": 9 } }, "kind": 2 },
|
|
{ "range": { "start": { "line": 6, "character": 0 }, "end": { "line": 6, "character": 5 } } }
|
|
]
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
(_, Some(idv)) => {
|
|
// Generic echo response.
|
|
let resp = serde_json::json!({
|
|
"jsonrpc": "2.0",
|
|
"id": idv,
|
|
"result": { "echo": params, "method": method }
|
|
});
|
|
write_frame(&mut stdout, &resp);
|
|
}
|
|
_ => {}
|
|
}
|
|
if crashed_after_init {
|
|
// Honour the test's request to die after the first
|
|
// useful exchange.
|
|
std::process::exit(7);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_frame<R: Read>(r: &mut R) -> io::Result<Option<Vec<u8>>> {
|
|
// Read header byte-by-byte until \r\n\r\n, then read body.
|
|
let mut header = Vec::new();
|
|
let mut window = [0u8; 4];
|
|
let mut filled = 0usize;
|
|
loop {
|
|
let mut byte = [0u8; 1];
|
|
match r.read(&mut byte) {
|
|
Ok(0) => return Ok(None),
|
|
Ok(_) => {}
|
|
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
|
Err(e) => return Err(e),
|
|
}
|
|
header.push(byte[0]);
|
|
if filled < 4 {
|
|
window[filled] = byte[0];
|
|
filled += 1;
|
|
} else {
|
|
window.copy_within(1..4, 0);
|
|
window[3] = byte[0];
|
|
}
|
|
if &window == b"\r\n\r\n" {
|
|
break;
|
|
}
|
|
}
|
|
let header_str =
|
|
std::str::from_utf8(&header).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
|
let mut content_length: Option<usize> = None;
|
|
for line in header_str.split("\r\n") {
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
if let Some((k, v)) = line.split_once(':')
|
|
&& k.trim().eq_ignore_ascii_case("content-length")
|
|
{
|
|
content_length = v.trim().parse().ok();
|
|
}
|
|
}
|
|
let n = content_length
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing Content-Length"))?;
|
|
let mut body = vec![0u8; n];
|
|
r.read_exact(&mut body)?;
|
|
Ok(Some(body))
|
|
}
|
|
|
|
fn write_frame<W: Write>(w: &mut W, body: &serde_json::Value) {
|
|
let bytes = serde_json::to_vec(body).expect("json serialize");
|
|
let _ = write!(w, "Content-Length: {}\r\n\r\n", bytes.len());
|
|
let _ = w.write_all(&bytes);
|
|
let _ = w.flush();
|
|
}
|
|
|
|
fn write_garbage() {
|
|
let mut stdout = io::stdout().lock();
|
|
let _ = stdout.write_all(b"NotAValidLspFrame\r\nGarbageHeader\r\n\r\n{}");
|
|
let _ = stdout.flush();
|
|
}
|
|
|
|
fn document_end_position(text: &str) -> (u64, u64) {
|
|
let mut line = 0;
|
|
let mut col = 0;
|
|
for byte in text.bytes() {
|
|
if byte == b'\n' {
|
|
line += 1;
|
|
col = 0;
|
|
} else {
|
|
col += 1;
|
|
}
|
|
}
|
|
(line, col)
|
|
}
|
|
|
|
/// `rangeonly16` bounds validation: the request's end position must not
|
|
/// exceed the document end measured in UTF-16 code units (the
|
|
/// negotiated encoding). Byte-column overshoot on non-ASCII text is
|
|
/// exactly the client bug this catches.
|
|
fn utf16_range_error(
|
|
params: &serde_json::Value,
|
|
open_docs: &HashMap<String, String>,
|
|
) -> Option<String> {
|
|
// Fail-CLOSED: a fixture that silently skips validation on an
|
|
// unexpected state (missing uri / unrecorded doc) reads as a pass.
|
|
let Some(uri) = params
|
|
.get("textDocument")
|
|
.and_then(|t| t.get("uri"))
|
|
.and_then(serde_json::Value::as_str)
|
|
else {
|
|
return Some("range request carried no textDocument.uri".into());
|
|
};
|
|
let Some(text) = open_docs.get(uri) else {
|
|
return Some(format!("no didOpen text recorded for {uri}"));
|
|
};
|
|
let (mut line, mut col) = (0u64, 0u64);
|
|
for ch in text.chars() {
|
|
if ch == '\n' {
|
|
line += 1;
|
|
col = 0;
|
|
} else {
|
|
col += ch.len_utf16() as u64;
|
|
}
|
|
}
|
|
let end = params.get("range")?.get("end")?;
|
|
let end_line = end.get("line")?.as_u64()?;
|
|
let end_col = end.get("character")?.as_u64()?;
|
|
if end_line > line || (end_line == line && end_col > col) {
|
|
Some(format!(
|
|
"invalid utf-16 range end {end_line}:{end_col}; document ends at {line}:{col}"
|
|
))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// `posecho` validation for single-position requests. Unlike the
|
|
/// whole-document range fixture, this checks the requested line itself
|
|
/// so an overlarge byte column cannot hide behind a later line.
|
|
fn utf16_position_error(
|
|
params: &serde_json::Value,
|
|
open_docs: &HashMap<String, String>,
|
|
) -> Option<String> {
|
|
let Some(uri) = params
|
|
.get("textDocument")
|
|
.and_then(|t| t.get("uri"))
|
|
.and_then(serde_json::Value::as_str)
|
|
else {
|
|
return Some("position request carried no textDocument.uri".into());
|
|
};
|
|
let Some(text) = open_docs.get(uri) else {
|
|
return Some(format!("no didOpen text recorded for {uri}"));
|
|
};
|
|
let Some(position) = params.get("position") else {
|
|
return Some("position request carried no position".into());
|
|
};
|
|
let Some(line) = position.get("line").and_then(serde_json::Value::as_u64) else {
|
|
return Some("position request carried no numeric line".into());
|
|
};
|
|
let Some(col) = position
|
|
.get("character")
|
|
.and_then(serde_json::Value::as_u64)
|
|
else {
|
|
return Some("position request carried no numeric character".into());
|
|
};
|
|
let Ok(line_index) = usize::try_from(line) else {
|
|
return Some(format!("invalid utf-16 position line {line}"));
|
|
};
|
|
let Some(line_text) = text.split('\n').nth(line_index) else {
|
|
return Some(format!("invalid utf-16 position line {line}"));
|
|
};
|
|
let max_col = line_text.chars().map(char::len_utf16).sum::<usize>() as u64;
|
|
if col > max_col {
|
|
Some(format!(
|
|
"invalid utf-16 position {line}:{col}; line ends at {line}:{max_col}"
|
|
))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn inlay_range_error(
|
|
params: &serde_json::Value,
|
|
open_docs: &HashMap<String, String>,
|
|
) -> Option<String> {
|
|
let uri = params
|
|
.get("textDocument")
|
|
.and_then(|t| t.get("uri"))
|
|
.and_then(serde_json::Value::as_str)?;
|
|
let text = open_docs.get(uri)?;
|
|
let (last_line, last_col) = document_end_position(text);
|
|
let end = params.get("range")?.get("end")?;
|
|
let line = end.get("line")?.as_u64()?;
|
|
let col = end.get("character")?.as_u64()?;
|
|
if line > last_line || (line == last_line && col > last_col) {
|
|
Some(format!(
|
|
"invalid inlay range end {line}:{col}; document ends at {last_line}:{last_col}"
|
|
))
|
|
} else {
|
|
None
|
|
}
|
|
}
|