Skip to main content

scrive_core/intel/
providers.rs

1//! The completion provider contract: the `Completions` trait the app
2//! satisfies, plus the plain-data types it exchanges. Every type here is inert
3//! data — no editor internal is reachable from a provider.
4
5use core::ops::Range;
6
7use crate::{DocId, Point};
8
9/// Lookback window for `CompletionCx` (and, later, signature help), in lines —
10/// the single named source (no bare `40` / "~40" scattered around). The
11/// classifier sanitizes and scans at most this many lines ending at the caret.
12pub const LOOKBACK_LINES: u32 = 40;
13
14/// What caused a completion request — the classifier's dispatch ladder keys off
15/// this.
16#[derive(Clone, Copy, PartialEq, Eq, Debug)]
17pub enum CompletionTrigger {
18    /// A completion-word char extended the current word.
19    Typed(char),
20    /// One of the trigger set `( , = : . ␣` that opens completion at a new spot.
21    TriggerChar(char),
22    /// Ctrl+Space, or the `retrigger` flag on a just-accepted item.
23    Manual,
24}
25
26/// A revision-stamped request context — everything a provider may read. Complete
27/// by construction: the provider never touches the document.
28#[derive(Clone, Debug)]
29pub struct CompletionCx {
30    /// Which document the request is for (multi-tab guard — replies never cross).
31    pub doc: DocId,
32    /// The document revision the request was snapshotted at.
33    pub revision: u64,
34    /// Caret, clipped to a char boundary so a byte offset never splits a glyph.
35    pub position: Point,
36    /// Absolute byte range of the completion word under the caret (empty at a
37    /// boundary), computed with [`is_completion_word_char`] — the default
38    /// replace range on accept.
39    pub word: Range<u32>,
40    /// Raw text from the start of row `position.row − (LOOKBACK_LINES − 1)`
41    /// (clamped to 0) up to and truncated at the caret. Deliberately
42    /// **unsanitized** — stripping comments and strings is the classifier's own
43    /// first step, so it receives the text verbatim.
44    pub lookback: String,
45    /// What caused this request (drives the classifier's dispatch ladder).
46    pub trigger: CompletionTrigger,
47}
48
49/// The completion seam. **Synchronous by contract** (see the module docs): the
50/// widget calls `complete()` in `update()` and opens/refreshes the popup from
51/// the returned `Vec` the same frame — no async reply, no revision guard. A
52/// genuinely slow provider would motivate an async variant of the seam; the
53/// synchronous contract holds until one is needed.
54pub trait Completions {
55    /// Produce the completion items for `cx`. Called synchronously in the
56    /// widget's `update()`; an empty `Vec` closes the popup.
57    fn complete(&mut self, cx: &CompletionCx) -> Vec<CompletionItem>;
58}
59
60/// The kind of a completion item — drives the popup's per-row icon/label color.
61#[derive(Clone, Copy, PartialEq, Eq, Debug)]
62pub enum CompletionKind {
63    /// A language keyword (`if`, `for`, `return`).
64    Keyword,
65    /// A multi-line construct/snippet (`for x { … }`).
66    Construct,
67    /// A named parameter of the enclosing call (`param=`).
68    Param,
69    /// A value/enum member in value position.
70    Value,
71    /// A type name (`u8`, `String`).
72    Type,
73    /// A declared event or signal name.
74    Event,
75    /// A field of an event/struct receiver.
76    Field,
77    /// A user-declared symbol (a variable, function, or type).
78    Symbol,
79    /// A method on a dotted receiver (`obj.method`).
80    Method,
81}
82
83/// What an accepted item inserts.
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub enum InsertText {
86    /// Inserted verbatim.
87    Plain(String),
88    /// LSP placeholder syntax (`$0`, `${1:name}`); parsed by the snippet engine
89    /// **at accept time**, never earlier — items are inert data until accepted.
90    Snippet(String),
91}
92
93/// One structured completion item. `#[non_exhaustive]` with a `new()` + `with_*`
94/// builder chain, so adding a field never breaks an app that builds these:
95/// construct with [`CompletionItem::new`], then layer on optional
96/// detail/doc/replace/sort-key/retrigger.
97#[non_exhaustive]
98#[derive(Clone, Debug)]
99pub struct CompletionItem {
100    /// The text shown in the popup row (and the default filter/insert text).
101    pub label: String,
102    /// The item's category (drives the row icon/color).
103    pub kind: CompletionKind,
104    /// Short type/shape hint, rendered right-aligned in the popup row.
105    pub detail: Option<String>,
106    /// Plain-text documentation, rendered as a plain block in the popup;
107    /// markdown rendering lives in the hover popup, not here.
108    pub doc: Option<String>,
109    /// What accepting the item inserts (plain text or a snippet).
110    pub insert: InsertText,
111    /// Absolute byte range to replace; `None` = the word range recomputed live
112    /// at accept time (the word may have grown since the request).
113    pub replace: Option<Range<u32>>,
114    /// Tier prefix + label, sorted lexicographically: `0_` params (required
115    /// before optional), `1_` symbols, `2_` constructs, `3_` keywords. Defaults
116    /// to the label when not set explicitly.
117    pub sort_key: String,
118    /// When set, accepting the item fires exactly one `Manual` re-request after
119    /// the transaction commits (e.g. `param=` reopens the popup in value
120    /// position).
121    pub retrigger: bool,
122}
123
124impl CompletionItem {
125    /// A minimal item: label, kind, and insertion. `sort_key` defaults to the
126    /// label; detail/doc/replace default absent; `retrigger` false.
127    #[must_use]
128    pub fn new(label: impl Into<String>, kind: CompletionKind, insert: InsertText) -> Self {
129        let label = label.into();
130        Self {
131            sort_key: label.clone(),
132            label,
133            kind,
134            detail: None,
135            doc: None,
136            insert,
137            replace: None,
138            retrigger: false,
139        }
140    }
141
142    /// Convenience: a plain-text item whose insertion equals its label.
143    #[must_use]
144    pub fn plain(label: impl Into<String>, kind: CompletionKind) -> Self {
145        let label = label.into();
146        Self::new(label.clone(), kind, InsertText::Plain(label))
147    }
148
149    /// Set the right-aligned type/shape hint.
150    #[must_use]
151    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
152        self.detail = Some(detail.into());
153        self
154    }
155
156    /// Set the plain-text documentation block.
157    #[must_use]
158    pub fn with_doc(mut self, doc: impl Into<String>) -> Self {
159        self.doc = Some(doc.into());
160        self
161    }
162
163    /// Override the replace range (default = the live word range at accept time).
164    #[must_use]
165    pub fn with_replace(mut self, replace: Range<u32>) -> Self {
166        self.replace = Some(replace);
167        self
168    }
169
170    /// Set the sort key (tier prefix + label); defaults to the label.
171    #[must_use]
172    pub fn with_sort_key(mut self, sort_key: impl Into<String>) -> Self {
173        self.sort_key = sort_key.into();
174        self
175    }
176
177    /// Mark the item to fire one `Manual` re-request after acceptance commits.
178    #[must_use]
179    pub fn with_retrigger(mut self, retrigger: bool) -> Self {
180        self.retrigger = retrigger;
181        self
182    }
183}
184
185/// Completion-word predicate: `alphanumeric | '_' | '-'`. Deliberately **wider**
186/// than the cursor-movement word classifier (where `-` is punctuation): kebab
187/// keywords (`read-only`, `multi-word-name`) must filter as one word. This
188/// one `pub fn` is the single seam if the two predicates ever need to converge.
189#[must_use]
190pub fn is_completion_word_char(c: char) -> bool {
191    c.is_alphanumeric() || c == '_' || c == '-'
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn completion_word_char_covers_kebab_identifiers() {
200        assert!(is_completion_word_char('a'));
201        assert!(is_completion_word_char('Z'));
202        assert!(is_completion_word_char('9'));
203        assert!(is_completion_word_char('_'));
204        assert!(is_completion_word_char('-'), "kebab keywords filter as one word");
205        assert!(!is_completion_word_char(' '));
206        assert!(!is_completion_word_char('('));
207        assert!(!is_completion_word_char('.'));
208        assert!(!is_completion_word_char('='));
209    }
210
211    #[test]
212    fn completion_item_builder_defaults_and_overrides() {
213        let item = CompletionItem::plain("if", CompletionKind::Keyword);
214        assert_eq!(item.label, "if");
215        assert_eq!(item.insert, InsertText::Plain("if".into()));
216        assert_eq!(item.sort_key, "if", "sort_key defaults to the label");
217        assert!(item.detail.is_none() && item.doc.is_none() && item.replace.is_none());
218        assert!(!item.retrigger);
219
220        let built = CompletionItem::new("size", CompletionKind::Param, InsertText::Snippet("size=${1:8}".into()))
221            .with_detail("u8")
222            .with_doc("the block size")
223            .with_sort_key("0_size")
224            .with_replace(4..9)
225            .with_retrigger(true);
226        assert_eq!(built.detail.as_deref(), Some("u8"));
227        assert_eq!(built.doc.as_deref(), Some("the block size"));
228        assert_eq!(built.sort_key, "0_size");
229        assert_eq!(built.replace, Some(4..9));
230        assert!(built.retrigger);
231        assert!(matches!(built.insert, InsertText::Snippet(_)));
232    }
233}