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 }));
}
}