T M4.5: derive LSP rootUri from the opened file's project (real-server hardening)

The default-bundle auto-attach path (lsp.lua ensure_server) never
forwarded cwd/root_uri to pmacs.lsp.spawn, so build_initialize fell
back to std::env::current_dir() — every auto-attached server received
the *editor's* cwd as rootUri regardless of which project the opened
file belonged to. Module-strict servers (gopls, rust-analyzer) return
nothing unless launched from the project dir; the fake-LSP and clangd
(which finds compile_flags near the file) masked this, gopls exposes
it. Same shape as the #26 transport bugs: lenient fakes hid a gap
strict real servers fall straight into.

Fix: project_root_for(language, path) in lsp.lua —
config[lang].root override -> pmacs.project.detect marker walk (the
canonical detector, honors set_search_boundary) -> the file's own
directory. attach_buffer resolves the path before ensure_server;
spawn now carries cwd/root_uri. Single-root only (fixes which root
the one per-language server uses); one-server-per-root multi-root
scoping stays deferred post-v0.1 (documented: first file of a
language fixes that server's root). New documented
pmacs.lsp.config[lang].root key.

Tests:
- m4_26: deterministic — new fake "rooturi" mode +
  PMACS_FAKE_LSP_ROOT_SINK side-channel; asserts the rootUri sent
  through a real find_or_open auto-attach is the go.mod dir, not the
  cwd, not the file's own dir.
- m4_27: PATH-gated real gopls — documentSymbol + hover round-trip is
  end-to-end proof of the fix against a real strict server.
- m4_28: PATH-gated real clangd — diagnostics arriving is the #26
  deferred-notification-flush + URI-absolutization regression guard;
  also exercises semantic tokens + documentSymbol.

No other latent bugs surfaced; gopls & clangd both clean through the
fixed path. rust-analyzer / basedpyright not installed here, so their
real end-to-end validation is still pending (the fix benefits them
identically — Cargo.toml / pyproject.toml are detect markers).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-19 15:55:45 -04:00
parent b306d414c6
commit 5180a627d1
3 changed files with 407 additions and 4 deletions

View File

@ -24,6 +24,15 @@
-- polling snapshot-diff watcher.
pmacs.lsp = pmacs.lsp or {}
-- Per-language server config. Each entry:
-- command (string) required — server binary
-- args (list) argv after command
-- env (table) extra environment
-- init_options (table) `initializationOptions`
-- settings (table) answered to `workspace/configuration`
-- root (string) optional explicit project root; overrides
-- the `pmacs.project.detect` marker walk used
-- to set `rootUri`/`cwd` (see project_root_for)
pmacs.lsp.config = pmacs.lsp.config or {}
-- Default rust-analyzer config. Users replace any field from init.lua
@ -235,11 +244,44 @@ local function active_buffer_language()
return ext and pmacs.lsp.filetypes[ext] or nil
end
local function ensure_server(language)
-- Directory component of a path, or nil if it has none.
local function dir_of(path)
if not path then return nil end
return path:match("^(.*)/[^/]*$")
end
-- The project root to send as `rootUri` / `cwd` in `initialize`, for
-- the file at `path`. Without this a server spawned by the auto-attach
-- hook gets the *editor's* cwd as its root (the `build_initialize`
-- fallback), which is wrong for project-model-strict servers: gopls
-- and rust-analyzer only analyze files under the module/workspace at
-- `rootUri`, so opening a file from outside the launch directory
-- yields zero diagnostics/hover/definition. Resolution order:
-- 1. explicit `pmacs.lsp.config[language].root` override,
-- 2. `pmacs.project.detect` marker walk (go.mod / Cargo.toml /
-- pyproject.toml / package.json / .git …) — the same detector
-- the rest of the editor uses, honoring set_search_boundary,
-- 3. the file's own directory (a lone file still gets a sane root
-- rather than leaking the editor cwd).
-- This is single-root: it fixes which root the one per-language server
-- uses, NOT one-server-per-root scoping (still deferred post-v0.1).
local function project_root_for(language, path)
local cfg = pmacs.lsp.config[language]
if cfg and cfg.root then return cfg.root end
if not path then return nil end
local ok, det = pcall(pmacs.project.detect, path)
if ok and det and det.root then return det.root end
return dir_of(path)
end
local function ensure_server(language, path)
local cfg = pmacs.lsp.config[language]
if not cfg or not cfg.command then return nil end
-- Reuse an existing same-language server if one is up. Multi-root
-- scoping (one server per project root) ships post-v0.1.
-- scoping (one server per project root) ships post-v0.1, so the
-- first file that attaches a given language fixes that server's
-- root; later files of the same language reuse it regardless of
-- their own project (known, documented limitation).
for _, info in ipairs(pmacs.lsp.list()) do
if info.language_id == language and info.state then
local kind = info.state.kind
@ -248,6 +290,7 @@ local function ensure_server(language)
end
end
end
local root = project_root_for(language, path)
local ok, sid = pcall(pmacs.lsp.spawn, {
label = "default-" .. language,
language_id = language,
@ -256,6 +299,8 @@ local function ensure_server(language)
env = cfg.env,
init_options = cfg.init_options,
settings = cfg.settings,
cwd = root,
root_uri = root and file_uri_for(root) or nil,
})
if ok then return sid end
return nil
@ -284,9 +329,11 @@ local function attach_buffer(buf)
if existing then attachments[key] = nil end
local language = active_buffer_language()
if not language then return nil end
local sid = ensure_server(language)
if not sid then return nil end
-- Path resolved before spawn so the server's `rootUri` can be
-- derived from the file's project (see `project_root_for`).
local path = active_buffer_path()
local sid = ensure_server(language, path)
if not sid then return nil end
local uri = file_uri_for(path)
if not uri then return nil end
local rec = { language = language, server = sid, uri = uri, version = 1 }

View File

@ -28,6 +28,10 @@
//! * If launched with `PMACS_FAKE_LSP_MODE=crash`: replies to
//! `initialize`, then exits with code 7 immediately, so the
//! client can verify crash + restart handling.
//! * If launched with `PMACS_FAKE_LSP_MODE=rooturi`: writes the
//! `rootUri` received in `initialize` to the file named by
//! `PMACS_FAKE_LSP_ROOT_SINK`, so a test can assert the
//! auto-attach path derives the project root from the opened file.
use std::io::{self, Read, Write};
@ -147,6 +151,21 @@ fn main() {
resp["result"]["capabilities"]["renameProvider"] =
serde_json::json!({ "prepareProvider": true });
}
// T M4.5 hardening `rooturi`: record the `rootUri` the
// client sent in `initialize` to a side-channel file
// (env `PMACS_FAKE_LSP_ROOT_SINK`). Lets a test prove
// the auto-attach path derives the project root from
// the opened file, not the editor's cwd. Mirrors the
// `filewatch` mode's `.received` disk side-channel.
if mode == "rooturi"
&& let Ok(sink) = std::env::var("PMACS_FAKE_LSP_ROOT_SINK")
{
let recorded = params
.get("rootUri")
.and_then(|v| v.as_str())
.unwrap_or("<null>");
let _ = std::fs::write(&sink, recorded);
}
write_frame(&mut stdout, &resp);
if mode == "crash" {
crashed_after_init = true;

View File

@ -4450,6 +4450,292 @@ fn m4_25_tier1_language_server_configs_and_filetypes() {
assert!(probe.get::<bool>("ft").unwrap(), "filetype map");
}
/// Hardening: a server spawned by the default-bundle auto-attach hook
/// must receive `rootUri` derived from the *opened file's project*
/// (the `go.mod` ancestor here), NOT the editor's process cwd. Before
/// this fix `build_initialize` fell back to `std::env::current_dir()`
/// because `ensure_server` never forwarded `cwd`/`root_uri` — which
/// silently broke module-strict servers (gopls, rust-analyzer) unless
/// pmacs happened to be launched from the project directory. Drives
/// the real `buffer.after-load` → `attach_buffer` → `ensure_server`
/// path; the fake records the `rootUri` it received to a side-channel.
#[test]
fn m4_26_auto_attach_roots_server_at_opened_files_project() {
use pmacs::editor::EditorState;
let dir = tempfile::tempdir().expect("tempdir");
// Canonicalize so the path matches what the marker-walk yields
// (tempfile may hand back a path under a symlinked /tmp).
let root = std::fs::canonicalize(dir.path()).expect("canonicalize root");
std::fs::write(root.join("go.mod"), b"module example\n\ngo 1.21\n").expect("go.mod");
let sub = root.join("pkg");
std::fs::create_dir(&sub).expect("mkdir pkg");
let go_file = sub.join("main.go");
std::fs::write(&go_file, b"package main\n\nfunc main() {}\n").expect("main.go");
let sink = root.join(".rooturi_sink");
let root_disp = root.display().to_string();
let sink_disp = sink.display().to_string();
let go_file_disp = go_file.display().to_string();
let fake = fake_lsp_path();
let mut state = EditorState::new();
// Clamp the marker walk to the tempdir so a stray ancestor marker
// (a developer's /tmp/.git, say) can't masquerade as the root.
// Point the default `go` server at the fake in `rooturi` mode.
state
.lua_host
.lua()
.load(format!(
"pmacs.project.set_search_boundary('{root_disp}')
pmacs.lsp.config.go = {{
command = '{fake}',
env = {{
PMACS_FAKE_LSP_MODE = 'rooturi',
PMACS_FAKE_LSP_ROOT_SINK = '{sink_disp}',
}},
}}"
))
.exec()
.expect("configure go -> fake rooturi");
// Open the file from a *sub*directory of the module: fires
// `buffer.after-load`, which attaches & spawns the fake.
state
.lua_host
.lua()
.load(format!("pmacs.buffer.find_or_open('{go_file_disp}')"))
.exec()
.expect("open pkg/main.go");
// The fake writes the received rootUri on `initialize`; wait for
// the side-channel to carry the scheme.
assert!(
pump_until_file_contains(&mut state, &sink, "file://", 5),
"fake never recorded a rootUri (server didn't initialize?)"
);
let recorded = std::fs::read_to_string(&sink).expect("read sink");
let expected = format!("file://{root_disp}");
let cwd_uri = format!(
"file://{}",
std::fs::canonicalize(std::env::current_dir().unwrap())
.unwrap()
.display()
);
let sub_uri = format!("file://{}", sub.display());
assert_eq!(
recorded, expected,
"rootUri must be the go.mod dir, not the cwd ({cwd_uri}) or the file's own dir ({sub_uri})"
);
assert_ne!(
recorded, cwd_uri,
"regression: rootUri fell back to the editor's process cwd"
);
assert_ne!(
recorded, sub_uri,
"rootUri must be the project root, not the file's immediate directory"
);
}
/// PATH-gated real-server hardening: drive **real gopls** through the
/// default-bundle auto-attach path against a real Go module on disk,
/// and assert it actually analyzes the file (documentSymbol + hover
/// round-trip). gopls is module-strict — it returns nothing unless
/// `rootUri` is the `go.mod` directory — so a green run here is the
/// end-to-end proof of the `project_root_for` fix against a real
/// strict server, not just the fake. The fake-LSP arc could never
/// catch this (it ignores rootUri). Skips cleanly when gopls absent.
#[test]
fn m4_27_real_gopls_analyzes_module_via_auto_attach() {
use pmacs::editor::EditorState;
let Ok(gopls) = which_binary("gopls") else {
eprintln!("gopls not on PATH; skipping");
return;
};
let gopls = gopls.display().to_string();
let dir = tempfile::tempdir().expect("tempdir");
let root = std::fs::canonicalize(dir.path()).expect("canonicalize");
std::fs::write(root.join("go.mod"), b"module hardening\n\ngo 1.21\n").expect("go.mod");
let sub = root.join("pkg");
std::fs::create_dir(&sub).expect("mkdir pkg");
let src = "package main\n\
\n\
import \"fmt\"\n\
\n\
func Greet(name string) string {\n\
\treturn fmt.Sprintf(\"hello, %s\", name)\n\
}\n\
\n\
func main() {\n\
\tfmt.Println(Greet(\"world\"))\n\
}\n";
let go_file = sub.join("main.go");
std::fs::write(&go_file, src).expect("main.go");
let root_disp = root.display().to_string();
let go_file_disp = go_file.display().to_string();
let uri = format!("file://{go_file_disp}");
let mut state = EditorState::new();
// gopls handshake + workspace load is slow on a cold cache (30s).
real_server_open_and_init(&mut state, "go", &gopls, &root_disp, &go_file_disp);
// Fire documentSymbol + hover through the attached server and pump
// until both stores populate. Symbols coming back at all means
// gopls resolved the package — which only happens when rootUri is
// the module dir (the fix). Hover on `Greet` (line 4, col 5).
state
.lua_host
.lua()
.load(format!(
"local sid for _,r in ipairs(pmacs.lsp.list()) do sid=r.id break end
pmacs.lsp.request_document_symbol(sid, '{uri}')
pmacs.lsp.request_hover(sid, '{uri}', 4, 5)"
))
.exec()
.expect("fire documentSymbol + hover");
let deadline = Instant::now() + Duration::from_secs(30);
let mut have_syms = false;
let mut have_hover = false;
while Instant::now() < deadline && (!have_syms || !have_hover) {
state.tick_processes();
state.tick_lsp();
state.tick_async();
let (s, h): (bool, bool) = state
.lua_host
.lua()
.load(format!(
"local sid for _,r in ipairs(pmacs.lsp.list()) do sid=r.id break end
local syms = pmacs.document_symbol.symbols(sid, '{uri}')
local hv = pmacs.hover.current(sid, '{uri}')
return (syms and #syms > 0), (hv ~= nil and hv.contents ~= nil and #hv.contents > 0)"
))
.eval()
.unwrap_or((false, false));
have_syms = s;
have_hover = h;
if !have_syms || !have_hover {
std::thread::sleep(Duration::from_millis(50));
}
}
assert!(
have_syms,
"real gopls returned no documentSymbols — package not resolved (rootUri regression?)"
);
assert!(
have_hover,
"real gopls returned no hover for Greet — package not resolved (rootUri regression?)"
);
assert_no_lsp_crash(&mut state, "gopls");
}
/// PATH-gated real-server hardening: drive **real clangd** through the
/// default-bundle auto-attach path. clangd is the strict server that
/// surfaced the #26 bugs (it discards notifications sent before
/// `initialized`, and rejects a non-absolute file URI with -32602).
/// This re-verifies both fixes against the real binary — diagnostics
/// arriving at all proves the deferred-notification flush + path
/// absolutization still hold — and exercises a post-#26 feature
/// (semantic tokens) plus documentSymbol end to end. The fake-LSP arc
/// is lenient and could not catch a #26 regression. Skips cleanly
/// when clangd is absent.
#[test]
fn m4_28_real_clangd_diagnostics_and_semantic_tokens_via_auto_attach() {
use pmacs::editor::EditorState;
let Ok(clangd) = which_binary("clangd") else {
eprintln!("clangd not on PATH; skipping");
return;
};
let clangd = clangd.display().to_string();
let dir = tempfile::tempdir().expect("tempdir");
let root = std::fs::canonicalize(dir.path()).expect("canonicalize");
// compile_flags.txt is clangd's lightweight project model; -Wall
// makes the unused-variable warning below a deterministic
// diagnostic without breaking the AST (so symbols/tokens stay
// complete). Doubles as a non-language root marker for clangd.
std::fs::write(root.join("compile_flags.txt"), b"-std=c++17\n-Wall\n").expect("flags");
// No language marker `pmacs.project.detect` recognizes lives here,
// so `project_root_for` falls back to the file's own directory —
// which is `root`, exactly where clangd finds compile_flags.txt.
let src = "int add(int a, int b) { return a + b; }\n\
\n\
int main() {\n\
\tint unused = 41;\n\
\treturn add(1, 2);\n\
}\n";
let cpp = root.join("main.cpp");
std::fs::write(&cpp, src).expect("main.cpp");
let root_disp = root.display().to_string();
let cpp_disp = cpp.display().to_string();
let uri = format!("file://{cpp_disp}");
let mut state = EditorState::new();
real_server_open_and_init(&mut state, "cpp", &clangd, &root_disp, &cpp_disp);
// Fire a semantic-tokens request; diagnostics flow unsolicited
// from clangd after it parses the (auto-opened) document.
state
.lua_host
.lua()
.load(format!(
"local sid for _,r in ipairs(pmacs.lsp.list()) do sid=r.id break end
pmacs.lsp.request_semantic_tokens(sid, '{uri}')
pmacs.lsp.request_document_symbol(sid, '{uri}')"
))
.exec()
.expect("fire semantic tokens + documentSymbol");
let deadline = Instant::now() + Duration::from_secs(30);
let (mut have_diag, mut have_tokens, mut have_syms) = (false, false, false);
while Instant::now() < deadline && !(have_diag && have_tokens && have_syms) {
state.tick_processes();
state.tick_lsp();
state.tick_async();
let (d, t, s): (bool, bool, bool) = state
.lua_host
.lua()
.load(format!(
"local sid for _,r in ipairs(pmacs.lsp.list()) do sid=r.id break end
local toks = pmacs.semantic_tokens.tokens(sid, '{uri}')
local syms = pmacs.document_symbol.symbols(sid, '{uri}')
return (pmacs.diag.count('{uri}') > 0), (toks and #toks > 0), (syms and #syms > 0)"
))
.eval()
.unwrap_or((false, false, false));
have_diag = have_diag || d;
have_tokens = have_tokens || t;
have_syms = have_syms || s;
if !(have_diag && have_tokens && have_syms) {
std::thread::sleep(Duration::from_millis(50));
}
}
// Diagnostics arriving is the #26 regression guard: it can only
// happen if the pre-`initialized` `didOpen` was deferred & replayed
// (flush) AND the file URI was absolute (no -32602) against the
// strict server.
assert!(
have_diag,
"real clangd published no diagnostics — #26 regression \
(deferred-notification flush or URI absolutization broke)"
);
assert!(
have_tokens,
"real clangd returned no semantic tokens via auto-attach"
);
assert!(
have_syms,
"real clangd returned no documentSymbols via auto-attach"
);
assert_no_lsp_crash(&mut state, "clangd");
}
/// Default LSP bundle (`builtin/runtime/lsp.lua`) is wired in: the
/// hooks are defined, the namespace tables exist, the user-facing
/// commands are registered with the command registry, and the default
@ -4690,6 +4976,57 @@ fn pump_until_file_contains(
}
}
/// Shared scaffold for the PATH-gated real-server hardening tests:
/// clamp project detection to `root` (so a stray ancestor marker
/// can't masquerade as the root), point `config[lang_key]` at the real
/// `command`, open `file` (firing `buffer.after-load` → auto-attach),
/// and pump until the attached server reports `initialized`. Panics
/// with a clear message if it never does.
fn real_server_open_and_init(
state: &mut pmacs::editor::EditorState,
lang_key: &str,
command: &str,
root: &str,
file: &str,
) {
state
.lua_host
.lua()
.load(format!(
"pmacs.project.set_search_boundary('{root}')
pmacs.lsp.config.{lang_key} = {{ command = '{command}', args = {{}} }}
pmacs.buffer.find_or_open('{file}')"
))
.exec()
.expect("configure real server + open file");
assert!(
pump_lua_flag(
state,
"(function() for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end return false end)()",
30,
),
"real {lang_key} server never reached initialized"
);
}
/// Assert no LSP server is in the `crashed` state — the real-server
/// tests use this to confirm the server survived the exchange.
fn assert_no_lsp_crash(state: &mut pmacs::editor::EditorState, label: &str) {
let crashed: bool = state
.lua_host
.lua()
.load(
"for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='crashed' then return true end \
end return false",
)
.eval()
.unwrap();
assert!(!crashed, "{label} crashed during the exchange");
}
/// Spawn the fake LSP via the Lua surface (optionally in a test mode)
/// and pump until the manager reports it `initialized`.
fn spawn_lsp_and_init(state: &mut pmacs::editor::EditorState, mode: Option<&str>) {