tinymist_query/
completion.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
use crate::analysis::{CompletionCursor, CompletionWorker};
use crate::prelude::*;

pub(crate) mod proto;
pub use proto::*;
pub(crate) mod snippet;
pub use snippet::*;

/// The [`textDocument/completion`] request is sent from the client to the
/// server to compute completion items at a given cursor position.
///
/// If computing full completion items is expensive, servers can additionally
/// provide a handler for the completion item resolve request
/// (`completionItem/resolve`). This request is sent when a completion item is
/// selected in the user interface.
///
/// [`textDocument/completion`]: https://microsoft.github.io/language-server-protocol/specification#textDocument_completion
///
/// # Compatibility
///
/// Since 3.16.0, the client can signal that it can resolve more properties
/// lazily. This is done using the `completion_item.resolve_support` client
/// capability which lists all properties that can be filled in during a
/// `completionItem/resolve` request.
///
/// All other properties (usually `sort_text`, `filter_text`, `insert_text`, and
/// `text_edit`) must be provided in the `textDocument/completion` response and
/// must not be changed during resolve.
#[derive(Debug, Clone)]
pub struct CompletionRequest {
    /// The path of the document to compute completions.
    pub path: PathBuf,
    /// The position in the document at which to compute completions.
    pub position: LspPosition,
    /// Whether the completion is triggered explicitly.
    pub explicit: bool,
    /// The character that triggered the completion, if any.
    pub trigger_character: Option<char>,
}

impl StatefulRequest for CompletionRequest {
    type Response = CompletionList;

    fn request(self, ctx: &mut LocalContext, graph: LspComputeGraph) -> Option<Self::Response> {
        // These trigger characters are for completion on positional arguments,
        // which follows the configuration item
        // `tinymist.completion.triggerOnSnippetPlaceholders`.
        if matches!(self.trigger_character, Some('(' | ',' | ':'))
            && !ctx.analysis.completion_feat.trigger_on_snippet_placeholders
        {
            return None;
        }

        let document = graph.snap.success_doc.as_ref();
        let source = ctx.source_by_path(&self.path).ok()?;
        let cursor = ctx.to_typst_pos_offset(&source, self.position, 0)?;

        // Please see <https://github.com/nvarner/typst-lsp/commit/2d66f26fb96ceb8e485f492e5b81e9db25c3e8ec>
        //
        // FIXME: correctly identify a completion which is triggered
        // by explicit action, such as by pressing control and space
        // or something similar.
        //
        // See <https://github.com/microsoft/language-server-protocol/issues/1101>
        // > As of LSP 3.16, CompletionTriggerKind takes the value Invoked for
        // > both manually invoked (for ex: ctrl + space in VSCode) completions
        // > and always on (what the spec refers to as 24/7 completions).
        //
        // Hence, we cannot distinguish between the two cases. Conservatively, we
        // assume that the completion is not explicit.
        //
        // Second try: According to VSCode:
        // - <https://github.com/microsoft/vscode/issues/130953>
        // - <https://github.com/microsoft/vscode/commit/0984071fe0d8a3c157a1ba810c244752d69e5689>
        // Checks the previous text to filter out letter explicit completions.
        //
        // Second try is failed.
        let explicit = false;
        let mut cursor = CompletionCursor::new(ctx.shared_(), &source, cursor)?;

        let mut worker = CompletionWorker::new(ctx, document, explicit, self.trigger_character)?;
        worker.work(&mut cursor)?;

        // todo: define it well, we were needing it because we wanted to do interactive
        // path completion, but now we've scanned all the paths at the same time.
        // is_incomplete = ic;
        let _ = worker.incomplete;

        // To response completions in fine-grained manner, we need to mark result as
        // incomplete. This follows what rust-analyzer does.
        // https://github.com/rust-lang/rust-analyzer/blob/f5a9250147f6569d8d89334dc9cca79c0322729f/crates/rust-analyzer/src/handlers/request.rs#L940C55-L940C75
        Some(CompletionList {
            is_incomplete: false,
            items: worker.completions,
        })
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashSet;

    use insta::with_settings;

    use super::*;
    use crate::{completion::proto::CompletionItem, syntax::find_module_level_docs, tests::*};

    struct TestConfig {
        pkg_mode: bool,
    }

    fn run(config: TestConfig) -> impl Fn(&mut LocalContext, PathBuf) {
        fn test(ctx: &mut LocalContext, id: TypstFileId) {
            let source = ctx.source_by_id(id).unwrap();
            let rng = find_test_range_(&source);
            let text = source.text()[rng.clone()].to_string();

            let docs = find_module_level_docs(&source).unwrap_or_default();
            let properties = get_test_properties(&docs);

            let trigger_character = properties
                .get("trigger_character")
                .map(|v| v.chars().next().unwrap());
            let explicit = match properties.get("explicit").copied().map(str::trim) {
                Some("true") => true,
                Some("false") | None => false,
                Some(v) => panic!("invalid value for 'explicit' property: {v}"),
            };

            let mut includes = HashSet::new();
            let mut excludes = HashSet::new();

            let graph = compile_doc_for_test(ctx, &properties);

            for kk in properties.get("contains").iter().flat_map(|v| v.split(',')) {
                // split first char
                let (kind, item) = kk.split_at(1);
                if kind == "+" {
                    includes.insert(item.trim());
                } else if kind == "-" {
                    excludes.insert(item.trim());
                } else {
                    includes.insert(kk.trim());
                }
            }
            let get_items = |items: Vec<CompletionItem>| {
                let mut res: Vec<_> = items
                    .into_iter()
                    .filter(|item| {
                        if !excludes.is_empty() && excludes.contains(item.label.as_str()) {
                            panic!("{item:?} was excluded in {excludes:?}");
                        }
                        if includes.is_empty() {
                            return true;
                        }
                        includes.contains(item.label.as_str())
                    })
                    .map(|item| CompletionItem {
                        label: item.label,
                        label_details: item.label_details,
                        sort_text: item.sort_text,
                        kind: item.kind,
                        text_edit: item.text_edit,
                        command: item.command,
                        ..Default::default()
                    })
                    .collect();

                res.sort_by(|a, b| {
                    a.sort_text
                        .as_ref()
                        .cmp(&b.sort_text.as_ref())
                        .then_with(|| a.label.cmp(&b.label))
                });
                res
            };

            let mut results = vec![];
            for s in rng.clone() {
                let request = CompletionRequest {
                    path: ctx.path_for_id(id).unwrap().as_path().to_owned(),
                    position: ctx.to_lsp_pos(s, &source),
                    explicit,
                    trigger_character,
                };
                let result = request
                    .request(ctx, graph.clone())
                    .map(|list| CompletionList {
                        is_incomplete: list.is_incomplete,
                        items: get_items(list.items),
                    });
                results.push(result);
            }
            with_settings!({
                description => format!("Completion on {text} ({rng:?})"),
            }, {
                assert_snapshot!(JsonRepr::new_pure(results));
            })
        }

        move |ctx, path| {
            if config.pkg_mode {
                let files = ctx
                    .source_files()
                    .iter()
                    .filter(|id| !id.vpath().as_rootless_path().ends_with("lib.typ"));
                for id in files.copied().collect::<Vec<_>>() {
                    test(ctx, id);
                }
            } else {
                test(ctx, ctx.file_id_by_path(&path).unwrap());
            }
        }
    }

    #[test]
    fn test_base() {
        snapshot_testing("completion", &run(TestConfig { pkg_mode: false }));
    }

    #[test]
    fn test_pkgs() {
        snapshot_testing("pkgs", &run(TestConfig { pkg_mode: true }));
    }
}