diff --git a/docs/active-work.md b/docs/active-work.md index 147f568..75eed40 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -414,13 +414,33 @@ compatible. - **DAP waits for Stage 2, not Stage 1** — that dependency is now satisfied. -## Test ambient-root isolation — FRAMING OPEN, revision 4 +## Test ambient-root isolation — IMPLEMENTATION OPEN -- **Branch `test-ambient-config-isolation`**, worktree - `../pmacs-test-isolation`, based on `githubsucks/main` @ `4cd4a7b`. - **Framing only; no code, no PR yet.** - `docs/test-ambient-config-isolation-framing.md` revision 4, three review - rounds closed (eight blocking, six major, all accepted). +- **Framing MERGED (#201)**; + `docs/test-ambient-config-isolation-framing.md` **revision 5** + (revision 4 approved after three review rounds — eight blocking, six + major, all accepted; revision 5 rides the implementation PR and records + findings, not a new design round). +- **PR #206 OPEN**, one review round closed. **§7's branch plan was + consciously exceeded**: the whole-corpus migration rides this PR rather + than a follow-up lane, because splitting would either leave 65 suites + writing the real data root or ship acceptance 12's ratchet with a + ~65-file allowlist that does not ratchet. Recorded in framing revision + 5; accepted in review. +- **Implementation branch `test-ambient-isolation-impl`**, worktree + `../pmacs-test-isolation-impl`, based on `githubsucks/main` @ + `54a092e`. The framing-only worktree `../pmacs-test-isolation` + (branch `test-ambient-config-isolation`) is spent — its doc is on + `main`. +- **What landed on the branch.** `pmacs::bootstrap::BootstrapRoots` + (config/data/state/cache, `ambient()` for production), reachable from + `EditorState::new_with_roots` **and** `open_with_roots` and consulted + again by `install_state_dirs`; all 342 in-process construction sites + in 65 files migrated; `journey_acceptance` keeps the ambient `open` + and re-execs itself per test with controlled roots instead; the shared + daemon and PTY spawners now set all five storage variables; + `tests/ambient_isolation_acceptance.rs` carries the hostile-environment + proof and the adoption ratchet. - **What it is.** Integration tests use the developer's real ambient roots. `#[cfg(not(test))]` guards config loading against the crate's own unit tests only, so the **65** files in `tests/` that construct an @@ -455,9 +475,9 @@ compatible. ```sh git fetch githubsucks - git worktree add ../pmacs-test-isolation \ - -b test-ambient-config-isolation \ - githubsucks/test-ambient-config-isolation + git worktree add ../pmacs-test-isolation-impl \ + -b test-ambient-isolation-impl \ + githubsucks/test-ambient-isolation-impl ``` ## Reap-ledger silent failures — MERGED (#202); kept for its parked follow-ons diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index d4542f4..ad61320 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1639,6 +1639,35 @@ git diff --check Machine-specific caveats — re-verify on a machine you haven't used before trusting them: +- **Ambient storage roots: control all FIVE, not four.** Until the + ambient-root isolation lane lands + (`docs/test-ambient-config-isolation-framing.md`), the ~96 integration + suites read the developer's real `~/.config/pmacs/init.lua` and + **write** bundled packages into the real data root: + `#[cfg(not(test))]` guards the crate's own unit tests only, and + `EditorState::new` materializes packages outside every `cfg` guard. + A local full-suite run therefore needs, all pointed at a fresh + directory: + + ``` + XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME + PMACS_STATE_HOME + ``` + + **The fifth is not redundant.** `PMACS_STATE_HOME` outranks + `XDG_STATE_HOME` (`src/state.rs`), so redirecting the four XDG + variables on a machine that exports it leaves the real state root + live. `HOME` is deliberately left alone: it is the fallback for the + XDG roots (already covered once they are set) and separately drives + `~`-expansion, which `find_file_acceptance` pins on purpose. + A run isolating only `XDG_CONFIG_HOME` stops the `init.lua` reads and + still writes through the real data root — every local gate run in this + repo before 2026-07-31 had that hole. + + **After the lane lands** the in-process population isolates itself + through `EditorState::new_with_roots(&crate::iso::roots())` and the + five variables are belt-and-braces rather than required. + - **basedpyright**: the desktop binary was **never broken** — this was a real code defect, diagnosed and fixed 2026-07-29 (see §5, "A `Drop` body runs before its fields"). `RuntimeHandles::drop` joined its reader diff --git a/docs/test-ambient-config-isolation-framing.md b/docs/test-ambient-config-isolation-framing.md index d9971ba..b8aa64e 100644 --- a/docs/test-ambient-config-isolation-framing.md +++ b/docs/test-ambient-config-isolation-framing.md @@ -1,9 +1,11 @@ # Framing — integration tests use the developer's real ambient roots -**Revision 4.** Status: awaiting review round 4. Proposed lane: -`test-ambient-config-isolation`, worktree `../pmacs-test-isolation`, -based on `githubsucks/main` @ `4cd4a7b` (a reading; re-measure at branch -time). +**Revision 5.** Status: implemented on branch +`test-ambient-isolation-impl` (worktree `../pmacs-test-isolation-impl`, +based on `githubsucks/main` @ `54a092e`), PR #206. Revision 4 was +approved after three review rounds and merged as #201; revision 5 records +implementation findings and one **deliberate departure from §7's branch +plan**, not a new design round. **The suite is green in CI and red on a developer machine that has a real `~/.config/pmacs/init.lua`.** Not flaky — deterministic, and @@ -16,6 +18,51 @@ directory**. The lane is therefore about *ambient roots*, not about ## Revision history +**Revision 4 → 5**, at implementation. No design changed; two things are +recorded that a later reader would otherwise have to reconstruct. + +### The §7 branch plan was consciously exceeded + +**§7 said: classification first and alone, "its answer belongs in review +before any mechanical edit rides on it."** The classification came back +at **342 in-process construction sites across 66 of 97 files** — large +enough that §7's shape would naturally suggest splitting the mechanical +migration into its own lane. **It was not split, and that is deliberate.** + +The alternatives were both worse, and both worse in the way this lane +exists to prevent: + +- **Split, migrating nothing now.** The seam would land while 65 files + kept reading the developer's real `init.lua` and writing their real + data root. The lane's own defect would survive its own PR. +- **Split, with a broad temporary allowlist.** Acceptance 12's ratchet + would ship exempting ~65 files. A ratchet whose allowlist is most of + the corpus does not ratchet; it records. And the exemption would have + to be removed later by the same reviewer who granted it, with nothing + failing in the meantime to remind anyone. + +So the whole-corpus migration is the atomic remediation and rides this +PR. §7's *ordering* is honoured — the classification is the first commit, +before any mechanical edit — while its implied *scoping* is not. The +qualification this leaves on acceptance 12 (the allowlist is narrow +because everything else moved, not because the change was small) was +raised in the PR and accepted in review round 1. + +### The exemption shape acceptance 12 needs + +Review round 1 found an ambient `EditorState::new()` in an ordinary +parent test of the isolation suite itself — the exposure, committed by +the suite written to remove it. **A file-level allowlist did not catch +it**, because the file was already exempted for its re-exec'd positive +control, and a bare exemption licenses a named file to grow new ambient +sites indefinitely. + +The fix is that **every exemption carries its exact site count**, so an +added site fails even inside an exempted file. That is the shape +acceptance 12 needs; "a narrow, named allowlist" is not sufficient on its +own, because narrowness constrains which files are exempt and says +nothing about how far each exemption stretches. + **Revision 3 → 4**, after review round 3 (one blocking, two major). All three accepted. diff --git a/src/bootstrap.rs b/src/bootstrap.rs new file mode 100644 index 0000000..87857e2 --- /dev/null +++ b/src/bootstrap.rs @@ -0,0 +1,391 @@ +// src/bootstrap.rs --- explicit bootstrap storage roots. + +//! Where pmacs stores things at startup, as a value rather than as an +//! ambient property of the process environment. +//! +//! # Why this exists +//! +//! `EditorState::new` resolves two storage roots from the environment +//! before it returns: +//! +//! * the **data** root, which +//! [`crate::builtin_packages::bundled_runtime_dir`] resolves from +//! `XDG_DATA_HOME` (else `$HOME/.local/share`) and which +//! [`crate::builtin_packages::materialize_all`] then **writes into** +//! --- unconditionally, outside any `cfg` guard; and +//! * the **config** root, which [`crate::config::user_config_dir`] +//! resolves from `XDG_CONFIG_HOME` (else `$HOME/.config`) and from +//! which `init.lua` is read. +//! +//! An integration test in `tests/` links this crate as an ordinary +//! dependency, so it is compiled **without** `cfg(test)`: the +//! `#[cfg(not(test))]` guard around config loading is inactive for +//! every one of them. They read the developer's real `init.lua` and +//! write into the developer's real data root. +//! +//! They cannot fix that themselves. `std::env::set_var` has been +//! `unsafe` since Rust 2024 and this crate is `#![forbid(unsafe_code)]` +//! --- the same constraint that produced +//! [`crate::packages::installer::Installer::with_install_root_override`] +//! and [`crate::lua_bindings::PackageInstallOverride`]. So isolation has +//! to arrive as a **parameter**, which is what this type is. +//! +//! # Contract +//! +//! [`BootstrapRoots::ambient()`] is production: every root stays `None` +//! and every resolution goes to the environment exactly as before. A +//! root that is `Some` replaces the environment lookup for that root and +//! **only** that root. +//! +//! # Scope: storage roots only +//! +//! This type covers the four roots that decide where pmacs *stores* +//! things: config, data, state and cache. It deliberately does not +//! cover: +//! +//! * **`HOME`'s non-storage semantics.** `expand_tilde` +//! ([`crate::editor_core`]) resolves a leading `~` for ordinary path +//! entry, and `tests/find_file_acceptance.rs` consumes `HOME` on +//! purpose to pin that expansion. Redirecting a storage root is the +//! right fix for a storage root and the wrong fix for a +//! path-expansion root. +//! * **`XDG_RUNTIME_DIR`**, which addresses sockets rather than stored +//! data. + +use std::path::{Path, PathBuf}; + +/// The bootstrap storage roots an [`crate::editor::EditorState`] is +/// constructed against. +/// +/// Each field is the *base* directory --- the value `XDG__HOME` +/// would hold --- not the `pmacs/` subdirectory under it. `None` means +/// "resolve from the environment", which is what production does. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BootstrapRoots { + config: Option, + data: Option, + state: Option, + cache: Option, +} + +/// The version-keyed leaf `bundled_runtime_dir` materializes into. +fn bundled_leaf() -> String { + format!("v{}", env!("CARGO_PKG_VERSION")) +} + +impl BootstrapRoots { + /// Production: every root resolves from the process environment. + #[must_use] + pub fn ambient() -> Self { + Self::default() + } + + /// Every storage root redirected under `base`, into sibling + /// `config/`, `data/`, `state/` and `cache/` directories. + /// + /// The layout mirrors the XDG one so a caller that also spawns a + /// child process can hand the same four paths to `XDG_CONFIG_HOME`, + /// `XDG_DATA_HOME`, `XDG_STATE_HOME` and `XDG_CACHE_HOME` (plus + /// `PMACS_STATE_HOME`, which outranks `XDG_STATE_HOME`) and get the + /// same tree from an in-process and a spawned editor. + #[must_use] + pub fn isolated_under(base: &Path) -> Self { + Self { + config: Some(base.join("config")), + data: Some(base.join("data")), + state: Some(base.join("state")), + cache: Some(base.join("cache")), + } + } + + /// Builder: redirect the config root (the `XDG_CONFIG_HOME` value). + #[must_use] + pub fn with_config_root(mut self, root: PathBuf) -> Self { + self.config = Some(root); + self + } + + /// Builder: redirect the data root (the `XDG_DATA_HOME` value). + #[must_use] + pub fn with_data_root(mut self, root: PathBuf) -> Self { + self.data = Some(root); + self + } + + /// Builder: redirect the state root (the `XDG_STATE_HOME` value). + #[must_use] + pub fn with_state_root(mut self, root: PathBuf) -> Self { + self.state = Some(root); + self + } + + /// Builder: redirect the cache root (the `XDG_CACHE_HOME` value). + #[must_use] + pub fn with_cache_root(mut self, root: PathBuf) -> Self { + self.cache = Some(root); + self + } + + /// True when nothing is redirected --- i.e. this is production's + /// [`Self::ambient`]. + #[must_use] + pub fn is_ambient(&self) -> bool { + self.config.is_none() && self.data.is_none() && self.state.is_none() && self.cache.is_none() + } + + /// The config *base*, if redirected. + #[must_use] + pub fn config_root(&self) -> Option<&Path> { + self.config.as_deref() + } + + /// The data *base*, if redirected. + #[must_use] + pub fn data_root(&self) -> Option<&Path> { + self.data.as_deref() + } + + /// The state *base*, if redirected. + #[must_use] + pub fn state_root(&self) -> Option<&Path> { + self.state.as_deref() + } + + /// The cache *base*, if redirected. + #[must_use] + pub fn cache_root(&self) -> Option<&Path> { + self.cache.as_deref() + } + + /// The directory `init.lua` is read from --- `/pmacs`, the + /// same shape [`crate::config::user_config_dir`] builds. + #[must_use] + pub fn config_dir(&self) -> Option { + self.config.as_ref().map(|p| p.join("pmacs")) + } + + /// Where bundled packages are materialized --- + /// `/pmacs/builtin-packages/v`, the same shape + /// [`crate::builtin_packages::bundled_runtime_dir`] builds. + #[must_use] + pub fn bundled_runtime_dir(&self) -> Option { + self.data.as_ref().map(|p| { + p.join("pmacs") + .join("builtin-packages") + .join(bundled_leaf()) + }) + } + + /// The user-scope package install root --- `/pmacs/packages`. + #[must_use] + pub fn package_install_root(&self) -> Option { + self.data.as_ref().map(|p| p.join("pmacs").join("packages")) + } + + /// The package fetcher's bare-mirror cache --- `/pmacs/git`. + #[must_use] + pub fn package_cache_dir(&self) -> Option { + self.cache.as_ref().map(|p| p.join("pmacs").join("git")) + } + + /// The editor state directory --- `/pmacs`, the same shape + /// [`crate::state::user_state_dir`] builds. + #[must_use] + pub fn state_dir(&self) -> Option { + self.state.as_ref().map(|p| p.join("pmacs")) + } + + /// The minibuffer history directory --- `/pmacs/history`. + #[must_use] + pub fn history_dir(&self) -> Option { + self.state_dir().map(|d| d.join("history")) + } + + /// The environment a **child** `pmacs` process must be given so it + /// resolves the same roots this value names. + /// + /// An in-process caller passes the value; a caller that spawns + /// `pmacs --daemon` (or re-execs a test binary) cannot, and has to + /// go through the environment instead. This is that translation, so + /// the two paths cannot drift. + /// + /// **Five variables, not four.** `PMACS_STATE_HOME` outranks + /// `XDG_STATE_HOME` ([`crate::state::user_state_dir`]), so a child + /// given only the four XDG variables still resolves the *inherited* + /// `PMACS_STATE_HOME` if the launching environment exports one --- a + /// hole that is invisible on a machine that does not. + /// + /// A root left ambient emits no variable, so the child inherits it. + #[must_use] + pub fn child_env(&self) -> Vec<(&'static str, PathBuf)> { + let mut out = Vec::with_capacity(5); + if let Some(p) = &self.config { + out.push(("XDG_CONFIG_HOME", p.clone())); + } + if let Some(p) = &self.data { + out.push(("XDG_DATA_HOME", p.clone())); + } + if let Some(p) = &self.state { + out.push(("XDG_STATE_HOME", p.clone())); + out.push(("PMACS_STATE_HOME", p.clone())); + } + if let Some(p) = &self.cache { + out.push(("XDG_CACHE_HOME", p.clone())); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ambient_redirects_nothing() { + let roots = BootstrapRoots::ambient(); + assert!(roots.is_ambient()); + assert_eq!(roots.config_dir(), None); + assert_eq!(roots.bundled_runtime_dir(), None); + assert_eq!(roots.state_dir(), None); + assert_eq!(roots.package_cache_dir(), None); + } + + #[test] + fn isolated_under_mirrors_the_xdg_layout() { + let roots = BootstrapRoots::isolated_under(Path::new("/scratch")); + assert!(!roots.is_ambient()); + assert_eq!( + roots.config_dir().unwrap(), + Path::new("/scratch/config/pmacs") + ); + assert_eq!( + roots.bundled_runtime_dir().unwrap(), + Path::new("/scratch/data/pmacs/builtin-packages").join(bundled_leaf()) + ); + assert_eq!( + roots.package_install_root().unwrap(), + Path::new("/scratch/data/pmacs/packages") + ); + assert_eq!( + roots.package_cache_dir().unwrap(), + Path::new("/scratch/cache/pmacs/git") + ); + assert_eq!( + roots.state_dir().unwrap(), + Path::new("/scratch/state/pmacs") + ); + assert_eq!( + roots.history_dir().unwrap(), + Path::new("/scratch/state/pmacs/history") + ); + } + + /// A builder that sets one root leaves the other three ambient --- + /// the "only that root" half of the contract. + #[test] + fn a_single_builder_leaves_the_other_roots_ambient() { + let roots = BootstrapRoots::ambient().with_config_root(PathBuf::from("/only/config")); + assert!(!roots.is_ambient()); + assert_eq!(roots.config_dir().unwrap(), Path::new("/only/config/pmacs")); + assert_eq!(roots.bundled_runtime_dir(), None); + assert_eq!(roots.state_dir(), None); + assert_eq!(roots.package_cache_dir(), None); + } + + /// The five-variable contract, asserted as content: naming only the + /// four XDG variables leaves `PMACS_STATE_HOME` --- which outranks + /// `XDG_STATE_HOME` --- pointing wherever the launching environment + /// left it. + #[test] + fn child_env_names_all_five_storage_variables() { + let roots = BootstrapRoots::isolated_under(Path::new("/scratch")); + let env = roots.child_env(); + let names: Vec<&str> = env.iter().map(|(k, _)| *k).collect(); + assert_eq!( + names, + vec![ + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "PMACS_STATE_HOME", + "XDG_CACHE_HOME", + ] + ); + let value = |name: &str| { + env.iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.clone()) + .unwrap() + }; + assert_eq!(value("XDG_CONFIG_HOME"), Path::new("/scratch/config")); + assert_eq!(value("XDG_DATA_HOME"), Path::new("/scratch/data")); + assert_eq!(value("XDG_STATE_HOME"), Path::new("/scratch/state")); + assert_eq!(value("PMACS_STATE_HOME"), Path::new("/scratch/state")); + assert_eq!(value("XDG_CACHE_HOME"), Path::new("/scratch/cache")); + } + + /// An ambient root emits no variable — the child inherits it. A + /// blanket five-variable emission would silently redirect roots the + /// caller deliberately left alone. + #[test] + fn child_env_emits_nothing_for_ambient_roots() { + assert!(BootstrapRoots::ambient().child_env().is_empty()); + let only_config = BootstrapRoots::ambient().with_config_root(PathBuf::from("/only/config")); + assert_eq!( + only_config.child_env(), + vec![("XDG_CONFIG_HOME", PathBuf::from("/only/config"))] + ); + } + + /// `child_env` and the in-process resolvers must describe the same + /// tree: the spawned daemon and the in-process editor are both + /// supposed to land in the isolated roots, and a mismatch would let + /// one of them escape while the other looked fine. + #[test] + fn child_env_agrees_with_the_in_process_resolvers() { + let roots = BootstrapRoots::isolated_under(Path::new("/scratch")); + let env = roots.child_env(); + let value = |name: &str| { + env.iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.clone()) + .unwrap() + }; + assert_eq!( + roots.config_dir().unwrap(), + value("XDG_CONFIG_HOME").join("pmacs") + ); + assert_eq!( + roots.package_install_root().unwrap(), + value("XDG_DATA_HOME").join("pmacs").join("packages") + ); + assert_eq!( + roots.state_dir().unwrap(), + value("PMACS_STATE_HOME").join("pmacs") + ); + assert_eq!( + roots.package_cache_dir().unwrap(), + value("XDG_CACHE_HOME").join("pmacs").join("git") + ); + } + + /// The isolated bundled dir must agree with the ambient resolver's + /// shape, version leaf included: a mismatch would make an isolated + /// editor materialize somewhere production never looks. + #[test] + fn bundled_leaf_matches_the_ambient_resolver() { + let roots = BootstrapRoots::isolated_under(Path::new("/scratch")); + let isolated = roots.bundled_runtime_dir().unwrap(); + // `bundled_runtime_dir()` reads the live environment, so compare + // only the tail that does not depend on it. + let ambient = crate::builtin_packages::bundled_runtime_dir(); + let tail = |p: &Path| { + p.components() + .rev() + .take(3) + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + }; + assert_eq!(tail(&isolated), tail(&ambient)); + } +} diff --git a/src/editor.rs b/src/editor.rs index b089973..bf897b4 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -273,6 +273,14 @@ pub struct EditorState { /// steal or clear another's in-flight gesture, and concurrent drags /// are perfectly legal. window_drag: HashMap, + /// The bootstrap storage roots this session was constructed + /// against (`docs/test-ambient-config-isolation-framing.md`). + /// + /// [`BootstrapRoots::ambient`] in production. Retained past + /// construction because [`Self::install_state_dirs`] resolves a root + /// too and runs *after* the constructor --- resolving it from the + /// environment there would reopen the hole the constructor closed. + bootstrap_roots: crate::bootstrap::BootstrapRoots, } #[derive(Default)] @@ -345,6 +353,24 @@ impl EditorState { /// Panics only if Lua initialization or the builtin command/keymap /// chunks fail to load --- both indicate broken builds. #[must_use] + pub fn new() -> Self { + Self::new_with_roots(&crate::bootstrap::BootstrapRoots::ambient()) + } + + /// Construct a fresh editor against explicit bootstrap storage + /// roots (`docs/test-ambient-config-isolation-framing.md` §1.4). + /// + /// [`Self::new`] is this with [`BootstrapRoots::ambient`], so + /// production behaviour is unchanged. An integration test passes + /// redirected roots instead: it links this crate without + /// `cfg(test)`, so the guard below is live for it, and without a + /// parameter it would read the developer's real `init.lua` and + /// write bundled packages into the developer's real data root. + /// `std::env::set_var` is not an alternative --- it is `unsafe` and + /// this crate is `#![forbid(unsafe_code)]`. + /// + /// [`BootstrapRoots::ambient`]: crate::bootstrap::BootstrapRoots::ambient + #[must_use] #[allow( clippy::too_many_lines, reason = "linear bootstrap sequence: registry → core → LuaHost → \ @@ -352,7 +378,7 @@ impl EditorState { Splitting into helpers fragments the wiring without removing \ any single decision the reader needs to follow." )] - pub fn new() -> Self { + pub fn new_with_roots(roots: &crate::bootstrap::BootstrapRoots) -> Self { // Build the buffer registry first so EditorCore and LuaHost // share the same `Rc`. Both reach buffers through this handle; // multi-window dispatch (T M2.8) requires that ids resolve to @@ -738,9 +764,34 @@ impl EditorState { // Depends on `pmacs.buffer.add_intercept` (T M6.4 Stage 1) // and `pmacs.ansi.parser()` (T M6.4 Stage 2), both available // by the time `attach_editor` returns above. - let bundled_root = crate::builtin_packages::bundled_runtime_dir(); + // + // `materialize_all` CREATES DIRECTORIES AND WRITES FILES, and + // it is outside every `cfg` guard — so this, not config + // loading, is the write half of the ambient-roots exposure + // (`docs/test-ambient-config-isolation-framing.md` §1.6). The + // redirected root is consulted first for exactly that reason. + let bundled_root = roots + .bundled_runtime_dir() + .unwrap_or_else(crate::builtin_packages::bundled_runtime_dir); let bundled_packages = crate::builtin_packages::materialize_all(&bundled_root) .expect("materialize bundled packages"); + // Redirected roots also redirect where `pmacs.packages.install` + // would later fetch and install, so a caller that isolated its + // storage cannot reach the real `$XDG_CACHE_HOME/pmacs/git` or + // `$XDG_DATA_HOME/pmacs/packages` through Lua either. Installed + // before user config runs, so an `init.lua` that installs a + // package lands inside the isolated tree. Production leaves the + // slot empty (ambient roots take this branch not at all). + if !roots.is_ambient() { + let mut override_ = crate::lua_bindings::PackageInstallOverride::new(); + if let Some(cache) = roots.package_cache_dir() { + override_ = override_.with_cache_dir(cache); + } + if let Some(install) = roots.package_install_root() { + override_ = override_.with_user_install_root(install); + } + lua_host.set_package_install_override(override_); + } { let slot = lua_host .lua() @@ -772,17 +823,43 @@ impl EditorState { // to exercise config loading do so explicitly via // [`crate::config::load_user_config_at`]. // + // **`cfg(test)` covers the lib's unit tests and NOTHING ELSE.** + // An integration test in `tests/` links this crate as an + // ordinary dependency, compiled without `cfg(test)`, so this + // block is fully live for all ~96 of them: `cargo test --lib` is + // protected, `cargo test --test ` is not. That is what the + // `roots` parameter is for — an integration test redirects the + // config root instead of relying on a guard that does not reach + // it. (Nor is this the only ambient root: the bundled-package + // materialization above runs unconditionally and WRITES.) See + // `docs/test-ambient-config-isolation-framing.md` §1.2, §1.6. + // + // The guard is deliberately NOT widened to cover integration + // tests: production deciding it is under test is how a suite + // passes against behaviour production never runs (Q#TI1). + // // The init-complete flip happens here too so lifecycle-gated // Lua APIs (e.g. `pmacs.attach`, M5.6d+) become inert after // user config returns. Tests that need post-init semantics flip // the flag explicitly via [`crate::lua::LuaHost::set_init_complete`]; // see the option-(A) discussion in the M5.6c survey. + // + // Redirected roots change *which directory* is read, never + // *whether* the block runs: `tests/m8_2_acceptance.rs:75` + // documents its dependence on integration-test construction + // finishing init-complete, so skipping the block for isolated + // construction would leave every such test permanently in the + // init phase (framing §1.8). #[cfg(not(test))] { - crate::config::load_user_config(&mut lua_host); + match roots.config_dir() { + Some(dir) => crate::config::load_user_config_at(&mut lua_host, &dir), + None => crate::config::load_user_config(&mut lua_host), + } lua_host.set_init_complete(); } Self { + bootstrap_roots: roots.clone(), core, lua_host, dispatchers: HashMap::new(), @@ -879,10 +956,24 @@ impl EditorState { /// `~/.local/state/pmacs`. Honors the `PMACS_STATE_HOME` override /// (see [`crate::state::user_state_dir`]). pub fn install_state_dirs(&self) { - if let Some(dir) = crate::minibuffer::user_history_dir() { + // Redirected roots win over the environment here too. This runs + // after construction, so resolving from the environment would + // hand an isolated session the developer's real state dir --- and + // `PMACS_STATE_HOME` outranks `XDG_STATE_HOME`, so an + // environment-side fix would have to cover five variables, not + // four (framing §1.6a). + let history = self + .bootstrap_roots + .history_dir() + .or_else(crate::minibuffer::user_history_dir); + if let Some(dir) = history { self.core.borrow_mut().minibuffer.history_dir = Some(dir); } - if let Some(dir) = crate::state::user_state_dir() { + let state = self + .bootstrap_roots + .state_dir() + .or_else(crate::state::user_state_dir); + if let Some(dir) = state { self.lua_host .lua() .set_app_data(crate::lua_bindings::StateDir(dir)); @@ -1029,7 +1120,27 @@ impl EditorState { behavioral gain" )] pub fn open(path: PathBuf) -> io::Result { - let mut state = Self::new(); + Self::open_with_roots(path, &crate::bootstrap::BootstrapRoots::ambient()) + } + + /// [`Self::open`] against explicit bootstrap storage roots. + /// + /// `open` calls `Self::new()` internally, so a roots parameter on + /// the constructor alone leaves every open-path test ambient + /// (`docs/test-ambient-config-isolation-framing.md` §1.7). The two + /// entry points therefore gain the parameter together, and `open` + /// stays a thin ambient caller of this — the golden-journey ratchet + /// requires that exact public entry point to have a production + /// caller shape. + #[allow( + clippy::needless_pass_by_value, + reason = "mirrors `open`'s stable signature; see the note there" + )] + pub fn open_with_roots( + path: PathBuf, + roots: &crate::bootstrap::BootstrapRoots, + ) -> io::Result { + let mut state = Self::new_with_roots(roots); let resolved = state .core .borrow_mut() diff --git a/src/lib.rs b/src/lib.rs index e3971a3..43c2a3b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ pub mod attach_dispatch; pub mod attach_reconnect; pub mod audit; pub mod autosave; +pub mod bootstrap; pub mod buffer; pub mod buffer_registry; pub mod builtin_packages; diff --git a/tests/ambient_isolation_acceptance.rs b/tests/ambient_isolation_acceptance.rs new file mode 100644 index 0000000..fee892d --- /dev/null +++ b/tests/ambient_isolation_acceptance.rs @@ -0,0 +1,706 @@ +// tests/ambient_isolation_acceptance.rs --- integration tests must not +// read or write the developer's real ambient roots. + +//! Acceptance for `docs/test-ambient-config-isolation-framing.md`. +//! +//! # The defect +//! +//! `src/editor.rs` guards user-config loading with `#[cfg(not(test))]`. +//! `cfg(test)` is set only while compiling the crate's *own* unit tests; +//! an integration test in `tests/` links `pmacs` as an ordinary +//! dependency, so the guard is inactive for all of them. `cargo test +//! --lib` is protected, `cargo test --test ` is not. And config +//! loading is only the read half: `EditorState::new` materializes +//! bundled packages into `$XDG_DATA_HOME/pmacs` (else +//! `$HOME/.local/share`) **unconditionally**, outside every `cfg` guard, +//! creating directories and writing files. +//! +//! # The census (framing acceptance 1), read at `54a092e` +//! +//! Method: every occurrence was listed with its enclosing context and +//! read. A grep for the bare name over-counts — the framing's revision 1 +//! reported 18 by grepping `Editor::new`, which does not even match the +//! real constructor `EditorState::new`. +//! +//! **In-process construction — 342 sites in 66 of 97 files.** +//! +//! * `EditorState::new()` — 334 textual occurrences, of which **330 are +//! calls**. The other 4 are prose: `persistence_acceptance.rs:6` and +//! `:76`, `m7_11_acceptance.rs:92`, `m8_2_acceptance.rs:75`. A fifth +//! file, `m5_6_acceptance.rs:94`, names the constructor only to say it +//! deliberately does **not** use it — the third place in the tree +//! documenting this same `cfg(test)` gap. +//! * `EditorState::open(` — 14 textual occurrences in 3 files, of which +//! **12 are calls** (`journey_acceptance` 7, `m4_acceptance` 4, +//! `theme_faces_acceptance` 1). The other 2 are assertion-message +//! strings in `journey_acceptance.rs:2030,2038`. +//! * Only 329 of the 330 `new()` calls are `let` bindings; the odd one +//! is `m8_1_acceptance.rs:39`, a bare tail expression in a +//! `fresh_editor()` helper. Sites, not files, are the unit. +//! +//! **Spawned `pmacs` — 14 sites in 8 files.** `grep CARGO_BIN_EXE_pmacs` +//! reports 36 sites in 26 files, but 18 of those are the *sibling* +//! binaries `CARGO_BIN_EXE_pmacs_fake_lsp` / `_fake_mcp`, which are LSP +//! and MCP stubs and not pmacs at all. Reading each occurrence: +//! +//! * `tests/common/daemon.rs:158` — the shared `--daemon` harness. +//! * `tests/common/pty.rs:111` — the shared real-PTY spawner. +//! * `m5_7_acceptance.rs` ×5, `m5_8_acceptance.rs` ×3, +//! `m5_5_acceptance.rs` ×1, `m5_perf_acceptance.rs` ×1, +//! `gpu_invocation_acceptance.rs` ×2 — direct `Command::new`. +//! * 4 further occurrences are **path derivations, not spawns**: +//! `gpu_invocation_acceptance.rs:115`, `vterm_stage3_acceptance.rs:644` +//! and `:1180`, `bottom_panel_stage2b_gpu_acceptance.rs:513` all take +//! `CARGO_BIN_EXE_pmacs`'s *parent* to locate the `pmacs-gpu` sibling. +//! +//! **Mixed files are real: 5 files are both in-process and spawned** — +//! `vterm_stage3_acceptance` constructs an editor at `:159` and reaches +//! a daemon at `:665`. A file-level partition cannot represent them, +//! which is why the ratchet below keys on sites. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use pmacs::bootstrap::BootstrapRoots; +use pmacs::editor::EditorState; + +#[path = "common/iso.rs"] +mod iso; + +// --------------------------------------------------------------------------- +// Isolated construction still finishes initialization (framing §1.8) +// --------------------------------------------------------------------------- + +/// **N** — isolated construction skips the ambient *reads* and still +/// flips the init-complete gate. +/// +/// Config loading and `set_init_complete()` share one conditional block, +/// so the tempting fix — skip the block when roots are redirected — +/// would leave every isolated test permanently in the init phase. +/// `tests/m8_2_acceptance.rs:75` documents its dependence on +/// integration-test construction being init-complete, so that is not a +/// hypothetical. +/// +/// Falsified by wrapping the block in `if roots.is_ambient()`. +#[test] +fn isolated_construction_is_init_complete() { + let state = EditorState::new_with_roots(&iso::roots()); + assert!( + state.lua_host.is_init_complete(), + "isolated construction must still leave the init phase, or every \ + suite that reopens it (m8_2) breaks" + ); + // The paired half — that the *ambient* constructor is unchanged — + // deliberately does NOT live here. Asserting it needs an ambient + // `EditorState::new()`, and an ambient construction in an ordinary + // parent test reads the developer's real `init.lua` and materializes + // packages into their real data root: the exact exposure this suite + // exists to remove, committed by the suite itself. It lives in the + // re-exec'd positive control below, where the roots are controlled + // by construction. +} + +/// **N** — the init phase is genuinely closed, not merely reported +/// closed. +/// +/// A flag read is one bool; this asserts the *behaviour* the flag gates, +/// so a fix that sets the flag without the surrounding block having run +/// cannot pass. `pmacs.attach` is init-only and must now refuse. +#[test] +fn isolated_construction_closes_the_init_only_lua_surface() { + let state = EditorState::new_with_roots(&iso::roots()); + let err = state + .lua_host + .lua() + .load(r#"pmacs.attach { target = "local:/run/pmacs/x.sock" }"#) + .exec() + .expect_err("pmacs.attach must refuse after init"); + let text = err.to_string(); + assert!( + text.contains("init"), + "the refusal must name the init phase; got {text}" + ); +} + +// --------------------------------------------------------------------------- +// Isolated construction redirects the writes (framing §1.6) +// --------------------------------------------------------------------------- + +/// **N** — the bundled-package materialization lands in the redirected +/// data root. +/// +/// Asserts content produced, not an invariant preserved: the package +/// tree has to actually exist under the isolated root. A test that only +/// checked "the real root was not modified" would pass on a machine +/// where the real root already held identical bytes, because +/// `write_if_changed` is content-gated. +#[test] +fn isolated_construction_materializes_into_the_redirected_data_root() { + let base = Path::new(env!("CARGO_TARGET_TMPDIR")).join("materialize-probe"); + let _ = std::fs::remove_dir_all(&base); + let roots = BootstrapRoots::isolated_under(&base); + let dir = roots.bundled_runtime_dir().expect("redirected data root"); + assert!(!dir.exists(), "the probe root must start absent"); + + let _state = EditorState::new_with_roots(&roots); + + let manifest = dir.join("repl").join("pmacs.toml"); + assert!( + manifest.is_file(), + "bundled packages must materialize under the redirected data \ + root; {} is missing", + manifest.display() + ); + let text = std::fs::read_to_string(&manifest).expect("read materialized manifest"); + assert!( + text.contains("repl"), + "the materialized manifest must be the bundled package's own; got {text:?}" + ); +} + +/// **N** — `install_state_dirs` honours the redirected state root. +/// +/// It runs *after* the constructor, so a constructor-only parameter +/// would leave it resolving `PMACS_STATE_HOME` / `XDG_STATE_HOME` from +/// the environment and hand an isolated session the developer's real +/// state dir. +#[test] +fn install_state_dirs_honours_the_redirected_state_root() { + let base = Path::new(env!("CARGO_TARGET_TMPDIR")).join("state-probe"); + let roots = BootstrapRoots::isolated_under(&base); + let state = EditorState::new_with_roots(&roots); + state.install_state_dirs(); + + let dir = state + .lua_host + .lua() + .app_data_ref::() + .expect("install_state_dirs must configure a state dir") + .0 + .clone(); + assert_eq!( + dir, + roots.state_dir().unwrap(), + "state dir must be redirected" + ); + let history = state.core.borrow().minibuffer.history_dir.clone(); + assert_eq!( + history, + roots.history_dir(), + "minibuffer history must be redirected too" + ); +} + +// --------------------------------------------------------------------------- +// Hostile ambient environment (framing Bet 3, acceptance 7) +// --------------------------------------------------------------------------- + +/// Names the isolated base for the isolated child; its presence is the +/// signal that this process *is* that child. +const ISOLATED_CHILD_BASE: &str = "PMACS_AMBIENT_ISOLATION_CHILD_BASE"; +/// Presence marks the ambient positive-control child. +const AMBIENT_CONTROL_CHILD: &str = "PMACS_AMBIENT_ISOLATION_CONTROL"; + +/// An `init.lua` that leaves a mark an assertion can see. The real +/// developer `init.lua` that produced this lane broke +/// `compile_mode_acceptance` by redefining a command pmacs already +/// defines; a marker global is the same exposure with a cheaper failure +/// mode, and it discriminates read-vs-not-read directly. +const HOSTILE_INIT_LUA: &str = "_G.HOSTILE_INIT_RAN = true\n"; + +fn hostile_root(name: &str) -> PathBuf { + let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name); + let _ = std::fs::remove_dir_all(&root); + let config = root.join("pmacs"); + std::fs::create_dir_all(&config).expect("create hostile config dir"); + std::fs::write(config.join("init.lua"), HOSTILE_INIT_LUA).expect("write hostile init.lua"); + // Pre-seed the data root the ambient resolver would use, so a write + // into it is visible as a *change*, not just as a new tree. + let seeded = root + .join("pmacs") + .join("builtin-packages") + .join(format!("v{}", env!("CARGO_PKG_VERSION"))); + std::fs::create_dir_all(&seeded).expect("seed hostile data root"); + std::fs::write(seeded.join("SEED"), b"pre-seeded\n").expect("write seed marker"); + root +} + +/// Flat snapshot of a tree: relative path → contents (directories map to +/// the empty vector). Sorted, so comparison is order-independent. +fn snapshot(root: &Path) -> BTreeMap> { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path + .strip_prefix(root) + .expect("entry is under root") + .to_path_buf(); + if path.is_dir() { + out.insert(rel, Vec::new()); + walk(root, &path, out); + } else { + out.insert(rel, std::fs::read(&path).unwrap_or_default()); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +/// The five storage variables plus `HOME`, all aimed at `root`. `HOME` +/// is included here — and only here — because this is the adversary: a +/// machine that leaves an XDG variable unset falls back to it, and the +/// point of a hostile environment is to leave no path out. +fn hostile_env(root: &Path) -> Vec<(&'static str, PathBuf)> { + vec![ + ("HOME", root.to_path_buf()), + ("XDG_CONFIG_HOME", root.to_path_buf()), + ("XDG_DATA_HOME", root.to_path_buf()), + ("XDG_STATE_HOME", root.to_path_buf()), + ("PMACS_STATE_HOME", root.to_path_buf()), + ("XDG_CACHE_HOME", root.to_path_buf()), + ] +} + +fn run_child(test_name: &str, env: Vec<(&'static str, PathBuf)>) -> (bool, String) { + let exe = std::env::current_exe().expect("current test binary"); + let output = Command::new(exe) + .args(["--exact", test_name, "--nocapture", "--test-threads=1"]) + .envs(env) + .output() + .unwrap_or_else(|e| panic!("re-exec `{test_name}`: {e}")); + let mut log = String::new(); + log.push_str("--- stdout ---\n"); + log.push_str(&String::from_utf8_lossy(&output.stdout)); + log.push_str("--- stderr ---\n"); + log.push_str(&String::from_utf8_lossy(&output.stderr)); + assert!( + log.contains("1 passed") || !output.status.success(), + "child `{test_name}` ran no test — the `--exact` filter went stale\n{log}" + ); + (output.status.success(), log) +} + +/// **Positive control.** Under the hostile environment, the *ambient* +/// constructor really is captured by it. +/// +/// Without this the isolation assertion below is unfalsifiable: an +/// `init.lua` that never loads under any circumstances would satisfy +/// "the isolated editor did not load it" while proving nothing. +/// +/// **This is the suite's only ambient construction, and it runs only as +/// a re-exec'd child** (the marker gates it), where the roots are +/// controlled by construction. An ambient `EditorState::new()` in an +/// ordinary parent test would read the developer's real `init.lua` and +/// write their real data root — so the one place that legitimately needs +/// the ambient constructor is also the one place where the environment +/// has already been redirected. Every ambient claim this suite makes +/// belongs here for that reason. +#[test] +fn ambient_construction_under_a_hostile_environment_is_captured_by_it() { + if std::env::var_os(AMBIENT_CONTROL_CHILD).is_none() { + return; + } + let state = EditorState::new(); + // Relocated from `isolated_construction_is_init_complete`: the + // ambient constructor is unchanged by this lane and still finishes + // initialization. Asserting it needs an ambient construction, which + // is only safe here. + assert!( + state.lua_host.is_init_complete(), + "the ambient constructor must still leave the init phase — the \ + roots parameter changes which directory is read, never whether \ + the block runs" + ); + let ran: bool = state + .lua_host + .lua() + .load("return _G.HOSTILE_INIT_RAN == true") + .eval() + .expect("read the hostile marker"); + assert!( + ran, + "the ambient constructor must load the hostile init.lua — if it \ + does not, the isolation assertion proves nothing" + ); + // And the write half: the ambient constructor materializes into the + // hostile data root. + let dir = pmacs::builtin_packages::bundled_runtime_dir(); + assert!( + dir.join("repl").join("pmacs.toml").is_file(), + "the ambient constructor must write into the hostile data root; \ + {} is empty", + dir.display() + ); +} + +/// The isolated child: same hostile environment, redirected roots. +#[test] +fn isolated_construction_under_a_hostile_environment_ignores_it() { + let Some(base) = std::env::var_os(ISOLATED_CHILD_BASE) else { + return; + }; + let base = PathBuf::from(base); + let roots = BootstrapRoots::isolated_under(&base); + let state = EditorState::new_with_roots(&roots); + let ran: bool = state + .lua_host + .lua() + .load("return _G.HOSTILE_INIT_RAN == true") + .eval() + .expect("read the hostile marker"); + assert!(!ran, "the hostile init.lua must not have been loaded"); + assert!( + state.lua_host.is_init_complete(), + "and initialization must still have finished" + ); + // Content produced, in the right place. + let dir = roots.bundled_runtime_dir().expect("redirected data root"); + assert!( + dir.join("repl").join("pmacs.toml").is_file(), + "bundled packages must land under the isolated root; {} is empty", + dir.display() + ); +} + +/// **N** — the whole of Bet 3: green under a hostile environment, and +/// the hostile root byte-identical afterwards. +/// +/// Two children, because the two halves need opposite environments to be +/// meaningful: the positive control must be *captured* by its hostile +/// root (and so modifies it), while the isolated child must leave its +/// own hostile root untouched. +#[test] +fn a_hostile_ambient_environment_is_neither_read_nor_written() { + // Half 1 — the control. Its hostile root is expected to change. + let control = hostile_root("hostile-control"); + let before_control = snapshot(&control); + let (ok, log) = run_child( + "ambient_construction_under_a_hostile_environment_is_captured_by_it", + { + let mut env = hostile_env(&control); + env.push((AMBIENT_CONTROL_CHILD, PathBuf::from("1"))); + env + }, + ); + assert!(ok, "the ambient positive control must be captured\n{log}"); + let after_control = snapshot(&control); + assert_ne!( + before_control, after_control, + "the control's hostile root must have been written into — if it \ + was not, this environment is not hostile and the isolated half \ + below asserts nothing" + ); + + // Half 2 — the isolated child. Its hostile root must be untouched. + let hostile = hostile_root("hostile-isolated"); + let isolated_base = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hostile-isolated-roots"); + let _ = std::fs::remove_dir_all(&isolated_base); + let before = snapshot(&hostile); + assert!(!before.is_empty(), "the hostile root must not be empty"); + let (ok, log) = run_child( + "isolated_construction_under_a_hostile_environment_ignores_it", + { + let mut env = hostile_env(&hostile); + env.push((ISOLATED_CHILD_BASE, isolated_base.clone())); + env + }, + ); + assert!(ok, "the isolated child must stay green\n{log}"); + let after = snapshot(&hostile); + assert_eq!( + before, after, + "the hostile root must be byte-identical afterwards — a green \ + suite that still wrote into it has not demonstrated isolation" + ); + // And the writes went somewhere: the isolated tree exists. + assert!( + isolated_base.join("data").join("pmacs").is_dir(), + "the isolated data root must have been written instead" + ); +} + +// --------------------------------------------------------------------------- +// Adoption ratchet (framing acceptance 12) +// --------------------------------------------------------------------------- + +/// Files permitted to construct an editor through the **ambient** entry +/// points, **with the exact number of sites each is permitted**. +/// +/// `journey_acceptance` is ambient on purpose: it is the golden-journey +/// ratchet, and its whole claim is that the production entry point +/// `pmacs FILE` calls has a caller. It is isolated by re-execing itself +/// with controlled roots instead (framing §1.10). This file is ambient +/// only inside the positive control above, which never runs except as a +/// deliberately re-exec'd child. +/// +/// **The count is the point, not decoration.** A bare file-level +/// exemption is the weakest form of this ratchet: it licenses the named +/// file to grow *new* ambient sites forever. That is not hypothetical — +/// review round 1 of this PR found an ambient `EditorState::new()` in an +/// ordinary parent test of **this very file**, and the file-level +/// exemption is precisely what let it through a green ratchet. Every +/// exemption is now a census entry, so an added site fails even inside +/// an allowlisted file, and a removed one has to be recorded. +const AMBIENT_ALLOWLIST: &[(&str, usize)] = &[ + // 19 `new()` + 7 `open(` — the golden journey, every one of them + // reached only from inside a re-exec'd child. (29 textual + // occurrences; the scanner drops 2 assertion-message mentions and + // the assembled `concat!` needle in the self-source check.) + ("journey_acceptance.rs", 26), + // Exactly one: the re-exec'd positive control. + ("ambient_isolation_acceptance.rs", 1), +]; + +/// Strip comments and string-literal *contents* from Rust source, so a +/// scan counts calls rather than mentions. +/// +/// Both matter here. `m5_6_acceptance.rs:94` names `EditorState::new` in +/// a comment only to say it deliberately does not call it, and +/// `journey_acceptance.rs:2030` carries it inside an assertion message. +/// Raw strings (`r#"..."#`) are pervasive in this suite, so they are +/// handled rather than hoped about. +fn strip_comments_and_strings(src: &str) -> String { + let b: Vec = src.chars().collect(); + let mut out = String::with_capacity(src.len()); + let mut i = 0; + while i < b.len() { + // Line comment. + if b[i] == '/' && i + 1 < b.len() && b[i + 1] == '/' { + while i < b.len() && b[i] != '\n' { + i += 1; + } + continue; + } + // Block comment (Rust's nest). + if b[i] == '/' && i + 1 < b.len() && b[i + 1] == '*' { + let mut depth = 1; + i += 2; + while i < b.len() && depth > 0 { + if b[i] == '/' && i + 1 < b.len() && b[i + 1] == '*' { + depth += 1; + i += 2; + } else if b[i] == '*' && i + 1 < b.len() && b[i + 1] == '/' { + depth -= 1; + i += 2; + } else { + i += 1; + } + } + out.push(' '); + continue; + } + // Raw string: r, then any number of #, then ". + if b[i] == 'r' { + let mut j = i + 1; + let mut hashes = 0; + while j < b.len() && b[j] == '#' { + hashes += 1; + j += 1; + } + if j < b.len() && b[j] == '"' { + j += 1; + loop { + if j >= b.len() { + break; + } + if b[j] == '"' { + let mut k = j + 1; + let mut seen = 0; + while k < b.len() && b[k] == '#' && seen < hashes { + seen += 1; + k += 1; + } + if seen == hashes { + j = k; + break; + } + } + j += 1; + } + out.push(' '); + i = j; + continue; + } + } + // Ordinary string. + if b[i] == '"' { + i += 1; + while i < b.len() { + if b[i] == '\\' { + i += 2; + continue; + } + if b[i] == '"' { + i += 1; + break; + } + i += 1; + } + out.push(' '); + continue; + } + out.push(b[i]); + i += 1; + } + out +} + +fn tests_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests") +} + +/// Every `.rs` file under `tests/`, including `tests/common/`. +fn test_sources() -> Vec<(String, String)> { + let mut out = Vec::new(); + let dir = tests_dir(); + let push_dir = |d: &Path, out: &mut Vec<(String, String)>| { + for entry in std::fs::read_dir(d).expect("read tests dir").flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "rs") { + let name = path + .file_name() + .expect("file name") + .to_string_lossy() + .into_owned(); + out.push((name, std::fs::read_to_string(&path).expect("read source"))); + } + } + }; + push_dir(&dir, &mut out); + push_dir(&dir.join("common"), &mut out); + out +} + +/// **N** — a *durable* adoption ratchet, not a one-time census. +/// +/// One self-spawning hostile-environment test proves the seam works; it +/// cannot notice a raw `EditorState::new()` added to a different binary +/// next month. This can. Falsified by adding an ambient constructor to +/// any non-allowlisted suite. +#[test] +fn no_test_outside_the_allowlist_constructs_an_ambient_editor() { + let sources = test_sources(); + // A broken glob must not read as a clean tree. + assert!( + sources.len() > 90, + "expected the whole tests/ corpus; found only {} files", + sources.len() + ); + let needles = [ + concat!("EditorState::", "new()"), + concat!("EditorState::", "open("), + ]; + let mut offenders: Vec = Vec::new(); + let mut miscounted: Vec = Vec::new(); + let mut seen_allowlisted: Vec<&str> = Vec::new(); + for (name, src) in &sources { + let code = strip_comments_and_strings(src); + let hits: usize = needles.iter().map(|n| code.matches(n).count()).sum(); + if hits == 0 { + continue; + } + match AMBIENT_ALLOWLIST.iter().find(|(f, _)| *f == name.as_str()) { + Some((file, allowed)) => { + seen_allowlisted.push(file); + // An allowlisted file is exempted for the sites it was + // reviewed with, not for any it grows later. + if hits != *allowed { + miscounted.push(format!("{name}: {hits} site(s), allowlist says {allowed}")); + } + } + None => offenders.push(format!("{name} ({hits} site(s))")), + } + } + offenders.sort(); + miscounted.sort(); + assert!( + offenders.is_empty(), + "these suites construct an editor through the ambient entry \ + points, so they read the developer's real init.lua and write \ + into their real data root: {offenders:?}\n\ + Use `EditorState::new_with_roots(&crate::iso::roots())` (see \ + tests/common/iso.rs), or add the file to AMBIENT_ALLOWLIST with \ + a reason.", + ); + assert!( + miscounted.is_empty(), + "allowlisted files whose ambient site count moved: {miscounted:?}\n\ + MORE than allowed means a new ambient construction slipped into \ + an exempted file — the failure mode a bare file-level exemption \ + cannot see. FEWER means the census is stale; update the count.", + ); + // Dead allowlist entries are how a ratchet rots: an entry that no + // longer needs to be there silently licenses a future regression. + let mut missing: Vec<&str> = AMBIENT_ALLOWLIST + .iter() + .map(|(f, _)| *f) + .filter(|f| !seen_allowlisted.contains(f)) + .collect(); + missing.sort_unstable(); + assert!( + missing.is_empty(), + "allowlisted files that no longer construct an ambient editor — \ + remove them: {missing:?}" + ); +} + +/// **N** — the ratchet's scanner is not fooled by prose. +/// +/// A grep-shaped answer is what cost this lane a review round; the scan +/// above only ratchets if it distinguishes a call from a mention. Pinned +/// with the exact shapes the corpus actually contains. +#[test] +fn the_ratchet_scanner_counts_calls_not_mentions() { + let sample = r##" +//! `EditorState::new()` in a doc comment. +// `EditorState::new()` in a line comment. +/* `EditorState::new()` in a block comment. */ +fn f() { + let msg = "EditorState::open(file) must not greet"; + let raw = r#"EditorState::new() inside a raw string"#; + let _ = EditorState::new(); +} +"##; + let code = strip_comments_and_strings(sample); + assert_eq!( + code.matches(concat!("EditorState::", "new()")).count(), + 1, + "only the call survives; got {code:?}" + ); + assert_eq!( + code.matches(concat!("EditorState::", "open(")).count(), + 0, + "the assertion-message mention must not count; got {code:?}" + ); +} + +/// **N** — the seam actually reached the corpus. +/// +/// The ratchet above is an absence check, and an absence check passes on +/// a tree where nobody constructs an editor at all. This asserts the +/// positive: the isolated constructor has broad adoption. +#[test] +fn the_isolated_constructor_has_been_adopted_across_the_corpus() { + let sources = test_sources(); + let adopters: Vec<&String> = sources + .iter() + .filter(|(_, src)| { + let code = strip_comments_and_strings(src); + code.contains(concat!("EditorState::new", "_with_roots(")) + || code.contains(concat!("EditorState::open", "_with_roots(")) + }) + .map(|(name, _)| name) + .collect(); + assert!( + adopters.len() >= 60, + "expected the whole in-process population to have migrated; only \ + {} files did", + adopters.len() + ); +} diff --git a/tests/auto_indent_acceptance.rs b/tests/auto_indent_acceptance.rs index 5d40590..17c8403 100644 --- a/tests/auto_indent_acceptance.rs +++ b/tests/auto_indent_acceptance.rs @@ -74,7 +74,7 @@ fn status(s: &EditorState) -> String { /// Fresh editor whose active scratch buffer holds `body`, cursor at 0. fn editor_with(body: &str) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); if !body.is_empty() { exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})")); } @@ -503,3 +503,10 @@ fn isearch_ret_accepts_instead_of_inserting() { "RET during isearch accepts; no newline is inserted" ); } + +// 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; diff --git a/tests/auto_pair_acceptance.rs b/tests/auto_pair_acceptance.rs index 29291b3..7a20b06 100644 --- a/tests/auto_pair_acceptance.rs +++ b/tests/auto_pair_acceptance.rs @@ -29,7 +29,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -114,7 +114,7 @@ fn status(s: &EditorState) -> String { /// Fresh scratch-buffer editor whose buffer holds `body`, cursor at 0. /// No state dir / no files: scratch pairing uses the `default` set. fn editor_with(body: &str) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); if !body.is_empty() { exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})")); } @@ -1378,3 +1378,10 @@ fn relocated_closer_first_did_change_carries_the_complete_effective_text() { text in the first didChange — never an opener-only intermediate" ); } + +// 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; diff --git a/tests/autosave_acceptance.rs b/tests/autosave_acceptance.rs index 4567f96..8433f28 100644 --- a/tests/autosave_acceptance.rs +++ b/tests/autosave_acceptance.rs @@ -24,7 +24,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -861,3 +861,10 @@ fn before_quit_sweeps_synchronously_without_vetoing() { ); std::fs::remove_dir_all(&dir).ok(); } + +// 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; diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index fb3a4ad..400754a 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -39,7 +39,7 @@ const COLS: u32 = 60; const AREA_ROWS: u32 = ROWS - 1; fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); // Geometry is authoritative state, and a grid frontend's real frame // size IS its declaration. Every test that does not render declares @@ -2571,7 +2571,7 @@ fn panel_hidden_never_describes_a_panel_that_no_longer_exists() { #[test] fn unknown_geometry_is_not_twenty_four_by_eighty() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); let fid = FrontendId(77); attach_frontend(&s, fid, false); @@ -2619,3 +2619,10 @@ fn cell_coord_helper_is_used() { // Keeps the CellCoord import honest for grid assertions above. assert_eq!(CellCoord::new(1, 2).row, 1); } + +// 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; diff --git a/tests/bottom_panel_stage2a_acceptance.rs b/tests/bottom_panel_stage2a_acceptance.rs index d39d804..1e5ed05 100644 --- a/tests/bottom_panel_stage2a_acceptance.rs +++ b/tests/bottom_panel_stage2a_acceptance.rs @@ -22,7 +22,7 @@ const ROWS: u32 = 24; const COLS: u32 = 60; fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); s @@ -896,3 +896,10 @@ fn a_provider_closing_the_document_split_still_clears_the_statusline() { the phase-1 document identity, even when a callback closed that window; got {msgs:?}" ); } + +// 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; diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index 0bbf8d0..cc80422 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -56,7 +56,7 @@ impl Session { /// A semantic, panel-capable frontend with one document window and /// no geometry declared yet. fn new() -> Self { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); let document = { let mut core = state.core.borrow_mut(); @@ -659,7 +659,7 @@ fn a2b1_a_rejected_declaration_reconciles_nothing() { fn a2b1_grid_allocator_exhaustion_clears_the_declaration_and_hides() { // The grid/LOCAL allocator, which mints its own epochs. `LOCAL` is // panel-capable, so this is the production path for a TUI. - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); exec( @@ -1340,3 +1340,10 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() { ); exec(&session.state, "pmacs.terminal.terminate(TERM_BUF)"); } + +// 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; diff --git a/tests/comment_toggle_acceptance.rs b/tests/comment_toggle_acceptance.rs index 0e00f21..caa06e1 100644 --- a/tests/comment_toggle_acceptance.rs +++ b/tests/comment_toggle_acceptance.rs @@ -25,7 +25,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -366,3 +366,10 @@ fn toggle_between_kills_breaks_the_kill_chain() { "C-k, M-;, C-k yields two ring entries (chain broken)" ); } + +// 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; diff --git a/tests/common/daemon.rs b/tests/common/daemon.rs index 1c8bc0d..1e67642 100644 --- a/tests/common/daemon.rs +++ b/tests/common/daemon.rs @@ -159,9 +159,31 @@ pub fn spawn_daemon_process_with_env(socket_path: &Path, env_vars: &[(&str, &str cmd.args(["--daemon", "--socket"]) .arg(socket_path) .env("HOME", isolated_home) - .env("XDG_CONFIG_HOME", isolated_home) .stdout(Stdio::null()) .stderr(Stdio::from(stderr)); + // All FIVE storage variables, not just `XDG_CONFIG_HOME` + // (`docs/test-ambient-config-isolation-framing.md` §1.6a). + // + // `HOME` above is only a *fallback*: it isolates a root only while + // the corresponding `XDG_*` variable is unset. On a developer + // machine that exports `XDG_DATA_HOME`, a daemon given `HOME` alone + // still materializes bundled packages into the real data root — so + // the harness's apparent adequacy was a property of one machine's + // environment, not of the harness. And `PMACS_STATE_HOME` outranks + // `XDG_STATE_HOME`, so the four XDG variables alone leave a + // higher-precedence state override live. + // + // Flat layout (every root at the socket's tempdir) so + // `spawn_with_config`'s `/pmacs/init.lua` keeps loading + // through the real `load_user_config` path. + let roots = pmacs::bootstrap::BootstrapRoots::ambient() + .with_config_root(isolated_home.to_path_buf()) + .with_data_root(isolated_home.to_path_buf()) + .with_state_root(isolated_home.to_path_buf()) + .with_cache_root(isolated_home.to_path_buf()); + for (key, value) in roots.child_env() { + cmd.env(key, value); + } for (key, value) in env_vars { cmd.env(key, value); } diff --git a/tests/common/iso.rs b/tests/common/iso.rs new file mode 100644 index 0000000..bdc6f8f --- /dev/null +++ b/tests/common/iso.rs @@ -0,0 +1,68 @@ +// tests/common/iso.rs --- isolated bootstrap roots for integration tests. + +//! The integration suite's side of `pmacs::bootstrap`. +//! +//! An integration test links `pmacs` as an ordinary dependency, so it is +//! compiled **without** `cfg(test)` and the `#[cfg(not(test))]` guard in +//! `EditorState::new` is live for it: a raw `EditorState::new()` reads +//! the developer's real `~/.config/pmacs/init.lua` and writes bundled +//! packages into the developer's real `~/.local/share/pmacs`. It cannot +//! fix that by setting an environment variable --- `std::env::set_var` +//! is `unsafe` and `pmacs` is `#![forbid(unsafe_code)]`. So it passes +//! [`roots`] to `EditorState::new_with_roots` instead. +//! +//! See `docs/test-ambient-config-isolation-framing.md`. +//! +//! Included per-file rather than through `mod common;` so a suite that +//! needs no daemon or PTY fixture does not compile them: +//! +//! ```ignore +//! #[path = "common/iso.rs"] +//! mod iso; +//! ``` +//! +//! [`roots`] is a **pure function of the build environment** --- no +//! counter, no `OnceLock`. Two copies of this module in one test binary +//! (one via `mod common;`, one via `#[path]`) therefore return the same +//! value rather than racing over a shared counter. + +#![allow(dead_code)] // not every including suite uses every helper + +use std::path::PathBuf; + +use pmacs::bootstrap::BootstrapRoots; + +/// The shared isolated base, under Cargo's per-package integration-test +/// temp directory. +/// +/// `CARGO_TARGET_TMPDIR` (`target//tmp`) rather than `/tmp`: +/// nothing here is ever unlinked --- there is no libtest teardown hook +/// to unlink it from --- so the tree has to live somewhere `cargo clean` +/// owns instead of leaking into the system temp dir once per run. +/// +/// Deliberately shared across tests and across test binaries. +/// `materialize_all` is content-gated and idempotent, so after the first +/// construction every later one is a no-op read; a per-test directory +/// would repeat the whole materialization ~330 times per run for no +/// isolation gain (the tree is byte-identical for every caller). +#[must_use] +pub fn base() -> PathBuf { + PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("ambient-isolation") +} + +/// Isolated bootstrap roots for an in-process editor. +/// +/// Pass to `EditorState::new_with_roots` / `open_with_roots`. +#[must_use] +pub fn roots() -> BootstrapRoots { + let base = base(); + let roots = BootstrapRoots::isolated_under(&base); + // Create the config dir eagerly. `load_user_config_at` registers it + // on Lua's `package.path` whether or not `init.lua` exists, and a + // suite that later writes a config chunk into it should not have to + // know the layout. + if let Some(dir) = roots.config_dir() { + std::fs::create_dir_all(&dir).expect("create isolated config dir"); + } + roots +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index e157208..7d2e4ca 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -14,8 +14,14 @@ //! - [`daemon`]: `pmacs --daemon` subprocess fixture. First //! consumer M5.5 acceptance suite; second consumer M10.11 //! doubled-PTY tests. +//! - [`iso`]: isolated bootstrap storage roots, so an in-process +//! editor neither reads the developer's real `init.lua` nor writes +//! into their real data root. Most suites include it directly via +//! `#[path = "common/iso.rs"] mod iso;` rather than through this +//! module; it is re-exported here for `daemon`'s use. #![allow(dead_code)] // not every integration-test file uses every helper pub mod daemon; +pub mod iso; pub mod pty; diff --git a/tests/common/pty.rs b/tests/common/pty.rs index a1f16c8..e8573e1 100644 --- a/tests/common/pty.rs +++ b/tests/common/pty.rs @@ -112,6 +112,21 @@ pub fn spawn_pmacs_in_pty(args: &[&str], envs: &[(&str, &Path)], rows: u16, cols for arg in args { cmd.arg(arg); } + // Backfill any of the five storage variables the caller did not set + // (`docs/test-ambient-config-isolation-framing.md` §1.6a). Callers + // pin the roots their assertions read (a per-test `XDG_CONFIG_HOME`, + // a `PMACS_STATE_HOME` they inspect); the rest would otherwise be + // inherited, and the child would materialize bundled packages into + // the developer's real data root. Caller entries win: this only + // fills holes. + let fallback = pmacs::bootstrap::BootstrapRoots::isolated_under(&super::iso::base()); + for (key, value) in fallback.child_env() { + if envs.iter().any(|(k, _)| *k == key) { + continue; + } + std::fs::create_dir_all(&value).expect("create isolated root for PTY child"); + cmd.env(key, value.as_os_str()); + } for (k, v) in envs { let mut value = OsString::new(); value.push(v); diff --git a/tests/compile_mode_acceptance.rs b/tests/compile_mode_acceptance.rs index e7f21e0..a223406 100644 --- a/tests/compile_mode_acceptance.rs +++ b/tests/compile_mode_acceptance.rs @@ -82,7 +82,7 @@ fn errors_buffer(s: &EditorState) -> String { /// Fresh editor with LSP spawning disabled (language detection still /// works; the after-load hook must not exec real servers). fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s } @@ -2853,3 +2853,10 @@ fn j1b1_context_reports_the_cwd_a_run_would_use() { ); assert_eq!(kind, "rust", "the kind is detected from the given cwd"); } + +// 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; diff --git a/tests/completion_popup_acceptance.rs b/tests/completion_popup_acceptance.rs index f15a705..1867fc2 100644 --- a/tests/completion_popup_acceptance.rs +++ b/tests/completion_popup_acceptance.rs @@ -52,7 +52,7 @@ fn probe(s: &EditorState) -> (String, bool, i64) { /// the prefix is replaced by the candidate in one step. #[test] fn typing_opens_popup_and_tab_accepts() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world "); let (_, visible, _) = probe(&s); assert!( @@ -95,7 +95,7 @@ fn typing_opens_popup_and_tab_accepts() { /// candidate wins. Uses a custom provider for a deterministic order. #[test] fn ctrl_n_navigates_then_ret_accepts_second_candidate() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host .lua() .load( @@ -133,7 +133,7 @@ fn ctrl_n_navigates_then_ret_accepts_second_candidate() { /// key self-inserts normally (the shadow is partial, not modal). #[test] fn esc_dismisses_and_typing_falls_through() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world he"); let (_, visible, _) = probe(&s); assert!(visible); @@ -154,7 +154,7 @@ fn esc_dismisses_and_typing_falls_through() { /// anchor. #[test] fn motion_before_anchor_closes_popup() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world he"); let (_, visible, _) = probe(&s); assert!(visible); @@ -169,7 +169,7 @@ fn motion_before_anchor_closes_popup() { /// the Q#C9 single-char signature rejects paste-shaped deltas. #[test] fn yank_shaped_edit_does_not_auto_open() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world hello"); // Select the trailing word and cut it (C-w): the popup that was // open over `hello` closes as its word dies. @@ -198,7 +198,7 @@ fn yank_shaped_edit_does_not_auto_open() { /// auto-open prefix threshold. #[test] fn at_point_command_opens_below_threshold() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world h"); let (_, visible, _) = probe(&s); assert!(!visible, "a 1-char prefix stays below the auto-open bar"); @@ -223,7 +223,7 @@ fn at_point_command_opens_below_threshold() { /// `C-g` abort) reaches the dispatcher instead of the popup shadow. #[test] fn pending_prefix_dismisses_popup_and_keeps_dispatcher_control() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello_world he"); let (_, visible, _) = probe(&s); assert!(visible); @@ -258,7 +258,7 @@ fn pending_prefix_dismisses_popup_and_keeps_dispatcher_control() { /// index sweep meant the server was never asked). #[test] fn empty_sync_sweep_still_leaves_a_pending_session() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); // A buffer whose only word is the one being typed: dabbrev is // structurally empty, no LSP attached. The popup cannot open... type_str(&mut s, "qz"); @@ -282,7 +282,7 @@ fn empty_sync_sweep_still_leaves_a_pending_session() { /// because the real LSP store is Rust-side. #[test] fn collect_scopes_lsp_candidates_to_ctx_uri() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let (scoped, unscoped): (u64, u64) = s .lua_host .lua() @@ -322,3 +322,10 @@ fn collect_scopes_lsp_candidates_to_ctx_uri() { assert_eq!(scoped, 1, "ctx.uri reaches Lua providers (9th arg)"); assert_eq!(unscoped, 2, "no uri → all documents"); } + +// 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; diff --git a/tests/config_registry_acceptance.rs b/tests/config_registry_acceptance.rs index 41749fa..e194003 100644 --- a/tests/config_registry_acceptance.rs +++ b/tests/config_registry_acceptance.rs @@ -47,7 +47,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -507,3 +507,10 @@ fn describe_setting_shows_a_buffer_local_override_when_one_exists() { "an existing buffer-local override must be reported, got {text:?}" ); } + +// 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; diff --git a/tests/cua_region_acceptance.rs b/tests/cua_region_acceptance.rs index c4c16d7..89f22a8 100644 --- a/tests/cua_region_acceptance.rs +++ b/tests/cua_region_acceptance.rs @@ -50,7 +50,7 @@ fn probe(s: &EditorState) -> (String, bool, i64) { #[test] fn backspace_deletes_the_shift_selected_region() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello"); // Shift+Left three times: region [2, 5), cursor at 2. @@ -85,7 +85,7 @@ fn backspace_deletes_the_shift_selected_region() { /// chorded deletion keys to this same dispatch path. #[test] fn ctrl_backspace_deletes_the_previous_word() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "alpha beta"); s.dispatch_key( @@ -111,7 +111,7 @@ fn ctrl_backspace_deletes_the_previous_word() { #[test] fn typing_replaces_the_shift_selected_region() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello"); // Select "llo" (region [2, 5), cursor at 2), then type 'X': @@ -149,7 +149,7 @@ fn typing_replaces_the_shift_selected_region() { #[test] fn type_over_is_a_single_undo_step() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello"); // Select "llo" (region [2, 5)) and type 'X' → "heX". @@ -177,7 +177,7 @@ fn type_over_is_a_single_undo_step() { #[test] fn delete_forward_deletes_the_shift_selected_region() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "world"); // Shift+Home-equivalent: extend left over the whole word. @@ -200,3 +200,10 @@ fn delete_forward_deletes_the_shift_selected_region() { assert_eq!(text, "b", "no region ⇒ plain forward delete at cursor"); assert_eq!(cursor, 0); } + +// 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; diff --git a/tests/desktop_acceptance.rs b/tests/desktop_acceptance.rs index 61cab86..dffe516 100644 --- a/tests/desktop_acceptance.rs +++ b/tests/desktop_acceptance.rs @@ -32,7 +32,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -428,3 +428,10 @@ fn startup_gate_respects_file_arg_and_arming() { ); std::fs::remove_dir_all(&dir).ok(); } + +// 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; diff --git a/tests/dired_acceptance.rs b/tests/dired_acceptance.rs index bff9134..cacfdea 100644 --- a/tests/dired_acceptance.rs +++ b/tests/dired_acceptance.rs @@ -116,7 +116,7 @@ fn eval(s: &EditorState, src: &str) -> T { /// frame size *is* its geometry declaration, and the panel tests need /// one before any side window can be placed). fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); s @@ -1997,3 +1997,10 @@ fn dired_the_fold_refusal_names_the_read_only_lock() { "and not the sentence that is no longer true; got {st:?}" ); } + +// 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; diff --git a/tests/editops_acceptance.rs b/tests/editops_acceptance.rs index 0a30f95..f58f53a 100644 --- a/tests/editops_acceptance.rs +++ b/tests/editops_acceptance.rs @@ -114,7 +114,7 @@ fn status(s: &EditorState) -> String { /// typing RET would route through edit.newline-and-indent and clone /// leading whitespace), cursor at 0. fn editor_with(text: &str) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, &format!( @@ -1155,7 +1155,7 @@ fn temp_path(name: &str) -> std::path::PathBuf { fn trim_on_save_defaults_off_and_writes_untouched_bytes() { let path = temp_path("off.txt"); std::fs::write(&path, "x \ny\t\n").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let on: bool = eval(&s, "return pmacs.editops.trim_on_save()"); assert!(!on, "default off"); exec( @@ -1175,7 +1175,7 @@ fn trim_on_save_defaults_off_and_writes_untouched_bytes() { fn trim_on_save_trims_the_written_bytes_before_later_callbacks() { let path = temp_path("on.txt"); std::fs::write(&path, "x \ny\t\n").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.editops.trim_on_save(true)"); // A callback registered AFTER editops' (load-time) hook observes // the fan-out order saveplace sees: post-trim text. @@ -1215,7 +1215,7 @@ fn trim_on_save_trims_the_written_bytes_before_later_callbacks() { fn trim_on_save_failure_never_vetoes_the_save() { let path = temp_path("veto-immune.txt"); std::fs::write(&path, "x \n").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.editops.trim_on_save(true)"); exec( &s, @@ -1246,7 +1246,7 @@ fn trim_on_save_failure_never_vetoes_the_save() { fn trim_on_save_unexpected_error_reports_and_still_saves() { let path = temp_path("unexpected.txt"); std::fs::write(&path, "x \n").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.editops.trim_on_save(true)"); // Capture the pmacs.error log (the m9_6 stub pattern — the // `if pmacs.error` branch is a no-op without it). @@ -1293,7 +1293,7 @@ fn trim_on_save_unexpected_error_reports_and_still_saves() { fn another_callbacks_veto_is_not_masked_by_trim() { let path = temp_path("veto.txt"); std::fs::write(&path, "x \n").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.editops.trim_on_save(true)"); exec( &s, @@ -1318,3 +1318,10 @@ fn another_callbacks_veto_is_not_masked_by_trim() { ); let _ = std::fs::remove_file(&path); } + +// 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; diff --git a/tests/find_file_acceptance.rs b/tests/find_file_acceptance.rs index fb793e3..f290a3a 100644 --- a/tests/find_file_acceptance.rs +++ b/tests/find_file_acceptance.rs @@ -90,7 +90,7 @@ fn status(s: &EditorState) -> String { fn editor_in(dir: &std::path::Path) -> EditorState { let anchor = dir.join("anchor.txt"); std::fs::write(&anchor, b"anchor\n").expect("write anchor"); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); let anchor_str = anchor.display().to_string(); exec( @@ -279,7 +279,7 @@ fn find_file_accepting_a_directory_reports_instead_of_raising() { /// stable, real candidate there. #[test] fn find_file_without_a_backing_path_roots_at_the_process_cwd() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.reopen_init_phase_for_testing(); assert!( active_path(&s).is_none(), @@ -352,7 +352,7 @@ fn find_file_expands_a_leading_tilde() { return; } - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.reopen_init_phase_for_testing(); open_prompt(&mut s); // Contains a '/', so the typed text reaches on_accept verbatim. @@ -371,3 +371,10 @@ fn find_file_expands_a_leading_tilde() { "the expansion must use $HOME; got {path} with HOME={home}" ); } + +// 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; diff --git a/tests/folding_acceptance.rs b/tests/folding_acceptance.rs index 6df069a..01de6a5 100644 --- a/tests/folding_acceptance.rs +++ b/tests/folding_acceptance.rs @@ -301,7 +301,7 @@ fn insert_into(s: &EditorState, id: BufferId, text: &str) { #[test] fn command_path_self_insert_unfolds_at_point() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); insert_into(&s, id, "line0\nline1\nline2\nline3\n"); // Fold the interior of lines 1..2: [end of line0, end of line2]. @@ -330,7 +330,7 @@ fn self_insert_at_head_line_end_does_not_unfold() { // `(start, end]` containment: a self-insert exactly at the end of the // head line (== range.start) is outside the fold — it must NOT unfold, // and the translator shifts the fold right so the char lands visible. - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); insert_into(&s, id, "line0\nline1\nline2\nline3\n"); let store = { @@ -389,7 +389,7 @@ fn install_rust_parse(s: &EditorState, id: BufferId) { #[test] fn folding_moves_point_to_head_line() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; insert_into(&s, id, src); @@ -413,7 +413,7 @@ fn folding_moves_point_to_head_line() { #[test] fn data_api_validation() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); // A plain document buffer with four lines. exec( &s, @@ -465,7 +465,7 @@ fn data_api_validation() { #[test] fn forget_drops_store_and_detaches_view() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); insert_into(&s, id, "line0\nline1\nline2\n"); let store = { @@ -500,7 +500,7 @@ fn killing_a_buffer_through_the_real_path_purges_its_fold_store() { // assertion reads through the DEAD id on purpose: BufferIds are never // reused, so a stale id cannot alias a later buffer. Mirrors // config_registry's `killing_a_buffer_through_the_real_path_...`. - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "b = pmacs.buffer.from_bytes('kill.rs', 'aaa\\nbbb\\nccc\\nddd\\n')", @@ -530,7 +530,7 @@ fn close_all_command_moves_point_to_enclosing_head() { // Q#FD3 through the command surface: `fold.close-all` is interactive, // so when it collapses a top-level fold around the invoking point, the // point moves to that fold's head line (Finding 3, round 1). - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); let src = "fn first() {\n a();\n b();\n}\nfn second() {\n c();\n d();\n}\n"; insert_into(&s, id, src); @@ -553,7 +553,7 @@ fn close_all_command_moves_point_to_enclosing_head() { fn stale_parse_tree_refuses_fold() { // Q#FD10: an edit after the parse leaves `pending_edit_count() > 0`, // so a fold command refuses (the settled coordinates are stale). - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let id = active_id(&s); let src = "fn foo() {\n let x = 1;\n let y = 2;\n}\n"; insert_into(&s, id, src); @@ -571,7 +571,7 @@ fn stale_parse_tree_refuses_fold() { fn read_only_buffer_is_rejected() { // Q#FD11's "normal document buffer" guard: terminals are read-only, so // a read-only buffer is not foldable. - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "b = pmacs.buffer.from_bytes('ro.rs', 'aaa\\nbbb\\nccc\\n')", @@ -592,7 +592,7 @@ fn read_only_buffer_is_rejected() { #[test] fn unfold_normalizes_an_arbitrary_range_to_a_stored_fold() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "b = pmacs.buffer.from_bytes('doc.rs', 'aaa\\nbbb\\nccc\\nddd\\n')", @@ -608,3 +608,10 @@ fn unfold_normalizes_an_arbitrary_range_to_a_stored_fold() { let n: i64 = eval(&s, "return #pmacs.fold.folds(b)"); assert_eq!(n, 0); } + +// 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; diff --git a/tests/folding_stage2_acceptance.rs b/tests/folding_stage2_acceptance.rs index 79b41c8..473be53 100644 --- a/tests/folding_stage2_acceptance.rs +++ b/tests/folding_stage2_acceptance.rs @@ -61,7 +61,7 @@ fn end_of(line: usize) -> u64 { } fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s } @@ -1772,3 +1772,10 @@ fn wheel_over_an_inactive_unfolded_pane_uses_that_windows_map() { "the folded pane did not scroll" ); } + +// 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; diff --git a/tests/gpu_font_acceptance.rs b/tests/gpu_font_acceptance.rs index 264f25d..fe773ed 100644 --- a/tests/gpu_font_acceptance.rs +++ b/tests/gpu_font_acceptance.rs @@ -18,7 +18,8 @@ use pmacs::editor::EditorState; use pmacs::protocol::{ByteRange, FrontendId, InstanceMessage}; use pmacs::semantic_render::SemanticRenderState; -#[cfg(feature = "crdt")] +// Ungated: `common::iso` (isolated bootstrap roots) is needed in every +// build, not only the CRDT one. mod common; // --------------------------------------------------------------------------- @@ -43,7 +44,7 @@ fn eval(s: &EditorState, src: &str) -> T { /// Fresh editor with LSP spawning disabled. fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s } @@ -490,3 +491,11 @@ fn preference_set_before_attach_ships_on_the_first_frame() { "the first frame ships the pre-attach preference" ); } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 153ed6f..5a3bb88 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -38,6 +38,11 @@ fn non_crdt_root_rejects_gpu_before_socket_io_discovery_or_spawn() { .arg("--gpu") .env(TEST_GPU_OVERRIDE, &fake_gpu) .env("PMACS_TEST_MARKER", &marker) + .env("XDG_CONFIG_HOME", temp.path()) + .env("XDG_DATA_HOME", temp.path()) + .env("XDG_STATE_HOME", temp.path()) + .env("PMACS_STATE_HOME", temp.path()) + .env("XDG_CACHE_HOME", temp.path()) .env("XDG_RUNTIME_DIR", &runtime) .output() .expect("run non-CRDT pmacs --gpu"); @@ -61,6 +66,11 @@ fn non_crdt_root_rejects_gpu_before_socket_io_discovery_or_spawn() { .arg(&occupied_socket) .env(TEST_GPU_OVERRIDE, &fake_gpu) .env("PMACS_TEST_MARKER", &marker) + .env("XDG_CONFIG_HOME", temp.path()) + .env("XDG_DATA_HOME", temp.path()) + .env("XDG_STATE_HOME", temp.path()) + .env("PMACS_STATE_HOME", temp.path()) + .env("XDG_CACHE_HOME", temp.path()) .output() .expect("run non-CRDT pmacs --gpu against occupied socket"); assert!(!occupied.status.success()); diff --git a/tests/injection_acceptance.rs b/tests/injection_acceptance.rs index 88a314f..ac113e9 100644 --- a/tests/injection_acceptance.rs +++ b/tests/injection_acceptance.rs @@ -35,7 +35,7 @@ fn pump_async bool>(state: &mut EditorState, predicate: F /// resolver would leave the Lua write-through + snapshot bridge unproven. #[test] fn lua_alias_override_resolves_on_async_parse() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); // Add a bespoke fence alias from Lua (write-through to the registry). state @@ -98,7 +98,7 @@ fn lua_alias_override_resolves_on_async_parse() { /// alias discriminates the fix: with the empty map it would not resolve. #[test] fn sync_parse_now_resolves_alias() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let src = b"# Doc\n\n```py\nx = 1\n```\n"; let buf_id = state .lua_host @@ -136,7 +136,7 @@ fn sync_parse_now_resolves_alias() { /// the file drops below the cap and exceeds it again. #[test] fn injection_cap_surfaced_once_and_rearms_via_lua() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); // Capture pmacs.error messages into a Lua global. state .lua_host @@ -305,3 +305,10 @@ fn many_paragraph_settle_under_budget_with_tail_covered() { "the final paragraph still receives an inline layer (no tail loss)" ); } + +// 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; diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs index db88942..a9529ac 100644 --- a/tests/journey_acceptance.rs +++ b/tests/journey_acceptance.rs @@ -209,6 +209,85 @@ fn launch(path: &Path) -> EditorState { s } +// --------------------------------------------------------------------------- +// Ambient-roots isolation +// --------------------------------------------------------------------------- +// +// This suite is the one place that must NOT take the +// `EditorState::new_with_roots` / `open_with_roots` seam +// (`docs/test-ambient-config-isolation-framing.md` §1.7). Its whole job +// is to prove the production entry point is wired: an `open` arm with no +// production caller passes every direct-call test, so the ratchet drives +// the same `EditorState::open` that `pmacs FILE` does — parameterless, +// ambient, exactly as production calls it. +// +// That leaves nothing in the process able to isolate it. Cargo launches +// each integration-test binary with the caller's environment, and a +// binary cannot re-point its own roots before its tests run: +// `std::env::set_var` is `unsafe` and pmacs is +// `#![forbid(unsafe_code)]`. "Isolated by the environment it is launched +// with" is not a mechanism — it is a hope that whoever typed `cargo +// test` wrapped it, which is the external workaround this lane exists to +// delete. +// +// So each test is a thin **parent** that re-execs this same binary for +// its own test name, with controlled roots in the child's environment. +// The child sees the marker, runs the real body, and calls the ambient +// entry point — production's call, against a controlled tree. + +/// Marker the parent sets on the child. Its presence, not its value, is +/// the signal. +const ISOLATED_CHILD: &str = "PMACS_JOURNEY_ISOLATED_CHILD"; + +/// Re-exec this test binary for `name` under controlled bootstrap +/// storage roots. +/// +/// Returns `true` in the **parent** — the child has already run and been +/// asserted, so the caller must return without doing anything itself — +/// and `false` in the **child**, whose job is to run the body. +#[must_use] +fn reexec_isolated(name: &str) -> bool { + if std::env::var_os(ISOLATED_CHILD).is_some() { + return false; + } + // Under `target/`, not `/tmp`: nothing unlinks this (libtest has no + // teardown hook), so it belongs somewhere `cargo clean` owns. + let base = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join("journey-isolation") + .join(name); + let _ = std::fs::remove_dir_all(&base); + let roots = pmacs::bootstrap::BootstrapRoots::isolated_under(&base); + let child_env = roots.child_env(); + for (_, dir) in &child_env { + std::fs::create_dir_all(dir).expect("create controlled root"); + } + let exe = std::env::current_exe().expect("current test binary"); + let output = std::process::Command::new(exe) + .args(["--exact", name, "--nocapture", "--test-threads=1"]) + .env(ISOLATED_CHILD, "1") + .envs(child_env) + .output() + .unwrap_or_else(|e| panic!("re-exec `{name}`: {e}")); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "isolated child for `{name}` failed ({})\n--- child stdout ---\n{stdout}\ + \n--- child stderr ---\n{stderr}", + output.status + ); + // A filter that matches nothing exits 0. Without this the parent + // would pass while running no test at all — the failure mode a + // renamed test produces, and the one that would quietly hollow out + // the whole ratchet. + assert!( + stdout.contains("1 passed"), + "isolated child for `{name}` ran no test — the `--exact` filter went \ + stale against the function name\n--- child stdout ---\n{stdout}" + ); + true +} + // --------------------------------------------------------------------------- // Step 2 — launch unconfigured // --------------------------------------------------------------------------- @@ -216,6 +295,9 @@ fn launch(path: &Path) -> EditorState { /// **N** — the editor starts with no configuration and no arguments. #[test] fn journey_step2_launches_unconfigured_into_scratch() { + if reexec_isolated("journey_step2_launches_unconfigured_into_scratch") { + return; + } let s = EditorState::new(); assert_eq!(active_name(&s), "*scratch*"); assert!( @@ -236,6 +318,9 @@ fn journey_step2_launches_unconfigured_into_scratch() { /// `Err(EISDIR)` and `main` exited 1. #[test] fn journey_step3_opening_a_directory_lists_it() { + if reexec_isolated("journey_step3_opening_a_directory_lists_it") { + return; + } let td = project(); let s = launch(td.path()); @@ -259,6 +344,9 @@ fn journey_step3_opening_a_directory_lists_it() { /// the assertion above while `pmacs .` still printed a diagnostic. #[test] fn journey_step3_directory_startup_reports_no_error() { + if reexec_isolated("journey_step3_directory_startup_reports_no_error") { + return; + } let td = project(); let s = launch(td.path()); assert!( @@ -274,6 +362,9 @@ fn journey_step3_directory_startup_reports_no_error() { #[test] fn journey_step3_unreadable_directory_reports_without_failing_startup() { use std::os::unix::fs::PermissionsExt; + if reexec_isolated("journey_step3_unreadable_directory_reports_without_failing_startup") { + return; + } let td = tempfile::tempdir().expect("tempdir"); let locked = td.path().join("locked"); std::fs::create_dir(&locked).expect("mkdir"); @@ -301,6 +392,9 @@ fn journey_step3_unreadable_directory_reports_without_failing_startup() { /// because no buffer is created and `set_buffer_path` never runs. #[test] fn journey_directory_resolver_receives_a_canonical_path() { + if reexec_isolated("journey_directory_resolver_receives_a_canonical_path") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -331,6 +425,9 @@ fn journey_directory_resolver_receives_a_canonical_path() { /// fallback is a clearable slot rather than a builtin hook subscription. #[test] fn journey_unclaimed_directory_starts_successfully_with_a_status() { + if reexec_isolated("journey_unclaimed_directory_starts_successfully_with_a_status") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -360,6 +457,9 @@ fn journey_unclaimed_directory_starts_successfully_with_a_status() { /// a claim suppresses the fallback. #[test] fn journey_resolver_chain_is_first_claimant_wins() { + if reexec_isolated("journey_resolver_chain_is_first_claimant_wins") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -395,6 +495,9 @@ fn journey_resolver_chain_is_first_claimant_wins() { /// `proceed == false`. #[test] fn journey_a_raising_resolver_suppresses_the_fallback_and_reports() { + if reexec_isolated("journey_a_raising_resolver_suppresses_the_fallback_and_reports") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -458,6 +561,9 @@ const COMPETITOR: FrontendId = FrontendId(7); /// ambient display: the file then appears in the competitor's window. #[test] fn commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one() { + if reexec_isolated("commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -519,6 +625,9 @@ fn commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one() { /// `acting_frontend`, or by reordering it after the interactive origin. #[test] fn commit_to_outranks_an_interactive_origin() { + if reexec_isolated("commit_to_outranks_an_interactive_origin") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -586,6 +695,9 @@ fn commit_to_outranks_an_interactive_origin() { /// `display`, or by reading `prev` from the ambient window. #[test] fn a_background_open_uses_the_captured_window_not_the_selected_one() { + if reexec_isolated("a_background_open_uses_the_captured_window_not_the_selected_one") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -652,6 +764,9 @@ fn a_background_open_uses_the_captured_window_not_the_selected_one() { /// competitor and the assertion fails from the other direction). #[test] fn commit_to_scopes_and_restores_on_a_normal_return() { + if reexec_isolated("commit_to_scopes_and_restores_on_a_normal_return") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -701,6 +816,9 @@ fn commit_to_scopes_and_restores_on_a_normal_return() { /// through the scope, or by restoring on the success path only. #[test] fn commit_to_restores_when_the_callback_raises() { + if reexec_isolated("commit_to_restores_when_the_callback_raises") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -744,6 +862,9 @@ fn commit_to_restores_when_the_callback_raises() { /// ``. #[test] fn commit_to_refuses_an_await_and_restores() { + if reexec_isolated("commit_to_refuses_an_await_and_restores") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -795,6 +916,9 @@ fn commit_to_refuses_an_await_and_restores() { /// userdata after invoking the callback. #[test] fn commit_to_refuses_a_forged_destination() { + if reexec_isolated("commit_to_refuses_a_forged_destination") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -831,6 +955,9 @@ fn commit_to_refuses_a_forged_destination() { /// a shared mutable table. #[test] fn a_declining_listener_cannot_redirect_the_destination() { + if reexec_isolated("a_declining_listener_cannot_redirect_the_destination") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -907,6 +1034,9 @@ fn a_declining_listener_cannot_redirect_the_destination() { /// distinguish "validates" from "validates in time". #[test] fn preservation_a_failed_precondition_never_reaches_the_callback() { + if reexec_isolated("preservation_a_failed_precondition_never_reaches_the_callback") { + return; + } // (label, Lua that breaks the precondition, expected reason fragment) let cases: [(&str, &str, &str); 4] = [ ( @@ -994,6 +1124,9 @@ fn preservation_a_failed_precondition_never_reaches_the_callback() { /// (window-only validation). The dired buffer then replaces the user's. #[test] fn preservation_a_stale_destination_loses_to_the_users_newer_buffer() { + if reexec_isolated("preservation_a_stale_destination_loses_to_the_users_newer_buffer") { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -1062,6 +1195,11 @@ fn preservation_a_stale_destination_loses_to_the_users_newer_buffer() { /// `*competitor*`. #[test] fn preservation_dired_captures_prev_from_the_destination_not_the_ambient_frontend() { + if reexec_isolated( + "preservation_dired_captures_prev_from_the_destination_not_the_ambient_frontend", + ) { + return; + } let td = project(); let mut s = EditorState::new(); exec(&s, "pmacs.lsp.config = {}"); @@ -1117,6 +1255,9 @@ fn preservation_dired_captures_prev_from_the_destination_not_the_ambient_fronten /// the read-only contract rather than pin the journey. #[test] fn journey_step5_editing_a_file_reached_through_the_directory() { + if reexec_isolated("journey_step5_editing_a_file_reached_through_the_directory") { + return; + } let td = project(); let mut s = launch(td.path()); assert!(active_name(&s).starts_with("*dired:")); @@ -1169,6 +1310,9 @@ fn journey_step5_editing_a_file_reached_through_the_directory() { /// preserve is which window shows the file, and that is what this pins. #[test] fn preservation_opening_a_file_shows_it_in_the_active_window() { + if reexec_isolated("preservation_opening_a_file_shows_it_in_the_active_window") { + return; + } let td = project(); let target = td.path().join("alpha.txt"); let s = EditorState::open(target.clone()).expect("open"); @@ -1204,6 +1348,9 @@ fn preservation_opening_a_file_shows_it_in_the_active_window() { /// failure mode is a hard error on a perfectly ordinary gesture. #[test] fn preservation_a_missing_path_becomes_a_new_file_buffer() { + if reexec_isolated("preservation_a_missing_path_becomes_a_new_file_buffer") { + return; + } let td = project(); let fresh = td.path().join("not-yet.txt"); let s = EditorState::open(fresh.clone()).expect("a missing path is not an error"); @@ -1223,6 +1370,9 @@ fn preservation_a_missing_path_becomes_a_new_file_buffer() { #[test] fn preservation_an_unreadable_file_reports_with_its_path() { use std::os::unix::fs::PermissionsExt; + if reexec_isolated("preservation_an_unreadable_file_reports_with_its_path") { + return; + } let td = project(); let locked = td.path().join("locked.txt"); std::fs::write(&locked, b"secret\n").expect("write"); @@ -1270,6 +1420,9 @@ fn preservation_an_unreadable_file_reports_with_its_path() { /// real accept path; this one pins the primitive and the window state. #[test] fn preservation_display_file_still_refuses_a_directory() { + if reexec_isolated("preservation_display_file_still_refuses_a_directory") { + return; + } let td = project(); let mut s = EditorState::open(td.path().join("alpha.txt")).expect("open"); exec(&s, "pmacs.lsp.config = {}"); @@ -1440,6 +1593,9 @@ fn walk_to_open_file(dir: &Path, name: &str) -> EditorState { /// `COHERENCE.md` says is missing. #[test] fn journey_step9_the_compile_chord_opens_the_prompt() { + if reexec_isolated("journey_step9_the_compile_chord_opens_the_prompt") { + return; + } let td = cargo_project(); let mut s = walk_to_open_file(td.path(), "Cargo.toml"); press_compile_chord(&mut s); @@ -1453,6 +1609,9 @@ fn journey_step9_the_compile_chord_opens_the_prompt() { /// **N** — the prompt is prefilled from the detected project kind. #[test] fn journey_step9_the_prompt_is_prefilled_for_a_cargo_project() { + if reexec_isolated("journey_step9_the_prompt_is_prefilled_for_a_cargo_project") { + return; + } let td = cargo_project(); let mut s = walk_to_open_file(td.path(), "Cargo.toml"); press_compile_chord(&mut s); @@ -1472,6 +1631,9 @@ fn journey_step9_the_prompt_is_prefilled_for_a_cargo_project() { /// which is what re-resolving at accept time looks like. #[test] fn journey_step9_the_prompt_runs_in_the_directory_it_captured() { + if reexec_isolated("journey_step9_the_prompt_runs_in_the_directory_it_captured") { + return; + } let a = cargo_project(); let b = project(); let mut s = walk_to_open_file(a.path(), "Cargo.toml"); @@ -1521,6 +1683,9 @@ fn journey_step9_the_prompt_runs_in_the_directory_it_captured() { /// real process. #[test] fn journey_step9_the_offered_command_builds_the_project() { + if reexec_isolated("journey_step9_the_offered_command_builds_the_project") { + return; + } if !binary_available("cargo") { assert!( std::env::var_os("PMACS_REQUIRE_CARGO_BUILD").is_none(), @@ -1574,6 +1739,9 @@ fn journey_step9_the_offered_command_builds_the_project() { /// from it yields `node` again and this pin stays green. #[test] fn journey_step9_a_nested_project_gets_its_own_kind_not_the_outer_one() { + if reexec_isolated("journey_step9_a_nested_project_gets_its_own_kind_not_the_outer_one") { + return; + } let outer = cargo_project(); let sub = outer.path().join("sub"); std::fs::create_dir_all(&sub).expect("mkdir sub"); @@ -1610,6 +1778,9 @@ fn journey_step9_a_nested_project_gets_its_own_kind_not_the_outer_one() { /// runner's cwd and pinning it would pin the environment. #[test] fn journey_step9_the_compile_context_is_total_even_with_no_file_open() { + if reexec_isolated("journey_step9_the_compile_context_is_total_even_with_no_file_open") { + return; + } let td = cargo_project(); let s = launch(td.path()); assert!( @@ -1638,6 +1809,9 @@ fn journey_step9_the_compile_context_is_total_even_with_no_file_open() { /// is reordering the precedence chain to put `defaults[kind]` first. #[test] fn journey_step9_preservation_the_last_command_outranks_the_default() { + if reexec_isolated("journey_step9_preservation_the_last_command_outranks_the_default") { + return; + } let td = cargo_project(); let mut s = walk_to_open_file(td.path(), "Cargo.toml"); exec(&s, "pmacs.compile.run('true')"); @@ -1660,6 +1834,9 @@ fn journey_step9_preservation_the_last_command_outranks_the_default() { /// over one of these sequences. #[test] fn journey_step9_preservation_the_existing_compile_bindings_survive() { + if reexec_isolated("journey_step9_preservation_the_existing_compile_bindings_survive") { + return; + } let td = cargo_project(); let s = walk_to_open_file(td.path(), "Cargo.toml"); for (sequence, command) in [ @@ -1705,6 +1882,9 @@ fn binary_available(name: &str) -> bool { #[cfg(unix)] #[test] fn journey_step9_the_compile_directory_is_detection_canonical() { + if reexec_isolated("journey_step9_the_compile_directory_is_detection_canonical") { + return; + } let parent = tempfile::tempdir().expect("tempdir"); let real = parent.path().join("real"); std::fs::create_dir_all(real.join("src")).expect("mkdir real"); @@ -1804,6 +1984,9 @@ fn start_local() -> EditorState { /// `prepare_startup` — the mutation every by-hand pin would survive. #[test] fn journey_step4_a_no_target_launch_greets_in_scratch() { + if reexec_isolated("journey_step4_a_no_target_launch_greets_in_scratch") { + return; + } let s = start_local(); // Preconditions asserted, not assumed (framing §3.2b): a developer @@ -1835,6 +2018,9 @@ fn journey_step4_a_no_target_launch_greets_in_scratch() { /// is two chords and nothing in the rendered text marks the boundary. #[test] fn journey_step4_every_advertised_key_is_bound() { + if reexec_isolated("journey_step4_every_advertised_key_is_bound") { + return; + } let s = start_local(); let entries = welcome_entries(&s); assert!( @@ -1858,6 +2044,9 @@ fn journey_step4_every_advertised_key_is_bound() { /// Pin 2 alone would pass if rendering silently dropped one. #[test] fn journey_step4_the_rendered_welcome_contains_every_entry() { + if reexec_isolated("journey_step4_the_rendered_welcome_contains_every_entry") { + return; + } let s = start_local(); let text = active_text(&s); for (keys, label) in welcome_entries(&s) { @@ -1884,6 +2073,9 @@ fn journey_step4_the_rendered_welcome_contains_every_entry() { /// so nothing about the accepted value survives afterwards. #[test] fn journey_step4_m_x_help_renders_the_cheat_sheet() { + if reexec_isolated("journey_step4_m_x_help_renders_the_cheat_sheet") { + return; + } let mut s = start_local(); s.dispatch_key( @@ -1922,6 +2114,9 @@ fn journey_step4_m_x_help_renders_the_cheat_sheet() { /// would lift read-only, discard history, and fail the insert. #[test] fn journey_step4_preservation_the_greeted_scratch_is_editable_and_clean() { + if reexec_isolated("journey_step4_preservation_the_greeted_scratch_is_editable_and_clean") { + return; + } let mut s = start_local(); assert!( eval::( @@ -1942,6 +2137,9 @@ fn journey_step4_preservation_the_greeted_scratch_is_editable_and_clean() { /// **P** — a file target does not greet. #[test] fn journey_step4_preservation_a_file_target_does_not_greet() { + if reexec_isolated("journey_step4_preservation_a_file_target_does_not_greet") { + return; + } let td = project(); let path = td.path().join("alpha.txt"); let s = match pmacs::editor::prepare_startup(Some(path.clone())).expect("startup") { @@ -1965,6 +2163,9 @@ fn journey_step4_preservation_a_file_target_does_not_greet() { /// still exists to be wrongly greeted. #[test] fn journey_step4_preservation_a_directory_target_does_not_greet() { + if reexec_isolated("journey_step4_preservation_a_directory_target_does_not_greet") { + return; + } let td = project(); let mut s = match pmacs::editor::prepare_startup(Some(td.path().to_path_buf())).expect("startup") { @@ -1979,6 +2180,9 @@ fn journey_step4_preservation_a_directory_target_does_not_greet() { /// **P** — a non-empty `*scratch*` is never overwritten. #[test] fn journey_step4_preservation_existing_scratch_content_survives() { + if reexec_isolated("journey_step4_preservation_existing_scratch_content_survives") { + return; + } let mut s = EditorState::new(); exec(&s, "pmacs.window.buffer():insert(0, 'user content')"); s.finalize_local_launch(false); @@ -1993,6 +2197,9 @@ fn journey_step4_preservation_existing_scratch_content_survives() { /// desktop restore having put something else in front. #[test] fn journey_step4_preservation_a_backgrounded_scratch_is_not_greeted() { + if reexec_isolated("journey_step4_preservation_a_backgrounded_scratch_is_not_greeted") { + return; + } let td = project(); let mut s = EditorState::new(); exec( @@ -2019,6 +2226,9 @@ fn journey_step4_preservation_a_backgrounded_scratch_is_not_greeted() { /// reached in production. #[test] fn journey_step4_preservation_constructors_never_greet() { + if reexec_isolated("journey_step4_preservation_constructors_never_greet") { + return; + } let bare = EditorState::new(); assert_eq!(scratch_text(&bare), "", "EditorState::new must not greet"); @@ -2078,6 +2288,9 @@ fn lsp_segment(s: &EditorState) -> Option { /// vacuous. #[test] fn journey_step6_a_missing_language_server_is_reported_not_swallowed() { + if reexec_isolated("journey_step6_a_missing_language_server_is_reported_not_swallowed") { + return; + } let td = tempfile::tempdir().expect("tempdir"); std::fs::write(td.path().join("Cargo.toml"), b"[package]\nname=\"x\"\n").expect("write toml"); std::fs::write(td.path().join("main.rs"), b"fn main() {}\n").expect("write rs"); @@ -2138,6 +2351,9 @@ fn journey_step6_a_missing_language_server_is_reported_not_swallowed() { #[test] fn journey_step4_the_welcome_paints_as_multiple_rows_on_the_first_frame() { use pmacs::cell::{Cell, CellGrid, CellSize, Glyph}; + if reexec_isolated("journey_step4_the_welcome_paints_as_multiple_rows_on_the_first_frame") { + return; + } let s = start_local(); let (rows, cols) = (12u32, 100u32); @@ -2192,3 +2408,86 @@ fn journey_step4_the_welcome_paints_as_multiple_rows_on_the_first_frame() { row_text(0) ); } + +// --------------------------------------------------------------------------- +// The isolation mechanism itself +// --------------------------------------------------------------------------- + +/// **N** — the re-exec of §1.10 actually redirects the child's roots. +/// +/// Every other test in this file is *protected* by the mechanism; none +/// of them fails if it silently stops working, because they assert about +/// editor behaviour and a developer's `init.lua` usually leaves that +/// alone. This one asserts the mechanism's own output: the roots the +/// child resolves, from inside the child. +/// +/// Falsified by dropping `.envs(child_env)` from `reexec_isolated` — the +/// child then resolves the developer's real `~/.config/pmacs` and +/// `~/.local/share`, and all four assertions below fail. +#[test] +fn journey_isolated_child_resolves_the_controlled_roots() { + if reexec_isolated("journey_isolated_child_resolves_the_controlled_roots") { + return; + } + let base = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join("journey-isolation") + .join("journey_isolated_child_resolves_the_controlled_roots"); + let under = |p: Option, what: &str| { + let p = p.unwrap_or_else(|| panic!("{what} must resolve in the child")); + assert!( + p.starts_with(&base), + "{what} resolved to {} — outside the controlled base {}", + p.display(), + base.display() + ); + }; + under(pmacs::config::user_config_dir(), "the config dir"); + under( + Some(pmacs::builtin_packages::bundled_runtime_dir()), + "the bundled-package dir", + ); + // `PMACS_STATE_HOME` is the fifth variable: it outranks + // `XDG_STATE_HOME`, so a child given only the four XDG ones inherits + // whatever the launching shell exported. + under(pmacs::state::user_state_dir(), "the state dir"); + under(pmacs::minibuffer::user_history_dir(), "the history dir"); +} + +/// **N** — the ambient entry point is still what this suite drives. +/// +/// The isolation seam has a `new_with_roots` / `open_with_roots` sibling +/// that every other suite in the tree now takes. Taking it *here* would +/// be a silent regression of what the ratchet exists to prove — a +/// production entry point with no caller — and would leave every +/// assertion in this file green. Asserted against this file's own source +/// rather than left as an obvious-by-inspection property. +#[test] +fn journey_drives_the_ambient_production_entry_point() { + let src = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("journey_acceptance.rs"), + ) + .expect("read this suite's own source"); + // Every needle is assembled rather than written whole: a literal + // spelled out here would appear in this file and match itself, which + // turns the positive check vacuous and the negative ones into + // guaranteed failures. + let ambient_open = concat!("EditorState::open(", "path.to_path_buf())"); + let seam_open = concat!("EditorState::open", "_with_roots("); + let seam_new = concat!("EditorState::new", "_with_roots("); + assert!( + src.contains(ambient_open), + "journey must call the parameterless production `open`" + ); + assert!( + !src.contains(seam_open), + "journey must not take the isolation seam — its isolation comes \ + from the environment its child is launched with" + ); + assert!( + !src.contains(seam_new), + "journey must not take the isolation seam — its isolation comes \ + from the environment its child is launched with" + ); +} diff --git a/tests/kill_ring_acceptance.rs b/tests/kill_ring_acceptance.rs index 1a375ad..11cb3e3 100644 --- a/tests/kill_ring_acceptance.rs +++ b/tests/kill_ring_acceptance.rs @@ -74,7 +74,7 @@ fn status(s: &EditorState) -> String { /// Fresh editor whose scratch buffer holds `text`, cursor at 0. fn editor_with(text: &str) -> EditorState { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, text); exec(&s, "pmacs.editor.goto_byte(0)"); s @@ -855,3 +855,10 @@ fn frontend_detached_drops_per_frontend_state() { ); assert!(gone, "detach dropped B's killring state"); } + +// 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; diff --git a/tests/lean4_server_acceptance.rs b/tests/lean4_server_acceptance.rs index 86be1d5..113f886 100644 --- a/tests/lean4_server_acceptance.rs +++ b/tests/lean4_server_acceptance.rs @@ -90,7 +90,7 @@ impl Fixture { /// resolver. That combination is the point: the root rule under test is /// production code, only the command is a stand-in. fn editor(fx: &Fixture) -> EditorState { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); exec( &state, @@ -1880,3 +1880,10 @@ fn r6_no_swap_retires_only_the_failed_root() { swap occurred" ); } + +// 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; diff --git a/tests/lean4_stage1_acceptance.rs b/tests/lean4_stage1_acceptance.rs index 9aafcab..6a9c469 100644 --- a/tests/lean4_stage1_acceptance.rs +++ b/tests/lean4_stage1_acceptance.rs @@ -38,7 +38,7 @@ fn fresh_state_dir() -> PathBuf { } fn editor(state_dir: &std::path::Path) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host .lua() @@ -243,7 +243,7 @@ fn acc10b_the_prime_suffix_does_not_pair_in_lean() { /// WRITE-ONLY proxy (the canonical map lives Rust-side), so an /// alias-table read would prove nothing about what the parser does. fn markdown_layer_languages(src: &[u8]) -> Vec { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let buf_id = state .lua_host .registry() @@ -323,7 +323,7 @@ fn acc12_opening_lean_spawns_no_process_without_a_server_config() { // Constructing an editor touches no process, even though the Lean // config now exists and names `lake`. - let pristine = EditorState::new(); + let pristine = EditorState::new_with_roots(&crate::iso::roots()); let at_init: i64 = eval(&pristine, "return #pmacs.process.list()"); assert_eq!( at_init, 0, @@ -352,3 +352,10 @@ fn acc12_opening_lean_spawns_no_process_without_a_server_config() { "with no server configured, opening a Lean buffer spawns nothing" ); } + +// 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; diff --git a/tests/lean_input_acceptance.rs b/tests/lean_input_acceptance.rs index 9b6a3b8..825184d 100644 --- a/tests/lean_input_acceptance.rs +++ b/tests/lean_input_acceptance.rs @@ -72,7 +72,7 @@ fn lean_editor() -> (EditorState, PathBuf) { let dir = fresh_dir(); let f = dir.join("a.lean"); std::fs::write(&f, "").unwrap(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); let fd = f.display().to_string(); exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); @@ -543,7 +543,7 @@ fn no_abbreviation_state_is_opened_outside_a_lean_buffer() { let dir = fresh_dir(); let f = dir.join("a.rs"); std::fs::write(&f, "").unwrap(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); let fd = f.display().to_string(); exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); @@ -811,7 +811,7 @@ fn the_expansion_reaches_the_first_did_change() { let f = dir.join("a.lean"); std::fs::write(&f, "").unwrap(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host.lua().set_app_data(StateDir(dir.clone())); exec(&s, "pmacs.lsp.config = {}"); @@ -1018,3 +1018,10 @@ fn detaching_a_frontend_purges_only_its_own_pending_state() { "B's detachment purged B's entries and left A's record valid" ); } + +// 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; diff --git a/tests/listview_acceptance.rs b/tests/listview_acceptance.rs index 8c15629..d546f00 100644 --- a/tests/listview_acceptance.rs +++ b/tests/listview_acceptance.rs @@ -212,7 +212,7 @@ fn probe(s: &EditorState) -> (String, String, i64, Option) { #[test] fn open_seats_cursor_and_ret_visits_the_row() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); let (name, text, line, _) = probe(&s); assert_eq!(name, "*test-panel*"); @@ -227,7 +227,7 @@ fn open_seats_cursor_and_ret_visits_the_row() { #[test] fn header_row_is_not_visitable() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); press(&mut s, KeyCode::Char('p')); // up onto the header press(&mut s, KeyCode::Enter); @@ -237,7 +237,7 @@ fn header_row_is_not_visitable() { #[test] fn q_restores_the_previous_buffer() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); press(&mut s, KeyCode::Char('q')); let (name, _, _, _) = probe(&s); @@ -246,7 +246,7 @@ fn q_restores_the_previous_buffer() { #[test] fn panel_rejects_typing() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); let (_, before, _, _) = probe(&s); press(&mut s, KeyCode::Char('z')); // unbound printable → self-insert → intercept rejects @@ -258,7 +258,7 @@ fn panel_rejects_typing() { fn dispatch_idle_is_false_while_a_panel_is_focused() { // Q#P6: while the panel is the active buffer, semantic frontends // must round-trip every key (RET = visit, not an optimistic \n). - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); assert!(s.dispatch_idle(), "scratch buffer: idle"); open_test_panel(&mut s); assert!(!s.dispatch_idle(), "panel focused: keys must round-trip"); @@ -268,7 +268,7 @@ fn dispatch_idle_is_false_while_a_panel_is_focused() { #[test] fn refresh_reruns_the_source_and_reseats() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); press(&mut s, KeyCode::Char('g')); let (_, text, line, _) = probe(&s); @@ -304,7 +304,7 @@ const PANEL_TEXT: &str = "3 items RET visit q quit\nalpha\nbeta\ngamma"; /// consulting the intercept chain. #[test] fn s1_1_the_undo_chord_cannot_empty_a_listview_panel() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); assert_eq!(active_text(&s), PANEL_TEXT, "precondition: rendered"); @@ -327,7 +327,7 @@ fn s1_1_the_undo_chord_cannot_empty_a_listview_panel() { /// *Bite:* same empty result on the pre-image. #[test] fn s1_2_m_x_buffer_undo_cannot_empty_a_listview_panel() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); m_x(&mut s, "buffer.undo"); @@ -355,7 +355,7 @@ fn s1_2_m_x_buffer_undo_cannot_empty_a_listview_panel() { /// raising. #[test] fn s1_4_the_owners_refresh_still_works_after_the_lock() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); let panel = id_of(&s, "*test-panel*"); assert!( @@ -388,7 +388,7 @@ fn s1_4_the_owners_refresh_still_works_after_the_lock() { /// therefore passes the rope half and fails the lifted half. #[test] fn s1_5_the_rope_lock_and_named_intercept_refuse_in_order() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); let panel = id_of(&s, "*test-panel*"); let before = active_text(&s); @@ -453,7 +453,7 @@ fn s1_5_the_rope_lock_and_named_intercept_refuse_in_order() { /// pinned through `dispatch_idle_for` rather than through `read_only`. #[test] fn s1_6_round_trip_input_survives_the_adoption() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); // (a) the premise. @@ -498,7 +498,7 @@ fn s1_6_round_trip_input_survives_the_adoption() { /// the old line index live and this paint assertion bites. #[test] fn s1_7_a_shrinking_refresh_reaches_the_window() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); open_test_panel(&mut s); let painted = paint_active_window(&s, 6, 24); assert_eq!( @@ -540,7 +540,7 @@ fn s1_7_a_shrinking_refresh_reaches_the_window() { /// builtin/runtime/listview.lua` falsifies it. #[test] fn s1_9_a_foreign_buffer_with_the_panels_name_is_never_adopted() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "FOREIGN = pmacs.buffer.create('*test-panel*')\n\ @@ -585,7 +585,7 @@ fn s1_9_a_foreign_buffer_with_the_panels_name_is_never_adopted() { /// created. #[test] fn s1_10_the_disambiguation_limit_raises_rather_than_adopting() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "MINE = pmacs.buffer.create('*test-panel*')\n\ @@ -624,7 +624,7 @@ fn s1_10_the_disambiguation_limit_raises_rather_than_adopting() { /// command produced, never on "it did not raise". #[test] fn s1_11_a_disambiguated_panel_still_answers_ret_g_and_q() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "FOREIGN = pmacs.buffer.create('*test-panel*')\n\ @@ -678,7 +678,7 @@ fn s1_11_a_disambiguated_panel_still_answers_ret_g_and_q() { /// the disambiguation and `q` lands back in `*test-panel*<2>`. #[test] fn s1_12_the_q_target_capture_is_not_inverted_across_two_panels() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); exec( &s, "FOREIGN = pmacs.buffer.create('*test-panel*')\n\ @@ -755,3 +755,10 @@ fn s1_14_no_bypass_write_or_name_keyed_identity_remains() { "every `panels[` subscript must be an append; found {subscripts:?}" ); } + +// 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; diff --git a/tests/lsp_dispatch_seams_acceptance.rs b/tests/lsp_dispatch_seams_acceptance.rs index f644367..531c4f3 100644 --- a/tests/lsp_dispatch_seams_acceptance.rs +++ b/tests/lsp_dispatch_seams_acceptance.rs @@ -36,7 +36,7 @@ fn fake_lsp_path() -> String { /// 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(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); state } @@ -722,3 +722,10 @@ fn acc34a_canonicalize_declines_a_non_utf8_resolution() { 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; diff --git a/tests/lsp_multi_root_acceptance.rs b/tests/lsp_multi_root_acceptance.rs index 39ac68f..7a4c570 100644 --- a/tests/lsp_multi_root_acceptance.rs +++ b/tests/lsp_multi_root_acceptance.rs @@ -35,7 +35,7 @@ fn fake_lsp_path() -> String { /// 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(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); state } @@ -702,3 +702,10 @@ fn a_resolver_returning_nil_declines_silently() { file_uri(&fx.dir("proj")) ); } + +// 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; diff --git a/tests/lsp_spawn_guidance_acceptance.rs b/tests/lsp_spawn_guidance_acceptance.rs index fd48ace..6ac5b3c 100644 --- a/tests/lsp_spawn_guidance_acceptance.rs +++ b/tests/lsp_spawn_guidance_acceptance.rs @@ -89,7 +89,7 @@ fn open(state: &EditorState, path: &Path) { } fn editor_for(dir: &Path) -> EditorState { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); // Clamp detection so a stray marker above the tempdir cannot leak in. exec( &state, @@ -565,3 +565,10 @@ fn j1b2_preservation_a_spawnable_server_still_attaches() { ); assert_eq!(failure_count(&state), 0); } + +// 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; diff --git a/tests/m11_5_semantic_acceptance.rs b/tests/m11_5_semantic_acceptance.rs index 09a4e7e..761a0ba 100644 --- a/tests/m11_5_semantic_acceptance.rs +++ b/tests/m11_5_semantic_acceptance.rs @@ -129,7 +129,7 @@ fn assert_disjoint_within(ranges: &[ByteRange], vp: ByteRange) { #[test] fn incremental_reconstruction_equals_fresh_full_projection() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let buffer_id = active_buffer(&state); let vp1 = ByteRange { start: 0, end: 64 }; @@ -327,3 +327,11 @@ fn daemon_routes_semantic_family_to_semantic_session_only() { "grid session must NOT receive the semantic family" ); } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 64323e0..32511ff 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -258,7 +258,7 @@ fn m4_1_parse_tree_introspectable_via_lua() { use pmacs::editor::EditorState; use pmacs::lua_bindings::BufferIdLua; - let editor = EditorState::new(); + let editor = EditorState::new_with_roots(&crate::iso::roots()); // Insert a buffer with known shape; bind its handle into Lua as // `BUF` so the script can reference it by name. let buf_id = editor @@ -359,7 +359,8 @@ fn m4_2_opening_a_rust_file_produces_a_parse_tree() { let path = dir.path().join("hello.rs"); std::fs::write(&path, b"fn main() { let x = 1 + 2; }\n").expect("write"); - let mut state = pmacs::editor::EditorState::open(path).expect("open .rs"); + let mut state = + pmacs::editor::EditorState::open_with_roots(path, &crate::iso::roots()).expect("open .rs"); pump_async(&mut state, |s| current_tree_language(s).is_some()); assert_eq!(current_tree_language(&state).as_deref(), Some("rust")); @@ -389,7 +390,8 @@ fn m4_2_opening_a_lua_file_produces_a_parse_tree() { let path = dir.path().join("hello.lua"); std::fs::write(&path, b"local x = 1\nreturn x + 2\n").expect("write"); - let mut state = pmacs::editor::EditorState::open(path).expect("open .lua"); + let mut state = + pmacs::editor::EditorState::open_with_roots(path, &crate::iso::roots()).expect("open .lua"); pump_async(&mut state, |s| current_tree_language(s).is_some()); assert_eq!(current_tree_language(&state).as_deref(), Some("lua")); @@ -420,7 +422,8 @@ fn m4_2_register_extension_attaches_for_a_runtime_added_extension() { let path = dir.path().join("hello.myrust"); std::fs::write(&path, b"fn main() {}\n").expect("write"); - let mut state = pmacs::editor::EditorState::open(path).expect("open .myrust"); + let mut state = pmacs::editor::EditorState::open_with_roots(path, &crate::iso::roots()) + .expect("open .myrust"); // The auto-attach hook fires *during* open, but at that point our // custom extension isn't registered yet --- so the first hook // invocation is a no-op. Register the extension and trigger the @@ -516,7 +519,8 @@ fn render_active_window_to_grid( /// either the parse settles (so highlights can attach) or the /// timeout deadline hits. fn open_and_wait_for_parse(path: std::path::PathBuf) -> pmacs::editor::EditorState { - let mut state = pmacs::editor::EditorState::open(path).expect("open file"); + let mut state = + pmacs::editor::EditorState::open_with_roots(path, &crate::iso::roots()).expect("open file"); pump_async(&mut state, |s| current_tree_language(s).is_some()); state } @@ -1155,7 +1159,7 @@ fn m4_4_pty_mode_child_observes_a_tty() { /// to Lua" spec invariant for the process case. #[test] fn m4_4_lua_surface_drives_lifecycle() { - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let id_raw: i64 = state .lua_host .lua() @@ -1611,7 +1615,7 @@ fn m4_5_protocol_violation_surfaces_as_structured_error() { fn m4_5_lua_surface_drives_lsp_lifecycle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let lua = state.lua_host.lua(); let sid_raw: u64 = lua @@ -1846,7 +1850,7 @@ fn m4_6_diagnostic_source_field_is_preserved() { fn m4_6_lua_surface_reads_diagnostics() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let lua = state.lua_host.lua(); let sid_raw: u64 = lua @@ -1958,7 +1962,7 @@ fn m4_6_lua_surface_reads_diagnostics() { fn m4_6_diag_navigate_commands_and_bindings_are_registered() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let commands: Vec = lua @@ -2028,7 +2032,7 @@ fn m4_6_diag_navigate_commands_and_bindings_are_registered() { fn m4_6_diag_attach_view_pushes_diagnostic_overlay() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let attached: bool = state .lua_host .lua() @@ -2260,7 +2264,7 @@ fn m4_7_signature_help_during_function_call() { fn m4_7_lua_surface_drives_completion_hover_signature() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let lua = state.lua_host.lua(); let sid_raw: u64 = lua @@ -2581,7 +2585,7 @@ fn m4_8_status_buffer_text_reflects_state_and_capabilities() { fn m4_8_lua_surface_exposes_status() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let lua = state.lua_host.lua(); let sid_raw: u64 = lua @@ -2761,7 +2765,7 @@ fn m4_9_project_switch_is_first_class() { touch_file(&d1.path().join("Cargo.toml")); touch_file(&d2.path().join(".luarc.json")); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let d1_path = d1.path().display().to_string(); let d2_path = d2.path().display().to_string(); @@ -2817,7 +2821,7 @@ fn m4_9_lsp_runs_per_project_not_per_buffer() { touch_file(&d1.path().join("Cargo.toml")); touch_file(&d2.path().join("Cargo.toml")); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let d1_path = d1.path().display().to_string(); let d2_path = d2.path().display().to_string(); @@ -2929,7 +2933,7 @@ fn m4_10_index_persists_across_sessions() { // Session A: index a synthetic source file, save to disk. { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let saved_path: String = lua .load(format!( @@ -2957,7 +2961,7 @@ fn m4_10_index_persists_across_sessions() { // searches still find what session A indexed --- without ever // calling upsert_file again. let started = std::time::Instant::now(); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (file_count, symbol_count, hit_count, hit_name): (u64, u64, u64, String) = lua .load(format!( @@ -3063,7 +3067,7 @@ fn m4_10_lua_surface_drives_index() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path().display().to_string(); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (files_before, syms_before, hit_count, after_invalidate, generation_after): ( @@ -3111,7 +3115,7 @@ fn m4_10_lua_surface_ingests_lsp_workspace_symbol() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path().display().to_string(); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); // The URI we feed has to be under the project root so the // resulting absolute path is deterministic. @@ -3160,7 +3164,7 @@ fn m4_10_lua_surface_ingests_lsp_workspace_symbol() { fn m4_11_multiple_sources_combine_without_duplicates() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (parse_count, parse_source, total_count): (u64, String, u64) = lua @@ -3235,7 +3239,7 @@ fn m4_11_multiple_sources_combine_without_duplicates() { fn m4_11_source_priority_configurable() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (winner_first, winner_second): (String, String) = lua @@ -3289,7 +3293,7 @@ fn m4_11_source_priority_configurable() { fn m4_11_custom_sources_from_lua() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (custom_count_active, custom_label, custom_source, count_disabled, count_unregistered): ( @@ -3373,7 +3377,7 @@ fn m4_11_custom_sources_from_lua() { fn m4_11_snippets_surface_through_completion() { use pmacs::editor::EditorState; - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (label, kind, insert_text, source): (String, String, String, String) = lua .load( @@ -3529,7 +3533,7 @@ fn m4_12_formatting_response_lands_in_store() { fn m4_12_lua_surface_drives_definition_and_formatting() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); // Spawn a server through the Lua surface. @@ -3667,7 +3671,7 @@ fn m4_12_cross_file_go_to_definition_and_jump_back() { let b_disp = b_path.display().to_string(); let b_uri = format!("file://{b_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); // Point the default `rust` server at the fake, in `defenv` mode, @@ -3787,7 +3791,7 @@ fn m4_13_rename_applies_cross_file_workspace_edit() { let b_disp = b_path.display().to_string(); let b_uri = format!("file://{b_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state @@ -3915,7 +3919,7 @@ fn m4_14_code_action_command_drives_apply_edit() { std::fs::write(&a_path, b"abcfooxyz\n___zzz\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state @@ -4028,7 +4032,7 @@ fn m4_15_workspace_edit_resource_ops_apply_in_order() { let created = dir.path().join("created.rs"); let b2 = dir.path().join("b2.rs"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state @@ -4131,7 +4135,7 @@ fn m4_15_workspace_edit_resource_ops_apply_in_order() { /// kinds, and `paddingRight`. #[test] fn m4_16_lua_surface_drives_inlay_hints() { - let mut s = pmacs::editor::EditorState::new(); + let mut s = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut s, Some("inlaybounds")); let uri = "file:///tmp/m4_16_inlay.rs"; @@ -4190,7 +4194,7 @@ fn m4_16_lua_surface_drives_inlay_hints() { /// the `token_type` index resolves to a name. #[test] fn m4_17_lua_surface_drives_semantic_tokens() { - let mut s = pmacs::editor::EditorState::new(); + let mut s = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut s, None); let uri = "file:///tmp/m4_17_sem.rs"; @@ -4261,7 +4265,7 @@ fn m4_18_inlay_hint_refresh_repulls_via_server_request() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4314,7 +4318,7 @@ fn m4_18b_inlay_hints_auto_pull_after_initialize() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4365,7 +4369,7 @@ fn m4_19_semantic_tokens_refresh_repulls_via_server_request() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4422,7 +4426,7 @@ fn arc1c_semantic_tokens_auto_pull_on_attach() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4466,7 +4470,7 @@ fn arc1c_semantic_tokens_repull_after_edit_flush() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4529,7 +4533,7 @@ fn arc1d_signature_help_auto_triggers_on_trigger_char() { std::fs::write(&a_path, b"\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4589,7 +4593,7 @@ fn arc1d_signature_help_does_not_trigger_on_ordinary_typing() { std::fs::write(&a_path, b"\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4648,7 +4652,7 @@ fn arc1c_range_only_server_is_served_range_requests() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4703,7 +4707,7 @@ fn arc1c_range_only_utf16_server_gets_converted_bounds() { std::fs::write(&a_path, "fn a() {}\nlet x = \u{e9}\u{e9};".as_bytes()).expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4752,7 +4756,7 @@ fn arc1d_signature_help_triggers_on_non_ascii_trigger_char() { std::fs::write(&a_path, b"\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4810,7 +4814,7 @@ fn arc1d_signature_help_ignores_non_typed_edits() { std::fs::write(&a_path, b"\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4881,7 +4885,7 @@ fn arc1c_full_only_server_repulls_via_full_not_delta() { std::fs::write(&a_path, b"fn a() {}\n\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -4954,7 +4958,7 @@ fn arc1c_full_only_server_repulls_via_full_not_delta() { /// fake returns one token. #[test] fn m4_20_semantic_tokens_range() { - let mut s = pmacs::editor::EditorState::new(); + let mut s = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut s, None); let uri = "file:///tmp/m4_20_sem.rs"; @@ -4998,7 +5002,7 @@ fn m4_20_semantic_tokens_range() { /// `resultId` "rid-2". #[test] fn m4_21_semantic_tokens_full_then_delta() { - let mut s = pmacs::editor::EditorState::new(); + let mut s = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut s, None); let uri = "file:///tmp/m4_21_sem.rs"; @@ -5078,7 +5082,7 @@ fn m4_22_rename_prepare_gates_and_prefills() { std::fs::write(&a_path, b"abcfooxyz\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -5179,7 +5183,7 @@ fn m4_23_rename_prepare_refusal_aborts() { std::fs::write(&a_path, b"abcfooxyz\n").expect("write a"); let a_disp = a_path.display().to_string(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -5272,7 +5276,7 @@ fn m4_24_workspace_did_change_watched_files() { let received = base.join(".received"); let foo_uri = format!("file://{}", base.join("foo.txt").display()); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -5351,7 +5355,7 @@ fn m4_24_workspace_did_change_watched_files() { fn m4_25_tier1_language_server_configs_and_filetypes() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let probe: mlua::Table = s .lua_host .lua() @@ -5455,7 +5459,7 @@ fn m4_26_auto_attach_roots_server_at_opened_files_project() { let go_file_disp = go_file.display().to_string(); let fake = fake_lsp_path(); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); // 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. @@ -5567,7 +5571,7 @@ fn m4_27_real_gopls_analyzes_module_via_auto_attach() { let go_file_disp = go_file.display().to_string(); let uri = format!("file://{go_file_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); // 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); @@ -5664,7 +5668,7 @@ fn m4_28_real_clangd_diagnostics_and_semantic_tokens_via_auto_attach() { let cpp_disp = cpp.display().to_string(); let uri = format!("file://{cpp_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); real_server_open_and_init(&mut state, "cpp", &clangd, &root_disp, &cpp_disp); // Fire a semantic-tokens request; diagnostics flow unsolicited @@ -5767,7 +5771,7 @@ fn m4_29_real_rust_analyzer_inlay_hints_via_auto_attach() { let file_disp = file.display().to_string(); let uri = format!("file://{file_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); real_server_open_and_init(&mut state, "rust", &rust_analyzer, &root_disp, &file_disp); // rust-analyzer only answers `textDocument/inlayHint` after it has @@ -5815,7 +5819,7 @@ fn m4_29_real_rust_analyzer_inlay_hints_via_auto_attach() { fn m4_12_default_bundle_wires_commands_and_keymaps() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let probe: mlua::Table = s .lua_host .lua() @@ -5870,7 +5874,7 @@ fn m4_12_default_bundle_wires_commands_and_keymaps() { fn m4_12_default_bundle_wires_cuda() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let probe: mlua::Table = s .lua_host .lua() @@ -5926,7 +5930,7 @@ fn m4_12_default_bundle_wires_cuda() { fn m4_12_default_bundle_wires_bash() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let probe: mlua::Table = s .lua_host .lua() @@ -5970,7 +5974,7 @@ fn m4_12_default_bundle_wires_bash() { #[test] fn m4_shebang_resolver_maps_interpreters() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let resolve = |first_line: &str| -> Option { s.lua_host .lua() @@ -6030,7 +6034,7 @@ fn m4_shebang_resolver_maps_interpreters() { #[test] fn m4_shebang_extensionless_script_resolves_bash() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let hook = dir.path().join("pre-commit"); // no extension std::fs::write(&hook, b"#!/bin/sh\nset -e\necho building\n").expect("write"); @@ -6071,7 +6075,7 @@ fn m4_shebang_extensionless_script_resolves_bash() { #[test] fn m4_shebang_does_not_override_extension() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let f = dir.path().join("tool.py"); std::fs::write(&f, b"#!/bin/sh\nprint('hi')\n").expect("write"); @@ -6116,7 +6120,7 @@ fn m4_shebang_does_not_override_extension() { #[test] fn m4_shebang_extensionless_grammarless_language_is_silent() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let f = dir.path().join("generate"); // no extension // `ruby` is deliberately grammarless (and serverless) — a language the @@ -6166,7 +6170,7 @@ fn m4_shebang_extensionless_grammarless_language_is_silent() { #[test] fn m4_shebang_edit_keeps_pinned_grammar() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let hook = dir.path().join("deploy"); // no extension std::fs::write(&hook, b"#!/bin/sh\necho one\n").expect("write"); @@ -6261,7 +6265,7 @@ fn m4_shebang_edit_keeps_pinned_grammar() { #[test] fn m4_modeline_overrides_extension_end_to_end() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let file = dir.path().join("misleading.py"); std::fs::write(&file, b"-- -*- mode: lua -*-\nprint('ok')\n").expect("write"); @@ -6299,7 +6303,7 @@ fn m4_modeline_overrides_extension_end_to_end() { #[test] fn m4_modeline_parser_matches_supported_emacs_and_vim_forms() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let resolve = |text: &str| -> Option { s.lua_host .lua() @@ -6344,7 +6348,7 @@ fn m4_modeline_parser_matches_supported_emacs_and_vim_forms() { #[test] fn m4_modeline_parser_enforces_boundaries_aliases_and_conflicts() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let resolve = |text: &str| -> Option { s.lua_host .lua() @@ -6428,7 +6432,7 @@ fn m4_modeline_parser_enforces_boundaries_aliases_and_conflicts() { #[test] fn m4_modeline_unknown_mode_is_quiet_and_parser_free() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let file = dir.path().join("notes.txt"); std::fs::write(&file, b"# vim:ft=prose:\nhello\n").expect("write"); @@ -6464,7 +6468,7 @@ fn m4_modeline_unknown_mode_is_quiet_and_parser_free() { #[test] fn m4_modeline_language_is_pinned_until_reopen() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let file = dir.path().join("mutable.txt"); let other = dir.path().join("other.txt"); @@ -6558,7 +6562,7 @@ fn m4_modeline_language_is_pinned_until_reopen() { #[test] fn m4_modeline_shared_resolver_preserves_pathless_lsp_guard() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let (syntax, lsp): (Option, Option) = s .lua_host .lua() @@ -6580,7 +6584,7 @@ fn m4_modeline_shared_resolver_preserves_pathless_lsp_guard() { #[test] fn m4_filename_map_resolves_special_files() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let probe: mlua::Table = s .lua_host .lua() @@ -6650,7 +6654,7 @@ fn m4_filename_map_resolves_special_files() { #[test] fn m4_filename_extensionless_dockerfile_highlights() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let dir = tempfile::tempdir().expect("tempdir"); let f = dir.path().join("Dockerfile"); // no extension std::fs::write(&f, b"FROM alpine:3\nRUN apk add curl\n").expect("write"); @@ -6690,7 +6694,7 @@ fn m4_filename_extensionless_dockerfile_highlights() { #[test] fn m4_gap_grammars_align_with_lsp_configs() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); for (path, id) in [ ("app.py", "python"), ("srv.go", "go"), @@ -6733,7 +6737,7 @@ fn m4_gap_grammars_align_with_lsp_configs() { #[test] fn m4_json_yaml_lsp_configs_pin_command_and_sections() { use pmacs::editor::EditorState; - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let lua = s.lua_host.lua(); // json: the `@t1ckbase/vscode-langservers-extracted@2.0.2` binary @@ -6809,7 +6813,7 @@ fn m4_json_yaml_lsp_configs_pin_command_and_sections() { #[test] fn m4_5_initial_config_pushed_via_did_change_configuration() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let dir = tempfile::tempdir().expect("tempdir"); let sink = dir.path().join("config.jsonl"); @@ -6895,7 +6899,7 @@ fn m4_real_json_provider_receives_config_and_reports_diagnostics() { let file_disp = file.display().to_string(); let uri = format!("file://{file_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state .lua_host .lua() @@ -6964,7 +6968,7 @@ fn m4_real_yaml_provider_pulls_config_and_reports_diagnostics() { let file_disp = file.display().to_string(); let uri = format!("file://{file_disp}"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state .lua_host .lua() @@ -7043,7 +7047,7 @@ fn m4_real_yaml_provider_pulls_config_and_reports_diagnostics() { fn m4_lua_bundle_debounces_did_change_per_keystroke() { use pmacs::editor::EditorState; - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); let dir = tempfile::TempDir::new().unwrap(); let file = dir.path().join("debounce.rs"); @@ -7144,12 +7148,12 @@ fn m4_lua_bundle_debounces_did_change_per_keystroke() { fn m4_12_default_bundle_after_load_robust_to_missing_server() { use pmacs::editor::EditorState; let _dir = tempfile::TempDir::new().unwrap(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); // Plain after-load with no buffer path attached — the hook should // run, find no language for path=nil, and exit silently. If it // throws, the *errors* buffer would record it; verify it doesn't. s.lua_host.hooks(); - let s2 = EditorState::new(); + let s2 = EditorState::new_with_roots(&crate::iso::roots()); drop(s); let saw_error: bool = s2 .lua_host @@ -7199,7 +7203,7 @@ fn project_set_search_boundary_clamps_lua_detect_call() { let f = workspace.join("src/main.rs"); std::fs::write(&f, b"").expect("touch file"); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let workspace_str = workspace.display().to_string(); let f_str = f.display().to_string(); @@ -7234,7 +7238,7 @@ fn project_search_boundary_round_trips_via_lua() { let dir = tempfile::tempdir().expect("dir"); let dir_str = dir.path().display().to_string(); - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); let (initial, after_set, after_clear): (Option, Option, Option) = lua @@ -7420,7 +7424,7 @@ fn spawn_lsp_and_init(state: &mut pmacs::editor::EditorState, mode: Option<&str> #[test] fn m4_5_await_completion_returns_result_and_populates_store() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, None); state .lua_host @@ -7456,7 +7460,7 @@ fn m4_5_await_completion_returns_result_and_populates_store() { #[test] fn m4_5_await_server_error_raises_failed() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("error")); state .lua_host @@ -7496,7 +7500,7 @@ fn m4_5_await_server_error_raises_failed() { #[test] fn m4_5_await_cancelled_when_server_stops_mid_request() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("silent")); state .lua_host @@ -7534,7 +7538,7 @@ fn m4_5_await_cancelled_when_server_stops_mid_request() { #[test] fn m4_5_await_times_out_against_silent_server() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("silent")); state .lua_host @@ -7576,7 +7580,7 @@ fn m4_5_await_times_out_against_silent_server() { #[test] fn m4_5_await_superseded_request_is_cancelled() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("silent")); state .lua_host @@ -7622,7 +7626,7 @@ fn m4_5_await_superseded_request_is_cancelled() { #[test] fn m4_5_await_resolves_same_frame_as_response_absorbed() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, None); state .lua_host @@ -7710,7 +7714,7 @@ fn m4_5_await_resolves_same_frame_as_response_absorbed() { #[test] fn m4_5_position_encoding_utf16_round_trips_non_ascii() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("posecho")); state .lua_host @@ -7766,7 +7770,7 @@ fn m4_5_position_encoding_utf16_round_trips_non_ascii() { fn m4_5_utf16_rename_and_prepare_rename_convert_positions() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, Some("posecho")); let uri = "file:///tmp/m4_5_rename_utf16.rs"; state @@ -7806,7 +7810,7 @@ fn m4_5_utf16_rename_and_prepare_rename_convert_positions() { #[test] fn m4_5_workspace_configuration_answered_from_settings() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -7863,7 +7867,7 @@ fn m4_5_workspace_configuration_answered_from_settings() { #[test] fn m4_5_location_nav_requests_route_by_kind() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, None); state .lua_host @@ -7913,7 +7917,7 @@ fn m4_5_location_nav_requests_route_by_kind() { #[test] fn m4_5_symbols_and_highlight_round_trip() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); spawn_lsp_and_init(&mut state, None); state .lua_host @@ -7977,7 +7981,7 @@ fn m4_5_symbols_and_highlight_round_trip() { /// Open `path` against the fake server and wait for initialization /// (shared bootstrap for the panel tests). fn open_against_fake(path: &std::path::Path) -> pmacs::editor::EditorState { - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_lsp_path(); state .lua_host @@ -8223,7 +8227,7 @@ fn rd1_delete_refuses_when_a_bound_buffer_is_modified() { let f = dir.path().join("a.rs"); std::fs::write(&f, b"original\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &f); state .lua_host @@ -8265,7 +8269,7 @@ fn rd2_delete_still_succeeds_for_a_clean_open_buffer() { let f = dir.path().join("clean.rs"); std::fs::write(&f, b"untouched\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &f); let (ok, err) = rd_delete(&mut state, &f, ""); @@ -8310,7 +8314,7 @@ fn rd3_filesystem_failure_leaves_the_clean_buffer_intact() { let target = dir.path().join("target"); std::fs::write(&target, b"was a file\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &target); // The path changes type behind pmacs's back — the same class of @@ -8354,7 +8358,7 @@ fn rd4_on_removed_observes_the_path_already_gone() { std::fs::write(&f, b"bye\n").expect("write"); let p = f.display().to_string(); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &f); state .lua_host @@ -8399,7 +8403,7 @@ fn rd5_delete_from_inside_the_targets_own_intercept_refuses_before_disk() { std::fs::write(&f, b"busy\n").expect("write"); let p = f.display().to_string(); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &f); state .lua_host @@ -8455,7 +8459,7 @@ fn rd6_a_clean_first_match_cannot_hide_a_modified_duplicate() { std::fs::write(&f, b"shared\n").expect("write"); let p = f.display().to_string(); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); state .lua_host .lua() @@ -8493,7 +8497,7 @@ fn rd7_a_sibling_directory_sharing_a_name_prefix_does_not_block() { let outside = sibling.join("out.rs"); std::fs::write(&outside, b"out\n").expect("write out"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &outside); state .lua_host @@ -8527,7 +8531,7 @@ fn rd8_recursive_delete_refuses_for_a_modified_descendant() { let inner = nested.join("deep.rs"); std::fs::write(&inner, b"deep\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &inner); state .lua_host @@ -8575,7 +8579,7 @@ fn rd9_clean_recursive_delete_reconciles_descendants_through_both_phases() { let inner = tree.join("kept.rs"); std::fs::write(&inner, b"kept\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &inner); // Display it, so the phase-1 window redirect has something to do. state @@ -8629,7 +8633,7 @@ fn rd10_absent_plus_ignore_does_not_destroy_a_modified_buffer() { let f = dir.path().join("vanished.rs"); std::fs::write(&f, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &f); state .lua_host @@ -8684,7 +8688,7 @@ fn rd14_clean_duplicates_all_reconcile() { std::fs::write(&f, b"twin\n").expect("write"); let p = f.display().to_string(); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); state .lua_host .lua() @@ -8940,7 +8944,7 @@ fn rd11_absent_plus_ignore_succeeds_through_the_server_pump() { let victim = dir.path().join("victim.rs"); std::fs::write(&victim, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "delete", "uri": rd_uri(&victim), @@ -8991,7 +8995,7 @@ fn rd11a_present_plus_ignore_with_a_modified_buffer_is_refused() { let victim = dir.path().join("victim.rs"); std::fs::write(&victim, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "delete", "uri": rd_uri(&victim), @@ -9049,7 +9053,7 @@ fn rd11b_a_dangling_symlink_counts_as_present() { std::fs::write(&real, b"content\n").expect("write"); std::os::unix::fs::symlink(&real, &link).expect("symlink"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "delete", "uri": rd_uri(&link), @@ -9113,7 +9117,7 @@ fn rd11c_absent_without_ignore_refuses_before_the_earlier_op_runs() { let missing = dir.path().join("never-existed.rs"); std::fs::write(&a, b"abcfooxyz\n").expect("write a"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ rd_edit_op(&a, 0, 3, 6, "EDITED"), @@ -9167,7 +9171,7 @@ fn rd11d_an_unanswerable_stat_fails_closed_in_the_plan() { std::fs::write(¬_a_dir, b"I am a file\n").expect("write the would-be parent"); let target = not_a_dir.join("child.rs"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ rd_edit_op(&a, 0, 3, 6, "EDITED"), @@ -9223,7 +9227,7 @@ fn rd12a_edit_then_delete_answers_the_server_and_reports_partial_work() { let a = dir.path().join("a.rs"); std::fs::write(&a, b"abcfooxyz\n").expect("write a"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ rd_edit_op(&a, 0, 3, 6, "EDITED"), @@ -9276,7 +9280,7 @@ fn rd12b_rename_into_delete_answers_the_server_and_reports_partial_work() { let inside = tree.join("m.rs"); std::fs::write(&outside, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "rename", "oldUri": rd_uri(&outside), "newUri": rd_uri(&inside) }, @@ -9341,7 +9345,7 @@ fn rd13_the_refusal_answers_the_server_and_leaves_a_durable_trace() { let victim = dir.path().join("victim.rs"); std::fs::write(&victim, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "delete", "uri": rd_uri(&victim) } ] }); @@ -9411,7 +9415,7 @@ fn rd15_defensive_a_parse_failure_still_attempts_a_response() { let a = dir.path().join("a.rs"); std::fs::write(&a, b"abcfooxyz\n").expect("write a"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); // A plan that would otherwise succeed, so a response saying // `applied = false` can only have come from the parse stub. let plan = serde_json::json!({ @@ -9476,7 +9480,7 @@ fn rd18_non_recursive_delete_is_not_blocked_by_an_orphan_beneath_it() { let gone = tree.join("gone.rs"); std::fs::write(&gone, b"content\n").expect("write"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); rd_open(&mut state, "B", &gone); state .lua_host @@ -9521,7 +9525,7 @@ fn rd19a_create_then_delete_is_not_refused_by_the_plan_time_preflight() { let transient = dir.path().join("transient.rs"); let witness = dir.path().join("witness.rs"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "create", "uri": rd_uri(&transient) }, @@ -9563,7 +9567,7 @@ fn rd19b_rename_then_delete_is_not_refused_by_the_plan_time_preflight() { let destination = dir.path().join("destination.rs"); std::fs::write(&source, b"moving\n").expect("write source"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "rename", "oldUri": rd_uri(&source), "newUri": rd_uri(&destination) }, @@ -9607,7 +9611,7 @@ fn rd19c_deferring_the_check_does_not_skip_it() { std::fs::write(&a, b"abcfooxyz\n").expect("write a"); let fresh = dir.path().join("fresh.rs"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "create", "uri": rd_uri(&fresh) }, @@ -9671,7 +9675,7 @@ fn rd20_the_user_facing_message_reports_partial_application() { let a = dir.path().join("a.rs"); std::fs::write(&a, b"abcfooxyz\n").expect("write a"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ rd_edit_op(&a, 0, 3, 6, "EDITED"), @@ -9753,7 +9757,7 @@ fn rd21_equivalent_dot_path_create_then_delete_is_not_preflight_refused() { "fixture: the URI spellings must differ" ); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [ { "kind": "create", "uri": dot_uri }, @@ -9792,7 +9796,7 @@ fn rd22a_partial_edits_inside_one_item_are_reported_conservatively() { let target = dir.path().join("target.rs"); std::fs::write(&target, b"abcdef\n").expect("write target"); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [{ "textDocument": { "uri": rd_uri(&target), "version": 1 }, @@ -9877,7 +9881,7 @@ fn rd22b_a_failing_resource_item_can_leave_filesystem_state() { "fixture: destination parent must start absent" ); - let mut state = pmacs::editor::EditorState::new(); + let mut state = pmacs::editor::EditorState::new_with_roots(&crate::iso::roots()); let plan = serde_json::json!({ "documentChanges": [{ "kind": "rename", @@ -9907,3 +9911,10 @@ fn rd22b_a_failing_resource_item_can_leave_filesystem_state() { "the response must not deny the directory left on disk: {reason:?}" ); } + +// 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; diff --git a/tests/m5_5_acceptance.rs b/tests/m5_5_acceptance.rs index 031e86b..47adb6b 100644 --- a/tests/m5_5_acceptance.rs +++ b/tests/m5_5_acceptance.rs @@ -253,6 +253,10 @@ fn second_daemon_same_socket_fails_clearly() { .arg(daemon_a.socket_path()) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdout(Stdio::null()) .stderr(Stdio::piped()) .output() diff --git a/tests/m5_7_acceptance.rs b/tests/m5_7_acceptance.rs index ce85591..24829c3 100644 --- a/tests/m5_7_acceptance.rs +++ b/tests/m5_7_acceptance.rs @@ -139,6 +139,10 @@ fn spawn_pmacs_daemon(socket_path: &Path) -> Child { .arg(socket_path) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -270,6 +274,10 @@ fn cli_attach_invokes_test_ssh_with_expected_argv() { .env(PMACS_TEST_SSH_BIN, &fake_ssh) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -323,6 +331,10 @@ fn cli_attach_with_user_and_instance_name_passes_through_dash_l_and_dash_dash_so .env(PMACS_TEST_SSH_BIN, &fake_ssh) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -361,6 +373,10 @@ fn spawn_bridge(socket: &Path, isolated_home: &Path) -> (Child, ChildStdin, Chil .arg(socket) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -466,6 +482,10 @@ fn cli_attach_with_missing_ssh_binary_surfaces_spawn_failure() { .env(PMACS_TEST_SSH_BIN, &missing_ssh) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) diff --git a/tests/m5_8_acceptance.rs b/tests/m5_8_acceptance.rs index f58c609..8bf2a43 100644 --- a/tests/m5_8_acceptance.rs +++ b/tests/m5_8_acceptance.rs @@ -151,6 +151,10 @@ fn handshake_retry_cap_fires_after_three_failed_handshakes() { .env(PMACS_TEST_BACKOFF_SCALE_MS, "1") .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -198,6 +202,10 @@ fn backoff_scaling_observable_in_wall_clock_runtime() { .env(PMACS_TEST_BACKOFF_SCALE_MS, scale_ms) .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -278,6 +286,10 @@ fn ssh_stderr_from_handshake_attempts_reaches_user() { .env(PMACS_TEST_BACKOFF_SCALE_MS, "1") .env("HOME", isolated_home) .env("XDG_CONFIG_HOME", isolated_home) + .env("XDG_DATA_HOME", isolated_home) + .env("XDG_STATE_HOME", isolated_home) + .env("PMACS_STATE_HOME", isolated_home) + .env("XDG_CACHE_HOME", isolated_home) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) diff --git a/tests/m5_perf_acceptance.rs b/tests/m5_perf_acceptance.rs index 12139b3..6d261d7 100644 --- a/tests/m5_perf_acceptance.rs +++ b/tests/m5_perf_acceptance.rs @@ -98,6 +98,10 @@ impl TestDaemon { .arg(&socket_path) .env("HOME", tempdir.path()) .env("XDG_CONFIG_HOME", tempdir.path()) + .env("XDG_DATA_HOME", tempdir.path()) + .env("XDG_STATE_HOME", tempdir.path()) + .env("PMACS_STATE_HOME", tempdir.path()) + .env("XDG_CACHE_HOME", tempdir.path()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() diff --git a/tests/m6_4_repl_acceptance.rs b/tests/m6_4_repl_acceptance.rs index 200dea0..9dbfce0 100644 --- a/tests/m6_4_repl_acceptance.rs +++ b/tests/m6_4_repl_acceptance.rs @@ -43,7 +43,7 @@ use pmacs::lua_bindings::BufferIdLua; /// translate them into typed Rust errors because the chunk's /// assertions are the test contract. fn run(chunk: &str) { - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval(Some("@m6_4_test"), chunk) @@ -53,7 +53,7 @@ fn run(chunk: &str) { /// Construct a fresh editor and return a captured value (for tests /// that want to do final assertions on the Rust side). fn run_returning(chunk: &str) -> T { - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval(Some("@m6_4_test"), chunk) @@ -438,7 +438,7 @@ fn m6_4_buffer_id_is_a_real_buffer_handle() { // The handle's `buffer_id()` returns a real BufferIdLua that // resolves through the registry. This locks in that the package // is built atop genuinely-public surface; no shadow APIs. - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); let returned: mlua::Value = editor .lua_host .eval( @@ -457,3 +457,10 @@ fn m6_4_buffer_id_is_a_real_buffer_handle() { let buf = r.get(id_lua.id()).expect("registered"); assert_eq!(buf.name(), "*handle*"); } + +// 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; diff --git a/tests/m6_5_repl_acceptance.rs b/tests/m6_5_repl_acceptance.rs index 44d6b95..99135b3 100644 --- a/tests/m6_5_repl_acceptance.rs +++ b/tests/m6_5_repl_acceptance.rs @@ -80,7 +80,7 @@ fn locate_shell(name: &str) -> Option { /// Construct a fresh editor and run the given Lua chunk against it. fn run(chunk: &str) { let _guard = pump_test_guard(); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval(Some("@m6_5_test"), chunk) @@ -95,7 +95,7 @@ fn run(chunk: &str) { /// after-tick contract is exercised end-to-end. fn run_with_pump(setup_chunk: &str, predicate_chunk: &str, timeout_ms: u64) { let _guard = pump_test_guard(); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval(Some("@m6_5_setup"), setup_chunk) @@ -528,3 +528,10 @@ fn m6_5_close_terminates_child_and_unregisters() { pmacs.hook.run("process.after-tick") "#); } + +// 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; diff --git a/tests/m6_7_scrollback_acceptance.rs b/tests/m6_7_scrollback_acceptance.rs index 2cc4dec..7c97f66 100644 --- a/tests/m6_7_scrollback_acceptance.rs +++ b/tests/m6_7_scrollback_acceptance.rs @@ -31,7 +31,7 @@ use pmacs::editor::EditorState; // --------------------------------------------------------------------------- fn run(chunk: &str) { - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval(Some("@m6_7_test"), chunk) @@ -341,3 +341,10 @@ fn m6_7_50_truncation_events_preserve_block_boundaries() { pmacs.repl.config.scrollback_bytes = 16 * 1024 * 1024 "#); } + +// 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; diff --git a/tests/m6_8_multi_repl_acceptance.rs b/tests/m6_8_multi_repl_acceptance.rs index 4bc482f..fbc9afc 100644 --- a/tests/m6_8_multi_repl_acceptance.rs +++ b/tests/m6_8_multi_repl_acceptance.rs @@ -180,7 +180,7 @@ fn m6_8_three_repls_render_independently() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); spawn_three_and_wait_running(&mut editor, &lua); // Write a unique marker through each REPL: `io.write("MARK_\n")` @@ -246,7 +246,7 @@ fn m6_8_three_repls_respond_independently() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); spawn_three_and_wait_running(&mut editor, &lua); // Type into h1, switch to h2, type into h2, etc. Each typed marker @@ -308,7 +308,7 @@ fn m6_8_close_one_does_not_affect_others() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); spawn_three_and_wait_running(&mut editor, &lua); // Capture h2's proc_id before close; Handle:close clears the @@ -375,7 +375,7 @@ fn m6_8_supervisor_reaps_all_children_across_cycles() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); // Baseline: list size before any spawning. The post-cycle list // size must equal this — no REPL processes left behind. @@ -480,7 +480,7 @@ fn m6_8_supervisor_reaps_all_children_across_cycles() { /// `pmacs.repl.create` (which the spawn path also calls). #[test] fn m6_8_repls_have_independent_parser_state() { - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval( @@ -505,7 +505,7 @@ fn m6_8_repls_have_independent_parser_state() { /// accidentally shared. #[test] fn m6_8_repls_have_independent_scrollback_state() { - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); editor .lua_host .eval( @@ -535,7 +535,7 @@ fn m6_8_buffer_scoped_bindings_route_to_active_buffer() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); let setup = format!( r#" _G.h1 = pmacs.repl.spawn {{ argv = {{ "{lua}", "-i" }} }} @@ -618,7 +618,7 @@ fn m6_8_after_tick_hook_drains_all_handles_per_tick() { let Some(lua) = locate_lua() else { return; }; - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); spawn_three_and_wait_running(&mut editor, &lua); // Capture pre-tick history_end on each handle. @@ -692,3 +692,10 @@ fn m6_8_after_tick_hook_drains_all_handles_per_tick() { .exec() .expect("teardown"); } + +// 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; diff --git a/tests/m6_perf_acceptance.rs b/tests/m6_perf_acceptance.rs index 5ca857b..094808f 100644 --- a/tests/m6_perf_acceptance.rs +++ b/tests/m6_perf_acceptance.rs @@ -278,7 +278,7 @@ fn m6_6_sustained_ingest_rate_meets_100mbps_gate() { ) .max(1); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); // 100 chars + newline per line. The exact value is unimportant; // what matters is that `yes` blasts at a higher rate than pmacs's // ingest path, so pmacs is the bottleneck under measurement. @@ -389,7 +389,7 @@ fn m6_6_buffer_memory_stays_under_200mb_during_run() { baseline_rss as f64 / (1024.0 * 1024.0) ); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); // 100-char line; the line content is unimportant for the ceiling // gate. Total target = 150 MB of bytes-into-history. let line = "a".repeat(100); @@ -526,7 +526,7 @@ fn m6_6_cancel_response_p99_under_100ms() { let delay_ms = MIN_DELAY_MS + (r % (max_delay_ms - MIN_DELAY_MS + 1)); let delay = Duration::from_millis(delay_ms); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); let _ = spawn_repl(&mut editor, &["yes"]); wait_until_running(&mut editor); @@ -670,7 +670,7 @@ fn m6_7_scrollback_navigation_p99_under_16ms() { const TRIALS: usize = 1000; const P99_THRESHOLD: Duration = Duration::from_millis(16); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); populate_scrollback(&mut editor, LINES); seek_cursor_to_middle(&mut editor, LINES); @@ -749,7 +749,7 @@ fn m6_7_scrollback_search_p99_under_100ms() { const TRIALS: usize = 1000; const P99_THRESHOLD: Duration = Duration::from_millis(100); - let mut editor = EditorState::new(); + let mut editor = EditorState::new_with_roots(&crate::iso::roots()); populate_scrollback(&mut editor, LINES); // Warmup: 100 searches at varied positions. Same trace-compilation @@ -815,3 +815,10 @@ fn m6_7_scrollback_search_p99_under_100ms() { p50={p50:?}, p90={p90:?}, max={max:?}" ); } + +// 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; diff --git a/tests/m7_11_acceptance.rs b/tests/m7_11_acceptance.rs index 5b574ba..03c904c 100644 --- a/tests/m7_11_acceptance.rs +++ b/tests/m7_11_acceptance.rs @@ -89,14 +89,14 @@ fn repl_manifest_declares_exports_and_pmacs_required() { // Bullet 2b: bootstrap loads through the package system // --------------------------------------------------------------------------- // -// EditorState::new() runs the M7.11 bootstrap that materializes +// `EditorState::new()` runs the M7.11 bootstrap that materializes // the bundled REPL and pushes its InstalledPackage record into // the roster. After that, `require("repl")` from Lua resolves // through the M7.7 searcher (not the legacy direct eval). #[test] fn editor_init_makes_repl_loadable_via_require() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let result: bool = state .lua_host .lua() @@ -202,3 +202,10 @@ fn materialize_all_produces_one_record_per_bundled_package() { assert!(repl.entry_path().exists()); let _ = std::fs::remove_dir_all(&tmp); } + +// 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; diff --git a/tests/m8_10_acceptance.rs b/tests/m8_10_acceptance.rs index 74cbc20..24f106d 100644 --- a/tests/m8_10_acceptance.rs +++ b/tests/m8_10_acceptance.rs @@ -45,7 +45,7 @@ fn outline_package_path() -> PathBuf { fn editor_with_outline() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1262,3 +1262,10 @@ fn outline_aggregate_empty_sources_rejected() { "error must mention non-empty; got: {msg}" ); } + +// 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; diff --git a/tests/m8_1_acceptance.rs b/tests/m8_1_acceptance.rs index 5e5b899..3d33f20 100644 --- a/tests/m8_1_acceptance.rs +++ b/tests/m8_1_acceptance.rs @@ -36,7 +36,7 @@ fn pump_until bool>(state: &mut EditorState, predicate: F /// Spin up a fresh editor with no file open. The caller drives the /// fs API entirely from Lua chunks via `lua_host.eval`. fn fresh_editor() -> EditorState { - EditorState::new() + EditorState::new_with_roots(&crate::iso::roots()) } /// Run a Lua chunk to completion (no `:await`); returns the chunk's @@ -609,3 +609,10 @@ fn fs_watch_reports_file_change_and_can_cancel() { .expect("cancelled"); assert!(cancelled, "watch handle must report cancellation"); } + +// 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; diff --git a/tests/m8_2_acceptance.rs b/tests/m8_2_acceptance.rs index c61abdb..a7e7e63 100644 --- a/tests/m8_2_acceptance.rs +++ b/tests/m8_2_acceptance.rs @@ -80,7 +80,7 @@ fn pump_until bool>(state: &mut EditorState, predicate: F fn editor_with_dired() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1251,3 +1251,10 @@ fn dired_source_size_under_audit_ceiling() { "M8.2 spec: dired source under 1500 lines; got {lines}" ); } + +// 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; diff --git a/tests/m8_3_acceptance.rs b/tests/m8_3_acceptance.rs index 7706efd..742cec1 100644 --- a/tests/m8_3_acceptance.rs +++ b/tests/m8_3_acceptance.rs @@ -44,7 +44,7 @@ fn pump_until bool>(state: &mut EditorState, predicate: F fn editor_with_dired() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -2101,3 +2101,10 @@ fn dired_active_handle_is_cleared_when_active_buffer_removed() { "active_handle must not return a stale handle after its buffer is removed" ); } + +// 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; diff --git a/tests/m8_5_acceptance.rs b/tests/m8_5_acceptance.rs index ecba842..2fac268 100644 --- a/tests/m8_5_acceptance.rs +++ b/tests/m8_5_acceptance.rs @@ -52,7 +52,7 @@ fn pump_until bool>(state: &mut EditorState, predicate: F fn editor_with_magit() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -913,3 +913,10 @@ fn magit_cursor_on_hidden_descendant_reseats_to_visible_ancestor() { "cursor on hidden A1 must reseat to A's header (line 0)" ); } + +// 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; diff --git a/tests/m8_6_acceptance.rs b/tests/m8_6_acceptance.rs index 4ddcffe..e9d8baf 100644 --- a/tests/m8_6_acceptance.rs +++ b/tests/m8_6_acceptance.rs @@ -64,7 +64,7 @@ fn pump_until_with_deadline bool>( fn editor_with_magit() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -653,3 +653,10 @@ fn magit_build_spec_section_ids_are_stable_canonical_set() { .expect("build_spec ids"); assert_eq!(ids, "working,staged,log,branches,stashes"); } + +// 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; diff --git a/tests/m8_7_acceptance.rs b/tests/m8_7_acceptance.rs index fb3cd88..6990557 100644 --- a/tests/m8_7_acceptance.rs +++ b/tests/m8_7_acceptance.rs @@ -62,7 +62,7 @@ fn pump_until_with_deadline bool>( fn editor_with_magit() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1011,3 +1011,10 @@ fn magit_b_c_keybinding_dispatches_to_branch_create_prompt() { .eval(Some("cancel"), "pmacs.minibuffer.cancel()") .expect("cancel"); } + +// 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; diff --git a/tests/m8_9_acceptance.rs b/tests/m8_9_acceptance.rs index 11834a6..2877b23 100644 --- a/tests/m8_9_acceptance.rs +++ b/tests/m8_9_acceptance.rs @@ -45,7 +45,7 @@ fn outline_package_path() -> PathBuf { fn editor_with_outline() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1090,3 +1090,10 @@ fn outline_parser_unit_parses_org_subset() { .expect("tags"); assert_eq!(n_tags, 2); } + +// 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; diff --git a/tests/m9_1_acceptance.rs b/tests/m9_1_acceptance.rs index 47c8a59..a7eb45d 100644 --- a/tests/m9_1_acceptance.rs +++ b/tests/m9_1_acceptance.rs @@ -725,7 +725,7 @@ fn m9_1_oncrash_policy_does_not_restart_on_clean_exit() { fn m9_1_lua_surface_drives_mcp_lifecycle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_mcp_path(); let lua = state.lua_host.lua(); let sid_raw: u64 = lua @@ -815,7 +815,7 @@ fn m9_1_lua_surface_drives_mcp_lifecycle() { fn m9_1_lua_send_request_returns_awaitable_handle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_mcp_path(); let lua = state.lua_host.lua(); @@ -924,3 +924,10 @@ fn m9_1_lua_send_request_returns_awaitable_handle() { .load("pmacs.mcp.stop(_G._mcp_test_server)") .exec(); } + +// 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; diff --git a/tests/m9_2_acceptance.rs b/tests/m9_2_acceptance.rs index 06338c9..e0f5437 100644 --- a/tests/m9_2_acceptance.rs +++ b/tests/m9_2_acceptance.rs @@ -587,7 +587,7 @@ fn m9_2_cancelled_sibling_wins_over_queued_response() { fn m9_2_lua_read_resource_returns_awaitable_handle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_mcp_path(); state @@ -738,3 +738,10 @@ fn m9_2_lua_read_resource_returns_awaitable_handle() { .load("pmacs.mcp.stop(_G._mcp_test_server)") .exec(); } + +// 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; diff --git a/tests/m9_3_acceptance.rs b/tests/m9_3_acceptance.rs index aadf606..cf020ec 100644 --- a/tests/m9_3_acceptance.rs +++ b/tests/m9_3_acceptance.rs @@ -332,7 +332,7 @@ fn m9_3_cancellation_reaches_server() { fn m9_3_lua_invoke_tool_returns_awaitable_handle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_mcp_path(); state @@ -496,3 +496,10 @@ fn m9_3_lua_invoke_tool_returns_awaitable_handle() { .load("pmacs.mcp.stop(_G._mcp_test_server)") .exec(); } + +// 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; diff --git a/tests/m9_4_acceptance.rs b/tests/m9_4_acceptance.rs index 8474db1..43e2de3 100644 --- a/tests/m9_4_acceptance.rs +++ b/tests/m9_4_acceptance.rs @@ -317,7 +317,7 @@ fn m9_4_no_args_wire_shape_is_empty_object() { fn m9_4_lua_get_prompt_returns_awaitable_handle() { use pmacs::editor::EditorState; - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = fake_mcp_path(); state @@ -494,3 +494,10 @@ fn m9_4_lua_get_prompt_returns_awaitable_handle() { .load("pmacs.mcp.stop(_G._mcp_test_server)") .exec(); } + +// 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; diff --git a/tests/m9_5_acceptance.rs b/tests/m9_5_acceptance.rs index 8831935..c0e44cc 100644 --- a/tests/m9_5_acceptance.rs +++ b/tests/m9_5_acceptance.rs @@ -51,7 +51,7 @@ fn resources_package_path() -> PathBuf { fn editor_with_resources() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1275,3 +1275,10 @@ fn m9_5_stale_buffer_recovers_on_reopen_after_restart() { "after re-open, the buffer must have fresh content; got {reopen_body:?}" ); } + +// 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; diff --git a/tests/m9_6_acceptance.rs b/tests/m9_6_acceptance.rs index 4b5166d..ee3eac9 100644 --- a/tests/m9_6_acceptance.rs +++ b/tests/m9_6_acceptance.rs @@ -48,7 +48,7 @@ fn tools_package_path() -> PathBuf { fn editor_with_tools() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1532,3 +1532,10 @@ fn m9_6_editor_describe_command_unknown_name_status_only() { "*help* must not be created for an unknown-name describe-command call" ); } + +// 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; diff --git a/tests/m9_7_acceptance.rs b/tests/m9_7_acceptance.rs index 456ad97..63d88eb 100644 --- a/tests/m9_7_acceptance.rs +++ b/tests/m9_7_acceptance.rs @@ -74,7 +74,7 @@ fn prompts_package_path() -> PathBuf { fn editor_with_prompts() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1386,3 +1386,10 @@ fn m9_7_prompt_hash_includes_required_argument_order() { "reordering required args must change the prompt hash so reconcile re-registers" ); } + +// 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; diff --git a/tests/m9_8_acceptance.rs b/tests/m9_8_acceptance.rs index 06ed3a3..0b418e8 100644 --- a/tests/m9_8_acceptance.rs +++ b/tests/m9_8_acceptance.rs @@ -56,7 +56,7 @@ fn prompts_package_path() -> PathBuf { fn editor_with_ai() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() @@ -1122,3 +1122,10 @@ fn m9_8_configured_prompt_missing_on_server_surfaces_error() { state.core.borrow().status ); } + +// 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; diff --git a/tests/overlay_reattach_acceptance.rs b/tests/overlay_reattach_acceptance.rs index 48eafbb..efd57f8 100644 --- a/tests/overlay_reattach_acceptance.rs +++ b/tests/overlay_reattach_acceptance.rs @@ -41,7 +41,7 @@ fn count_of(kinds: &[String], kind: &str) -> usize { #[test] fn switch_away_and_back_reattaches_syntax_overlay_exactly_once() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let path = open_probe_file(&s); s.lua_host .lua() @@ -86,7 +86,7 @@ fn switch_away_and_back_reattaches_syntax_overlay_exactly_once() { #[test] fn panel_quit_restores_overlays_on_the_source_buffer() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); let path = open_probe_file(&s); s.lua_host .lua() @@ -121,3 +121,10 @@ fn panel_quit_restores_overlays_on_the_source_buffer() { "leaving a panel restores the source buffer's styling (got {after:?})" ); } + +// 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; diff --git a/tests/persistence_acceptance.rs b/tests/persistence_acceptance.rs index c3a6c9d..3045709 100644 --- a/tests/persistence_acceptance.rs +++ b/tests/persistence_acceptance.rs @@ -26,7 +26,7 @@ fn editor_with_state_dir() -> (EditorState, PathBuf) { SEQ.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir_all(&dir).expect("mk state tempdir"); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); // Override whatever startup configured with our tempdir. s.lua_host.lua().remove_app_data::(); s.lua_host.lua().set_app_data(StateDir(dir.clone())); @@ -73,12 +73,12 @@ fn state_round_trips_and_rejects_escapes() { #[test] fn state_is_inert_when_unconfigured() { - // A plain EditorState::new() must NOT configure a state dir — that + // A plain `EditorState::new()` must NOT configure a state dir — that // is what keeps the whole integration-test suite (which links the // lib without cfg(test)) from writing to a developer's real // ~/.local/state/pmacs. Only the real entry points call // install_state_dirs(); tests construct EditorState directly. - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); assert!( s.lua_host.lua().app_data_ref::().is_none(), "new() must leave the state dir unconfigured" @@ -182,3 +182,10 @@ fn saveplace_can_be_disabled() { assert_eq!(cursor, 0, "disabled saveplace leaves the cursor at the top"); std::fs::remove_dir_all(&dir).ok(); } + +// 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; diff --git a/tests/query_replace_acceptance.rs b/tests/query_replace_acceptance.rs index 4fa69b3..a36ba56 100644 --- a/tests/query_replace_acceptance.rs +++ b/tests/query_replace_acceptance.rs @@ -85,7 +85,7 @@ fn start_query_replace(s: &mut EditorState, from: &str, to: &str, regex: bool) { #[test] fn replace_skip_and_quit_is_selective() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "x x x x"); // Cursor to buffer start so all four are ahead of point. goto_start(&s); @@ -107,7 +107,7 @@ fn replace_skip_and_quit_is_selective() { #[test] fn bang_replaces_all_remaining() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); @@ -119,7 +119,7 @@ fn bang_replaces_all_remaining() { #[test] fn dot_replaces_current_then_quits() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "z", false); @@ -133,7 +133,7 @@ fn dot_replaces_current_then_quits() { fn growing_replacement_does_not_loop() { // a → aa must not re-match the inserted text (offset-shift + the // search-forward-past-replacement rule). - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "aa", false); @@ -144,7 +144,7 @@ fn growing_replacement_does_not_loop() { #[test] fn empty_to_deletes() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a-b-c"); goto_start(&s); start_query_replace(&mut s, "-", "", false); @@ -155,7 +155,7 @@ fn empty_to_deletes() { #[test] fn regex_query_replace_via_binding() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a1 b2 c3"); goto_start(&s); start_query_replace(&mut s, "[0-9]", "#", true); @@ -167,7 +167,7 @@ fn regex_query_replace_via_binding() { #[test] fn m_percent_binding_starts_query_replace() { // The literal chord: M-% (Alt + Shift+5 → Char('%') with ALT). - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "cat cat"); goto_start(&s); s.dispatch_key( @@ -196,7 +196,7 @@ fn m_percent_binding_starts_query_replace() { fn c_m_percent_binding_starts_regexp_query_replace() { // Control-meta-shifted punctuation — the chord most likely to parse // differently across key paths (the C-c H lesson). - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "x1 x2"); goto_start(&s); s.dispatch_key( @@ -224,7 +224,7 @@ fn c_m_percent_binding_starts_regexp_query_replace() { #[test] fn nothing_matched_leaves_buffer_untouched() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "hello"); start_query_replace(&mut s, "zzz", "q", false); let (text, active) = probe(&s); @@ -236,7 +236,7 @@ fn nothing_matched_leaves_buffer_untouched() { fn query_replace_flips_dispatch_idle_so_gpu_round_trips() { // While the interactive phase runs, dispatch_idle must be false so a // semantic frontend round-trips y/n/etc. instead of self-inserting. - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); @@ -251,7 +251,7 @@ fn query_replace_flips_dispatch_idle_so_gpu_round_trips() { #[test] fn replace_fires_after_edit_hook() { // The Q#QR1 hook: an LSP/syntax observer must see replaced text. - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host .lua() .load( @@ -289,7 +289,7 @@ fn bang_fires_after_edit_hook_once_for_the_batch() { // Q#QR1: `!` applies many replacements under one keypress, but the // debounced didChange wants a single after-edit — the shadow // compares revision once across the whole handler. - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host .lua() .load( @@ -322,7 +322,7 @@ fn bang_fires_after_edit_hook_once_for_the_batch() { fn quit_via_ret_and_esc_keeps_replacements() { // Q#QR10: RET and Esc both quit (keeping replacements), not just q. for quit in [KeyCode::Enter, KeyCode::Esc] { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); @@ -337,7 +337,7 @@ fn quit_via_ret_and_esc_keeps_replacements() { #[test] fn ctrl_g_quits_keeping_replacements() { // Q#QR10: C-g exits and KEEPS replacements (unlike isearch's C-g). - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); @@ -353,7 +353,7 @@ fn ctrl_g_quits_keeping_replacements() { #[test] fn del_key_skips_like_n() { - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); @@ -373,7 +373,7 @@ fn focus_drift_mid_session_aborts_without_touching_either_buffer() { // (simulated by switch_buffer, which the pointer path also uses) // while query-replace is active. The next y must abort, not apply // the origin-buffer match to the now-active unrelated buffer. - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); type_str(&mut s, "foo foo"); goto_start(&s); start_query_replace(&mut s, "foo", "bar", false); @@ -416,3 +416,10 @@ fn focus_drift_mid_session_aborts_without_touching_either_buffer() { "origin buffer untouched by the aborted replace" ); } + +// 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; diff --git a/tests/resource_reconciliation_acceptance.rs b/tests/resource_reconciliation_acceptance.rs index c9beba4..6a00681 100644 --- a/tests/resource_reconciliation_acceptance.rs +++ b/tests/resource_reconciliation_acceptance.rs @@ -113,7 +113,7 @@ impl Fixture { } fn editor() -> EditorState { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); // No language server may spawn from these fixtures. The LSP rows // that DO want one configure it explicitly. exec(&state, "pmacs.lsp.config = {}"); @@ -2043,3 +2043,10 @@ fn a_subscriber_reconciliation_failure_is_reported_and_the_rest_still_reconcile( unreconciled — the raise has to come after the loop, not inside it" ); } + +// 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; diff --git a/tests/save_clobber_guard_acceptance.rs b/tests/save_clobber_guard_acceptance.rs index f1700c7..da177b7 100644 --- a/tests/save_clobber_guard_acceptance.rs +++ b/tests/save_clobber_guard_acceptance.rs @@ -57,7 +57,7 @@ fn save_refuses_to_clobber_a_file_changed_on_disk() { write(&f, "original\n"); let fs = f.display().to_string(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); open_and_dirty(&s, &fs); // Another writer lands between our read and our save. @@ -88,7 +88,7 @@ fn the_save_command_does_not_fire_after_save_when_it_refuses() { write(&f, "original\n"); let fs = f.display().to_string(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); open_and_dirty(&s, &fs); exec( &s, @@ -110,7 +110,7 @@ fn save_anyway_overwrites_deliberately_and_resyncs_meta() { write(&f, "original\n"); let fs = f.display().to_string(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); open_and_dirty(&s, &fs); write(&f, "theirs\n"); assert!(!eval::(&s, "return pmacs.editor.save()")); @@ -137,7 +137,7 @@ fn an_unchanged_file_saves_normally_and_repeatedly() { write(&f, "original\n"); let fs = f.display().to_string(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); open_and_dirty(&s, &fs); assert!(eval::(&s, "return pmacs.editor.save()")); assert_eq!(read(&f), "mine original\n"); @@ -157,7 +157,7 @@ fn a_deleted_file_is_recreated_not_refused() { write(&f, "original\n"); let fs = f.display().to_string(); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); open_and_dirty(&s, &fs); // Nothing on disk to clobber, so recreating it is not data loss. std::fs::remove_file(&f).unwrap(); @@ -174,7 +174,7 @@ fn a_new_file_buffer_refuses_once_someone_else_creates_the_file() { let dir = tempdir(); let missing = dir.join("draft.txt"); - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); // The argv `[new file]` shape: a path with nothing on disk, no meta. exec( &s, @@ -200,3 +200,10 @@ fn a_new_file_buffer_refuses_once_someone_else_creates_the_file() { assert_eq!(read(&missing), "my draft"); std::fs::remove_dir_all(&dir).ok(); } + +// 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; diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 2f5cfd9..2df0161 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -29,7 +29,8 @@ use pmacs::statusline::{ StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline, }; -#[cfg(feature = "crdt")] +// Ungated: `common::iso` (isolated bootstrap roots) is needed in every +// build, not only the CRDT one. mod common; fn exec(state: &EditorState, source: &str) { @@ -41,7 +42,7 @@ fn eval(state: &EditorState, source: &str) -> T { } fn editor() -> EditorState { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, "pmacs.lsp.config = {}"); state } @@ -689,7 +690,7 @@ fn a09_11_full_tui_frame_composes_unicode_clips_and_preserves_echo() { // map and polls the real tracker without a buffer edit. #[test] fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let fake = env!("CARGO_BIN_EXE_pmacs_fake_lsp"); let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("statusline.rs"); @@ -1048,3 +1049,11 @@ fn a16_26_real_daemon_v17_gate_v18_first_frame_and_late_join() { "late join sees established state" ); } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/terminal_config_acceptance.rs b/tests/terminal_config_acceptance.rs index ceeb8fe..9a52bc8 100644 --- a/tests/terminal_config_acceptance.rs +++ b/tests/terminal_config_acceptance.rs @@ -187,7 +187,7 @@ fn escape_was_armed(state: &mut EditorState, buffer: pmacs::buffer::BufferId, pr /// Acceptance 1: a profile spec is strict, and rejects before anything spawns. #[test] fn acc1_profile_specs_are_strict_and_reject_before_spawning() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let before = state.core.borrow().registry.borrow().ids().len(); exec( @@ -218,7 +218,7 @@ fn acc1_profile_specs_are_strict_and_reject_before_spawning() { /// Acceptance 2: an unknown profile names the known ones and creates nothing. #[test] fn acc2_unknown_profile_lists_known_names_and_creates_nothing() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); exec( &state, @@ -262,7 +262,7 @@ fn acc2_unknown_profile_lists_known_names_and_creates_nothing() { /// unknown-profile path, replacing the exact error being asked for. #[test] fn acc2_malformed_profile_keys_do_not_mask_the_unknown_profile_error() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); exec( &state, @@ -297,7 +297,7 @@ fn acc2_malformed_profile_keys_do_not_mask_the_unknown_profile_error() { /// `env` MERGES rather than replacing. #[test] fn acc3_field_resolution_order_and_env_merge() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r#" @@ -323,7 +323,7 @@ fn acc3_field_resolution_order_and_env_merge() { /// Acceptance 3 (explicit command wins) and 4 (`""` means no profile). #[test] fn acc3_acc4_explicit_command_wins_and_empty_default_means_no_profile() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); exec( &state, @@ -373,7 +373,7 @@ pmacs.terminal.profiles.fill = { /// fallback) deleted outright. #[test] fn acc5_scrollback_setting_reaches_retained_history() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, FILL_PROFILE); // Arm 1: `0` is legal, and means the early rows are GONE. @@ -423,7 +423,7 @@ fn acc5_scrollback_setting_reaches_retained_history() { /// values, and `0` is inside it rather than a disabled sentinel. #[test] fn acc5_scrollback_bounds() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#); assert_eq!( state @@ -458,7 +458,7 @@ fn acc5_scrollback_bounds() { /// THAT chord to the child, and an ordinary `C-c` still reaches the child. #[test] fn acc6_acc9_configured_escape_chord_and_literal_repeat() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); let buffer = open_cat_terminal(&state, r#"profile = "echo""#); assert!(tick_until(&mut state, "READY", buffer)); @@ -499,7 +499,7 @@ fn acc6_acc9_configured_escape_chord_and_literal_repeat() { /// count that does not grow, and a cache that dies with its terminal. #[test] fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); let a = open_cat_terminal(&state, r#"profile = "echo""#); exec(&state, "TERM_A = TERM_BUF"); @@ -633,7 +633,7 @@ fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() { /// the status line, and reports once per terminal per effective bad value. #[test] fn acc10_acc10a_invalid_escape_falls_back_and_reports_once() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, CAT_PROFILE); let buffer = open_cat_terminal(&state, r#"profile = "echo""#); assert!(tick_until(&mut state, "READY", buffer)); @@ -699,7 +699,7 @@ fn acc10_acc10a_invalid_escape_falls_back_and_reports_once() { /// proves the second half). #[test] fn acc11_terminal_opening_binding_is_bound_and_shadowed_nothing() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let command: Option = state .lua_host .lua() @@ -717,7 +717,7 @@ fn acc11_terminal_opening_binding_is_bound_and_shadowed_nothing() { /// defaults reproduce the pre-arc behavior. #[test] fn acc12_defaults_reproduce_prior_behavior() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let lua = state.lua_host.lua(); assert_eq!( lua.load(r#"return pmacs.config.get("terminal.default-profile")"#) @@ -744,3 +744,10 @@ fn acc12_defaults_reproduce_prior_behavior() { "no profiles are registered by default" ); } + +// 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; diff --git a/tests/terminal_copy_mode_acceptance.rs b/tests/terminal_copy_mode_acceptance.rs index aeba1ce..ecb6e69 100644 --- a/tests/terminal_copy_mode_acceptance.rs +++ b/tests/terminal_copy_mode_acceptance.rs @@ -223,7 +223,7 @@ fn buffer_count(state: &EditorState) -> usize { /// `copy_selection_bytes` itself. #[test] fn acc13_snapshot_is_the_whole_retained_range_through_the_shared_serializer() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); @@ -250,7 +250,7 @@ fn acc13_snapshot_is_the_whole_retained_range_through_the_shared_serializer() { /// buffer-shaped consumer work and what removes the transport arm. #[test] fn acc14_the_snapshot_is_an_ordinary_non_terminal_buffer() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -272,7 +272,7 @@ fn acc14_the_snapshot_is_an_ordinary_non_terminal_buffer() { /// with no change to `src/search.rs` (B1). #[test] fn acc15_isearch_finds_content_only_in_scrollback() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); @@ -319,7 +319,7 @@ fn acc15_isearch_finds_content_only_in_scrollback() { /// agreement; what stops the mutation is this. #[test] fn acc16_dispatch_idle_is_false_while_the_snapshot_is_focused() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -344,7 +344,7 @@ fn acc16_dispatch_idle_is_false_while_the_snapshot_is_focused() { /// protection does not depend on which key or command was used. #[test] fn acc16b_the_snapshot_is_immutable_at_the_rope_not_merely_intercepted() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -390,7 +390,7 @@ fn acc16b_the_snapshot_is_immutable_at_the_rope_not_merely_intercepted() { /// leaves the buffer emptiable. Only rope-level `read_only` closes both. #[test] fn acc16c_undo_cannot_empty_the_snapshot_by_chord_or_by_command() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -523,7 +523,7 @@ fn grid_row(cells: &[pmacs::cell::Cell], row: u32, cols: u32) -> String { /// any other owner that adopts the primitive later. #[test] fn acc16d_a_generated_write_notifies_the_window_that_displays_it() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" @@ -567,7 +567,7 @@ fn acc16d_a_generated_write_notifies_the_window_that_displays_it() { #[cfg(feature = "crdt")] #[test] fn acc16e_a_refresh_queues_the_owners_write_for_replica_mirrors() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -606,7 +606,7 @@ fn acc16e_a_refresh_queues_the_owners_write_for_replica_mirrors() { /// both directions. #[test] fn acc18_reinvoke_refreshes_in_place_and_lifecycle_runs_both_ways() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); @@ -681,7 +681,7 @@ fn acc18_reinvoke_refreshes_in_place_and_lifecycle_runs_both_ways() { /// `q` returns to the source terminal. #[test] fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); let terminal_name = active_buffer_name(&state); @@ -749,7 +749,7 @@ fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() { /// tail. #[test] fn acc20_live_terminal_keys_are_unchanged_while_a_snapshot_exists() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); let key = focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -806,7 +806,7 @@ fn acc20_live_terminal_keys_are_unchanged_while_a_snapshot_exists() { /// (or nothing) while the keys behaved differently. #[test] fn acc21_describe_key_reports_the_truth_for_the_snapshot_bindings() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); @@ -847,7 +847,7 @@ fn acc21_describe_key_reports_the_truth_for_the_snapshot_bindings() { /// (dired's F7 rule); a taken name gets a `<2>` variant instead. #[test] fn acc18a_a_foreign_same_named_buffer_is_never_adopted_or_clobbered() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let terminal = open_fill_terminal(&mut state); focus_terminal(&state, terminal); @@ -898,7 +898,7 @@ fn acc18a_a_foreign_same_named_buffer_is_never_adopted_or_clobbered() { /// terminal, and killing either one removes the shared snapshot. #[test] fn acc18b_two_same_named_terminals_get_two_independent_snapshots() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); exec(&state, FILL_PROFILE); let before = terminal_buffers(&state); @@ -985,7 +985,7 @@ fn acc18b_two_same_named_terminals_get_two_independent_snapshots() { /// snapshot of nothing. #[test] fn copy_mode_refuses_a_non_terminal_buffer() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); let err = eval_err(&state, "return pmacs.terminal.copy_mode()"); assert!( err.contains("not a terminal"), @@ -1010,7 +1010,7 @@ fn copy_mode_refuses_a_non_terminal_buffer() { /// state. Falsify by deleting the `win.cursor > len` clamp. #[test] fn acc16f_a_shrinking_generated_write_clamps_the_window_cursor() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" @@ -1066,7 +1066,7 @@ fn acc16f_a_shrinking_generated_write_clamps_the_window_cursor() { /// the measurement. #[test] fn acc16g_a_line_collapsing_generated_write_clamps_view_top() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" @@ -1129,7 +1129,7 @@ fn acc16g_a_line_collapsing_generated_write_clamps_view_top() { /// `notify_buffer_edit`; the first copy reaches the stale-anchor panic. #[test] fn acc16h_a_shrinking_generated_write_clamps_or_clears_the_selection() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); exec( &state, r" @@ -1224,7 +1224,7 @@ fn acc16h_a_shrinking_generated_write_clamps_or_clears_the_selection() { /// `acc16h` stays green. #[test] fn acc16i_a_shrinking_view_rebuild_clamps_or_clears_the_selection() { - let state = EditorState::new(); + let state = EditorState::new_with_roots(&crate::iso::roots()); // 286 bytes, then 154: a real shrink through the help renderer. exec( &state, @@ -1300,3 +1300,10 @@ fn acc16i_a_shrinking_view_rebuild_clamps_or_clears_the_selection() { "the collapsed region is not retained as active-but-empty" ); } + +// 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; diff --git a/tests/theme_faces_acceptance.rs b/tests/theme_faces_acceptance.rs index c81ace7..c9e5fb2 100644 --- a/tests/theme_faces_acceptance.rs +++ b/tests/theme_faces_acceptance.rs @@ -20,7 +20,8 @@ use pmacs::protocol::{ByteRange, FrontendId, InstanceMessage, ThemeFace}; use pmacs::semantic_render::SemanticRenderState; use std::time::{Duration, Instant}; -#[cfg(feature = "crdt")] +// Ungated: `common::iso` (isolated bootstrap roots) is needed in every +// build, not only the CRDT one. mod common; // --------------------------------------------------------------------------- @@ -78,7 +79,7 @@ fn eval(s: &EditorState, src: &str) -> T { /// Fresh editor with LSP spawning disabled. fn editor() -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); exec(&s, "pmacs.lsp.config = {}"); s } @@ -184,7 +185,7 @@ fn current_tree_language(state: &EditorState) -> Option { /// Open `path` and pump until its parse settles (highlights attached). fn open_and_wait_for_parse(path: std::path::PathBuf) -> EditorState { - let mut state = EditorState::open(path).expect("open file"); + let mut state = EditorState::open_with_roots(path, &crate::iso::roots()).expect("open file"); exec(&state, "pmacs.lsp.config = {}"); pump_async(&mut state, |s| current_tree_language(s).is_some()); state @@ -1511,3 +1512,11 @@ fn minibuffer_and_candidate_faces_apply_through_m_x() { panic!("no candidate suffix rendered: {text:?}"); } } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/typed_edit_chain_acceptance.rs b/tests/typed_edit_chain_acceptance.rs index 8ff9c13..2a65cb7 100644 --- a/tests/typed_edit_chain_acceptance.rs +++ b/tests/typed_edit_chain_acceptance.rs @@ -78,7 +78,7 @@ fn status(s: &EditorState) -> String { /// Fresh scratch-buffer editor, cursor at 0. Scratch pairing uses the /// `default` set, so `(` pairs — which is what 46c reads. fn editor_with(body: &str) -> EditorState { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); if !body.is_empty() { exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})")); } @@ -656,7 +656,7 @@ fn a_chain_consumers_edit_reaches_the_first_did_change() { let sink_disp = sink.display().to_string(); let fake = fake_lsp_path(); - let mut s = EditorState::new(); + let mut s = EditorState::new_with_roots(&crate::iso::roots()); s.lua_host.lua().remove_app_data::(); s.lua_host.lua().set_app_data(StateDir(dir.clone())); exec(&s, "pmacs.lsp.config = {}"); @@ -733,3 +733,10 @@ fn a_chain_consumers_edit_reaches_the_first_did_change() { before lsp.lua's synchronous flush (Q#AP7)" ); } + +// 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; diff --git a/tests/vterm_stage1_acceptance.rs b/tests/vterm_stage1_acceptance.rs index d1a4a63..489ca9a 100644 --- a/tests/vterm_stage1_acceptance.rs +++ b/tests/vterm_stage1_acceptance.rs @@ -60,7 +60,7 @@ fn terminal_cells_reject_child_control_characters() { #[test] fn spawn_failure_is_transactional() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let buffers_before = state.core.borrow().registry.borrow().len(); let processes_before = state.process_supervisor.borrow().ids().count(); state.process_supervisor.borrow_mut().shutdown(); @@ -77,7 +77,7 @@ fn spawn_failure_is_transactional() { #[test] fn strict_owned_spec_rejects_before_spawn_and_is_mutation_independent() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let buffers_before = state.core.borrow().registry.borrow().len(); let mut invalid = TerminalSpec::new("/bin/sh"); invalid.rows = 0; @@ -193,7 +193,7 @@ fn read_only_empty_crdt_bootstrap_is_immutable_against_remote_content() { #[test] fn final_output_precedes_exact_nonzero_annotation_and_buffer_is_retained() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let mut spec = TerminalSpec::new("/bin/sh"); spec.args = vec![ "-c".into(), @@ -306,7 +306,7 @@ fn normal_and_signal_annotations_use_exact_pid_and_outcome() { "exited abnormally with signal SIGTERM", ), ] { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let mut spec = TerminalSpec::new("/bin/sh"); spec.args = vec!["-c".into(), script.into()]; spec.rows = 6; @@ -334,7 +334,7 @@ fn normal_and_signal_annotations_use_exact_pid_and_outcome() { #[test] fn killing_terminal_buffer_prunes_session_and_reaps_owned_process() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let mut spec = TerminalSpec::new("/bin/sh"); spec.args = vec!["-c".into(), "sleep 30".into()]; let buffer_id = state.open_terminal(spec).expect("open terminal"); @@ -364,7 +364,7 @@ fn killing_terminal_buffer_prunes_session_and_reaps_owned_process() { #[test] fn editor_shutdown_kills_term_ignoring_terminal_child() { let pid = { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state .process_supervisor .borrow_mut() @@ -397,7 +397,7 @@ fn editor_shutdown_kills_term_ignoring_terminal_child() { #[test] fn terminal_tick_does_not_take_non_terminal_process_events() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh"); process.args = vec!["-c".into(), "printf ordinary".into()]; let ordinary_id = state @@ -424,3 +424,10 @@ fn terminal_tick_does_not_take_non_terminal_process_events() { "TerminalManager must not steal ordinary process output" ); } + +// 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; diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index 852ed1f..d5b86e3 100644 --- a/tests/vterm_stage2_acceptance.rs +++ b/tests/vterm_stage2_acceptance.rs @@ -60,7 +60,7 @@ fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { reason = "cross-surface Lua transaction scenario" )] fn lua_surface_is_strict_fresh_transactional_and_context_safe() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let command_lua = lua_string("/bin/sh"); let baseline_buffer_id = state.core.borrow().active_buffer_id(); @@ -364,7 +364,7 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { #[test] #[allow(clippy::too_many_lines, reason = "shared view and controller scenario")] fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let mut spec = TerminalSpec::new("/bin/sh"); spec.args = vec![ "-c".into(), @@ -508,7 +508,7 @@ fn terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt() { ready_path.to_str().expect("UTF-8 ready path"), input_path.to_str().expect("UTF-8 input path") ); - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); state .lua_host .lua() @@ -986,3 +986,11 @@ fn longest_rendered_prefix_separates_absent_child_text_from_a_split_render() { needle.len() ); } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index e4eb3c1..c415abf 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -156,7 +156,7 @@ fn one_frame(messages: &[InstanceMessage]) -> TerminalFrame { reason = "one producer-baseline lifecycle scenario" )] fn a30_first_frame_is_authoritative_then_only_real_changes_emit() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let frontend_id = FrontendId(31); let terminal_buffer = open_terminal( &mut state, @@ -314,7 +314,7 @@ fn a30_first_frame_is_authoritative_then_only_real_changes_emit() { reason = "one shared-session two-frontend scenario" )] fn a31_two_semantic_frontends_share_one_session_with_independent_views() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let first_id = FrontendId(41); let second_id = FrontendId(42); let terminal_buffer = open_terminal( @@ -459,7 +459,7 @@ fn a31_two_semantic_frontends_share_one_session_with_independent_views() { reason = "one case per rejected identity or bound" )] fn a32_forged_stale_and_out_of_bounds_terminal_events_change_nothing() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let owner = FrontendId(51); let attacker = FrontendId(52); let terminal_buffer = open_terminal(&mut state, "sleep 30", 6, 20); @@ -978,7 +978,7 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { /// gesture still claims; only motion does not. #[test] fn hover_does_not_steal_terminal_control_from_the_active_frontend() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let owner = FrontendId(71); let bystander = FrontendId(72); let terminal_buffer = open_terminal(&mut state, "sleep 30", 6, 20); @@ -1075,7 +1075,7 @@ fn hover_does_not_steal_terminal_control_from_the_active_frontend() { /// the empty identity buffer. #[test] fn a28_a30_a_v18_semantic_peer_has_no_terminal_surface() { - let mut state = EditorState::new(); + let mut state = EditorState::new_with_roots(&crate::iso::roots()); let frontend_id = FrontendId(61); let terminal_buffer = open_terminal(&mut state, "sleep 30", 4, 20); tick_until(&mut state, Duration::from_secs(5), |state| { @@ -1341,3 +1341,11 @@ fn gpu_terminal_input_reaches_the_child_and_returns_in_a_frame() { report() ); } + +// 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. Re-exported rather than re-declared +// with `#[path]` — this file already pulls in `common`, and loading one +// source file as two modules is `clippy::duplicate_mod`. +use common::iso; diff --git a/tests/worker_shutdown_acceptance.rs b/tests/worker_shutdown_acceptance.rs index 3893a20..a9cf831 100644 --- a/tests/worker_shutdown_acceptance.rs +++ b/tests/worker_shutdown_acceptance.rs @@ -30,7 +30,7 @@ fn live_threads() -> usize { fn editor_state_drop_releases_workers_and_shutdown_is_idempotent() { let baseline = live_threads(); for _ in 0..3 { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); drop(s); } // Signal-only shutdown: parked workers exit within their 100ms @@ -45,7 +45,7 @@ fn editor_state_drop_releases_workers_and_shutdown_is_idempotent() { // Idempotence: explicit shutdown twice, then drop runs it a // third time --- none may hang or panic. - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.async_runtime.shutdown_workers(); s.async_runtime.shutdown_workers(); drop(s); @@ -55,8 +55,15 @@ fn editor_state_drop_releases_workers_and_shutdown_is_idempotent() { #[cfg(not(target_os = "linux"))] #[test] fn explicit_shutdown_is_idempotent() { - let s = EditorState::new(); + let s = EditorState::new_with_roots(&crate::iso::roots()); s.async_runtime.shutdown_workers(); s.async_runtime.shutdown_workers(); // second call must not hang or panic drop(s); // drop runs shutdown a third time } + +// 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;