T M4.5: default Python LSP (basedpyright) + LSP-independent filetypes

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 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-18 20:51:12 -04:00
parent f63876bb46
commit 9ab931d6dd
2 changed files with 80 additions and 1 deletions

View File

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

View File

@ -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<std::path::PathBuf> {