From 9ab931d6ddd813c2f9e7af851747bee562e17b7f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 18 May 2026 20:51:12 -0400 Subject: [PATCH] T M4.5: default Python LSP (basedpyright) + LSP-independent filetypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real language on the now-correct async + UTF-16 substrate. - pmacs.lsp.config.python → `basedpyright-langserver --stdio`. basedpyright (MIT fork of pyright) re-enables inlay hints / semantic tokens in the OSS server that upstream pyright withholds for Pylance — matches the deferred-feature roadmap. No init_options: strictness is project config (pyrightconfig.json / [tool.pyright]); pmacs does not yet advertise workspace/configuration, so an editor-side typeCheckingMode would not be honoured regardless (documented in-line, with the upstream-pyright one-field override). - LSP language detection separated from tree-sitter. pmacs.parse's extension registry is grammar-gated (rejects "python" — no bundled grammar). New user-extensible pmacs.lsp.filetypes map (py/pyi → python); active_buffer_language() tries grammar-backed parse first (rust/.rs etc. unchanged) then falls back to the map, so a language with a server but no grammar still auto-attaches. - PATH-gated acceptance test mirroring m4_5_rust_analyzer_initializes; unique assertion: a real basedpyright must negotiate a positionEncoding pmacs can encode — validates Option B against a real strict server, not just the fake. Skips when absent. Gate: fmt clean; clippy --all-targets -D warnings clean; lib 1229/0; m4_acceptance 61/0; m9_1 18/0; m8_1/m8_9/m8_10 green. Co-Authored-By: Claude Opus 4.7 --- builtin/runtime/lsp.lua | 35 ++++++++++++++++++++++++++++++- tests/m4_acceptance.rs | 46 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 1879485..96603f8 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -28,6 +28,33 @@ 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; +-- swapping to upstream pyright is just `command = "pyright-langserver"`. +pmacs.lsp.config.python = pmacs.lsp.config.python or { + command = "basedpyright-langserver", + args = { "--stdio" }, +} + +-- LSP-side extension → language map, deliberately independent of the +-- tree-sitter detection in `pmacs.parse` (which is grammar-gated: +-- Python has an LSP server but no bundled grammar). Consulted only +-- when `pmacs.parse.language_for_path` finds nothing, so grammar- +-- backed languages keep their existing detection. Extensible from +-- init.lua: `pmacs.lsp.filetypes.foo = "bar"`. +pmacs.lsp.filetypes = pmacs.lsp.filetypes or {} +pmacs.lsp.filetypes.py = pmacs.lsp.filetypes.py or "python" +pmacs.lsp.filetypes.pyi = pmacs.lsp.filetypes.pyi or "python" + -- Per-buffer attachment record: { language, server, uri, version }. -- Keyed by `tostring(BufferIdLua)` because BufferIdLua hands out fresh -- userdata each call (so two handles to the same buffer wouldn't hash @@ -69,7 +96,13 @@ end local function active_buffer_language() local path = active_buffer_path() if not path then return nil end - return pmacs.parse.language_for_path(path) + -- Grammar-backed detection first (keeps rust/.rs etc. exactly as + -- before); fall back to the LSP-only filetype map so languages + -- with a server but no tree-sitter grammar (Python) still attach. + local lang = pmacs.parse.language_for_path(path) + if lang then return lang end + local ext = path:match("%.([%w_]+)$") + return ext and pmacs.lsp.filetypes[ext] or nil end local function ensure_server(language) diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 7eac9bf..b4a8254 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -1144,6 +1144,52 @@ fn m4_5_rust_analyzer_initializes() { let _ = mgr.borrow_mut().stop(sid); } +/// PATH-gated, mirrors `m4_5_rust_analyzer_initializes` for the +/// default Python server (basedpyright). Validates the whole stack +/// against a real, strict-by-default server: the registry command + +/// `--stdio` launches, the LSP handshake completes, and the server +/// negotiates a `positionEncoding` against the +/// `general.positionEncodings: ["utf-8","utf-16"]` we advertise +/// (Option B) — proving negotiation round-trips with a real server, +/// not only the fake. Skips cleanly when basedpyright is absent. +#[test] +fn m4_5_basedpyright_initializes_and_negotiates_encoding() { + let Ok(_) = which_binary("basedpyright-langserver") else { + eprintln!("basedpyright-langserver not on PATH; skipping"); + return; + }; + let (sup, mgr) = make_lsp_test_manager(); + let mut spec = LspServerSpec::new("basedpyright", "python", "basedpyright-langserver"); + spec.args = vec!["--stdio".into()]; + spec.restart = LspRestartPolicy::Never; + let sid = mgr.borrow_mut().spawn(spec).expect("spawn basedpyright"); + let evs = drain_lsp_until(&sup, &mgr, sid, Duration::from_secs(20), |evs| { + evs.iter() + .any(|e| matches!(e.kind, LspEventKind::Initialized { .. })) + }); + let caps = evs + .iter() + .find_map(|e| match &e.kind { + LspEventKind::Initialized { capabilities } => Some(capabilities.clone()), + _ => None, + }) + .expect("must observe Initialized event"); + assert!(caps.is_object(), "capabilities should be a JSON object"); + // If basedpyright implements LSP 3.17 position-encoding it echoes + // its choice, which must be one we can actually encode. A server + // predating 3.17 omits the field — correct too, since pmacs then + // defaults to UTF-16 (the spec default). What must never happen: + // a third encoding we don't handle. + let enc = caps.get("positionEncoding").and_then(|v| v.as_str()); + assert!( + matches!(enc, None | Some("utf-8" | "utf-16")), + "basedpyright negotiated an encoding pmacs cannot handle: {enc:?}" + ); + let state = mgr.borrow().state(sid).cloned(); + assert!(matches!(state, Some(LspClientState::Initialized { .. }))); + let _ = mgr.borrow_mut().stop(sid); +} + /// Helper: scan PATH for a binary by name. Returns the absolute /// path if found. fn which_binary(name: &str) -> std::io::Result {