diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 02c53af..ce59fe4 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -47,6 +47,33 @@ pmacs.lsp.config.python = pmacs.lsp.config.python or { }, } +-- C / C++ via clangd. One clangd binary serves both; `config.c` and +-- `config.cpp` are separate entries only so the `language_id` sent in +-- `didOpen` is accurate (clangd respects it). clangd takes its +-- project model from `compile_commands.json` / `compile_flags.txt` +-- at the project root, not `workspace/configuration`, so no +-- `settings` here; `--background-index` enables cross-file features. +-- Users override from init.lua before a C/C++ file opens. +pmacs.lsp.config.c = pmacs.lsp.config.c or { + command = "clangd", + args = { "--background-index" }, +} +pmacs.lsp.config.cpp = pmacs.lsp.config.cpp or { + command = "clangd", + args = { "--background-index" }, +} + +-- Go via gopls. `gopls` with no args serves LSP over stdio. gopls +-- pulls its configuration via `workspace/configuration` (now +-- answered, #13) under the `gopls` section; an empty section means +-- "use defaults" — present, not null, which gopls prefers. Users +-- populate it (e.g. staticcheck, analyses) from init.lua. +pmacs.lsp.config.go = pmacs.lsp.config.go or { + command = "gopls", + args = {}, + settings = { gopls = {} }, +} + -- 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 @@ -56,6 +83,16 @@ pmacs.lsp.config.python = pmacs.lsp.config.python or { 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" +-- C. `.h` is ambiguous C/C++; default it to C (clangd copes either +-- way, and users can remap `pmacs.lsp.filetypes.h = "cpp"`). +pmacs.lsp.filetypes.c = pmacs.lsp.filetypes.c or "c" +pmacs.lsp.filetypes.h = pmacs.lsp.filetypes.h or "c" +-- C++. +for _, ext in ipairs({ "cpp", "cc", "cxx", "hpp", "hh", "hxx", "ipp", "inl", "cppm" }) do + pmacs.lsp.filetypes[ext] = pmacs.lsp.filetypes[ext] or "cpp" +end +-- Go. +pmacs.lsp.filetypes.go = pmacs.lsp.filetypes.go or "go" -- Per-buffer attachment record: { language, server, uri, version }. -- Keyed by `tostring(BufferIdLua)` because BufferIdLua hands out fresh diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 476b7bc..85d24e8 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -1190,6 +1190,70 @@ fn m4_5_basedpyright_initializes_and_negotiates_encoding() { let _ = mgr.borrow_mut().stop(sid); } +/// Shared body for the PATH-gated real-server smoke tests: spawn, +/// reach `Initialized`, and assert the server negotiated a +/// `positionEncoding` pmacs can actually encode (absent ⇒ pmacs +/// defaults to UTF-16, also fine; a third encoding must never slip +/// through). Mirrors the basedpyright test for clangd / gopls — +/// strict-by-default servers that exercise the Option B path against +/// real implementations, not just the fake. +fn assert_lsp_initializes_and_negotiates( + label: &str, + language_id: &str, + command: &str, + args: &[&str], +) { + let (sup, mgr) = make_lsp_test_manager(); + let mut spec = LspServerSpec::new(label, language_id, command); + spec.args = args.iter().map(|s| (*s).to_string()).collect(); + spec.restart = LspRestartPolicy::Never; + let sid = mgr.borrow_mut().spawn(spec).expect("spawn server"); + 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"); + let enc = caps.get("positionEncoding").and_then(|v| v.as_str()); + assert!( + matches!(enc, None | Some("utf-8" | "utf-16")), + "{label} 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); +} + +/// C/C++ via clangd (PATH-gated). clangd defaults UTF-16 and also +/// supports its own `offsetEncoding` extension; either way the +/// negotiated encoding must be one pmacs encodes. +#[test] +fn m4_5_clangd_initializes_and_negotiates_encoding() { + let Ok(_) = which_binary("clangd") else { + eprintln!("clangd not on PATH; skipping"); + return; + }; + assert_lsp_initializes_and_negotiates("clangd", "cpp", "clangd", &["--background-index"]); +} + +/// Go via gopls (PATH-gated). gopls implements LSP 3.17 +/// position-encoding, defaults UTF-16, and pulls config via +/// `workspace/configuration` — exercises the full stack end to end. +#[test] +fn m4_5_gopls_initializes_and_negotiates_encoding() { + let Ok(_) = which_binary("gopls") else { + eprintln!("gopls not on PATH; skipping"); + return; + }; + assert_lsp_initializes_and_negotiates("gopls", "go", "gopls", &[]); +} + /// Helper: scan PATH for a binary by name. Returns the absolute /// path if found. fn which_binary(name: &str) -> std::io::Result {