T M4.5: answer workspace/configuration pull from per-server settings

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 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-18 21:07:24 -04:00
parent 1dc7d794cf
commit 195949f73c
5 changed files with 207 additions and 15 deletions

View File

@ -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

View File

@ -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!({

View File

@ -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<Value>,
/// 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<Value>,
/// 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<Value> = 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);
}
}

View File

@ -6817,6 +6817,10 @@ fn lua_to_lsp_spec(t: &Table) -> mlua::Result<LspServerSpec> {
Some(Value::Nil) | None => None,
Some(other) => Some(lua_to_json(other)?),
};
let settings: Option<serde_json::Value> = match t.get::<Option<Value>>("settings")? {
Some(Value::Nil) | None => None,
Some(other) => Some(lua_to_json(other)?),
};
let capabilities: Option<serde_json::Value> = match t.get::<Option<Value>>("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<LspServerSpec> {
root_uri,
env,
init_options,
settings,
capabilities,
restart,
})

View File

@ -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<String> = 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::<Option<String>>()
.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:?}"
);
}