1use core::fmt::{self, Write};
2
3use tinymist_std::typst::TypstDocument;
4use typst::foundations::repr::separated_list;
5use typst_shim::syntax::LinkedNodeExt;
6
7use crate::analysis::get_link_exprs_in;
8use crate::bib::{RenderedBibCitation, render_citation_string};
9use crate::jump_from_cursor;
10use crate::prelude::*;
11use crate::upstream::{Tooltip, route_of_value, truncated_repr};
12
13#[derive(Debug, Clone)]
21pub struct HoverRequest {
22 pub path: PathBuf,
24 pub position: LspPosition,
26}
27
28impl StatefulRequest for HoverRequest {
29 type Response = Hover;
30
31 fn request(self, ctx: &mut LocalContext, graph: LspComputeGraph) -> Option<Self::Response> {
32 let doc = graph.snap.success_doc.clone();
33 let source = ctx.source_by_path(&self.path).ok()?;
34 let offset = ctx.to_typst_pos(self.position, &source)?;
35 let cursor = offset + 1;
37
38 let node = LinkedNode::new(source.root()).leaf_at_compat(cursor)?;
39 let range = ctx.to_lsp_range(node.range(), &source);
40
41 let mut worker = HoverWorker {
42 ctx,
43 source,
44 doc,
45 cursor,
46 def: Default::default(),
47 value: Default::default(),
48 preview: Default::default(),
49 docs: Default::default(),
50 actions: Default::default(),
51 };
52
53 worker.work();
54
55 let mut contents = vec![];
56
57 contents.append(&mut worker.def);
58 contents.append(&mut worker.value);
59 contents.append(&mut worker.preview);
60 contents.append(&mut worker.docs);
61 if !worker.actions.is_empty() {
62 let content = worker.actions.into_iter().join(" | ");
63 contents.push(content);
64 }
65
66 if contents.is_empty() {
67 return None;
68 }
69
70 Some(Hover {
71 contents: HoverContents::Scalar(MarkedString::String(contents.join("\n\n---\n\n"))),
74 range: Some(range),
75 })
76 }
77}
78
79struct HoverWorker<'a> {
80 ctx: &'a mut LocalContext,
81 source: Source,
82 doc: Option<TypstDocument>,
83 cursor: usize,
84 def: Vec<String>,
85 value: Vec<String>,
86 preview: Vec<String>,
87 docs: Vec<String>,
88 actions: Vec<CommandLink>,
89}
90
91impl HoverWorker<'_> {
92 fn work(&mut self) {
93 self.static_analysis();
94 self.preview();
95 self.dynamic_analysis();
96 }
97
98 fn static_analysis(&mut self) -> Option<()> {
100 let source = self.source.clone();
101 let leaf = LinkedNode::new(source.root()).leaf_at_compat(self.cursor)?;
102
103 self.definition(&leaf)
104 .or_else(|| self.star(&leaf))
105 .or_else(|| self.link(&leaf))
106 }
107
108 fn dynamic_analysis(&mut self) -> Option<()> {
110 let typst_tooltip = self.ctx.tooltip(&self.source, self.cursor)?;
111 self.value.push(match typst_tooltip {
112 Tooltip::Text(text) => text.to_string(),
113 Tooltip::Code(code) => format!("### Sampled Values\n```typc\n{code}\n```"),
114 });
115 Some(())
116 }
117
118 fn definition(&mut self, leaf: &LinkedNode) -> Option<()> {
120 let syntax = classify_syntax(leaf.clone(), self.cursor)?;
121 let def = self
122 .ctx
123 .def_of_syntax_or_dyn(&self.source, self.doc.as_ref(), syntax.clone())?;
124
125 use Decl::*;
126 match def.decl.as_ref() {
127 Label(..) => {
128 if let Some(val) = def.term.as_ref().and_then(|v| v.value()) {
129 self.def.push(format!("Ref: `{}`\n", def.name()));
130 self.def
131 .push(format!("```typc\n{}\n```", truncated_repr(&val)));
132 } else {
133 self.def.push(format!("Label: `{}`\n", def.name()));
134 }
135 }
136 BibEntry(..) => {
137 if let Some(details) = try_get_bib_details(&self.doc, self.ctx, def.name()) {
138 self.def.push(format!(
139 "Bibliography: `{}` {}",
140 def.name(),
141 details.citation
142 ));
143 self.def.push(details.bib_item);
144 } else {
145 self.def.push(format!("Bibliography: `{}`", def.name()));
147 }
148 }
149 _ => {
150 let sym_docs = self.ctx.def_docs(&def);
151
152 if matches!(
155 def.decl.kind(),
156 DefKind::Function | DefKind::Variable | DefKind::Constant
157 ) && !def.name().is_empty()
158 {
159 let mut type_doc = String::new();
160 type_doc.push_str("let ");
161 type_doc.push_str(def.name());
162
163 match &sym_docs {
164 Some(DefDocs::Variable(docs)) => {
165 push_result_ty(def.name(), docs.return_ty.as_ref(), &mut type_doc);
166 }
167 Some(DefDocs::Function(docs)) => {
168 let _ = docs.print(&mut type_doc);
169 push_result_ty(def.name(), docs.ret_ty.as_ref(), &mut type_doc);
170 }
171 _ => {}
172 }
173
174 self.def.push(format!("```typc\n{type_doc};\n```"));
175 }
176
177 if let Some(doc) = sym_docs {
178 let hover_docs = doc.hover_docs();
179
180 if !hover_docs.trim().is_empty() {
181 self.docs.push(hover_docs.into());
182 }
183 }
184
185 if let Some(link) = ExternalDocLink::get(&def) {
186 self.actions.push(link);
187 }
188 }
189 }
190
191 Some(())
192 }
193
194 fn star(&mut self, mut node: &LinkedNode) -> Option<()> {
195 if !matches!(node.kind(), SyntaxKind::Star) {
196 return None;
197 }
198
199 while !matches!(node.kind(), SyntaxKind::ModuleImport) {
200 node = node.parent()?;
201 }
202
203 let import_node = node.cast::<ast::ModuleImport>()?;
204 let scope_val = self
205 .ctx
206 .module_by_syntax(import_node.source().to_untyped())?;
207
208 let scope_items = scope_val.scope()?.iter();
209 let mut names = scope_items.map(|item| item.0.as_str()).collect::<Vec<_>>();
210 names.sort();
211
212 let content = format!("This star imports {}", separated_list(&names, "and"));
213 self.def.push(content);
214 Some(())
215 }
216
217 fn link(&mut self, mut node: &LinkedNode) -> Option<()> {
218 while !matches!(node.kind(), SyntaxKind::FuncCall) {
219 node = node.parent()?;
220 }
221
222 let links = get_link_exprs_in(node)?;
223 let links = links
224 .objects
225 .iter()
226 .filter(|link| link.range.contains(&self.cursor))
227 .collect::<Vec<_>>();
228 if links.is_empty() {
229 return None;
230 }
231
232 for obj in links {
233 let Some(target) = obj.target.resolve(self.ctx) else {
234 continue;
235 };
236 self.actions.push(CommandLink {
238 title: Some("Open in Tab".to_string()),
239 command_or_links: vec![CommandOrLink::Command {
240 id: "tinymist.openInternal".to_string(),
241 args: vec![JsonValue::String(target.to_string())],
242 }],
243 });
244 self.actions.push(CommandLink {
245 title: Some("Open Externally".to_string()),
246 command_or_links: vec![CommandOrLink::Command {
247 id: "tinymist.openExternal".to_string(),
248 args: vec![JsonValue::String(target.to_string())],
249 }],
250 });
251 if let Some(kind) = PathKind::from_ext(target.path()) {
252 self.def.push(format!("A `{kind:?}` file."));
253 }
254 }
255
256 Some(())
257 }
258
259 fn preview(&mut self) -> Option<()> {
260 let provider = self.ctx.analysis.periscope.clone()?;
262 let doc = self.doc.as_ref()?;
263 let jump = |cursor| {
264 jump_from_cursor(doc, &self.source, cursor)
265 .into_iter()
266 .next()
267 };
268 let position = jump(self.cursor);
269 let position = position.or_else(|| {
270 for idx in 1..100 {
271 let next_cursor = self.cursor + idx;
272 if next_cursor < self.source.text().len() {
273 let position = jump(next_cursor);
274 if position.is_some() {
275 return position;
276 }
277 }
278 let prev_cursor = self.cursor.checked_sub(idx);
279 if let Some(prev_cursor) = prev_cursor {
280 let position = jump(prev_cursor);
281 if position.is_some() {
282 return position;
283 }
284 }
285 }
286
287 None
288 });
289
290 log::info!("telescope position: {position:?}");
291
292 let preview_content = provider.periscope_at(self.ctx, doc, position?)?;
293 self.preview.push(preview_content);
294 Some(())
295 }
296}
297
298fn try_get_bib_details(
299 doc: &Option<TypstDocument>,
300 ctx: &LocalContext,
301 name: &str,
302) -> Option<RenderedBibCitation> {
303 let doc = doc.as_ref()?;
304 let support_html = !ctx.shared.analysis.remove_html;
305 let bib_info = ctx.analyze_bib(doc.introspector())?;
306 render_citation_string(&bib_info, name, support_html)
307}
308
309fn push_result_ty(
310 name: &str,
311 ty_repr: Option<&(EcoString, EcoString, EcoString)>,
312 type_doc: &mut String,
313) {
314 let Some((short, _, _)) = ty_repr else {
315 return;
316 };
317 if short == name {
318 return;
319 }
320
321 let _ = write!(type_doc, " = {short}");
322}
323
324struct ExternalDocLink;
325
326impl ExternalDocLink {
327 fn get(def: &Definition) -> Option<CommandLink> {
328 let value = def.value();
329
330 if matches!(value, Some(Value::Func(..)))
331 && let Some(builtin) = Self::builtin_func_tooltip("https://typst.app/docs/", def)
332 {
333 return Some(builtin);
334 };
335
336 value.and_then(|value| Self::builtin_value_tooltip("https://typst.app/docs/", &value))
337 }
338
339 fn builtin_func_tooltip(base: &str, def: &Definition) -> Option<CommandLink> {
340 let Some(Value::Func(func)) = def.value() else {
341 return None;
342 };
343
344 use typst::foundations::func::Repr;
345 let mut func = &func;
346 loop {
347 match func.inner() {
348 Repr::Element(..) | Repr::Native(..) => {
349 return Self::builtin_value_tooltip(base, &Value::Func(func.clone()));
350 }
351 Repr::With(w) => {
352 func = &w.0;
353 }
354 Repr::Closure(..) | Repr::Plugin(..) => {
355 return None;
356 }
357 }
358 }
359 }
360
361 fn builtin_value_tooltip(base: &str, value: &Value) -> Option<CommandLink> {
362 let base = base.trim_end_matches('/');
363 let route = route_of_value(value)?;
364 let link = format!("{base}/{route}");
365 Some(CommandLink {
366 title: Some("Open docs".to_owned()),
367 command_or_links: vec![CommandOrLink::Link(link)],
368 })
369 }
370}
371
372struct CommandLink {
373 title: Option<String>,
374 command_or_links: Vec<CommandOrLink>,
375}
376
377impl fmt::Display for CommandLink {
378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379 let title = self.title.as_deref().unwrap_or("");
381 let command_or_links = self.command_or_links.iter().join(" ");
382 write!(f, "[{title}]({command_or_links})")
383 }
384}
385
386enum CommandOrLink {
387 Link(String),
388 Command { id: String, args: Vec<JsonValue> },
389}
390
391impl fmt::Display for CommandOrLink {
392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393 match self {
394 Self::Link(link) => f.write_str(link),
395 Self::Command { id, args } => {
396 if args.is_empty() {
398 return write!(f, "command:{id}");
399 }
400
401 let args = serde_json::to_string(&args).unwrap();
402 let args = percent_encoding::utf8_percent_encode(
403 &args,
404 percent_encoding::NON_ALPHANUMERIC,
405 );
406 write!(f, "command:{id}?{args}")
407 }
408 }
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::tests::*;
416
417 #[test]
418 fn test() {
419 snapshot_testing("hover", &|ctx, path| {
420 let source = ctx.source_by_path(&path).unwrap();
421
422 let docs = find_module_level_docs(&source).unwrap_or_default();
423 let properties = get_test_properties(&docs);
424 let graph = compile_doc_for_test(ctx, &properties);
425
426 let request = HoverRequest {
427 path: path.clone(),
428 position: find_test_position(&source),
429 };
430
431 let result = request.request(ctx, graph);
432 let content = HoverDisplay(result.as_ref())
433 .to_string()
434 .replace("\n---\n", "\n\n======\n\n");
435 let content = JsonRepr::md_content(&content);
436 assert_snapshot!(content);
437 });
438 }
439
440 struct HoverDisplay<'a>(Option<&'a Hover>);
441
442 impl fmt::Display for HoverDisplay<'_> {
443 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
444 let Some(Hover { range, contents }) = self.0 else {
445 return write!(f, "No hover information");
446 };
447
448 if let Some(range) = range {
450 writeln!(f, "Range: {}\n", JsonRepr::range(range))?;
451 } else {
452 writeln!(f, "No range")?;
453 };
454
455 match contents {
457 HoverContents::Markup(content) => {
458 writeln!(f, "{}", content.value)?;
459 }
460 HoverContents::Scalar(MarkedString::String(content)) => {
461 writeln!(f, "{content}")?;
462 }
463 HoverContents::Scalar(MarkedString::LanguageString(lang_str)) => {
464 writeln!(f, "=== {} ===\n{}", lang_str.language, lang_str.value)?
465 }
466 HoverContents::Array(contents) => {
467 let content = contents
469 .iter()
470 .map(|content| match content {
471 MarkedString::String(text) => text.to_string(),
472 MarkedString::LanguageString(lang_str) => {
473 format!("=== {} ===\n{}", lang_str.language, lang_str.value)
474 }
475 })
476 .collect::<Vec<_>>()
477 .join("\n\n=====\n\n");
478 writeln!(f, "{content}")?;
479 }
480 }
481
482 Ok(())
483 }
484 }
485}