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}