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>;