Skip to main content

lattice_config/
modeline_zone.rs

1//! [`ModelineZone`] — the value type for the `ui.modeline.{left,
2//! center,right}` typed options (slice ML.5).
3//!
4//! The configurable modeline (`docs/dev/architecture/modeline.md` §11)
5//! lets the user assign element ids to zones, in order, Helix-style:
6//!
7//! ```toml
8//! [ui.modeline]
9//! left  = ["core.mode", "core.path"]
10//! right = ["lsp", "core.position", "core.lang"]
11//! ```
12//!
13//! Each zone option holds a [`ModelineZone`]: either [`ModelineZone::Auto`]
14//! (the default — fall back to the producer-registered descriptor
15//! placement, so a newly-registered mode element auto-appears without
16//! the user editing config) or [`ModelineZone::Ids`] (an explicit,
17//! ordered list; an empty list is an explicitly-cleared zone).
18//!
19//! ## Why a typed list and not a `String`
20//!
21//! This is the **first list-valued option** in the tree. It is a real
22//! [`OptionType`] (round-trips `:set`/`:customize`/TOML) rather than a
23//! delimited `String`, so the TOML surface keeps the Helix-shaped array
24//! the design specifies and any future list-shaped option reuses the
25//! same loader array-support path ([`OptionType::accepts_list`]). See
26//! the slice plan (ML.5) for the rejected `String`-per-zone alternative.
27//!
28//! ## Delimiters
29//!
30//! `format` joins ids with `,` (comma) — chosen so `:set
31//! ui.modeline.left=core.mode,core.path` works through the cmdline
32//! `:set` tokenizer, which splits args on whitespace. `parse` is
33//! lenient: it splits on commas **and** whitespace, so a TOML array
34//! joined either way round-trips. Element ids are namespaced with dots
35//! (`core.mode`, `<plugin>.<name>`) and never contain commas or spaces,
36//! so neither delimiter is ambiguous.
37
38use std::sync::Arc;
39
40use crate::option_type::OptionType;
41
42/// Reserved keyword: a zone value of exactly `auto` (case-insensitive)
43/// means [`ModelineZone::Auto`]. No built-in or mode element id is
44/// `auto`, so this never shadows a real id.
45const AUTO_KEYWORD: &str = "auto";
46
47/// A modeline zone's configured layout: descriptor-driven ([`Auto`]) or
48/// an explicit ordered element-id list ([`Ids`]).
49///
50/// [`Auto`]: ModelineZone::Auto
51/// [`Ids`]: ModelineZone::Ids
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub enum ModelineZone {
54    /// Use the producer-registered descriptor placement for this zone
55    /// (the built-in default). The default variant: with no config a
56    /// newly-registered mode element appears in its descriptor's zone
57    /// automatically (preserves extensibility, paramount #2).
58    #[default]
59    Auto,
60    /// An explicit, ordered list of element ids. Unknown / unregistered
61    /// ids are skipped + logged by the host layout resolver
62    /// (`lattice_host::modeline`); an empty list is an explicitly-blank
63    /// zone (distinct from `Auto`).
64    Ids(Vec<Arc<str>>),
65}
66
67impl ModelineZone {
68    /// The configured ids, or `None` for [`Self::Auto`].
69    pub fn ids(&self) -> std::option::Option<&[Arc<str>]> {
70        match self {
71            ModelineZone::Auto => None,
72            ModelineZone::Ids(v) => Some(v),
73        }
74    }
75
76    /// Whether this is the descriptor-driven default.
77    pub fn is_auto(&self) -> bool {
78        matches!(self, ModelineZone::Auto)
79    }
80}
81
82impl OptionType for ModelineZone {
83    fn parse(s: &str) -> Result<Self, String> {
84        let trimmed = s.trim();
85        // A bare `auto` (case-insensitive) is the descriptor-driven
86        // default; everything else is an explicit list (possibly empty).
87        if trimmed.eq_ignore_ascii_case(AUTO_KEYWORD) {
88            return Ok(ModelineZone::Auto);
89        }
90        let ids: Vec<Arc<str>> = trimmed
91            .split([',', ' ', '\t'])
92            .map(str::trim)
93            .filter(|t| !t.is_empty())
94            .map(Arc::from)
95            .collect();
96        Ok(ModelineZone::Ids(ids))
97    }
98
99    fn format(&self) -> String {
100        match self {
101            ModelineZone::Auto => AUTO_KEYWORD.to_string(),
102            ModelineZone::Ids(ids) => ids.iter().map(|s| s.as_ref()).collect::<Vec<_>>().join(","),
103        }
104    }
105
106    fn type_label() -> &'static str {
107        "modeline-zone"
108    }
109
110    /// The id space is open-ended (built-ins + any mode/plugin element),
111    /// so there is no fixed completion set — `auto` is surfaced as the
112    /// one keyword form.
113    fn enumerate() -> std::option::Option<Vec<&'static str>> {
114        Some(vec![AUTO_KEYWORD])
115    }
116
117    /// ML.5: this is the list-shaped option the loader joins TOML arrays
118    /// into. See [`OptionType::accepts_list`] + `loader::apply_array`.
119    fn accepts_list() -> bool {
120        true
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    #![allow(clippy::unwrap_used, clippy::panic)]
127    use super::*;
128
129    fn ids(v: &[&str]) -> ModelineZone {
130        ModelineZone::Ids(v.iter().map(|s| Arc::from(*s)).collect())
131    }
132
133    #[test]
134    fn parse_auto_keyword_case_insensitive() {
135        assert_eq!(ModelineZone::parse("auto").unwrap(), ModelineZone::Auto);
136        assert_eq!(ModelineZone::parse("  AUTO  ").unwrap(), ModelineZone::Auto);
137    }
138
139    #[test]
140    fn parse_comma_list() {
141        assert_eq!(
142            ModelineZone::parse("core.mode,core.path").unwrap(),
143            ids(&["core.mode", "core.path"])
144        );
145    }
146
147    #[test]
148    fn parse_whitespace_list_is_lenient() {
149        // A TOML array joined with spaces (or mixed) round-trips too.
150        assert_eq!(
151            ModelineZone::parse("core.mode core.path").unwrap(),
152            ids(&["core.mode", "core.path"])
153        );
154        assert_eq!(
155            ModelineZone::parse(" core.mode , core.path ").unwrap(),
156            ids(&["core.mode", "core.path"])
157        );
158    }
159
160    #[test]
161    fn parse_empty_is_explicit_empty_not_auto() {
162        // Distinct from Auto: an explicitly-blank zone renders nothing.
163        assert_eq!(ModelineZone::parse("").unwrap(), ModelineZone::Ids(vec![]));
164        assert_eq!(
165            ModelineZone::parse("   ").unwrap(),
166            ModelineZone::Ids(vec![])
167        );
168    }
169
170    #[test]
171    fn format_round_trips() {
172        for z in [
173            ModelineZone::Auto,
174            ModelineZone::Ids(vec![]),
175            ids(&["core.mode", "core.path"]),
176            ids(&["lsp", "core.position", "core.lang"]),
177        ] {
178            assert_eq!(ModelineZone::parse(&z.format()).unwrap(), z);
179        }
180    }
181
182    #[test]
183    fn format_auto_is_keyword_ids_are_comma_joined() {
184        assert_eq!(ModelineZone::Auto.format(), "auto");
185        assert_eq!(
186            ids(&["core.mode", "core.path"]).format(),
187            "core.mode,core.path"
188        );
189        assert_eq!(ModelineZone::Ids(vec![]).format(), "");
190    }
191
192    #[test]
193    fn accepts_list_marks_the_loader_array_path() {
194        assert!(ModelineZone::accepts_list());
195        // Scalar types stay false (default).
196        assert!(!bool::accepts_list());
197        assert!(!String::accepts_list());
198    }
199
200    #[test]
201    fn ids_and_is_auto_accessors() {
202        assert!(ModelineZone::Auto.is_auto());
203        assert!(ModelineZone::Auto.ids().is_none());
204        let z = ids(&["a", "b"]);
205        assert!(!z.is_auto());
206        assert_eq!(z.ids().unwrap().len(), 2);
207    }
208}