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}