tinymist_query/
completion.rs1use crate::analysis::{CompletionCursor, CompletionWorker};
2use crate::prelude::*;
3
4pub(crate) mod proto;
5pub use proto::*;
6pub(crate) mod snippet;
7pub use snippet::*;
8
9#[derive(Debug, Clone)]
30pub struct CompletionRequest {
31 pub path: PathBuf,
33 pub position: LspPosition,
35 pub explicit: bool,
37 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 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 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 let _ = worker.incomplete;
89
90 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 use std::path::Path;
104
105 use super::*;
106 use crate::{completion::proto::CompletionItem, syntax::find_module_level_docs, tests::*};
107
108 struct TestConfig {
109 pkg_mode: bool,
110 }
111
112 fn run(config: TestConfig) -> impl Fn(&mut LocalContext, PathBuf) {
113 fn test(ctx: &mut LocalContext, id: TypstFileId) {
114 let source = ctx.source_by_id(id).unwrap();
115 let rng = find_test_range_(&source);
116 let text = source.text()[rng.clone()].to_string();
117
118 let docs = find_module_level_docs(&source).unwrap_or_default();
119 let properties = get_test_properties(&docs);
120
121 let trigger_character = properties
122 .get("trigger_character")
123 .map(|v| v.chars().next().unwrap());
124 let explicit = match properties.get("explicit").copied().map(str::trim) {
125 Some("true") => true,
126 Some("false") | None => false,
127 Some(v) => panic!("invalid value for 'explicit' property: {v}"),
128 };
129
130 let mut includes = HashSet::new();
131 let mut excludes = HashSet::new();
132
133 for kk in properties.get("contains").iter().flat_map(|v| v.split(',')) {
134 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 additional_text_edits: item.additional_text_edits,
163 command: item.command,
164 ..Default::default()
165 })
166 .collect();
167
168 res.sort_by(|a, b| {
169 a.sort_text
170 .as_ref()
171 .cmp(&b.sort_text.as_ref())
172 .then_with(|| a.label.cmp(&b.label))
173 });
174 res
175 };
176
177 let mut results = vec![];
178 for s in rng.clone() {
179 let request = CompletionRequest {
180 path: ctx.path_for_id(id).unwrap().as_path().to_owned(),
181 position: ctx.to_lsp_pos(s, &source),
182 explicit,
183 trigger_character,
184 };
185 let result = request.request(ctx).map(|list| CompletionList {
186 is_incomplete: list.is_incomplete,
187 items: get_items(list.items),
188 });
189 results.push(result);
190 }
191 with_settings!({
192 description => format!("Completion on {text} ({rng:?})"),
193 }, {
194 assert_snapshot!(JsonRepr::new_pure(results));
195 })
196 }
197
198 move |ctx, path| {
199 if config.pkg_mode {
200 let files = ctx
201 .source_files()
202 .iter()
203 .filter(|id| !id.vpath().as_rootless_path().ends_with("lib.typ"));
204 for id in files.copied().collect::<Vec<_>>() {
205 test(ctx, id);
206 }
207 } else {
208 test(ctx, ctx.file_id_by_path(&path).unwrap());
209 }
210 }
211 }
212
213 #[test]
214 fn test_base() {
215 snapshot_testing("completion", &run(TestConfig { pkg_mode: false }));
216 }
217
218 #[test]
219 fn test_pkgs() {
220 snapshot_testing("pkgs", &run(TestConfig { pkg_mode: true }));
221 }
222
223 #[test]
224 fn explicit_citation_label_completion_strips_typed_angle_brackets() {
225 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
226 .join("src/fixtures/completion/complete_half_label_cite_explicit.typ");
227 let contents = std::fs::read_to_string(&path).unwrap();
228
229 run_with_sources(&contents, |verse: &mut LspUniverse, path| {
230 run_with_ctx(verse, path, &|ctx, path| {
231 let source = ctx.source_by_path(&path).unwrap();
232 let rng = find_test_range_(&source);
233 let request = CompletionRequest {
234 path: path.clone(),
235 position: ctx.to_lsp_pos(rng.start, &source),
236 explicit: false,
237 trigger_character: None,
238 };
239 let result = request.request(ctx).unwrap();
240 let item = result
241 .items
242 .into_iter()
243 .find(|item| item.label == "DBLP:books/lib/Knuth86a")
244 .unwrap();
245
246 assert_eq!(
247 item.text_edit.as_ref().unwrap().new_text.as_str(),
248 "label(\"DBLP:books/lib/Knuth86a\")"
249 );
250
251 let cleanup_edits = item.additional_text_edits.unwrap();
252 assert_eq!(cleanup_edits.len(), 1);
253
254 let cleanup = &cleanup_edits[0];
255 assert_eq!(cleanup.new_text.as_str(), "");
256 assert_eq!(cleanup.range.start.line, 3);
257 assert_eq!(cleanup.range.start.character, 6);
258 assert_eq!(cleanup.range.end.line, 3);
259 assert_eq!(cleanup.range.end.character, 7);
260 });
261 });
262 }
263}