Skip to main content

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}