lattice_config/root_markers.rs
1//! PR.2: the `project.root-markers` option type.
2//!
3//! Design: [`project-resolution.md`](../../../docs/dev/architecture/project-resolution.md)
4//! §4.
5//!
6//! The marker set `lattice_core::MarkerResolver` walks for. A list
7//! option in the shape [`crate::ModelineZone`] established: TOML uses a
8//! Helix-shaped array (`root-markers = [".git", "Cargo.toml"]`), `:set`
9//! uses the comma form.
10//!
11//! **Replaces rather than extends**, and the default *is* the full
12//! built-in list — so `:set project.root-markers?` shows exactly what a
13//! user is about to replace. That is vim's `:set` model for list
14//! options, and it is why the default is sourced from
15//! [`lattice_core::DEFAULT_ROOT_MARKERS`] rather than restated here:
16//! two copies of this list would drift, and the copy the resolver reads
17//! is the one that matters.
18
19use std::sync::Arc;
20
21use crate::option_type::OptionType;
22
23/// The ordered marker set a project root is recognised by.
24///
25/// Order is significant: the first marker present in a directory names
26/// the resulting [`lattice_core::ProjectKind::Marker`], so `.git` ahead
27/// of `Cargo.toml` means a repository that is also a crate reports
28/// `.git`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RootMarkers(pub Vec<Arc<str>>);
31
32impl Default for RootMarkers {
33 fn default() -> Self {
34 Self(
35 lattice_core::DEFAULT_ROOT_MARKERS
36 .iter()
37 .map(|m| Arc::from(*m))
38 .collect(),
39 )
40 }
41}
42
43impl RootMarkers {
44 /// The markers, in priority order.
45 pub fn markers(&self) -> &[Arc<str>] {
46 &self.0
47 }
48
49 /// Owned `String`s, the shape `MarkerResolver::new` takes.
50 pub fn to_vec(&self) -> Vec<String> {
51 self.0.iter().map(|m| m.to_string()).collect()
52 }
53}
54
55impl OptionType for RootMarkers {
56 fn parse(s: &str) -> Result<Self, String> {
57 let markers: Vec<Arc<str>> = s
58 .split([',', ' ', '\t'])
59 .map(str::trim)
60 .filter(|t| !t.is_empty())
61 .map(Arc::from)
62 .collect();
63 // An empty set is refused rather than accepted as "no markers".
64 // It would silently root every buffer at pwd — the editor would
65 // still work, which is exactly what makes it a bad failure: the
66 // user would see wrong roots everywhere and have no reason to
67 // suspect this option.
68 if markers.is_empty() {
69 return Err("at least one marker is required (e.g. `.git,Cargo.toml`); \
70 an empty set would root every buffer at the working directory"
71 .to_string());
72 }
73 Ok(RootMarkers(markers))
74 }
75
76 fn format(&self) -> String {
77 self.0
78 .iter()
79 .map(|m| m.as_ref())
80 .collect::<Vec<_>>()
81 .join(",")
82 }
83
84 fn type_label() -> &'static str {
85 "root-markers"
86 }
87
88 /// Open-ended: any filename can be a marker. The built-in set is
89 /// offered as the completion starting point, which is also the
90 /// value a user is most likely editing rather than replacing
91 /// wholesale.
92 fn enumerate() -> std::option::Option<Vec<&'static str>> {
93 Some(lattice_core::DEFAULT_ROOT_MARKERS.to_vec())
94 }
95
96 /// A TOML array joins into this — see `loader::apply_array`.
97 fn accepts_list() -> bool {
98 true
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 #![allow(clippy::unwrap_used)]
105 use super::*;
106
107 #[test]
108 fn the_default_is_cores_list_verbatim() {
109 // Sourced, not restated: a second copy would drift from the one
110 // the resolver actually walks.
111 assert_eq!(
112 RootMarkers::default().to_vec(),
113 lattice_core::DEFAULT_ROOT_MARKERS
114 .iter()
115 .map(|m| m.to_string())
116 .collect::<Vec<_>>()
117 );
118 }
119
120 #[test]
121 fn parses_the_comma_form() {
122 let m = RootMarkers::parse(".git,Cargo.toml").unwrap();
123 assert_eq!(m.to_vec(), vec![".git", "Cargo.toml"]);
124 }
125
126 #[test]
127 fn parses_whitespace_and_tolerates_padding() {
128 let m = RootMarkers::parse(" .git , WORKSPACE.bazel\t").unwrap();
129 assert_eq!(m.to_vec(), vec![".git", "WORKSPACE.bazel"]);
130 }
131
132 #[test]
133 fn an_empty_set_is_refused_with_a_reason() {
134 // Accepting it would root every buffer at pwd while the editor
135 // kept working — a silent wrong answer, not a visible failure.
136 for input in ["", " ", ",", " , ,\t"] {
137 let err = RootMarkers::parse(input).unwrap_err();
138 assert!(
139 err.contains("at least one marker"),
140 "input {input:?} gave {err:?}"
141 );
142 }
143 }
144
145 #[test]
146 fn parse_round_trips_with_format() {
147 // The `OptionType` contract: `T::parse(&v.format()) == Ok(v)`.
148 for v in [
149 RootMarkers::default(),
150 RootMarkers::parse(".git").unwrap(),
151 RootMarkers::parse("a,b,c").unwrap(),
152 ] {
153 assert_eq!(RootMarkers::parse(&v.format()).unwrap(), v);
154 }
155 }
156
157 #[test]
158 fn order_is_preserved_because_it_decides_the_reported_kind() {
159 let m = RootMarkers::parse("Cargo.toml,.git").unwrap();
160 assert_eq!(m.to_vec(), vec!["Cargo.toml", ".git"]);
161 }
162}