732 lines
24 KiB
Rust
732 lines
24 KiB
Rust
//! Arc 8 Stage 3a acceptance — LSP notification/response dispatch seams
|
||
//! and `pmacs.fs.canonicalize`.
|
||
//!
|
||
//! `docs/lean4-mode-framing.md` Q#LN9 and Q#LN20, acceptance 29–34 plus
|
||
//! 34a/34b.
|
||
//!
|
||
//! This suite deliberately contains **no Lean content**.
|
||
//! `handle_server_requests` (`builtin/runtime/lsp.lua`) is the single
|
||
//! LSP event drain for every language in pmacs, so the change is
|
||
//! exercised through an already-shipped language driven against
|
||
//! `pmacs_fake_lsp`. A suite that reached the drain only through Lean
|
||
//! would understate the blast radius — the same reasoning that shaped
|
||
//! Stage 2's suite.
|
||
//!
|
||
//! Every fixture calls `pmacs.project.set_search_boundary` at its own
|
||
//! tempdir root, so a stray marker above the temp directory cannot make
|
||
//! a "markerless" case silently detected.
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::time::Duration;
|
||
|
||
use pmacs::editor::EditorState;
|
||
|
||
fn exec(state: &EditorState, source: &str) {
|
||
state.lua_host.lua().load(source.to_owned()).exec().unwrap();
|
||
}
|
||
|
||
fn eval<T: mlua::FromLuaMulti>(state: &EditorState, source: &str) -> T {
|
||
state.lua_host.lua().load(source.to_owned()).eval().unwrap()
|
||
}
|
||
|
||
fn fake_lsp_path() -> String {
|
||
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
|
||
}
|
||
|
||
/// A fresh editor with the shipped language configs cleared, so the only
|
||
/// server any test can spawn is the fake one it configures itself.
|
||
fn editor() -> EditorState {
|
||
let state = EditorState::new_with_roots(&crate::iso::roots());
|
||
exec(&state, "pmacs.lsp.config = {}");
|
||
state
|
||
}
|
||
|
||
fn lua_str(path: &Path) -> String {
|
||
path.display()
|
||
.to_string()
|
||
.replace('\\', "\\\\")
|
||
.replace('"', "\\\"")
|
||
}
|
||
|
||
struct Fixture {
|
||
_dir: tempfile::TempDir,
|
||
root: PathBuf,
|
||
}
|
||
|
||
impl Fixture {
|
||
fn new() -> Self {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let root = std::fs::canonicalize(dir.path()).unwrap();
|
||
Self { _dir: dir, root }
|
||
}
|
||
|
||
fn write(&self, rel: &str, contents: &str) -> PathBuf {
|
||
let path = self.root.join(rel);
|
||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||
std::fs::write(&path, contents).unwrap();
|
||
path
|
||
}
|
||
|
||
fn dir(&self, rel: &str) -> PathBuf {
|
||
self.root.join(rel)
|
||
}
|
||
|
||
fn bind(&self, state: &EditorState) {
|
||
exec(
|
||
state,
|
||
&format!(
|
||
"pmacs.project.set_search_boundary(\"{}\")",
|
||
lua_str(&self.root)
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
fn configure(state: &EditorState, language: &str) {
|
||
exec(
|
||
state,
|
||
&format!(
|
||
"pmacs.lsp.config.{language} = {{ command = \"{}\" }}",
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
}
|
||
|
||
fn open(state: &EditorState, path: &Path) {
|
||
exec(
|
||
state,
|
||
&format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)),
|
||
);
|
||
}
|
||
|
||
/// `tick_async` is what drives the drain: `handle_server_requests` is
|
||
/// wrapped onto `pmacs._async.tick`, so a settle loop without it moves
|
||
/// the LSP state machine while never delivering a single event to Lua.
|
||
fn settle(state: &mut EditorState) {
|
||
for _ in 0..8 {
|
||
state.tick_processes();
|
||
state.tick_lsp();
|
||
state.tick_async();
|
||
std::thread::sleep(Duration::from_millis(2));
|
||
}
|
||
}
|
||
|
||
/// A rust project with one file, an attached fake server, and the
|
||
/// probes below installed. Returns the opened file's path.
|
||
fn attached_rust(state: &mut EditorState, fx: &Fixture) -> PathBuf {
|
||
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||
let file = fx.write("proj/src/main.rs", "fn main() {}\nlet x = 1;\n");
|
||
fx.bind(state);
|
||
configure(state, "rust");
|
||
open(state, &file);
|
||
settle(state);
|
||
file
|
||
}
|
||
|
||
/// The sid of the single live server, as a Lua expression fragment.
|
||
const THE_SID: &str = "pmacs.lsp.list()[1].id";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 29 — a notification reaches a registered subscriber.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc29_notification_reaches_a_registered_subscriber() {
|
||
let fx = Fixture::new();
|
||
let mut state = editor();
|
||
// Registered BEFORE the open, so the didOpen-triggered `pmacs/echo`
|
||
// is in the first drain.
|
||
exec(
|
||
&state,
|
||
r#"
|
||
_G.seen = {}
|
||
pmacs.lsp.on_notification("pmacs/echo", function(sid, params)
|
||
_G.seen[#_G.seen + 1] = tostring(params and params.uri)
|
||
end)
|
||
"#,
|
||
);
|
||
attached_rust(&mut state, &fx);
|
||
|
||
let n: i64 = eval(&state, "return #_G.seen");
|
||
assert!(
|
||
n >= 1,
|
||
"expected at least one pmacs/echo notification, got {n}"
|
||
);
|
||
let first: String = eval(&state, "return _G.seen[1]");
|
||
assert!(
|
||
first.starts_with("file://") && first.ends_with("main.rs"),
|
||
"subscriber got the document uri; saw {first:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn acc29_subscriber_for_an_unsent_method_does_not_fire() {
|
||
let fx = Fixture::new();
|
||
let mut state = editor();
|
||
exec(
|
||
&state,
|
||
r#"
|
||
_G.hits = 0
|
||
pmacs.lsp.on_notification("pmacs/never", function() _G.hits = _G.hits + 1 end)
|
||
"#,
|
||
);
|
||
attached_rust(&mut state, &fx);
|
||
|
||
// Non-vacuity for acc29: the seam is method-keyed, not a firehose.
|
||
// Without this, a subscriber invoked for every notification would
|
||
// pass the test above while being wrong.
|
||
let hits: i64 = eval(&state, "return _G.hits");
|
||
assert_eq!(hits, 0, "a subscriber must only fire for its own method");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 30 + 33 — dispatch integrity: with subscribers registered,
|
||
// a `workspace/applyEdit` request in the same drain is still handled.
|
||
//
|
||
// The fake server writes the applyEdit request and the executeCommand
|
||
// response back to back, so both land in one `events_take` batch. That
|
||
// co-occurrence is the point: a seam that consumed the batch, or that
|
||
// returned early, would starve the `request` arms that share it.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn drive_apply_edit(state: &mut EditorState, file: &Path) {
|
||
exec(
|
||
state,
|
||
&format!(
|
||
r#"
|
||
local sid = {THE_SID}
|
||
local uri = "file://{}"
|
||
_G.rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{
|
||
command = "pmacs.fake.applyEdit",
|
||
arguments = {{ uri }},
|
||
}})
|
||
_G.response_hits = 0
|
||
pmacs.lsp.on_response(sid, _G.rid, function(result, err)
|
||
_G.response_hits = _G.response_hits + 1
|
||
end)
|
||
"#,
|
||
lua_str(file)
|
||
),
|
||
);
|
||
settle(state);
|
||
}
|
||
|
||
fn buffer_text(state: &EditorState) -> String {
|
||
eval(
|
||
state,
|
||
"local b = pmacs.window.buffer() return b:slice(0, b:len())",
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn acc30_apply_edit_still_handled_with_a_notification_subscriber() {
|
||
let fx = Fixture::new();
|
||
let mut state = editor();
|
||
exec(
|
||
&state,
|
||
r#"
|
||
_G.notes = 0
|
||
pmacs.lsp.on_notification("pmacs/echo", function() _G.notes = _G.notes + 1 end)
|
||
"#,
|
||
);
|
||
let file = attached_rust(&mut state, &fx);
|
||
assert!(
|
||
eval::<i64>(&state, "return _G.notes") >= 1,
|
||
"precondition: the notification subscriber is actually firing"
|
||
);
|
||
|
||
drive_apply_edit(&mut state, &file);
|
||
|
||
assert!(
|
||
buffer_text(&state).contains("ED2"),
|
||
"workspace/applyEdit must still be applied with a subscriber \
|
||
registered; buffer was {:?}",
|
||
buffer_text(&state)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn acc33_apply_edit_still_handled_with_a_response_subscriber() {
|
||
let fx = Fixture::new();
|
||
let mut state = editor();
|
||
let file = attached_rust(&mut state, &fx);
|
||
drive_apply_edit(&mut state, &file);
|
||
|
||
// Both halves in one drain: the response was delivered to its
|
||
// one-shot AND the server-originated request was serviced.
|
||
assert_eq!(
|
||
eval::<i64>(&state, "return _G.response_hits"),
|
||
1,
|
||
"the executeCommand response reaches its one-shot"
|
||
);
|
||
assert!(
|
||
buffer_text(&state).contains("ED2"),
|
||
"workspace/applyEdit must still be applied with a response \
|
||
subscriber registered; buffer was {:?}",
|
||
buffer_text(&state)
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 31 — a raising subscriber does not stop later events in the
|
||
// same drain (and does not stop the `request` arms either).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc31_raising_notification_subscriber_does_not_stop_the_drain() {
|
||
let fx = Fixture::new();
|
||
let mut state = editor();
|
||
exec(
|
||
&state,
|
||
r#"
|
||
_G.second_hits = 0
|
||
pmacs.lsp.on_notification("pmacs/echo", function()
|
||
error("subscriber blew up")
|
||
end)
|
||
pmacs.lsp.on_notification("pmacs/echo", function()
|
||
_G.second_hits = _G.second_hits + 1
|
||
end)
|
||
"#,
|
||
);
|
||
let file = attached_rust(&mut state, &fx);
|
||
|
||
assert!(
|
||
eval::<i64>(&state, "return _G.second_hits") >= 1,
|
||
"a raising subscriber must not starve the ones after it"
|
||
);
|
||
|
||
// And the shared `request` arms still run in a later drain.
|
||
drive_apply_edit(&mut state, &file);
|
||
assert!(
|
||
buffer_text(&state).contains("ED2"),
|
||
"a raising subscriber must not stop workspace/applyEdit"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn acc33_raising_response_handler_does_not_stop_the_drain() {
|
||
let fx = Fixture::new();
|
||
let mut state = editor();
|
||
let file = attached_rust(&mut state, &fx);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
local sid = {THE_SID}
|
||
_G.notes_after = 0
|
||
pmacs.lsp.on_notification("pmacs/echo", function()
|
||
_G.notes_after = _G.notes_after + 1
|
||
end)
|
||
local rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{
|
||
command = "pmacs.fake.applyEdit",
|
||
arguments = {{ "file://{}" }},
|
||
}})
|
||
pmacs.lsp.on_response(sid, rid, function() error("handler blew up") end)
|
||
"#,
|
||
lua_str(&file)
|
||
),
|
||
);
|
||
settle(&mut state);
|
||
|
||
assert!(
|
||
buffer_text(&state).contains("ED2"),
|
||
"a raising response handler must not stop workspace/applyEdit in \
|
||
the same drain"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 32 — the one-shot is removed exactly once, whether or not
|
||
// the handler raises.
|
||
//
|
||
// Named for what it pins rather than for the framing's wording. Q#LN9
|
||
// specifies removal *before* invocation, and the implementation does
|
||
// that — but bite-testing showed the before/after ordering is not
|
||
// observable on its own: `pcall` catches the raise either way, so
|
||
// removal after the call is behaviorally identical unless a handler
|
||
// re-enters the drain, which nothing does. What IS observable, and what
|
||
// this pins, is that removal is **unconditional**: the bite that moves
|
||
// it inside `if ok then` fails here 2 != 1, because the surviving
|
||
// registration gets invoked a second time by the purge.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc32_response_one_shot_is_removed_even_when_the_handler_raises() {
|
||
let fx = Fixture::new();
|
||
let mut state = editor();
|
||
attached_rust(&mut state, &fx);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
local sid = {THE_SID}
|
||
_G.calls = 0
|
||
local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 1 }})
|
||
pmacs.lsp.on_response(sid, rid, function(result, err)
|
||
_G.calls = _G.calls + 1
|
||
error("handler raises after being removed")
|
||
end)
|
||
"#
|
||
),
|
||
);
|
||
settle(&mut state);
|
||
assert_eq!(
|
||
eval::<i64>(&state, "return _G.calls"),
|
||
1,
|
||
"the one-shot fires exactly once for its reply"
|
||
);
|
||
|
||
exec(&state, &format!("pmacs.lsp.stop({THE_SID})"));
|
||
settle(&mut state);
|
||
assert_eq!(
|
||
eval::<i64>(&state, "return _G.calls"),
|
||
1,
|
||
"a delivered one-shot must not be re-invoked by the purge — \
|
||
removal is unconditional, not gated on a clean return"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn acc32_response_carries_the_servers_result() {
|
||
let fx = Fixture::new();
|
||
let mut state = editor();
|
||
attached_rust(&mut state, &fx);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
local sid = {THE_SID}
|
||
_G.echoed = nil
|
||
_G.saw_err = "unset"
|
||
local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 42 }})
|
||
pmacs.lsp.on_response(sid, rid, function(result, err)
|
||
_G.echoed = result and result.echo and result.echo.v
|
||
_G.saw_err = tostring(err)
|
||
end)
|
||
"#
|
||
),
|
||
);
|
||
settle(&mut state);
|
||
|
||
// Non-vacuity: without this the seam could "fire" with nil payloads
|
||
// and every count-based assertion above would still pass.
|
||
assert_eq!(
|
||
eval::<i64>(&state, "return _G.echoed or -1"),
|
||
42,
|
||
"the handler receives the server's result payload"
|
||
);
|
||
assert_eq!(
|
||
eval::<String>(&state, "return _G.saw_err"),
|
||
"nil",
|
||
"a successful reply passes nil for err"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 34 — the pending purge, driven off `pmacs.lsp.list()` and
|
||
// NOT off a death event seen in the drain.
|
||
//
|
||
// The second test is the load-bearing one. `handle_server_requests`
|
||
// builds its sid list from `attachments`, so a server that is in no
|
||
// attachment is never drained — and its `stopped` event is therefore
|
||
// never seen. A purge wired to that event leaks exactly there.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn acc34_purge_settles_a_pending_one_shot_when_the_server_dies() {
|
||
let fx = Fixture::new();
|
||
let mut state = editor();
|
||
attached_rust(&mut state, &fx);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
local sid = {THE_SID}
|
||
_G.err_msg = "never called"
|
||
-- A method the fake server answers only after a delay would
|
||
-- be ideal; instead the server is stopped in the same breath,
|
||
-- so the reply can never arrive.
|
||
local rid = pmacs.lsp.send_request(sid, "test/slow", {{}})
|
||
pmacs.lsp.on_response(sid, rid, function(result, err)
|
||
_G.err_msg = tostring(err and err.message)
|
||
end)
|
||
pmacs.lsp.stop(sid)
|
||
"#
|
||
),
|
||
);
|
||
settle(&mut state);
|
||
|
||
let msg: String = eval(&state, "return _G.err_msg");
|
||
assert!(
|
||
msg.contains("server gone") || msg == "nil",
|
||
"a pending one-shot must be settled, not left waiting; saw {msg:?}"
|
||
);
|
||
assert_ne!(
|
||
msg, "never called",
|
||
"the one-shot was never settled — it leaked"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn acc34_purge_reaches_a_server_that_is_in_no_attachment() {
|
||
let fx = Fixture::new();
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
// Spawned directly, never attached to a buffer. `attachments` is
|
||
// empty, so `handle_server_requests` never visits this sid and its
|
||
// `stopped` event is never drained.
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
_G.settled = "never called"
|
||
local sid = pmacs.lsp.spawn({{
|
||
label = "orphan",
|
||
language_id = "rust",
|
||
command = "{}",
|
||
args = {{}},
|
||
}})
|
||
_G.orphan = sid
|
||
"#,
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
settle(&mut state);
|
||
|
||
exec(
|
||
&state,
|
||
r#"
|
||
local rid = pmacs.lsp.send_request(_G.orphan, "test/slow", {})
|
||
pmacs.lsp.on_response(_G.orphan, rid, function(result, err)
|
||
_G.settled = tostring(err and err.message)
|
||
end)
|
||
pmacs.lsp.stop(_G.orphan)
|
||
"#,
|
||
);
|
||
settle(&mut state);
|
||
|
||
let settled: String = eval(&state, "return _G.settled");
|
||
assert_ne!(
|
||
settled, "never called",
|
||
"the purge must not depend on the drain reaching this server — \
|
||
it is in no attachment, so the drain never does"
|
||
);
|
||
assert!(
|
||
settled.contains("server gone"),
|
||
"settled with the purge's error; saw {settled:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 34a — `pmacs.fs.canonicalize` (Q#LN20).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
#[cfg(unix)]
|
||
fn acc34a_canonicalize_resolves_symlinks_and_dot_segments() {
|
||
let fx = Fixture::new();
|
||
fx.write("pkg/sub/a.txt", "x\n");
|
||
// Built here rather than assumed: the whole point is the symlink.
|
||
std::os::unix::fs::symlink(fx.dir("pkg"), fx.dir("linkpkg")).unwrap();
|
||
let state = editor();
|
||
|
||
let noncanon = format!("{}/sub/./../sub/a.txt", fx.dir("linkpkg").display());
|
||
let got: String = eval(
|
||
&state,
|
||
&format!("return tostring(pmacs.fs.canonicalize(\"{noncanon}\"))"),
|
||
);
|
||
let want = fx.root.join("pkg/sub/a.txt").display().to_string();
|
||
assert_eq!(got, want, "symlink and dot segments both resolved");
|
||
|
||
// Falsification for 34b: the uncanonicalized spelling really is
|
||
// different, so the affinity test below is not vacuous.
|
||
assert_ne!(noncanon, want);
|
||
}
|
||
|
||
#[test]
|
||
fn acc34a_canonicalize_returns_nil_for_a_missing_path() {
|
||
let fx = Fixture::new();
|
||
let state = editor();
|
||
let missing = fx.dir("nope/not-here").display().to_string();
|
||
let got: String = eval(
|
||
&state,
|
||
&format!("return tostring(pmacs.fs.canonicalize(\"{missing}\"))"),
|
||
);
|
||
assert_eq!(
|
||
got, "nil",
|
||
"a nonexistent path declines rather than raising"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 34b — affinity survives a symlinked open.
|
||
//
|
||
// Asserted at the affinity layer, not just at the binding: the
|
||
// regression Q#LN20 exists to prevent is *two servers for one project*,
|
||
// and only this shape observes it.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn server_count(state: &EditorState) -> i64 {
|
||
eval(state, "return #pmacs.lsp.list()")
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(unix)]
|
||
fn acc34b_canonicalizing_resolver_reuses_one_server_across_a_symlink() {
|
||
let fx = Fixture::new();
|
||
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||
let real = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||
std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap();
|
||
let linked = fx.dir("linkproj").join("src/main.rs");
|
||
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.rust = {{
|
||
command = "{}",
|
||
root = function(path)
|
||
local dir = path:match("^(.*)/[^/]*$")
|
||
if not dir then return nil end
|
||
-- Walk up to the directory holding Cargo.toml, then
|
||
-- canonicalize — the Q#LN8 shape Stage 3b will use.
|
||
while dir and #dir > 0 do
|
||
local f = io.open(dir .. "/Cargo.toml", "r")
|
||
if f then
|
||
f:close()
|
||
return pmacs.fs.canonicalize(dir)
|
||
end
|
||
dir = dir:match("^(.*)/[^/]*$")
|
||
end
|
||
return nil
|
||
end,
|
||
}}
|
||
"#,
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
|
||
open(&state, &real);
|
||
settle(&mut state);
|
||
assert_eq!(server_count(&state), 1, "the real path spawns one server");
|
||
|
||
open(&state, &linked);
|
||
settle(&mut state);
|
||
assert_eq!(
|
||
server_count(&state),
|
||
1,
|
||
"the symlinked path must reuse the same server — two here is the \
|
||
exact regression Q#LN20 exists to prevent"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(unix)]
|
||
fn acc34b_falsified_by_a_resolver_that_skips_canonicalization() {
|
||
let fx = Fixture::new();
|
||
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||
let real = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||
std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap();
|
||
let linked = fx.dir("linkproj").join("src/main.rs");
|
||
|
||
let mut state = editor();
|
||
fx.bind(&state);
|
||
// Same resolver, minus the canonicalize call. This is the bite: if
|
||
// it also produced one server, the test above would be vacuous and
|
||
// `pmacs.fs.canonicalize` would be doing nothing.
|
||
exec(
|
||
&state,
|
||
&format!(
|
||
r#"
|
||
pmacs.lsp.config.rust = {{
|
||
command = "{}",
|
||
root = function(path)
|
||
local dir = path:match("^(.*)/[^/]*$")
|
||
while dir and #dir > 0 do
|
||
local f = io.open(dir .. "/Cargo.toml", "r")
|
||
if f then f:close() return dir end
|
||
dir = dir:match("^(.*)/[^/]*$")
|
||
end
|
||
return nil
|
||
end,
|
||
}}
|
||
"#,
|
||
fake_lsp_path()
|
||
),
|
||
);
|
||
|
||
open(&state, &real);
|
||
settle(&mut state);
|
||
open(&state, &linked);
|
||
settle(&mut state);
|
||
assert_eq!(
|
||
server_count(&state),
|
||
2,
|
||
"without canonicalization the two spellings key differently and \
|
||
spawn two servers — this is what 34b's positive case rules out"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Acceptance 34a, non-UTF-8 arm — an unrepresentable resolution declines
|
||
// rather than returning a lossy string.
|
||
//
|
||
// Review finding on PR #167: `display().to_string()` substitutes U+FFFD,
|
||
// which would hand back a path that does not exist on disk. That is
|
||
// strictly worse than nil here, because the value becomes a
|
||
// server-affinity key via `file_uri_for` and would silently fail to
|
||
// round-trip. Bites against the `display()` form, which returns a
|
||
// non-nil string for this fixture.
|
||
//
|
||
// **Linux-gated, and `cfg(unix)` was not enough** — CI caught that.
|
||
// APFS enforces valid UTF-8 in filenames, so on macOS the `write` below
|
||
// fails with EILSEQ ("Illegal byte sequence") before the code under test
|
||
// is ever reached: the fixture cannot be built there. That is a
|
||
// filesystem refusing to represent the case, not a behavioral
|
||
// difference — the subject itself, `to_str()` returning None, is
|
||
// platform-independent Rust. Gated explicitly rather than skipped at
|
||
// runtime, so a future failure here is a real failure and not a silent
|
||
// no-op.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
#[cfg(target_os = "linux")]
|
||
fn acc34a_canonicalize_declines_a_non_utf8_resolution() {
|
||
use std::ffi::OsStr;
|
||
use std::os::unix::ffi::OsStrExt as _;
|
||
|
||
let fx = Fixture::new();
|
||
// 0xFF is not valid UTF-8 in any position.
|
||
let raw = OsStr::from_bytes(b"bad-\xffname");
|
||
let target = fx.root.join(raw);
|
||
std::fs::write(&target, "x\n").unwrap();
|
||
// Reached through an ASCII symlink, so the *input* is representable
|
||
// and only the resolved output is not — which is the case
|
||
// `to_str()` has to catch and a UTF-8-only input check would miss.
|
||
let link = fx.dir("ascii-link");
|
||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||
|
||
let state = editor();
|
||
let got: String = eval(
|
||
&state,
|
||
&format!(
|
||
"return tostring(pmacs.fs.canonicalize(\"{}\"))",
|
||
lua_str(&link)
|
||
),
|
||
);
|
||
assert_eq!(
|
||
got, "nil",
|
||
"a resolution that lands on non-UTF-8 bytes must decline, not \
|
||
return a U+FFFD-substituted path that exists nowhere"
|
||
);
|
||
}
|
||
|
||
// Isolated bootstrap storage roots (see the module docs): an
|
||
// integration test is compiled without `cfg(test)`, so a raw
|
||
// `EditorState::new()` would read the developer's real `init.lua` and
|
||
// write into their real data root.
|
||
#[path = "common/iso.rs"]
|
||
mod iso;
|