lattice_snippet/activation.rs
1//! SN.3b — config-driven snippet activation policy.
2//!
3//! `SnippetMode`'s [`ActivationPolicy`](lattice_mode::ActivationPolicy)
4//! is folded from two user-facing options the mode owns
5//! ([[feedback_mode_owns_its_surface]]):
6//!
7//! - **`snippet.activation`** (`global` | `supported-languages` |
8//! `off`, default `global`) — the gate shape. A closed three-value
9//! set, so it's a typed enum ([`SnippetActivationMode`]) with an
10//! [`OptionType`](lattice_config::OptionType) impl, giving
11//! `:set snippet.activation=<Tab>` completion the same way
12//! `foldmethod` does.
13//! - **`snippet.languages`** (default empty) — a comma-separated
14//! language-id allowlist, consulted only when
15//! `snippet.activation = supported-languages`. It's a plain
16//! `String` because the typed-option loader rejects TOML arrays
17//! for scalar options (`lattice_config::loader::apply_scalar`):
18//! `snippet.languages = "rust,python"` in TOML, or
19//! `:set snippet.languages=rust,python`.
20//!
21//! The host folds both into a shared
22//! [`SnippetActivationPolicyHandle`] at boot and re-folds on the
23//! `apply_option_cascade` arm for either key (`:set` live). The
24//! resolver reads `SnippetMode::activation_policy()` — which loads
25//! the shared cell — on each `MajorEntered`, so new buffers pick up
26//! the live policy. (Already-open buffers are not retroactively
27//! re-resolved in SN.3b; the policy applies on their next major
28//! entry.)
29
30use std::sync::Arc;
31
32use arc_swap::ArcSwap;
33use lattice_mode::{ActivationPolicy, ModeId};
34
35/// Shared, swappable snippet activation policy. `SnippetMode` reads
36/// it via `activation_policy()`; the host folds config into it at
37/// boot and on every `snippet.activation` / `snippet.languages`
38/// `:set`. Cloning the outer `Arc` shares the same cell — the
39/// host's `Editor` keeps one clone to `store()` into and the mode
40/// keeps another to `load()` from.
41pub type SnippetActivationPolicyHandle = Arc<ArcSwap<ActivationPolicy>>;
42
43/// `snippet.activation` — which buffers get snippets.
44///
45/// A closed three-value set (hence a typed enum rather than a
46/// free-form string): `Global` activates on every real document
47/// buffer; `SupportedLanguages` activates only for major modes in
48/// the `snippet.languages` allowlist; `Off` never auto-activates.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum SnippetActivationMode {
51 /// Snippets on every document buffer (the default). The
52 /// completion *source* still self-filters by language, so
53 /// `Global` means "each buffer sees its own language's
54 /// snippets", not "all snippets everywhere".
55 #[default]
56 Global,
57 /// Snippets only for languages listed in `snippet.languages`.
58 /// Resolves to `Majors([<lang>-mode …])`, which matches only
59 /// *registered* major modes.
60 SupportedLanguages,
61 /// Snippets disabled — never auto-activate. Explicit
62 /// `:snippet-mode` / `<C-x><C-s>` paths still work where wired.
63 Off,
64}
65
66impl SnippetActivationMode {
67 /// Canonical `:set` label.
68 pub fn label(self) -> &'static str {
69 match self {
70 Self::Global => "global",
71 Self::SupportedLanguages => "supported-languages",
72 Self::Off => "off",
73 }
74 }
75
76 /// Parse a `:set snippet.activation=<value>` label.
77 pub fn parse_label(s: &str) -> Result<Self, String> {
78 match s {
79 "global" => Ok(Self::Global),
80 "supported-languages" => Ok(Self::SupportedLanguages),
81 "off" => Ok(Self::Off),
82 other => Err(format!(
83 "expected `global`, `supported-languages`, or `off`, got `{other}`"
84 )),
85 }
86 }
87}
88
89impl lattice_config::OptionType for SnippetActivationMode {
90 fn parse(s: &str) -> Result<Self, String> {
91 Self::parse_label(s)
92 }
93
94 fn format(&self) -> String {
95 self.label().to_string()
96 }
97
98 fn type_label() -> &'static str {
99 "snippet-activation"
100 }
101
102 fn enumerate() -> Option<Vec<&'static str>> {
103 Some(vec!["global", "supported-languages", "off"])
104 }
105
106 /// TC.1: closed — `parse` accepts these forms and nothing else, so
107 /// the schema is an `enum` and `:customize` can offer a picker.
108 fn enumerate_is_exhaustive() -> bool {
109 true
110 }
111}
112
113lattice_config::options! {
114 group = lattice_config::Snippet;
115
116 /// Which buffers get snippets. Default `global` — snippets on
117 /// every document buffer, each buffer seeing only its own
118 /// language's snippets (the completion source self-filters by
119 /// language). `supported-languages` restricts activation to the
120 /// `snippet.languages` allowlist; `off` disables auto-activation
121 /// entirely.
122 #[name("snippet.activation")]
123 pub SnippetActivation: SnippetActivationMode = SnippetActivationMode::Global;
124
125 /// Comma-separated language ids for which snippets activate when
126 /// `snippet.activation = supported-languages`. Each id `L` maps
127 /// to the major mode `L-mode`, so only *registered* majors match
128 /// (today: rust / python / javascript / markdown). Ignored when
129 /// `snippet.activation` is `global` or `off`. A list (not a
130 /// scalar) in spirit, but stored as a string because the typed-
131 /// option loader rejects TOML arrays: `snippet.languages =
132 /// "rust,python"`.
133 #[name("snippet.languages")]
134 pub SnippetLanguages: String = String::new();
135}
136
137/// Split a `snippet.languages` value into trimmed, non-empty
138/// language ids. Tolerant of stray whitespace and empty segments
139/// (`"rust, ,python,"` → `["rust", "python"]`).
140fn parse_languages(languages: &str) -> Vec<&str> {
141 languages
142 .split(',')
143 .map(str::trim)
144 .filter(|s| !s.is_empty())
145 .collect()
146}
147
148/// Fold `snippet.activation` + `snippet.languages` into the
149/// concrete [`ActivationPolicy`] the resolver consults.
150///
151/// - `Global` → [`ActivationPolicy::Global`] (every document buffer).
152/// - `Off` → [`ActivationPolicy::Manual`] (never auto-activate).
153/// - `SupportedLanguages` → [`ActivationPolicy::Majors`] of
154/// `<lang>-mode` for each id in `languages`. An empty allowlist
155/// yields `Majors([])`, which `ActivationPolicy::admits` treats as
156/// matching no major (Manual-equivalent) — the graceful "opted
157/// into language gating but named no languages" case.
158pub fn fold_activation_policy(
159 activation: SnippetActivationMode,
160 languages: &str,
161) -> ActivationPolicy {
162 match activation {
163 SnippetActivationMode::Global => ActivationPolicy::Global,
164 SnippetActivationMode::Off => ActivationPolicy::Manual,
165 SnippetActivationMode::SupportedLanguages => ActivationPolicy::Majors(
166 parse_languages(languages)
167 .into_iter()
168 .map(|lang| ModeId::new(&format!("{lang}-mode")))
169 .collect(),
170 ),
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 #![allow(clippy::unwrap_used, clippy::panic)]
177 use super::*;
178 use lattice_config::OptionType;
179
180 #[test]
181 fn activation_mode_round_trips_through_parse_and_format() {
182 for m in [
183 SnippetActivationMode::Global,
184 SnippetActivationMode::SupportedLanguages,
185 SnippetActivationMode::Off,
186 ] {
187 assert_eq!(SnippetActivationMode::parse(&m.format()), Ok(m));
188 }
189 }
190
191 #[test]
192 fn activation_mode_default_is_global() {
193 assert_eq!(
194 SnippetActivationMode::default(),
195 SnippetActivationMode::Global
196 );
197 }
198
199 #[test]
200 fn activation_mode_rejects_garbage_with_helpful_message() {
201 let e = SnippetActivationMode::parse("sometimes").unwrap_err();
202 assert!(e.contains("global"), "got `{e}`");
203 assert!(e.contains("sometimes"), "got `{e}`");
204 }
205
206 #[test]
207 fn activation_mode_enumerates_three_forms() {
208 assert_eq!(
209 SnippetActivationMode::enumerate(),
210 Some(vec!["global", "supported-languages", "off"])
211 );
212 }
213
214 #[test]
215 fn fold_global_is_policy_global() {
216 assert_eq!(
217 fold_activation_policy(SnippetActivationMode::Global, "rust,python"),
218 ActivationPolicy::Global,
219 "global ignores the language list"
220 );
221 }
222
223 #[test]
224 fn fold_off_is_manual() {
225 assert_eq!(
226 fold_activation_policy(SnippetActivationMode::Off, "rust"),
227 ActivationPolicy::Manual,
228 );
229 }
230
231 #[test]
232 fn fold_supported_languages_maps_to_lang_mode_majors() {
233 let policy =
234 fold_activation_policy(SnippetActivationMode::SupportedLanguages, "rust, python ,");
235 assert_eq!(
236 policy,
237 ActivationPolicy::Majors(vec![ModeId::new("rust-mode"), ModeId::new("python-mode"),]),
238 "trims whitespace + drops empty segments; appends `-mode`"
239 );
240 }
241
242 #[test]
243 fn fold_supported_languages_empty_list_matches_nothing() {
244 let policy = fold_activation_policy(SnippetActivationMode::SupportedLanguages, "");
245 // Majors([]) == Manual-equivalent per ActivationPolicy::admits.
246 assert_eq!(policy, ActivationPolicy::Majors(vec![]));
247 assert!(
248 !policy.admits("rust-mode", lattice_core::BufferKind::Document),
249 "empty allowlist admits no major"
250 );
251 }
252}