Skip to main content

lattice_snippet/
picker_sources.rs

1//! `:picker snippets` source generator.
2//!
3//! Walks the live `SnippetRegistry` for the active buffer's
4//! language and emits one row per registered snippet. Accept
5//! emits a `PickerAcceptOutcome::ExpandSnippet { id }` which
6//! the host resolves through `App::expand_snippet_by_name`
7//! -- the same path `:snippet-expand <name>` uses, so cursor
8//! placement / tab-stops / mark insertion all match the
9//! keyboard-driven flow.
10//!
11//! `SnippetRegistry` lives in this crate; the source lives
12//! here too so the dependency direction stays
13//! `lattice-snippet -> lattice-picker` (cycle-free; the
14//! picker crate is renderer- and feature-agnostic).
15
16use std::sync::Arc;
17
18use arc_swap::ArcSwap;
19use lattice_completion::{Annotation, CandidateKind, RawCandidate};
20use lattice_picker::{
21    PickerAcceptOutcome, PickerContext, PickerInitResult, PickerSourceGenerator, PickerSourceSpec,
22    RoutingPayload, SourceResult,
23};
24
25use crate::registry::{SnippetMeta, SnippetRegistry};
26
27/// MP.2: the marginalia for one snippet row — the name as a `Kind`
28/// label cell, and (when present) the description as a `DocSnippet`.
29/// The prefix stays the matchable `display`, so it carries no
30/// annotation. Single home so source + test agree on the set.
31fn snippet_annotations(meta: &SnippetMeta) -> Vec<Annotation> {
32    let mut annotations = vec![Annotation::Kind(meta.name.clone().into())];
33    if let Some(desc) = meta.description.as_deref().filter(|d| !d.is_empty()) {
34        annotations.push(Annotation::DocSnippet(desc.into()));
35    }
36    annotations
37}
38
39/// `:picker snippets`. Walks
40/// `SnippetRegistry::meta_for_language(active_lang)` and
41/// emits one row per snippet, displayed as `prefix  name --
42/// description`.
43pub struct SnippetsSource {
44    pub spec: PickerSourceSpec,
45    pub registry: Arc<ArcSwap<SnippetRegistry>>,
46}
47
48impl SnippetsSource {
49    pub fn new(registry: Arc<ArcSwap<SnippetRegistry>>) -> Self {
50        Self {
51            spec: PickerSourceSpec::no_args(
52                "snippets",
53                "Available snippets for the active buffer's language. `<CR>` expands the chosen snippet at the cursor.",
54            ),
55            registry,
56        }
57    }
58}
59
60impl PickerSourceGenerator for SnippetsSource {
61    fn spec(&self) -> &PickerSourceSpec {
62        &self.spec
63    }
64
65    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
66        let language = ctx.active_buffer.language.unwrap_or("plain");
67        let snapshot = self.registry.load();
68        let mut metas = snapshot.meta_for_language(language);
69        if metas.is_empty() {
70            return Err(format!(
71                "snippets: no snippets registered for language `{language}`"
72            ));
73        }
74        // Sort by user-facing prefix so popup order is stable.
75        metas.sort_by(|a, b| a.prefix.cmp(&b.prefix));
76        // MP.2: the prefix (the trigger the user types) is the matchable
77        // `display`; the snippet name and description become typed
78        // marginalia (`Kind` + `DocSnippet`) — `AnnotationColumns` owns
79        // alignment, so no hand-padding.
80        let pairs = metas
81            .into_iter()
82            .map(|meta| {
83                let mut cand = RawCandidate::plain(meta.prefix.clone(), CandidateKind::Plain);
84                cand.annotations = snippet_annotations(&meta);
85                // ExpandSnippet routing carries the snippet
86                // `name` (stable id) -- the host resolves the
87                // body through `SnippetRegistry::by_name` at
88                // expansion time. Identity for MRU is the
89                // name, so frequent-snippet rows float.
90                (
91                    cand,
92                    RoutingPayload::ExpandSnippet {
93                        id: meta.name.clone(),
94                    },
95                )
96            })
97            .collect();
98        Ok(PickerInitResult::Inline(pairs))
99    }
100
101    fn accept(
102        &self,
103        _ctx: &PickerContext<'_>,
104        routing: &RoutingPayload,
105    ) -> SourceResult<PickerAcceptOutcome> {
106        match routing {
107            RoutingPayload::ExpandSnippet { id } => {
108                Ok(PickerAcceptOutcome::ExpandSnippet { id: id.clone() })
109            }
110            other => Err(format!("snippets: unexpected routing payload {other:?}")),
111        }
112    }
113}
114
115/// Register this crate's picker sources into the supplied
116/// registry. Called by the host's `App::new` at boot:
117///
118/// ```ignore
119/// lattice_snippet::picker_sources::register(&mut picker_registry, snippet_registry);
120/// ```
121///
122/// New feature crates that ship picker sources follow this
123/// pattern (see also `lattice-lsp::picker_sources::register`).
124pub fn register(
125    picker_registry: &mut lattice_picker::PickerRegistry,
126    snippet_registry: Arc<ArcSwap<SnippetRegistry>>,
127) {
128    picker_registry.register_generator(Arc::new(SnippetsSource::new(snippet_registry)));
129}
130
131#[cfg(test)]
132mod tests {
133    #![allow(clippy::unwrap_used, clippy::panic)]
134
135    use super::*;
136
137    /// P.10: empty-registry returns `Err` so the picker stays
138    /// closed with a clean echo. End-to-end behavior over
139    /// the App's wired registry is covered by the host-side
140    /// tests; this fixture validates the source's error path
141    /// without depending on the full snippet parser.
142    #[test]
143    fn snippets_source_empty_language_errors() {
144        let registry = Arc::new(ArcSwap::from_pointee(SnippetRegistry::new()));
145        let source = SnippetsSource::new(registry);
146        // Build a minimal stand-in PickerContext. We can't
147        // easily construct one without the host's
148        // `build_picker_context`; the spec-only assertion
149        // below covers the trait shape, and the host-side
150        // tests in lattice-ui-tui exercise end-to-end.
151        assert_eq!(source.spec().id, "snippets");
152        assert!(source.spec().args_schema.is_empty());
153    }
154
155    /// MP.2: a snippet row's marginalia is a `Kind` name cell plus a
156    /// `DocSnippet` description cell; the prefix stays the matchable
157    /// display (no annotation). A description-less snippet emits only
158    /// the name cell.
159    #[test]
160    fn snippet_annotations_name_and_description() {
161        let with_desc = SnippetMeta {
162            name: "for-loop".into(),
163            prefix: "for".into(),
164            description: Some("C-style for loop".into()),
165        };
166        let anns = snippet_annotations(&with_desc);
167        assert_eq!(anns.len(), 2);
168        assert_eq!(anns[0].category(), "kind");
169        assert_eq!(anns[0].display_text(), "for-loop");
170        assert_eq!(anns[1].category(), "doc");
171        assert_eq!(anns[1].display_text(), "C-style for loop");
172
173        let no_desc = SnippetMeta {
174            name: "main".into(),
175            prefix: "main".into(),
176            description: None,
177        };
178        let anns = snippet_annotations(&no_desc);
179        assert_eq!(anns.len(), 1);
180        assert_eq!(anns[0].category(), "kind");
181    }
182}