From 195949f73cd767c06ba1dda67105cbcb1266fb4f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 18 May 2026 21:07:24 -0400 Subject: [PATCH] T M4.5: answer workspace/configuration pull from per-server settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gopls / pyright / basedpyright / clangd issue server→client `workspace/configuration` during startup and degrade (or fall back to defaults) without a reply. pmacs advertised `configuration:false`, so it never got the chance. - Advertise `workspace.configuration: true`. - New `settings` field on the spawn spec, threaded through lua_to_lsp_spec → ensure_server (pmacs.lsp.config[lang].settings). - handle_request intercepts `workspace/configuration` (mirrors the publishDiagnostics interception in handle_notification): each item's dotted `section` resolves against the server's settings via resolve_config_section; one array element per item; unknown sections answer `null` (the spec's "not configured" signal, distinct from a configured null). All other server→client requests still surface as a `Request` event for the consumer. - The Python default now ships `python.analysis.typeCheckingMode = "basic"` (+ basedpyright.* alias), so the #12 basedpyright-noise concern is now actually fixed rather than only documented; a project pyrightconfig.json / [tool.pyright] still wins where present. Scoped: `scopeUri` ignored (single-root; same settings regardless of scope) until multi-root, a separate deferred item. Tests: exhaustive resolve_config_section unit test (dotted paths, configured-null vs unknown-null, whole-object for absent section); new `wsconfig` fake mode pulls config at `initialized` and echoes pmacs's answer back; end-to-end test asserts the configured section round-trips. Gate: fmt clean; clippy --all-targets -D warnings clean; lib 1230/0; m4_acceptance 62/0; m9_1 18/0; m8_1/m8_9/m8_10 green. Co-Authored-By: Claude Opus 4.7 --- builtin/runtime/lsp.lua | 19 ++++--- src/bin/pmacs_fake_lsp.rs | 32 ++++++++++++ src/lsp.rs | 105 +++++++++++++++++++++++++++++++++++--- src/lua_bindings.rs | 5 ++ tests/m4_acceptance.rs | 61 ++++++++++++++++++++++ 5 files changed, 207 insertions(+), 15 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 96603f8..02c53af 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -31,18 +31,20 @@ pmacs.lsp.config.rust = pmacs.lsp.config.rust or { -- Default Python config: basedpyright (an MIT fork of pyright that -- re-enables inlay hints / semantic tokens in the open-source server, -- which upstream pyright withholds for Pylance). `--stdio` is the --- LSP transport. No `init_options`: basedpyright/pyright take their --- strictness from project config (`pyrightconfig.json` / --- `[tool.pyright]` in `pyproject.toml`), and pmacs does not yet --- advertise `workspace/configuration` (a deferred capability), so an --- editor-side `typeCheckingMode` would not be honoured anyway. Until --- the project pins it, basedpyright's stricter defaults can make the --- diagnostics gutter noisier than upstream pyright — documented, not --- a bug. Users override any field from init.lua before a .py opens; +-- LSP transport. `settings` is answered to basedpyright's +-- `workspace/configuration` pull (pmacs now advertises that +-- capability) — `basic` keeps the diagnostics gutter from being +-- flooded by the fork's stricter defaults. A project's +-- `pyrightconfig.json` / `[tool.pyright]` still wins where present. +-- Users override any field from init.lua before a .py opens; -- swapping to upstream pyright is just `command = "pyright-langserver"`. pmacs.lsp.config.python = pmacs.lsp.config.python or { command = "basedpyright-langserver", args = { "--stdio" }, + settings = { + python = { analysis = { typeCheckingMode = "basic" } }, + basedpyright = { analysis = { typeCheckingMode = "basic" } }, + }, } -- LSP-side extension → language map, deliberately independent of the @@ -124,6 +126,7 @@ local function ensure_server(language) command = cfg.command, args = cfg.args or {}, init_options = cfg.init_options, + settings = cfg.settings, }) if ok then return sid end return nil diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index 6c90aaa..c9fc496 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -70,6 +70,24 @@ fn main() { .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). @@ -120,6 +138,20 @@ fn main() { 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); + } ("initialized", _) => {} ("shutdown", Some(idv)) => { let resp = serde_json::json!({ diff --git a/src/lsp.rs b/src/lsp.rs index e7bce74..fd078a4 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -129,6 +129,11 @@ pub struct LspServerSpec { /// request. Free-form per server; pmacs marshalls it into JSON /// from a Lua table. pub init_options: Option, + /// T M4.5: workspace settings answered to server→client + /// `workspace/configuration` pull requests. A JSON object; + /// requested `section`s (dotted paths, e.g. `python.analysis`) + /// resolve into it, unknown sections answer `null` per spec. + pub settings: Option, /// Optional client-side capabilities override sent in /// `initialize`. `None` falls back to a conservative built-in /// default (text-sync full, hover, completion, definition). @@ -155,6 +160,7 @@ impl LspServerSpec { root_uri: None, env: Vec::new(), init_options: None, + settings: None, capabilities: None, restart: LspRestartPolicy::OnCrash, } @@ -509,6 +515,28 @@ impl PositionEncoding { } } +/// Resolve a `workspace/configuration` item's `section` against the +/// server's `settings`. LSP semantics: a dotted `section` +/// (`"python.analysis"`) walks nested objects; an absent/empty +/// section asks for the whole settings object; a section that does +/// not resolve answers `null` (the spec's "scope not configured" +/// signal — distinct from a configured `null`/`false`). +fn resolve_config_section(settings: &Value, section: Option<&str>) -> Value { + match section { + None | Some("") => settings.clone(), + Some(path) => { + let mut cur = settings; + for key in path.split('.') { + match cur.get(key) { + Some(v) => cur = v, + None => return Value::Null, + } + } + cur.clone() + } + } +} + /// The 0-based `line`-th `\n`-delimited slice of `text`, or `None` /// when `line` is past EOF. The distinction matters: a genuinely /// empty line (`Some("")`) is converted to byte 0, but a line we do @@ -2005,12 +2033,39 @@ impl LspManager { params: Value, now: Instant, ) { - // We don't synthesize a default error here --- expose the - // request to the consumer (M4.6+ wires diagnostics, etc.) - // and let it choose to reply via `send_response`. Until M4.6 - // ships replies for the requests we recognise, unknown - // requests will simply linger; the LSP spec tolerates - // delayed responses. + // T M4.5: answer `workspace/configuration` ourselves — it's a + // protocol-level pull (gopls/pyright/clangd issue it during + // startup and degrade without a reply), not something to defer + // to a Lua consumer. One array element per requested item; + // each `section` resolves against the server's `settings`, + // unknown sections answer `null` per spec. + if method == "workspace/configuration" { + let settings = self + .clients + .get(&sid) + .and_then(|c| c.spec.settings.clone()) + .unwrap_or(Value::Null); + let answers: Vec = params + .get("items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|item| { + resolve_config_section( + &settings, + item.get("section").and_then(Value::as_str), + ) + }) + .collect() + }) + .unwrap_or_default(); + let _ = self.send_response(sid, idv, Ok(Value::Array(answers))); + return; + } + // Everything else: expose the request to the consumer and let + // it reply via `send_response`. The LSP spec tolerates + // delayed responses; unrecognised requests simply linger. self.push_event( sid, now, @@ -2356,7 +2411,11 @@ fn default_capabilities() -> Value { }, "workspace": { "applyEdit": false, - "configuration": false, + // T M4.5: pmacs answers server→client `workspace/configuration` + // pull requests from the per-server `settings` (see + // `handle_request`). gopls / pyright / clangd all pull + // config this way and degrade without it. + "configuration": true, "workspaceFolders": true, "didChangeConfiguration": { "dynamicRegistration": false }, }, @@ -2743,4 +2802,36 @@ mod tests { rewrite_positions_to_bytes(&mut v2, doc, PositionEncoding::Utf8); assert_eq!(v2["character"], json!(1)); } + + // ---- T M4.5: workspace/configuration section resolution -------------- + + #[test] + fn resolve_config_section_semantics() { + let s = json!({ + "python": { "analysis": { "typeCheckingMode": "basic" } }, + "x": 1, + "nullable": null, + }); + // Dotted path walks nested objects. + assert_eq!( + resolve_config_section(&s, Some("python.analysis.typeCheckingMode")), + json!("basic") + ); + assert_eq!( + resolve_config_section(&s, Some("python.analysis")), + json!({ "typeCheckingMode": "basic" }) + ); + assert_eq!(resolve_config_section(&s, Some("x")), json!(1)); + // A configured `null` is returned as-is (distinct from unknown). + assert_eq!(resolve_config_section(&s, Some("nullable")), Value::Null); + // Unknown section ⇒ null (the spec's "not configured" signal). + assert_eq!( + resolve_config_section(&s, Some("python.missing")), + Value::Null + ); + assert_eq!(resolve_config_section(&s, Some("nope")), Value::Null); + // Absent / empty section ⇒ the whole settings object. + assert_eq!(resolve_config_section(&s, None), s); + assert_eq!(resolve_config_section(&s, Some("")), s); + } } diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index fd10477..0100564 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -6817,6 +6817,10 @@ fn lua_to_lsp_spec(t: &Table) -> mlua::Result { Some(Value::Nil) | None => None, Some(other) => Some(lua_to_json(other)?), }; + let settings: Option = match t.get::>("settings")? { + Some(Value::Nil) | None => None, + Some(other) => Some(lua_to_json(other)?), + }; let capabilities: Option = match t.get::>("capabilities")? { Some(Value::Nil) | None => None, Some(other) => Some(lua_to_json(other)?), @@ -6834,6 +6838,7 @@ fn lua_to_lsp_spec(t: &Table) -> mlua::Result { root_uri, env, init_options, + settings, capabilities, restart, }) diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index b4a8254..476b7bc 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -3838,3 +3838,64 @@ fn m4_5_position_encoding_utf16_round_trips_non_ascii() { identity would store 2" ); } + +/// T M4.5: pmacs answers the server→client `workspace/configuration` +/// pull from the per-server `settings` (the capability gopls / +/// pyright / clangd rely on). The `wsconfig` fake issues the request +/// at `initialized` with items `[pmacs.probe, does.not.exist]`, then +/// echoes pmacs's response array back as a `pmacs/wsconfig` +/// notification. The configured section must resolve to its value; +/// the unknown one to null (the null half is exhaustively covered by +/// the `resolve_config_section_semantics` unit test — here we assert +/// the end-to-end happy path: request intercepted + answered, not +/// surfaced as an unhandled `Request` event). +#[test] +fn m4_5_workspace_configuration_answered_from_settings() { + use pmacs::editor::EditorState; + let mut state = EditorState::new(); + let fake = fake_lsp_path(); + state + .lua_host + .lua() + .load(format!( + "_G._lsp = pmacs.lsp.spawn({{ + label='wscfg', language_id='python', command='{fake}', + restart='never', + env={{ PMACS_FAKE_LSP_MODE='wsconfig' }}, + settings={{ pmacs={{ probe='ok-42' }} }} + }})" + )) + .exec() + .expect("spawn wsconfig server"); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut got: Option = None; + while Instant::now() < deadline && got.is_none() { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + got = state + .lua_host + .lua() + .load( + "for _, ev in ipairs(pmacs.lsp.events_take(_G._lsp)) do + if ev.kind=='notification' and ev.method=='pmacs/wsconfig' then + local a = ev.params and ev.params.answer + if type(a)=='table' then return tostring(a[1]) end + end + end + return nil", + ) + .eval::>() + .unwrap_or(None); + if got.is_none() { + std::thread::sleep(Duration::from_millis(15)); + } + } + assert_eq!( + got.as_deref(), + Some("ok-42"), + "pmacs must answer workspace/configuration section 'pmacs.probe' \ + from the spec settings; got {got:?}" + ); +}