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}