Skip to main content

lattice_core/
project.rs

1//! Which project a path belongs to (slice PR.1).
2//!
3//! Design: [`project-resolution.md`](../../../docs/dev/architecture/project-resolution.md).
4//!
5//! A project is a **pure function of a path** with a cache in front of
6//! it. There is no "current project" here — no mutable session state to
7//! persist, invalidate, or fall out of sync. Multiple projects therefore
8//! co-exist by construction: buffers from three checkouts each answer
9//! with their own, and an action in one cannot re-root another because
10//! there is no shared cell for it to write.
11//!
12//! ## Why `for_path` is total
13//!
14//! [`ProjectResolver::for_path`] returns `Project`, never
15//! `Option<Project>`. That signature *is* the design. `lattice-magit`'s
16//! `workdir.rs` records what the alternative produced: eleven
17//! hand-written copies of the same discovery, three of them passing a
18//! file where a directory was required and silently defaulting — one of
19//! which meant gutter diff signs had never worked, for any file, since
20//! they landed. Every caller writing its own `.unwrap_or_else(cwd)` is
21//! every caller getting a chance to write it wrong. A consumer that
22//! cannot express "no project" cannot get "no project" wrong.
23//!
24//! ## Why no `gix`
25//!
26//! `.git` is the first marker, and a marker walk is sufficient: a `.git`
27//! entry marks a worktree root whether it is a directory (ordinary
28//! clone) or a file (submodule, `git worktree add`). The walk and
29//! `gix::discover` therefore agree in every case a user will meet; they
30//! diverge only under `GIT_WORK_TREE` / `GIT_DIR`, `core.worktree`, and
31//! ceiling directories.
32//!
33//! That divergence is accepted deliberately. Only three crates depend on
34//! `lattice-vcs` today, and every crate depends on this one — routing
35//! resolution through `gix` would pull a heavy compile-time and
36//! binary-size dependency behind `lattice-core` and therefore into the
37//! whole workspace, to walk up a directory tree. `magit` is the one
38//! consumer for which the distinction is load-bearing, and it keeps its
39//! own `gix` discovery because it needs the `Repository` object anyway.
40//!
41//! [`ProjectResolver`] is a trait, so this is reversible: a
42//! `gix`-backed impl can be registered later without any consumer
43//! changing.
44
45use std::collections::HashMap;
46use std::path::{Path, PathBuf};
47use std::sync::{Arc, Mutex};
48
49/// The markers a project root is recognised by, in priority order
50/// within a single directory.
51///
52/// `.git` first. `.lattice` is ours (it already marked a workspace root
53/// for the persistent-config loader before this module existed). The
54/// rest are the ecosystem manifests whose presence means "the thing
55/// above this is not my project".
56pub const DEFAULT_ROOT_MARKERS: &[&str] = &[
57    ".git",
58    ".hg",
59    ".jj",
60    ".lattice",
61    "Cargo.toml",
62    "go.work",
63    "go.mod",
64    "package.json",
65    "pyproject.toml",
66    "flake.nix",
67];
68
69/// How a [`Project`]'s root was decided — for `:project-root`, for
70/// diagnostics, and for the rare consumer that legitimately cares
71/// (magit wants a VCS root specifically; a terminal does not).
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum ProjectKind {
74    /// A marker file or directory was found. Carries which one, so
75    /// "why is my project root here" is answerable.
76    Marker(String),
77    /// No marker anywhere up the tree; the working directory stands in.
78    Pwd,
79}
80
81/// The project a path belongs to.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Project {
84    /// The project's root directory: the directory holding the marker for
85    /// [`ProjectKind::Marker`], or the working directory for
86    /// [`ProjectKind::Pwd`].
87    pub root: PathBuf,
88    /// How `root` was decided.
89    pub kind: ProjectKind,
90}
91
92impl Project {
93    /// Whether this root was found rather than fallen back to. A
94    /// consumer that wants to say "not in a project" in its UI asks
95    /// this — as opposed to being handed an `Option` it must decide
96    /// what to do with.
97    pub fn is_rooted(&self) -> bool {
98        matches!(self.kind, ProjectKind::Marker(_))
99    }
100}
101
102/// Resolves paths to projects.
103///
104/// `&self` throughout and `Send + Sync`, so any thread may ask: this is
105/// consulted off the actor thread by subsystems that spawn processes,
106/// and the cache write takes a short mutex never held across an await.
107pub trait ProjectResolver: Send + Sync + std::fmt::Debug {
108    /// The project containing `path`, which may be a file or a
109    /// directory. Total — see the module docs.
110    fn for_path(&self, path: &Path) -> Project;
111
112    /// Re-point the working directory the [`ProjectKind::Pwd`] fallback
113    /// uses. Called on `:cd`. Drops the cache, because entries that
114    /// fell back to the old pwd are now wrong.
115    fn set_pwd(&self, pwd: PathBuf);
116
117    /// Replace the marker set. Called when `project.root-markers`
118    /// changes, and at boot once config has been applied.
119    ///
120    /// The markers cannot be fixed at construction because of a
121    /// genuine ordering knot: the persistent-config loader finds
122    /// `.lattice/config.toml` *by resolving a project root*, so the
123    /// resolver has to exist before the config that configures it has
124    /// been read. It is built with the defaults and re-pointed here —
125    /// which is also why this drops the cache.
126    fn set_markers(&self, markers: Vec<String>);
127
128    /// Drop every cached answer — `:project-refresh`, after a `git init`
129    /// mid-session. The cache is an optimisation and never a source of
130    /// truth, so this changes latency and nothing else.
131    fn invalidate(&self);
132}
133
134/// Per the `ServiceRegistry` Arc/TypeId convention: register and look up
135/// under this exact alias.
136pub type ProjectResolverHandle = Arc<dyn ProjectResolver>;
137
138/// The project root containing the process's working directory, over
139/// the default markers.
140///
141/// The **boot-time** answer, for the persistent-config loader: it runs
142/// before any buffer exists (so there is nothing to resolve from) and
143/// before config has been read (so `project.root-markers` is not
144/// available yet — that config is what this call is on the way to
145/// loading). Everything after boot goes through the registered
146/// [`ProjectResolverHandle`] instead, which is buffer-aware and honours
147/// both `:cd` and the configured markers.
148///
149/// PR.2: this replaces `Editor::workspace_root_from_cwd`, which
150/// hand-walked for `.git` / `.lattice` and which each renderer called
151/// independently. Living here rather than on the host is the point —
152/// a renderer should not own a rule about where projects begin.
153///
154/// `None` only when the working directory itself is unreadable.
155pub fn root_from_cwd() -> Option<PathBuf> {
156    let cwd = std::env::current_dir().ok()?;
157    Some(
158        MarkerResolver::with_default_markers(cwd.clone())
159            .for_path(&cwd)
160            .root,
161    )
162}
163
164/// The built-in [`ProjectResolver`]: walk up for a marker, else pwd.
165///
166/// # Examples
167///
168/// ```
169/// use lattice_core::{MarkerResolver, ProjectKind, ProjectResolver};
170///
171/// # fn main() -> std::io::Result<()> {
172/// let root = std::env::temp_dir().join(format!("lattice-doc-project-{}", std::process::id()));
173/// std::fs::create_dir_all(root.join("src"))?;
174/// std::fs::write(root.join("Cargo.toml"), "")?;
175///
176/// let resolver = MarkerResolver::with_default_markers(std::env::temp_dir());
177/// // A file that does not exist yet still resolves, via its parent.
178/// let project = resolver.for_path(&root.join("src/main.rs"));
179/// assert_eq!(project.root, root);
180/// assert_eq!(project.kind, ProjectKind::Marker("Cargo.toml".into()));
181/// assert!(project.is_rooted());
182/// # std::fs::remove_dir_all(&root)?;
183/// # Ok(())
184/// # }
185/// ```
186pub struct MarkerResolver {
187    /// Guarded because `project.root-markers` re-points it; see
188    /// [`ProjectResolver::set_markers`].
189    markers: Mutex<Vec<String>>,
190    /// Guarded because `:cd` re-points it; see
191    /// [`ProjectResolver::set_pwd`].
192    pwd: Mutex<PathBuf>,
193    /// Keyed by **directory**, not file, so every buffer in a directory
194    /// shares one entry and the walk runs once per directory per
195    /// session.
196    cache: Mutex<HashMap<PathBuf, Project>>,
197}
198
199impl std::fmt::Debug for MarkerResolver {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        f.debug_struct("MarkerResolver")
202            .field(
203                "markers",
204                &self.markers.lock().map(|m| m.len()).unwrap_or_default(),
205            )
206            .field(
207                "cached",
208                &self.cache.lock().map(|c| c.len()).unwrap_or_default(),
209            )
210            .finish_non_exhaustive()
211    }
212}
213
214impl MarkerResolver {
215    /// Build a resolver over `markers`, falling back to `pwd`.
216    ///
217    /// The marker list arrives as a parameter rather than being read
218    /// from a typed option here because `lattice-config` depends on
219    /// *this* crate — core cannot name the option type. The host reads
220    /// `project.root-markers` and passes it in, which is also what
221    /// keeps this crate free of a config dependency for one
222    /// `Vec<String>`.
223    pub fn new(markers: Vec<String>, pwd: PathBuf) -> Self {
224        Self {
225            markers: Mutex::new(markers),
226            pwd: Mutex::new(pwd),
227            cache: Mutex::new(HashMap::new()),
228        }
229    }
230
231    /// The default marker set ([`DEFAULT_ROOT_MARKERS`]) over `pwd`.
232    pub fn with_default_markers(pwd: PathBuf) -> Self {
233        Self::new(
234            DEFAULT_ROOT_MARKERS
235                .iter()
236                .map(|m| (*m).to_string())
237                .collect(),
238            pwd,
239        )
240    }
241
242    /// The directory to start walking from: `path` itself when it is a
243    /// directory, else its parent.
244    ///
245    /// A path that does not exist yet (a buffer for a file not saved
246    /// yet) is not a directory, so it walks from its parent — which is
247    /// the wanted answer, not a special case.
248    ///
249    /// **Relative input is made absolute against pwd first**, and that
250    /// is load-bearing rather than tidiness. A relative path walks up to
251    /// the empty path, and `Path::new("").join(".git").exists()` is a
252    /// test against the *process's* working directory — so a relative
253    /// path would silently root wherever the process happened to be
254    /// started, reporting an empty root while looking like it worked.
255    /// That is the same silent-resolution-against-the-wrong-base bug
256    /// `magit/workdir.rs` exists to prevent; the totality test caught
257    /// this one.
258    fn start_dir(&self, path: &Path) -> PathBuf {
259        let absolute: PathBuf = if path.is_absolute() {
260            path.to_path_buf()
261        } else {
262            match self.pwd.lock() {
263                Ok(pwd) => pwd.join(path),
264                Err(_) => return PathBuf::from("/"),
265            }
266        };
267        if absolute.is_dir() {
268            absolute
269        } else {
270            absolute.parent().map(Path::to_path_buf).unwrap_or(absolute)
271        }
272    }
273
274    /// The first marker `dir` directly contains, if any.
275    ///
276    /// Refuses a relative `dir` outright. `start_dir` already
277    /// absolutises, so reaching here with one would mean the walk
278    /// produced it — and `Path::new("").join(".git")` tests the
279    /// process's working directory, which is never what was asked.
280    /// Belt and braces on the same bug, because the cost is one
281    /// comparison and the failure is silent.
282    fn marker_in(&self, dir: &Path) -> Option<String> {
283        if !dir.is_absolute() {
284            return None;
285        }
286        self.markers
287            .lock()
288            .ok()?
289            .iter()
290            .find(|m| dir.join(m.as_str()).exists())
291            .cloned()
292    }
293
294    fn fallback(&self) -> Project {
295        Project {
296            root: self
297                .pwd
298                .lock()
299                .map(|p| p.clone())
300                .unwrap_or_else(|_| PathBuf::from(".")),
301            kind: ProjectKind::Pwd,
302        }
303    }
304}
305
306impl ProjectResolver for MarkerResolver {
307    fn for_path(&self, path: &Path) -> Project {
308        let start = self.start_dir(path);
309
310        if let Ok(cache) = self.cache.lock()
311            && let Some(hit) = cache.get(&start)
312        {
313            return hit.clone();
314        }
315
316        // Walk up, remembering what we passed. Every directory between
317        // `start` and the hit was checked and had no marker, so they all
318        // resolve to the same root — caching them here means opening
319        // fifty files across a tree costs one walk, not fifty.
320        let mut passed: Vec<PathBuf> = Vec::new();
321        let mut cursor: Option<&Path> = Some(start.as_path());
322        let mut found: Option<Project> = None;
323
324        while let Some(dir) = cursor {
325            if let Some(marker) = self.marker_in(dir) {
326                found = Some(Project {
327                    root: dir.to_path_buf(),
328                    kind: ProjectKind::Marker(marker),
329                });
330                break;
331            }
332            passed.push(dir.to_path_buf());
333            cursor = dir.parent();
334        }
335
336        // The pwd fallback is deliberately NOT cached against the
337        // directories we passed: it is not a property of those
338        // directories, and `set_pwd` would have to know which entries
339        // to expire. It clears everything instead, which is only
340        // correct if a Pwd answer is never the thing being cleared
341        // selectively.
342        let Some(project) = found else {
343            return self.fallback();
344        };
345
346        if let Ok(mut cache) = self.cache.lock() {
347            for dir in passed {
348                cache.insert(dir, project.clone());
349            }
350            cache.insert(project.root.clone(), project.clone());
351        }
352        project
353    }
354
355    fn set_pwd(&self, pwd: PathBuf) {
356        if let Ok(mut p) = self.pwd.lock() {
357            *p = pwd;
358        }
359        self.invalidate();
360    }
361
362    fn set_markers(&self, markers: Vec<String>) {
363        if let Ok(mut m) = self.markers.lock() {
364            *m = markers;
365        }
366        self.invalidate();
367    }
368
369    fn invalidate(&self) {
370        if let Ok(mut cache) = self.cache.lock() {
371            cache.clear();
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    #![allow(clippy::unwrap_used)]
379    use super::*;
380    use std::sync::atomic::{AtomicU64, Ordering};
381
382    /// Per-test unique directory.
383    ///
384    /// The counter is not decoration: a timestamp alone collides under
385    /// parallel `cargo test`, because two tests can enter this within
386    /// the same nanosecond tick and then fight over the same tree.
387    fn tempdir() -> PathBuf {
388        static N: AtomicU64 = AtomicU64::new(0);
389        let id = std::time::SystemTime::now()
390            .duration_since(std::time::UNIX_EPOCH)
391            .map(|d| d.as_nanos())
392            .unwrap_or(0);
393        let n = N.fetch_add(1, Ordering::Relaxed);
394        let dir = std::env::temp_dir().join(format!("lattice-project-{id}-{n}"));
395        std::fs::create_dir_all(&dir).unwrap();
396        std::fs::canonicalize(&dir).unwrap()
397    }
398
399    fn cleanup(dir: &Path) {
400        let _ = std::fs::remove_dir_all(dir);
401    }
402
403    fn mkdirs(base: &Path, rel: &str) -> PathBuf {
404        let p = base.join(rel);
405        std::fs::create_dir_all(&p).unwrap();
406        p
407    }
408
409    fn touch(dir: &Path, name: &str) {
410        std::fs::write(dir.join(name), b"").unwrap();
411    }
412
413    fn resolver(pwd: &Path) -> MarkerResolver {
414        MarkerResolver::with_default_markers(pwd.to_path_buf())
415    }
416
417    #[test]
418    fn an_ordinary_repo_roots_at_the_worktree() {
419        let base = tempdir();
420        let repo = mkdirs(&base, "repo");
421        mkdirs(&repo, ".git");
422        let deep = mkdirs(&repo, "src/inner");
423
424        let r = resolver(&base);
425        let got = r.for_path(&deep.join("main.rs"));
426        assert_eq!(got.root, repo);
427        assert_eq!(got.kind, ProjectKind::Marker(".git".to_string()));
428        assert!(got.is_rooted());
429        cleanup(&base);
430    }
431
432    #[test]
433    fn a_git_file_roots_too() {
434        // Submodules and `git worktree add` write `.git` as a FILE, not
435        // a directory. Both mark a worktree root, which is why a marker
436        // walk is sufficient and `gix` is not needed here.
437        let base = tempdir();
438        let sub = mkdirs(&base, "outer/vendor/sub");
439        mkdirs(&base, "outer/.git");
440        touch(&sub, ".git");
441        let deep = mkdirs(&sub, "src");
442
443        let r = resolver(&base);
444        assert_eq!(r.for_path(&deep.join("lib.rs")).root, sub);
445        cleanup(&base);
446    }
447
448    #[test]
449    fn the_innermost_marker_wins() {
450        // A crate inside a Cargo workspace is its own project. This is
451        // deliberately the opposite of LSP's outermost-root rule: that
452        // asks where a language server should start, this asks where
453        // YOU are working.
454        let base = tempdir();
455        let ws = mkdirs(&base, "ws");
456        touch(&ws, "Cargo.toml");
457        mkdirs(&ws, ".git");
458        let member = mkdirs(&ws, "crates/thing");
459        touch(&member, "Cargo.toml");
460        let src = mkdirs(&member, "src");
461
462        let r = resolver(&base);
463        assert_eq!(r.for_path(&src.join("lib.rs")).root, member);
464        cleanup(&base);
465    }
466
467    #[test]
468    fn a_repo_nested_in_a_repo_roots_at_the_inner_one() {
469        let base = tempdir();
470        let outer = mkdirs(&base, "outer");
471        mkdirs(&outer, ".git");
472        let inner = mkdirs(&outer, "vendor/inner");
473        mkdirs(&inner, ".git");
474
475        let r = resolver(&base);
476        assert_eq!(r.for_path(&inner.join("a.rs")).root, inner);
477        assert_eq!(r.for_path(&outer.join("b.rs")).root, outer);
478        cleanup(&base);
479    }
480
481    #[test]
482    fn no_marker_anywhere_falls_back_to_pwd() {
483        let base = tempdir();
484        let lonely = mkdirs(&base, "nothing/here");
485        let pwd = mkdirs(&base, "elsewhere");
486
487        let r = resolver(&pwd);
488        let got = r.for_path(&lonely.join("scratch.rs"));
489        assert_eq!(got.root, pwd);
490        assert_eq!(got.kind, ProjectKind::Pwd);
491        assert!(!got.is_rooted());
492        cleanup(&base);
493    }
494
495    #[test]
496    fn a_directory_argument_resolves_from_itself() {
497        let base = tempdir();
498        let repo = mkdirs(&base, "repo");
499        mkdirs(&repo, ".git");
500        let sub = mkdirs(&repo, "sub");
501
502        let r = resolver(&base);
503        // Passing the directory must not walk from its PARENT — that is
504        // exactly the file-vs-directory confusion magit's workdir.rs
505        // was written to make unrepresentable.
506        assert_eq!(r.for_path(&sub).root, repo);
507        cleanup(&base);
508    }
509
510    #[test]
511    fn a_path_that_does_not_exist_yet_resolves_from_its_parent() {
512        // A buffer for a file that has never been saved.
513        let base = tempdir();
514        let repo = mkdirs(&base, "repo");
515        mkdirs(&repo, ".git");
516        let src = mkdirs(&repo, "src");
517
518        let r = resolver(&base);
519        assert_eq!(r.for_path(&src.join("brand-new.rs")).root, repo);
520        cleanup(&base);
521    }
522
523    #[test]
524    fn resolution_is_always_total() {
525        // The property the signature exists to guarantee: no input
526        // yields "no project", including paths that cannot exist.
527        let base = tempdir();
528        let r = resolver(&base);
529        for p in [
530            Path::new("/"),
531            Path::new("relative/thing.rs"),
532            Path::new(""),
533            &base.join("no/such/place/at/all.rs"),
534        ] {
535            let got = r.for_path(p);
536            assert!(
537                !got.root.as_os_str().is_empty(),
538                "{p:?} produced an empty root"
539            );
540        }
541        cleanup(&base);
542    }
543
544    #[test]
545    fn a_relative_path_resolves_against_pwd_not_the_process_cwd() {
546        // Regression, found by `resolution_is_always_total`. A relative
547        // path walks up to the EMPTY path, and
548        // `Path::new("").join(".git").exists()` tests the process's
549        // working directory — so `relative/thing.rs` rooted at "" for
550        // any test run from inside a git repo, i.e. always. It reported
551        // an empty root while looking like it had worked.
552        let base = tempdir();
553        let repo = mkdirs(&base, "repo");
554        mkdirs(&repo, ".git");
555        let src = mkdirs(&repo, "src");
556
557        // pwd is inside the repo; the relative path is relative to it.
558        let r = resolver(&src);
559        let got = r.for_path(Path::new("thing.rs"));
560        assert_eq!(got.root, repo, "relative paths resolve against pwd");
561
562        // And a relative path with nothing above it must still not
563        // reach the process cwd — it falls back to pwd.
564        let bare = tempdir();
565        let r2 = resolver(&bare);
566        let got2 = r2.for_path(Path::new("deep/er/thing.rs"));
567        assert_eq!(got2.root, bare);
568        assert_eq!(got2.kind, ProjectKind::Pwd);
569
570        cleanup(&base);
571        cleanup(&bare);
572    }
573
574    #[test]
575    fn the_walk_is_cached_for_every_directory_it_passed() {
576        let base = tempdir();
577        let repo = mkdirs(&base, "repo");
578        mkdirs(&repo, ".git");
579        let deep = mkdirs(&repo, "a/b/c");
580
581        let r = resolver(&base);
582        r.for_path(&deep.join("f.rs"));
583
584        // `a`, `a/b`, `a/b/c` and the root itself were all resolved by
585        // that one walk — fifty files across a tree cost one walk, not
586        // fifty.
587        let cache = r.cache.lock().unwrap();
588        for rel in ["a", "a/b", "a/b/c"] {
589            assert!(
590                cache.contains_key(&repo.join(rel)),
591                "{rel} should have been cached by the walk"
592            );
593        }
594        assert!(cache.contains_key(&repo));
595        cleanup(&base);
596    }
597
598    #[test]
599    fn a_cache_hit_answers_the_same_as_the_walk() {
600        let base = tempdir();
601        let repo = mkdirs(&base, "repo");
602        mkdirs(&repo, ".git");
603        let src = mkdirs(&repo, "src");
604
605        let r = resolver(&base);
606        let first = r.for_path(&src.join("a.rs"));
607        let second = r.for_path(&src.join("b.rs"));
608        assert_eq!(first, second);
609        cleanup(&base);
610    }
611
612    #[test]
613    fn invalidate_lets_a_new_marker_be_seen() {
614        // `git init` mid-session: the answer was cached before the
615        // marker existed.
616        let base = tempdir();
617        let dir = mkdirs(&base, "not-yet");
618        let pwd = mkdirs(&base, "pwd");
619
620        let r = resolver(&pwd);
621        assert_eq!(r.for_path(&dir.join("a.rs")).kind, ProjectKind::Pwd);
622
623        mkdirs(&dir, ".git");
624        r.invalidate();
625        assert_eq!(r.for_path(&dir.join("a.rs")).root, dir);
626        cleanup(&base);
627    }
628
629    #[test]
630    fn set_pwd_repoints_the_fallback_and_drops_stale_answers() {
631        let base = tempdir();
632        let lonely = mkdirs(&base, "lonely");
633        let before = mkdirs(&base, "before");
634        let after = mkdirs(&base, "after");
635
636        let r = resolver(&before);
637        assert_eq!(r.for_path(&lonely.join("a.rs")).root, before);
638
639        r.set_pwd(after.clone());
640        assert_eq!(
641            r.for_path(&lonely.join("a.rs")).root,
642            after,
643            "a `:cd` must re-point answers that had fallen back to pwd"
644        );
645        cleanup(&base);
646    }
647
648    #[test]
649    fn a_marker_rooted_answer_survives_a_cd() {
650        // `:cd` changes only the fallback. A path that HAS a project
651        // must not follow the working directory around.
652        let base = tempdir();
653        let repo = mkdirs(&base, "repo");
654        mkdirs(&repo, ".git");
655        let elsewhere = mkdirs(&base, "elsewhere");
656
657        let r = resolver(&base);
658        assert_eq!(r.for_path(&repo.join("a.rs")).root, repo);
659        r.set_pwd(elsewhere);
660        assert_eq!(r.for_path(&repo.join("a.rs")).root, repo);
661        cleanup(&base);
662    }
663
664    #[test]
665    fn set_markers_repoints_the_walk_and_drops_stale_answers() {
666        // The ordering knot this exists for: the persistent-config
667        // loader finds `.lattice/config.toml` BY resolving a project
668        // root, so the resolver is built with defaults before the
669        // config that configures it has been read, then re-pointed.
670        let base = tempdir();
671        let proj = mkdirs(&base, "proj");
672        touch(&proj, "WORKSPACE.bazel");
673        let deep = mkdirs(&proj, "src");
674
675        let r = resolver(&base);
676        // Not a default marker, so this falls back to pwd first.
677        assert_eq!(r.for_path(&deep.join("a.cc")).kind, ProjectKind::Pwd);
678
679        r.set_markers(vec!["WORKSPACE.bazel".to_string()]);
680        let got = r.for_path(&deep.join("a.cc"));
681        assert_eq!(got.root, proj, "the cached pwd answer must not survive");
682        assert_eq!(got.kind, ProjectKind::Marker("WORKSPACE.bazel".to_string()));
683
684        // And the reverse: dropping a marker un-roots what it rooted.
685        r.set_markers(vec![".git".to_string()]);
686        assert_eq!(r.for_path(&deep.join("a.cc")).kind, ProjectKind::Pwd);
687        cleanup(&base);
688    }
689
690    #[test]
691    fn a_custom_marker_set_is_honoured() {
692        // What `project.root-markers` buys: a new ecosystem needs a
693        // config line, not a release.
694        let base = tempdir();
695        let proj = mkdirs(&base, "proj");
696        touch(&proj, "WORKSPACE.bazel");
697        let deep = mkdirs(&proj, "src/x");
698
699        let r = MarkerResolver::new(vec!["WORKSPACE.bazel".to_string()], base.clone());
700        let got = r.for_path(&deep.join("a.cc"));
701        assert_eq!(got.root, proj);
702        assert_eq!(got.kind, ProjectKind::Marker("WORKSPACE.bazel".to_string()));
703        cleanup(&base);
704    }
705
706    #[test]
707    fn marker_priority_is_declaration_order_within_a_directory() {
708        // Both present in one directory: the earlier marker names the
709        // kind, so `:project-root` reports `.git` rather than whichever
710        // the filesystem happened to yield first.
711        let base = tempdir();
712        let proj = mkdirs(&base, "proj");
713        mkdirs(&proj, ".git");
714        touch(&proj, "Cargo.toml");
715
716        let r = resolver(&base);
717        assert_eq!(
718            r.for_path(&proj.join("a.rs")).kind,
719            ProjectKind::Marker(".git".to_string())
720        );
721        cleanup(&base);
722    }
723
724    #[test]
725    fn the_resolver_is_shareable_across_threads() {
726        // The trait bound that lets subsystems ask off the actor thread.
727        fn assert_shareable<T: Send + Sync + std::fmt::Debug>() {}
728        assert_shareable::<MarkerResolver>();
729
730        let base = tempdir();
731        let repo = mkdirs(&base, "repo");
732        mkdirs(&repo, ".git");
733        let handle: ProjectResolverHandle = Arc::new(resolver(&base));
734
735        let threads: Vec<_> = (0..8)
736            .map(|i| {
737                let h = handle.clone();
738                let p = repo.join(format!("f{i}.rs"));
739                std::thread::spawn(move || h.for_path(&p).root)
740            })
741            .collect();
742        for t in threads {
743            assert_eq!(t.join().unwrap(), repo);
744        }
745        cleanup(&base);
746    }
747}
748
749/// Where a composed line came from: the source document, its file, and the
750/// 0-based line within it.
751///
752/// `source` is the identity to ACT on and `path` the identity to show. They
753/// are not interchangeable: a multibuffer's sources are documents the view
754/// owns and no buffer store holds, so a caller that resolved only the path
755/// and then addressed the file by name would be editing a *different*
756/// document — the user's own buffer for that file, if any — and the view's
757/// `:w` could later overwrite one with the other.
758#[derive(Debug, Clone, PartialEq, Eq)]
759pub struct ExcerptSource {
760    /// The source document's id. Addressable by `Effect::ApplyEdit`.
761    pub source: crate::buffers::BufferId,
762    /// The source document's file path — the identity to *show*, never to
763    /// address the document by (see above).
764    pub path: std::path::PathBuf,
765    /// The line within the source document, 0-based.
766    pub line: u32,
767}
768
769/// Answers where a line of a composed (multibuffer) buffer came from.
770///
771/// Introduced in OA.23.
772///
773/// A multibuffer shows excerpts of other files, so a consumer holding composed
774/// coordinates cannot say which file it is looking at. The agenda is the case
775/// that needs this: rewriting a headline in place propagates through the
776/// excerpt, but writing a planning line BELOW it targets a line the view does
777/// not contain — and the view is a synthetic buffer with no path of its own.
778///
779/// An abstract handle for the same reason [`ProjectResolver`] is one: the
780/// plugin host must be able to answer the question without depending on
781/// `lattice-multibuffer`, which sits above it. Whoever owns multibuffers
782/// implements this and wires it in at boot; a host with none wired answers
783/// `None`, which is the honest degradation rather than a panic.
784pub trait ExcerptSourceResolver: Send + Sync + std::fmt::Debug {
785    /// Where composed `line` of `buffer` came from.
786    ///
787    /// `None` when `buffer` is not composed, when `line` is not source text (a
788    /// header or separator row belongs to the view, not to any file), or when
789    /// the source has no path. All three are ordinary answers: a caller asks
790    /// about the cursor's line, and a cursor can be anywhere.
791    fn excerpt_source(&self, buffer: crate::buffers::BufferId, line: u32) -> Option<ExcerptSource>;
792
793    /// One line of a source document, without its trailing newline (slice
794    /// OA.23b).
795    ///
796    /// The read that pairs with acting on [`ExcerptSource::source`]. A caller
797    /// deciding what to write at the line below a headline has to see what is
798    /// there, and that line is outside every excerpt — so neither the composed
799    /// text nor a file read answers it. A file read is wrong twice over: it
800    /// misses edits the view has made and not yet saved.
801    ///
802    /// `source` is an [`ExcerptSource::source`], not an arbitrary buffer:
803    /// `None` for anything no view owns.
804    fn source_line(&self, source: crate::buffers::BufferId, line: u32) -> Option<String>;
805}
806
807/// Shared handle to an [`ExcerptSourceResolver`].
808pub type ExcerptSourceResolverHandle = Arc<dyn ExcerptSourceResolver>;
809
810/// What arguments a provider view is currently showing (slice OA.27).
811///
812/// A scan view is opened with arguments the host routes verbatim to the
813/// provider and then KEEPS, so they are the whole of what the view displays:
814/// which command, which span, which day, which filters. Whoever owns the view
815/// owns them, and that is the host — a provider that tried to remember them
816/// instead has nowhere consistent to do it (a WASM provider is several
817/// `wasmtime::Store`s with separate memory, so "remember them in the guest"
818/// means one copy per seam, silently diverging).
819///
820/// Abstract for [`ExcerptSourceResolver`]'s reason: the plugin host must answer
821/// this without depending on `lattice-multibuffer`, which sits above it.
822/// Whoever owns provider views implements this and wires it at boot; a host
823/// with none wired answers `None`, which is the honest degradation.
824pub trait ViewArgsResolver: Send + Sync + std::fmt::Debug {
825    /// The arguments the view in `buffer` is showing.
826    ///
827    /// `None` when `buffer` is not a provider view, or is one the host holds no
828    /// state for. Both are ordinary answers: a caller asks about the buffer a
829    /// chord fired in, and a chord can fire anywhere.
830    fn view_args(&self, buffer: crate::buffers::BufferId) -> Option<Vec<String>>;
831}
832
833/// Shared handle to a [`ViewArgsResolver`].
834pub type ViewArgsResolverHandle = Arc<dyn ViewArgsResolver>;