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 show_filter_text = properties
145 .get("filter_text")
146 .map(|v| v.trim() == "true")
147 .unwrap_or(false);
148 let get_items = |items: Vec<CompletionItem>| {
149 let mut res: Vec<_> = items
150 .into_iter()
151 .filter(|item| {
152 if !excludes.is_empty() && excludes.contains(item.label.as_str()) {
153 panic!("{item:?} was excluded in {excludes:?}");
154 }
155 if includes.is_empty() {
156 return true;
157 }
158 includes.contains(item.label.as_str())
159 })
160 .map(|item| CompletionItem {
161 label: item.label,
162 label_details: item.label_details,
163 sort_text: item.sort_text,
164 filter_text: show_filter_text.then_some(item.filter_text).flatten(),
165 kind: item.kind,
166 text_edit: item.text_edit,
167 additional_text_edits: item.additional_text_edits,
168 command: item.command,
169 ..Default::default()
170 })
171 .collect();
172
173 res.sort_by(|a, b| {
174 a.sort_text
175 .as_ref()
176 .cmp(&b.sort_text.as_ref())
177 .then_with(|| a.label.cmp(&b.label))
178 });
179 res
180 };
181
182 let mut results = vec![];
183 for s in rng.clone() {
184 let request = CompletionRequest {
185 path: ctx.path_for_id(id).unwrap().as_path().to_owned(),
186 position: ctx.to_lsp_pos(s, &source),
187 explicit,
188 trigger_character,
189 };
190 let result = request.request(ctx).map(|list| CompletionList {
191 is_incomplete: list.is_incomplete,
192 items: get_items(list.items),
193 });
194 results.push(result);
195 }
196 with_settings!({
197 description => format!("Completion on {text} ({rng:?})"),
198 }, {
199 assert_snapshot!(JsonRepr::new_pure(results));
200 })
201 }
202
203 move |ctx, path| {
204 if config.pkg_mode {
205 let files = ctx
206 .source_files()
207 .iter()
208 .filter(|id| !id.vpath().get_without_slash().ends_with("lib.typ"));
209 for id in files.copied().collect::<Vec<_>>() {
210 test(ctx, id);
211 }
212 } else {
213 test(ctx, ctx.file_id_by_path(&path).unwrap());
214 }
215 }
216 }
217
218 #[test]
219 fn test_base() {
220 snapshot_testing("completion", &run(TestConfig { pkg_mode: false }));
221 }
222
223 #[test]
224 fn test_pkgs() {
225 snapshot_testing("pkgs", &run(TestConfig { pkg_mode: true }));
226 }
227
228 #[test]
229 fn explicit_citation_label_completion_strips_typed_angle_brackets() {
230 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
231 .join("src/fixtures/completion/complete_half_label_cite_explicit.typ");
232 let contents = std::fs::read_to_string(&path).unwrap();
233
234 run_with_sources(&contents, |verse: &mut LspUniverse, path| {
235 run_with_ctx(verse, path, &|ctx, path| {
236 let source = ctx.source_by_path(&path).unwrap();
237 let rng = find_test_range_(&source);
238 let request = CompletionRequest {
239 path: path.clone(),
240 position: ctx.to_lsp_pos(rng.start, &source),
241 explicit: false,
242 trigger_character: None,
243 };
244 let result = request.request(ctx).unwrap();
245 let item = result
246 .items
247 .into_iter()
248 .find(|item| item.label == "DBLP:books/lib/Knuth86a")
249 .unwrap();
250
251 assert_eq!(
252 item.text_edit.as_ref().unwrap().new_text().as_str(),
253 "label(\"DBLP:books/lib/Knuth86a\")"
254 );
255
256 let cleanup_edits = item.additional_text_edits.unwrap();
257 assert_eq!(cleanup_edits.len(), 1);
258
259 let cleanup = &cleanup_edits[0];
260 assert_eq!(cleanup.new_text.as_str(), "");
261 assert_eq!(cleanup.range.start.line, 3);
262 assert_eq!(cleanup.range.start.character, 6);
263 assert_eq!(cleanup.range.end.line, 3);
264 assert_eq!(cleanup.range.end.character, 7);
265 });
266 });
267 }
268}