Skip to main content

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}