tinymist_query/completion.rs
1use crate::analysis::{CompletionCursor, CompletionWorker};
2use crate::prelude::*;
3
4pub(crate) mod proto;
5pub use proto::*;
6pub(crate) mod snippet;
7pub use snippet::*;
8
9/// The [`textDocument/completion`] request is sent from the client to the
10/// server to compute completion items at a given cursor position.
11///
12/// If computing full completion items is expensive, servers can additionally
13/// provide a handler for the completion item resolve request
14/// (`completionItem/resolve`). This request is sent when a completion item is
15/// selected in the user interface.
16///
17/// [`textDocument/completion`]: https://microsoft.github.io/language-server-protocol/specification#textDocument_completion
18///
19/// # Compatibility
20///
21/// Since 3.16.0, the client can signal that it can resolve more properties
22/// lazily. This is done using the `completion_item.resolve_support` client
23/// capability which lists all properties that can be filled in during a
24/// `completionItem/resolve` request.
25///
26/// All other properties (usually `sort_text`, `filter_text`, `insert_text`, and
27/// `text_edit`) must be provided in the `textDocument/completion` response and
28/// must not be changed during resolve.
29#[derive(Debug, Clone)]
30pub struct CompletionRequest {
31 /// The path of the document to compute completions.
32 pub path: PathBuf,
33 /// The position in the document at which to compute completions.
34 pub position: LspPosition,
35 /// Whether the completion is triggered explicitly.
36 pub explicit: bool,
37 /// The character that triggered the completion, if any.
38 pub trigger_character: Option<char>,
39}
40
41impl SemanticRequest for CompletionRequest {
42 type Response = CompletionList;
43
44 fn request(self, ctx: &mut LocalContext) -> Option<Self::Response> {
45 // These trigger characters are for completion on positional arguments,
46 // which follows the configuration item
47 // `tinymist.completion.triggerOnSnippetPlaceholders`.
48 if matches!(self.trigger_character, Some('(' | ',' | ':'))
49 && !ctx.analysis.completion_feat.trigger_on_snippet_placeholders
50 {
51 return None;
52 }
53
54 let document = ctx.success_doc().cloned();
55 let source = ctx.source_by_path(&self.path).ok()?;
56 let cursor = ctx.to_typst_pos_offset(&source, self.position, 0)?;
57
58 // Please see <https://github.com/nvarner/typst-lsp/commit/2d66f26fb96ceb8e485f492e5b81e9db25c3e8ec>
59 //
60 // FIXME: correctly identify a completion which is triggered
61 // by explicit action, such as by pressing control and space
62 // or something similar.
63 //
64 // See <https://github.com/microsoft/language-server-protocol/issues/1101>
65 // > As of LSP 3.16, CompletionTriggerKind takes the value Invoked for
66 // > both manually invoked (for ex: ctrl + space in VSCode) completions
67 // > and always on (what the spec refers to as 24/7 completions).
68 //
69 // Hence, we cannot distinguish between the two cases. Conservatively, we
70 // assume that the completion is not explicit.
71 //
72 // Second try: According to VSCode:
73 // - <https://github.com/microsoft/vscode/issues/130953>
74 // - <https://github.com/microsoft/vscode/commit/0984071fe0d8a3c157a1ba810c244752d69e5689>
75 // Checks the previous text to filter out letter explicit completions.
76 //
77 // Second try is failed.
78 let explicit = false;
79 let mut cursor = CompletionCursor::new(ctx.shared_(), &source, cursor)?;
80
81 let mut worker =
82 CompletionWorker::new(ctx, document.as_ref(), explicit, self.trigger_character)?;
83 worker.work(&mut cursor)?;
84
85 // todo: define it well, we were needing it because we wanted to do interactive
86 // path completion, but now we've scanned all the paths at the same time.
87 // is_incomplete = ic;
88 let _ = worker.incomplete;
89
90 // To response completions in fine-grained manner, we need to mark result as
91 // incomplete. This follows what rust-analyzer does.
92 // https://github.com/rust-lang/rust-analyzer/blob/f5a9250147f6569d8d89334dc9cca79c0322729f/crates/rust-analyzer/src/handlers/request.rs#L940C55-L940C75
93 Some(CompletionList {
94 is_incomplete: false,
95 items: worker.completions,
96 })
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use std::collections::HashSet;
103
104 use super::*;
105 use crate::{completion::proto::CompletionItem, syntax::find_module_level_docs, tests::*};
106
107 struct TestConfig {
108 pkg_mode: bool,
109 }
110
111 fn run(config: TestConfig) -> impl Fn(&mut LocalContext, PathBuf) {
112 fn test(ctx: &mut LocalContext, id: TypstFileId) {
113 let source = ctx.source_by_id(id).unwrap();
114 let rng = find_test_range_(&source);
115 let text = source.text()[rng.clone()].to_string();
116
117 let docs = find_module_level_docs(&source).unwrap_or_default();
118 let properties = get_test_properties(&docs);
119
120 let trigger_character = properties
121 .get("trigger_character")
122 .map(|v| v.chars().next().unwrap());
123 let explicit = match properties.get("explicit").copied().map(str::trim) {
124 Some("true") => true,
125 Some("false") | None => false,
126 Some(v) => panic!("invalid value for 'explicit' property: {v}"),
127 };
128
129 let mut includes = HashSet::new();
130 let mut excludes = HashSet::new();
131
132 for kk in properties.get("contains").iter().flat_map(|v| v.split(',')) {
133 // split first char
134 let (kind, item) = kk.split_at(1);
135 if kind == "+" {
136 includes.insert(item.trim());
137 } else if kind == "-" {
138 excludes.insert(item.trim());
139 } else {
140 includes.insert(kk.trim());
141 }
142 }
143 let get_items = |items: Vec<CompletionItem>| {
144 let mut res: Vec<_> = items
145 .into_iter()
146 .filter(|item| {
147 if !excludes.is_empty() && excludes.contains(item.label.as_str()) {
148 panic!("{item:?} was excluded in {excludes:?}");
149 }
150 if includes.is_empty() {
151 return true;
152 }
153 includes.contains(item.label.as_str())
154 })
155 .map(|item| CompletionItem {
156 label: item.label,
157 label_details: item.label_details,
158 sort_text: item.sort_text,
159 kind: item.kind,
160 text_edit: item.text_edit,
161 command: item.command,
162 ..Default::default()
163 })
164 .collect();
165
166 res.sort_by(|a, b| {
167 a.sort_text
168 .as_ref()
169 .cmp(&b.sort_text.as_ref())
170 .then_with(|| a.label.cmp(&b.label))
171 });
172 res
173 };
174
175 let mut results = vec![];
176 for s in rng.clone() {
177 let request = CompletionRequest {
178 path: ctx.path_for_id(id).unwrap().as_path().to_owned(),
179 position: ctx.to_lsp_pos(s, &source),
180 explicit,
181 trigger_character,
182 };
183 let result = request.request(ctx).map(|list| CompletionList {
184 is_incomplete: list.is_incomplete,
185 items: get_items(list.items),
186 });
187 results.push(result);
188 }
189 with_settings!({
190 description => format!("Completion on {text} ({rng:?})"),
191 }, {
192 assert_snapshot!(JsonRepr::new_pure(results));
193 })
194 }
195
196 move |ctx, path| {
197 if config.pkg_mode {
198 let files = ctx
199 .source_files()
200 .iter()
201 .filter(|id| !id.vpath().as_rootless_path().ends_with("lib.typ"));
202 for id in files.copied().collect::<Vec<_>>() {
203 test(ctx, id);
204 }
205 } else {
206 test(ctx, ctx.file_id_by_path(&path).unwrap());
207 }
208 }
209 }
210
211 #[test]
212 fn test_base() {
213 snapshot_testing("completion", &run(TestConfig { pkg_mode: false }));
214 }
215
216 #[test]
217 fn test_pkgs() {
218 snapshot_testing("pkgs", &run(TestConfig { pkg_mode: true }));
219 }
220}