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 StatefulRequest for CompletionRequest {
42    type Response = CompletionList;
43
44    fn request(self, ctx: &mut LocalContext, graph: LspComputeGraph) -> 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 = graph.snap.success_doc.as_ref();
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 = CompletionWorker::new(ctx, document, explicit, self.trigger_character)?;
82        worker.work(&mut cursor)?;
83
84        // todo: define it well, we were needing it because we wanted to do interactive
85        // path completion, but now we've scanned all the paths at the same time.
86        // is_incomplete = ic;
87        let _ = worker.incomplete;
88
89        // To response completions in fine-grained manner, we need to mark result as
90        // incomplete. This follows what rust-analyzer does.
91        // https://github.com/rust-lang/rust-analyzer/blob/f5a9250147f6569d8d89334dc9cca79c0322729f/crates/rust-analyzer/src/handlers/request.rs#L940C55-L940C75
92        Some(CompletionList {
93            is_incomplete: false,
94            items: worker.completions,
95        })
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use std::collections::HashSet;
102
103    use super::*;
104    use crate::{completion::proto::CompletionItem, syntax::find_module_level_docs, tests::*};
105
106    struct TestConfig {
107        pkg_mode: bool,
108    }
109
110    fn run(config: TestConfig) -> impl Fn(&mut LocalContext, PathBuf) {
111        fn test(ctx: &mut LocalContext, id: TypstFileId) {
112            let source = ctx.source_by_id(id).unwrap();
113            let rng = find_test_range_(&source);
114            let text = source.text()[rng.clone()].to_string();
115
116            let docs = find_module_level_docs(&source).unwrap_or_default();
117            let properties = get_test_properties(&docs);
118
119            let trigger_character = properties
120                .get("trigger_character")
121                .map(|v| v.chars().next().unwrap());
122            let explicit = match properties.get("explicit").copied().map(str::trim) {
123                Some("true") => true,
124                Some("false") | None => false,
125                Some(v) => panic!("invalid value for 'explicit' property: {v}"),
126            };
127
128            let mut includes = HashSet::new();
129            let mut excludes = HashSet::new();
130
131            let graph = compile_doc_for_test(ctx, &properties);
132
133            for kk in properties.get("contains").iter().flat_map(|v| v.split(',')) {
134                // split first char
135                let (kind, item) = kk.split_at(1);
136                if kind == "+" {
137                    includes.insert(item.trim());
138                } else if kind == "-" {
139                    excludes.insert(item.trim());
140                } else {
141                    includes.insert(kk.trim());
142                }
143            }
144            let get_items = |items: Vec<CompletionItem>| {
145                let mut res: Vec<_> = items
146                    .into_iter()
147                    .filter(|item| {
148                        if !excludes.is_empty() && excludes.contains(item.label.as_str()) {
149                            panic!("{item:?} was excluded in {excludes:?}");
150                        }
151                        if includes.is_empty() {
152                            return true;
153                        }
154                        includes.contains(item.label.as_str())
155                    })
156                    .map(|item| CompletionItem {
157                        label: item.label,
158                        label_details: item.label_details,
159                        sort_text: item.sort_text,
160                        kind: item.kind,
161                        text_edit: item.text_edit,
162                        command: item.command,
163                        ..Default::default()
164                    })
165                    .collect();
166
167                res.sort_by(|a, b| {
168                    a.sort_text
169                        .as_ref()
170                        .cmp(&b.sort_text.as_ref())
171                        .then_with(|| a.label.cmp(&b.label))
172                });
173                res
174            };
175
176            let mut results = vec![];
177            for s in rng.clone() {
178                let request = CompletionRequest {
179                    path: ctx.path_for_id(id).unwrap().as_path().to_owned(),
180                    position: ctx.to_lsp_pos(s, &source),
181                    explicit,
182                    trigger_character,
183                };
184                let result = request
185                    .request(ctx, graph.clone())
186                    .map(|list| CompletionList {
187                        is_incomplete: list.is_incomplete,
188                        items: get_items(list.items),
189                    });
190                results.push(result);
191            }
192            with_settings!({
193                description => format!("Completion on {text} ({rng:?})"),
194            }, {
195                assert_snapshot!(JsonRepr::new_pure(results));
196            })
197        }
198
199        move |ctx, path| {
200            if config.pkg_mode {
201                let files = ctx
202                    .source_files()
203                    .iter()
204                    .filter(|id| !id.vpath().as_rootless_path().ends_with("lib.typ"));
205                for id in files.copied().collect::<Vec<_>>() {
206                    test(ctx, id);
207                }
208            } else {
209                test(ctx, ctx.file_id_by_path(&path).unwrap());
210            }
211        }
212    }
213
214    #[test]
215    fn test_base() {
216        snapshot_testing("completion", &run(TestConfig { pkg_mode: false }));
217    }
218
219    #[test]
220    fn test_pkgs() {
221        snapshot_testing("pkgs", &run(TestConfig { pkg_mode: true }));
222    }
223}