Skip to main content

lattice_lsp/
completion.rs

1//! LSP completion source (CSM.8a / CSM.8b).
2//!
3//! [`LspCompletionSource`] is the `AsyncCompletionSource` impl
4//! that the `lsp-completion-mode` contributes. It owns an
5//! `LspSupervisorHandle` and is wired into the
6//! `ActiveCompletionSources` cache via the mode's
7//! `completion_sources()` contribution. The source's
8//! `produce_async` future fans out across attached servers,
9//! dedupes by (label, kind), and pushes one `RawCandidate`
10//! per item into the supplied [`CandidateSink`]. Each
11//! candidate carries an `CandidateData::Extension` payload
12//! whose bytes are a JSON-encoded [`LspCompletionMeta`]; the
13//! host decodes the payload at accept / docs / commit-char
14//! time via [`decode_meta`].
15
16use std::future::Future;
17use std::pin::Pin;
18use std::sync::Arc;
19
20use lattice_completion::{
21    AsyncCompletionSource, CandidateSink, CompletionSourceContribution, CompletionSourceKind,
22    InsertContextSnapshot, SourceId,
23};
24use lattice_config::OptionOverrideSet;
25use lattice_mode::{
26    CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, ModeRegistry,
27};
28use lattice_protocol::CancellationToken;
29
30use crate::supervisor::LspSupervisorHandle;
31
32/// `Extension::kind_id` discriminant for LSP-sourced
33/// candidates. Values 0-99 reserved for first-party host
34/// data (snippet uses 2); plugins use 1000+. The host's
35/// decoder checks this id before attempting [`decode_meta`].
36pub const LSP_COMPLETION_KIND_ID: u32 = 1;
37
38/// LSP-sourced insert-completion candidate metadata. Carried
39/// inside the `RawCandidate`'s `CandidateData::Extension`
40/// payload as a JSON blob (see [`encode_meta`] / [`decode_meta`]).
41/// Replaces the pre-CSM.8b host-side sidecar -- the candidate
42/// IS the metadata; the host decodes on demand.
43///
44/// JSON over bincode keeps the payload debuggable and avoids
45/// pinning lsp-types' bincode behavior. Decoding cost is
46/// amortised by the host's per-popup decoded cache (rebuilt
47/// only when `state.raw` mutates), so the per-frame docs /
48/// glyph / commit-char paths don't repeatedly deserialise.
49#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50pub struct LspCompletionMeta {
51    pub label: String,
52    pub insert_text: String,
53    pub filter_text: Option<String>,
54    pub sort_text: Option<String>,
55    pub detail: Option<String>,
56    pub documentation: Option<String>,
57    pub kind: Option<lsp_types::CompletionItemKind>,
58    pub deprecated: bool,
59    pub preselect: bool,
60    pub commit_characters: Vec<char>,
61    pub additional_text_edits: Vec<lsp_types::TextEdit>,
62    pub command: Option<lsp_types::Command>,
63    pub insert_text_format: lsp_types::InsertTextFormat,
64    /// Range to replace, when the LSP item carries
65    /// `textEdit.range`. `None` ⇒ host uses the popup's anchor
66    /// / cursor as the replace bounds.
67    pub replace_range: Option<lsp_types::Range>,
68    /// Server that produced the item. Resolve / executeCommand
69    /// route back to the same server. Stored as `String` for
70    /// serde-friendliness; the host can `Arc::from` it on
71    /// decode if it wants the shared form back.
72    pub server_id: String,
73    /// Original server `CompletionItem` preserved verbatim so
74    /// `completionItem/resolve` round-trips it unchanged
75    /// (servers use the `data` field as an opaque blob;
76    /// mutating any field would break resolve).
77    pub original_item: lsp_types::CompletionItem,
78    /// True once `completionItem/resolve` has filled in the
79    /// missing fields. Subsequent docs-popup focuses don't
80    /// re-fire the resolve.
81    pub resolved: bool,
82}
83
84/// Encode `meta` to a JSON byte blob suitable for embedding
85/// in a `RawCandidate`'s `CandidateData::Extension::payload`.
86/// Errors are caller-handled by panicking in the source's
87/// produce path -- a serialize failure on lsp-types output
88/// indicates a malformed item, which the supervisor should
89/// have rejected before we got here.
90pub fn encode_meta(meta: &LspCompletionMeta) -> Vec<u8> {
91    serde_json::to_vec(meta).expect("LspCompletionMeta must serialise (all fields serde-derived)")
92}
93
94/// Decode a payload produced by [`encode_meta`]. Returns
95/// `None` when the bytes don't deserialise as
96/// [`LspCompletionMeta`] -- the candidate's `kind_id` may not
97/// be ours (plugin source colliding on the kind, or a stray
98/// payload), or the wire format may have drifted across
99/// versions.
100pub fn decode_meta(payload: &[u8]) -> Option<LspCompletionMeta> {
101    serde_json::from_slice(payload).ok()
102}
103
104/// CSM.8a stub. Holds the supervisor handle so CSM.8b can move
105/// the fan-out into `produce_async` without changing the mode's
106/// public surface. Today the future is a no-op.
107#[derive(Debug, Clone)]
108pub struct LspCompletionSource {
109    pub lsp: LspSupervisorHandle,
110}
111
112impl AsyncCompletionSource for LspCompletionSource {
113    fn produce_async(
114        &self,
115        ctx: InsertContextSnapshot,
116        sink: Arc<dyn CandidateSink>,
117        token: CancellationToken,
118    ) -> Pin<Box<dyn Future<Output = ()> + Send>> {
119        let lsp = self.lsp.clone();
120        Box::pin(async move {
121            // CSM.8b: full multi-server fan-out + dedup +
122            // isIncomplete + per-item payload encode. Each
123            // server's response items become one
124            // `RawCandidate` whose `CandidateData::Extension`
125            // payload is the serde-encoded
126            // `LspCompletionMeta`; the host decodes via
127            // `decode_meta` at accept / docs / commit-char
128            // time without a sidecar.
129            const MAX_LSP_ITEMS: usize = 500;
130            use std::str::FromStr;
131            let Some(uri_string) = ctx.uri.as_deref() else {
132                return;
133            };
134            let Ok(uri) = lsp_types::Uri::from_str(uri_string) else {
135                return;
136            };
137            let Some((line, character)) = ctx.lsp_position else {
138                return;
139            };
140            let lsp_position = lsp_types::Position { line, character };
141            let (lsp_trigger_kind, lsp_trigger_char) = match ctx.trigger {
142                lattice_completion::CompletionTrigger::TriggerChar(c) => (
143                    lsp_types::CompletionTriggerKind::TRIGGER_CHARACTER,
144                    Some(c.to_string()),
145                ),
146                lattice_completion::CompletionTrigger::IncompleteRefresh => (
147                    lsp_types::CompletionTriggerKind::TRIGGER_FOR_INCOMPLETE_COMPLETIONS,
148                    None,
149                ),
150                _ => (lsp_types::CompletionTriggerKind::INVOKED, None),
151            };
152            let handles: Vec<crate::ServerHandle> = lsp.servers_for(&uri);
153            if handles.is_empty() {
154                return;
155            }
156            let mut emitted = 0usize;
157            let mut any_incomplete = false;
158            let mut seen_keys: std::collections::HashSet<(String, String)> =
159                std::collections::HashSet::new();
160            let lsp_source_id =
161                lattice_completion::SourceId::new(lattice_completion::LSP_COMPLETION_SOURCE_ID);
162            for handle in handles {
163                if token.is_cancelled() {
164                    return;
165                }
166                if !handle.capabilities().supports_completion() {
167                    continue;
168                }
169                let params = lsp_types::CompletionParams {
170                    text_document_position: lsp_types::TextDocumentPositionParams {
171                        text_document: lsp_types::TextDocumentIdentifier { uri: uri.clone() },
172                        position: lsp_position,
173                    },
174                    work_done_progress_params: Default::default(),
175                    partial_result_params: Default::default(),
176                    context: Some(lsp_types::CompletionContext {
177                        trigger_kind: lsp_trigger_kind,
178                        trigger_character: lsp_trigger_char.clone(),
179                    }),
180                };
181                let Ok(Some(resp)) = handle.completion(params, token.clone()).await else {
182                    continue;
183                };
184                let (items, is_incomplete) = match resp {
185                    lsp_types::CompletionResponse::Array(items) => (items, false),
186                    lsp_types::CompletionResponse::List(list) => (list.items, list.is_incomplete),
187                };
188                if is_incomplete {
189                    any_incomplete = true;
190                }
191                for ci in items {
192                    let kind = ci.kind;
193                    let label = ci.label.clone();
194                    let kind_tag = kind
195                        .map(|k| format!("{k:?}"))
196                        .unwrap_or_else(|| "none".to_string());
197                    let key = (label.clone(), kind_tag);
198                    if !seen_keys.insert(key) {
199                        continue;
200                    }
201                    let deprecated = ci
202                        .tags
203                        .as_ref()
204                        .map(|t| t.contains(&lsp_types::CompletionItemTag::DEPRECATED))
205                        .unwrap_or(false)
206                        || ci.deprecated.unwrap_or(false);
207                    let (insert_text, replace_range) = match ci.text_edit.as_ref() {
208                        Some(lsp_types::CompletionTextEdit::Edit(te)) => {
209                            (te.new_text.clone(), Some(te.range))
210                        }
211                        Some(lsp_types::CompletionTextEdit::InsertAndReplace(ir)) => {
212                            (ir.new_text.clone(), Some(ir.replace))
213                        }
214                        None => (
215                            ci.insert_text.clone().unwrap_or_else(|| label.clone()),
216                            None,
217                        ),
218                    };
219                    let documentation = ci.documentation.as_ref().map(|d| match d {
220                        lsp_types::Documentation::String(s) => s.clone(),
221                        lsp_types::Documentation::MarkupContent(mc) => mc.value.clone(),
222                    });
223                    let commit_characters = ci
224                        .commit_characters
225                        .as_ref()
226                        .map(|chars| chars.iter().filter_map(|s| s.chars().next()).collect())
227                        .unwrap_or_default();
228                    let meta = LspCompletionMeta {
229                        label: label.clone(),
230                        insert_text,
231                        filter_text: ci.filter_text.clone(),
232                        sort_text: ci.sort_text.clone(),
233                        detail: ci.detail.clone(),
234                        documentation,
235                        kind,
236                        deprecated,
237                        preselect: ci.preselect.unwrap_or(false),
238                        commit_characters,
239                        additional_text_edits: ci.additional_text_edits.clone().unwrap_or_default(),
240                        command: ci.command.clone(),
241                        insert_text_format: ci
242                            .insert_text_format
243                            .unwrap_or(lsp_types::InsertTextFormat::PLAIN_TEXT),
244                        replace_range,
245                        server_id: handle.server_id().to_string(),
246                        original_item: ci,
247                        resolved: false,
248                    };
249                    let display = match meta.detail.as_ref() {
250                        Some(d) => format!("{}  {}", meta.label, d),
251                        None => meta.label.clone(),
252                    };
253                    let match_text = meta
254                        .filter_text
255                        .clone()
256                        .unwrap_or_else(|| meta.label.clone());
257                    let payload = encode_meta(&meta);
258                    let mut raw = lattice_completion::RawCandidate::plain(
259                        match_text,
260                        lattice_completion::CandidateKind::Plain,
261                    )
262                    .with_source(lsp_source_id.clone());
263                    raw.display = display;
264                    raw.data = lattice_completion::CandidateData::Extension {
265                        kind_id: LSP_COMPLETION_KIND_ID,
266                        payload,
267                    };
268                    sink.push(raw);
269                    emitted += 1;
270                    if emitted >= MAX_LSP_ITEMS {
271                        break;
272                    }
273                }
274                if emitted >= MAX_LSP_ITEMS {
275                    break;
276                }
277            }
278            if any_incomplete {
279                sink.mark_incomplete();
280            }
281        })
282    }
283}
284
285/// `lsp-completion-mode` -- LSP-driven insert-mode completion
286/// (CSM.8a). M.6.0 declared it as a marker minor; CSM.8a makes
287/// it source-contributing. Holds the [`LspSupervisorHandle`]
288/// so the contributed source can fan out to attached servers.
289/// `popup_filter_chord = Some('o')` ⇒ `<C-o>` inside
290/// `completion-popup-mode` narrows the popup to LSP only
291/// (CSM.K2).
292#[derive(Debug, Clone)]
293pub struct LspCompletionMode {
294    pub lsp: LspSupervisorHandle,
295}
296
297impl LspCompletionMode {
298    pub fn mode_id() -> ModeId {
299        ModeId::new("lsp-completion-mode")
300    }
301}
302
303impl Mode for LspCompletionMode {
304    type Guard = ();
305    fn id(&self) -> ModeId {
306        Self::mode_id()
307    }
308    fn kind(&self) -> ModeKind {
309        ModeKind::Minor
310    }
311    fn options(&self) -> OptionOverrideSet {
312        OptionOverrideSet::default()
313    }
314    fn required_capabilities(&self) -> CapabilitySet {
315        CapabilitySet::empty()
316    }
317    fn completion_sources(&self) -> Vec<CompletionSourceContribution> {
318        vec![CompletionSourceContribution {
319            accepts_non_word_query: false,
320            id: SourceId::new(lattice_completion::LSP_COMPLETION_SOURCE_ID),
321            // 200 per insert-completion.md §3.4 -- LSP outranks
322            // every sync source.
323            default_priority: 200,
324            auto_trigger: true,
325            // Server-advertised triggers populated dynamically
326            // when the source reads the attached server's
327            // `completionProvider.triggerCharacters`.
328            trigger_chars: Vec::new(),
329            popup_filter_chord: Some('o'),
330            kind: CompletionSourceKind::Async(Arc::new(LspCompletionSource {
331                lsp: self.lsp.clone(),
332            })),
333        }]
334    }
335    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
336        Box::pin(async { Ok(()) })
337    }
338}
339
340/// Register `lsp-completion-mode` against `registry`. Called
341/// from the App's boot path alongside
342/// [`crate::modes::register_lsp_log_modes`]; the supervisor
343/// handle is shared so source produce + host accept-path read
344/// the same data.
345pub fn register_lsp_completion_mode(registry: &mut ModeRegistry, lsp: LspSupervisorHandle) {
346    registry
347        .register(LspCompletionMode { lsp })
348        .expect("lsp-completion-mode must register without conflict");
349}
350
351#[cfg(test)]
352mod tests {
353    #![allow(clippy::unwrap_used, clippy::panic)]
354    use super::*;
355
356    fn sample_meta() -> LspCompletionMeta {
357        LspCompletionMeta {
358            label: "println!".into(),
359            insert_text: "println!(\"$1\")$0".into(),
360            filter_text: Some("println".into()),
361            sort_text: Some("00println".into()),
362            detail: Some("macro_rules! println".into()),
363            documentation: Some("Prints to the standard output.".into()),
364            kind: Some(lsp_types::CompletionItemKind::SNIPPET),
365            deprecated: false,
366            preselect: true,
367            commit_characters: vec!['(', '!'],
368            additional_text_edits: vec![lsp_types::TextEdit {
369                range: lsp_types::Range {
370                    start: lsp_types::Position {
371                        line: 0,
372                        character: 0,
373                    },
374                    end: lsp_types::Position {
375                        line: 0,
376                        character: 0,
377                    },
378                },
379                new_text: "use std::println;\n".into(),
380            }],
381            command: None,
382            insert_text_format: lsp_types::InsertTextFormat::SNIPPET,
383            replace_range: Some(lsp_types::Range {
384                start: lsp_types::Position {
385                    line: 1,
386                    character: 4,
387                },
388                end: lsp_types::Position {
389                    line: 1,
390                    character: 11,
391                },
392            }),
393            server_id: "rust-analyzer".to_string(),
394            original_item: lsp_types::CompletionItem::default(),
395            resolved: false,
396        }
397    }
398
399    /// CSM.8b.1: full round-trip preserves every field. JSON is
400    /// the wire format -- exotic types (Range, TextEdit,
401    /// CompletionItemKind) must serialise / deserialise without
402    /// loss for the host's accept path to be correct.
403    #[test]
404    fn encode_decode_round_trip_preserves_all_fields() {
405        let meta = sample_meta();
406        let bytes = encode_meta(&meta);
407        let decoded = decode_meta(&bytes).expect("decode");
408        assert_eq!(decoded.label, meta.label);
409        assert_eq!(decoded.insert_text, meta.insert_text);
410        assert_eq!(decoded.filter_text, meta.filter_text);
411        assert_eq!(decoded.sort_text, meta.sort_text);
412        assert_eq!(decoded.detail, meta.detail);
413        assert_eq!(decoded.documentation, meta.documentation);
414        assert_eq!(decoded.kind, meta.kind);
415        assert_eq!(decoded.deprecated, meta.deprecated);
416        assert_eq!(decoded.preselect, meta.preselect);
417        assert_eq!(decoded.commit_characters, meta.commit_characters);
418        assert_eq!(decoded.additional_text_edits, meta.additional_text_edits);
419        assert_eq!(decoded.insert_text_format, meta.insert_text_format);
420        assert_eq!(decoded.replace_range, meta.replace_range);
421        assert_eq!(decoded.server_id, meta.server_id);
422        assert_eq!(decoded.resolved, meta.resolved);
423    }
424
425    /// CSM.8b.1: garbled bytes don't decode -- the host's
426    /// `lsp_completion_meta_for` returns `None` for stale /
427    /// foreign payloads and falls through to the sync-source
428    /// path.
429    #[test]
430    fn decode_meta_returns_none_for_garbage() {
431        assert!(decode_meta(b"not json").is_none());
432        assert!(decode_meta(b"").is_none());
433    }
434}