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:
parent
1dc7d794cf
commit
195949f73c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
105
src/lsp.rs
105
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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue