1use core::fmt::{self, Write};
2use std::cmp::Reverse;
3
4use tinymist_std::typst::TypstDocument;
5use tinymist_world::package::{PackageSpec, PackageSpecExt};
6use typst::foundations::repr::separated_list;
7use typst_shim::syntax::LinkedNodeExt;
8
9use crate::analysis::get_link_exprs_in;
10use crate::bib::{RenderedBibCitation, render_citation_string};
11use crate::jump_from_cursor;
12use crate::package::parse_package_import;
13use crate::prelude::*;
14use crate::upstream::{Tooltip, route_of_value, truncated_repr};
15
16#[derive(Debug, Clone)]
24pub struct HoverRequest {
25 pub path: PathBuf,
27 pub position: LspPosition,
29}
30
31pub(crate) fn hover_from_definition_shared(
32 ctx: &Arc<crate::analysis::SharedContext>,
33 def: &Definition,
34 range: Option<LspRange>,
35) -> Option<Hover> {
36 hover_from_docs(def, range, ctx.def_docs(def))
37}
38
39fn hover_from_docs(
40 def: &Definition,
41 range: Option<LspRange>,
42 sym_docs: Option<DefDocs>,
43) -> Option<Hover> {
44 let mut contents = vec![];
45
46 use Decl::*;
47 match def.decl.as_ref() {
48 Label(..) => {
49 if let Some(val) = def.term.as_ref().and_then(|v| v.value()) {
50 contents.push(format!("Ref: `{}`\n", def.name()));
51 contents.push(format!("```typc\n{}\n```", truncated_repr(&val)));
52 } else {
53 contents.push(format!("Label: `{}`\n", def.name()));
54 }
55 }
56 BibEntry(..) => {
57 contents.push(format!("Bibliography: `{}`", def.name()));
58 }
59 _ => {
60 if matches!(
61 def.decl.kind(),
62 DefKind::Function | DefKind::Variable | DefKind::Constant
63 ) && !def.name().is_empty()
64 {
65 let mut type_doc = String::new();
66 type_doc.push_str("let ");
67 type_doc.push_str(def.name());
68
69 match &sym_docs {
70 Some(DefDocs::Variable(docs)) => {
71 push_result_ty(def.name(), docs.return_ty.as_ref(), &mut type_doc);
72 }
73 Some(DefDocs::Function(docs)) => {
74 let _ = docs.print(&mut type_doc);
75 push_result_ty(def.name(), docs.ret_ty.as_ref(), &mut type_doc);
76 }
77 _ => {}
78 }
79
80 contents.push(format!("```typc\n{type_doc};\n```"));
81 }
82
83 if let Some(doc) = sym_docs {
84 let hover_docs = doc.hover_docs();
85
86 if !hover_docs.trim().is_empty() {
87 contents.push(hover_docs.into());
88 }
89 }
90
91 if let Some(link) = ExternalDocLink::get(def) {
92 contents.push(link.to_string());
93 }
94 }
95 }
96
97 if contents.is_empty() {
98 return None;
99 }
100
101 Some(Hover {
102 contents: HoverContents::Scalar(MarkedString::String(contents.join("\n\n---\n\n"))),
103 range,
104 })
105}
106
107impl SemanticRequest for HoverRequest {
108 type Response = Hover;
109
110 fn request(self, ctx: &mut LocalContext) -> Option<Self::Response> {
111 let doc = ctx.success_doc().cloned();
112 let source = ctx.source_by_path(&self.path).ok()?;
113 let offset = ctx.to_typst_pos(self.position, &source)?;
114 let cursor = offset + 1;
116
117 let node = LinkedNode::new(source.root()).leaf_at_compat(cursor)?;
118 let range = ctx.to_lsp_range(node.range(), &source);
119
120 let mut worker = HoverWorker {
121 ctx,
122 source,
123 doc,
124 cursor,
125 def: Default::default(),
126 value: Default::default(),
127 preview: Default::default(),
128 docs: Default::default(),
129 actions: Default::default(),
130 };
131
132 worker.work();
133
134 let mut contents = vec![];
135
136 contents.append(&mut worker.def);
137 contents.append(&mut worker.value);
138 contents.append(&mut worker.preview);
139 contents.append(&mut worker.docs);
140 if !worker.actions.is_empty() {
141 let content = worker.actions.into_iter().join(" | ");
142 contents.push(content);
143 }
144
145 if contents.is_empty() {
146 return None;
147 }
148
149 Some(Hover {
150 contents: HoverContents::Scalar(MarkedString::String(contents.join("\n\n---\n\n"))),
153 range: Some(range),
154 })
155 }
156}
157
158struct HoverWorker<'a> {
159 ctx: &'a mut LocalContext,
160 source: Source,
161 doc: Option<TypstDocument>,
162 cursor: usize,
163 def: Vec<String>,
164 value: Vec<String>,
165 preview: Vec<String>,
166 docs: Vec<String>,
167 actions: Vec<CommandLink>,
168}
169
170impl HoverWorker<'_> {
171 fn work(&mut self) {
172 self.static_analysis();
173 self.preview();
174 self.dynamic_analysis();
175 }
176
177 fn static_analysis(&mut self) -> Option<()> {
179 let source = self.source.clone();
180 let leaf = LinkedNode::new(source.root()).leaf_at_compat(self.cursor)?;
181
182 self.package_import(&leaf);
183 self.definition(&leaf)
184 .or_else(|| self.star(&leaf))
185 .or_else(|| self.link(&leaf))
186 }
187
188 fn dynamic_analysis(&mut self) -> Option<()> {
190 let typst_tooltip = self.ctx.tooltip(&self.source, self.cursor)?;
191 self.value.push(match typst_tooltip {
192 Tooltip::Text(text) => text.to_string(),
193 Tooltip::Code(code) => format!("### Sampled Values\n```typc\n{code}\n```"),
194 });
195 Some(())
196 }
197
198 fn definition(&mut self, leaf: &LinkedNode) -> Option<()> {
200 let syntax = classify_syntax(leaf.clone(), self.cursor)?;
201 let def = self
202 .ctx
203 .def_of_syntax_or_dyn(&self.source, syntax.clone())?;
204
205 use Decl::*;
206 match def.decl.as_ref() {
207 Label(..) => {
208 if let Some(val) = def.term.as_ref().and_then(|v| v.value()) {
209 self.def.push(format!("Ref: `{}`\n", def.name()));
210 self.def
211 .push(format!("```typc\n{}\n```", truncated_repr(&val)));
212 } else {
213 self.def.push(format!("Label: `{}`\n", def.name()));
214 }
215 }
216 BibEntry(..) => {
217 if let Some(details) = try_get_bib_details(&self.doc, self.ctx, def.name()) {
218 self.def.push(format!(
219 "Bibliography: `{}` {}",
220 def.name(),
221 details.citation
222 ));
223 self.def.push(details.bib_item);
224 } else {
225 self.def.push(format!("Bibliography: `{}`", def.name()));
227 }
228 }
229 _ => {
230 let sym_docs = self.ctx.def_docs(&def);
231
232 if matches!(
235 def.decl.kind(),
236 DefKind::Function | DefKind::Variable | DefKind::Constant
237 ) && !def.name().is_empty()
238 {
239 let mut type_doc = String::new();
240 type_doc.push_str("let ");
241 type_doc.push_str(def.name());
242
243 match &sym_docs {
244 Some(DefDocs::Variable(docs)) => {
245 push_result_ty(def.name(), docs.return_ty.as_ref(), &mut type_doc);
246 }
247 Some(DefDocs::Function(docs)) => {
248 let _ = docs.print(&mut type_doc);
249 push_result_ty(def.name(), docs.ret_ty.as_ref(), &mut type_doc);
250 }
251 _ => {}
252 }
253
254 self.def.push(format!("```typc\n{type_doc};\n```"));
255 }
256
257 if let Some(doc) = sym_docs {
258 let hover_docs = doc.hover_docs();
259
260 if !hover_docs.trim().is_empty() {
261 self.docs.push(hover_docs.into());
262 }
263 }
264
265 if let Some(link) = ExternalDocLink::get(&def) {
266 self.actions.push(link);
267 }
268 }
269 }
270
271 Some(())
272 }
273
274 fn star(&mut self, mut node: &LinkedNode) -> Option<()> {
275 if !matches!(node.kind(), SyntaxKind::Star) {
276 return None;
277 }
278
279 while !matches!(node.kind(), SyntaxKind::ModuleImport) {
280 node = node.parent()?;
281 }
282
283 let import_node = node.cast::<ast::ModuleImport>()?;
284 let scope_val = self
285 .ctx
286 .module_by_syntax(import_node.source().to_untyped())?;
287
288 let scope_items = scope_val.scope()?.iter();
289 let mut names = scope_items.map(|item| item.0.as_str()).collect::<Vec<_>>();
290 names.sort();
291
292 let content = format!("This star imports {}", separated_list(&names, "and"));
293 self.def.push(content);
294 Some(())
295 }
296
297 fn package_import(&mut self, node: &LinkedNode) -> Option<()> {
298 let package_spec = parse_package_import(node)?;
299 self.def
300 .push(self.get_package_hover_info(&package_spec, node));
301 Some(())
302 }
303
304 fn get_package_hover_info(
306 &self,
307 package_spec: &PackageSpec,
308 import_str_node: &LinkedNode,
309 ) -> String {
310 let versionless_spec = package_spec.versionless();
311
312 let w = self.ctx.world().clone();
314 let mut packages = vec![];
315 if package_spec.is_preview() {
316 packages.extend(
317 w.packages()
318 .iter()
319 .filter(|it| it.matches_versionless(&versionless_spec)),
320 );
321 }
322 #[cfg(feature = "local-registry")]
324 let local_packages = self.ctx.non_preview_packages();
325 #[cfg(feature = "local-registry")]
326 if !package_spec.is_preview() {
327 packages.extend(
328 local_packages
329 .iter()
330 .filter(|it| it.matches_versionless(&versionless_spec)),
331 );
332 }
333
334 packages.sort_by_key(|entry| Reverse(entry.package.version));
336
337 let current_entry = packages
338 .iter()
339 .find(|entry| entry.package.version == package_spec.version);
340
341 let mut info = String::new();
342 {
343 let mut links_line = Vec::new();
345
346 if package_spec.is_preview() {
347 let package_name = &package_spec.name;
348
349 let universe_url = format!("https://typst.app/universe/package/{package_name}");
351 links_line.push(format!("[Universe]({universe_url})"));
352 }
353
354 if let Some(current_entry) = current_entry {
355 if let Some(ref repo) = current_entry.package.repository {
357 links_line.push(format!("[Repository]({repo})"));
358 }
359
360 if let Some(ref homepage) = current_entry.package.homepage {
362 links_line.push(format!("[Homepage]({homepage})"));
363 }
364 }
365
366 if !links_line.is_empty() {
367 info.push_str(&links_line.iter().join(" | "));
368 info.push_str("\n\n");
369 }
370 }
371
372 if !package_spec.is_preview() {
374 info.push_str("Info: This is a local package\n\n");
375 }
376
377 info.push_str(&format!("**Package:** `{package_spec}`\n"));
378 if current_entry.is_none() {
380 info.push_str(&format!(
381 "**Version {} not found**\n\n",
382 package_spec.version
383 ));
384 } else if let Some(latest) = packages.first() {
385 let latest_version = &latest.package.version;
386 if *latest_version != package_spec.version {
387 info.push_str(&format!("**Newer version available: {latest_version}**\n"));
388 } else {
389 info.push_str("**Up to date** (latest version)\n");
390 }
391 }
392 info.push('\n');
393
394 let date_format = tinymist_std::time::yyyy_mm_dd();
395
396 if let Some(current_entry) = current_entry {
398 let pkg_info = ¤t_entry.package;
399
400 if !pkg_info.authors.is_empty() {
401 info.push_str(&format!("**Authors:** {}\n\n", pkg_info.authors.join(", ")));
402 }
403
404 if let Some(description) = pkg_info.description.as_ref() {
405 info.push_str(&format!("**Description:** {description}\n\n"));
406 }
407
408 if let Some(license) = &pkg_info.license {
409 info.push_str(&format!("**License:** {license}\n\n"));
410 }
411
412 if let Some(updated_at) = ¤t_entry.updated_at {
413 info.push_str(&format!(
414 "**Updated:** {}\n\n",
415 updated_at
416 .format(&date_format)
417 .unwrap_or_else(|_| "unknown".to_string())
418 ));
419 }
420 }
421
422 if !packages.is_empty() {
424 info.push_str(&format!(
425 "**Available Versions ({})** (click to replace):\n",
426 packages.len()
427 ));
428 for entry in &packages {
429 let version = &entry.package.version;
430 let release_date = entry
431 .updated_at
432 .and_then(|time| time.format(&date_format).ok())
433 .unwrap_or_default();
434 if *version == package_spec.version {
435 info.push_str(&format!("- **{version}** / {release_date}\n"));
437 continue;
438 }
439 let lsp_range = self.ctx.to_lsp_range(import_str_node.range(), &self.source);
441 let args = serde_json::json!({
442 "range": lsp_range,
443 "replace": format!(
444 "\"@{}/{}:{}\"",
445 package_spec.namespace, package_spec.name, version
446 )
447 });
448 let json_str = match serde_json::to_string(&args) {
449 Ok(s) => s,
450 Err(e) => {
451 log::error!("Failed to serialize arguments for replaceText command: {e}");
452 continue;
453 }
454 };
455 let encoded = percent_encoding::utf8_percent_encode(
456 &json_str,
457 percent_encoding::NON_ALPHANUMERIC,
458 );
459 let version_url = format!("command:tinymist.replaceText?{encoded}");
460 info.push_str(&format!("- [{version}]({version_url}) / {release_date}\n"));
461 }
462 info.push('\n');
463 }
464
465 info
466 }
467
468 fn link(&mut self, mut node: &LinkedNode) -> Option<()> {
469 while !matches!(node.kind(), SyntaxKind::FuncCall) {
470 node = node.parent()?;
471 }
472
473 let links = get_link_exprs_in(node);
474 let links = links
475 .objects
476 .iter()
477 .filter(|link| link.range.contains(&self.cursor))
478 .collect::<Vec<_>>();
479 if links.is_empty() {
480 return None;
481 }
482
483 for obj in links {
484 let Some(target) = obj.target.resolve(self.ctx) else {
485 continue;
486 };
487 self.actions.push(CommandLink {
489 title: Some("Open in Tab".to_string()),
490 command_or_links: vec![CommandOrLink::Command {
491 id: "tinymist.openInternal".to_string(),
492 args: vec![JsonValue::String(target.to_string())],
493 }],
494 });
495 self.actions.push(CommandLink {
496 title: Some("Open Externally".to_string()),
497 command_or_links: vec![CommandOrLink::Command {
498 id: "tinymist.openExternal".to_string(),
499 args: vec![JsonValue::String(target.to_string())],
500 }],
501 });
502 if let Some(kind) = PathKind::from_ext(target.path()) {
503 self.def.push(format!("A `{kind:?}` file."));
504 }
505 }
506
507 Some(())
508 }
509
510 fn preview(&mut self) -> Option<()> {
511 let provider = self.ctx.analysis.periscope.clone()?;
513 let doc = self.doc.as_ref()?;
514 let jump = |cursor| {
515 jump_from_cursor(doc, &self.source, cursor)
516 .into_iter()
517 .next()
518 };
519 let position = jump(self.cursor);
520 let position = position.or_else(|| {
521 for idx in 1..100 {
522 let next_cursor = self.cursor + idx;
523 if next_cursor < self.source.text().len() {
524 let position = jump(next_cursor);
525 if position.is_some() {
526 return position;
527 }
528 }
529 let prev_cursor = self.cursor.checked_sub(idx);
530 if let Some(prev_cursor) = prev_cursor {
531 let position = jump(prev_cursor);
532 if position.is_some() {
533 return position;
534 }
535 }
536 }
537
538 None
539 });
540
541 log::info!("telescope position: {position:?}");
542
543 let preview_content = provider.periscope_at(self.ctx, doc, position?)?;
544 self.preview.push(preview_content);
545 Some(())
546 }
547}
548
549fn try_get_bib_details(
550 doc: &Option<TypstDocument>,
551 ctx: &LocalContext,
552 name: &str,
553) -> Option<RenderedBibCitation> {
554 let doc = doc.as_ref()?;
555 let support_html = !ctx.shared.analysis.remove_html;
556 let bib_info = ctx.analyze_bib(doc.introspector())?;
557 render_citation_string(&bib_info, name, support_html)
558}
559
560fn push_result_ty(
561 name: &str,
562 ty_repr: Option<&(EcoString, EcoString, EcoString)>,
563 type_doc: &mut String,
564) {
565 let Some((short, _, _)) = ty_repr else {
566 return;
567 };
568 if short == name {
569 return;
570 }
571
572 let _ = write!(type_doc, " = {short}");
573}
574
575struct ExternalDocLink;
576
577impl ExternalDocLink {
578 fn get(def: &Definition) -> Option<CommandLink> {
579 let value = def.value();
580
581 if matches!(value, Some(Value::Func(..)))
582 && let Some(builtin) = Self::builtin_func_tooltip("https://typst.app/docs/", def)
583 {
584 return Some(builtin);
585 };
586
587 value.and_then(|value| Self::builtin_value_tooltip("https://typst.app/docs/", &value))
588 }
589
590 fn builtin_func_tooltip(base: &str, def: &Definition) -> Option<CommandLink> {
591 let Some(Value::Func(func)) = def.value() else {
592 return None;
593 };
594
595 use typst::foundations::FuncInner;
596 let mut func = &func;
597 loop {
598 match func.inner() {
599 FuncInner::Element(..) | FuncInner::Native(..) => {
600 return Self::builtin_value_tooltip(base, &Value::Func(func.clone()));
601 }
602 FuncInner::With(w) => {
603 func = &w.0;
604 }
605 FuncInner::Closure(..) | FuncInner::Plugin(..) => {
606 return None;
607 }
608 }
609 }
610 }
611
612 fn builtin_value_tooltip(base: &str, value: &Value) -> Option<CommandLink> {
613 let base = base.trim_end_matches('/');
614 let route = route_of_value(value)?;
615 let link = format!("{base}/{route}");
616 Some(CommandLink {
617 title: Some("Open docs".to_owned()),
618 command_or_links: vec![CommandOrLink::Link(link)],
619 })
620 }
621}
622
623struct CommandLink {
624 title: Option<String>,
625 command_or_links: Vec<CommandOrLink>,
626}
627
628impl fmt::Display for CommandLink {
629 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
630 let title = self.title.as_deref().unwrap_or("");
632 let command_or_links = self.command_or_links.iter().join(" ");
633 write!(f, "[{title}]({command_or_links})")
634 }
635}
636
637enum CommandOrLink {
638 Link(String),
639 Command { id: String, args: Vec<JsonValue> },
640}
641
642impl fmt::Display for CommandOrLink {
643 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
644 match self {
645 Self::Link(link) => f.write_str(link),
646 Self::Command { id, args } => {
647 if args.is_empty() {
649 return write!(f, "command:{id}");
650 }
651
652 let args = serde_json::to_string(&args).unwrap();
653 let args = percent_encoding::utf8_percent_encode(
654 &args,
655 percent_encoding::NON_ALPHANUMERIC,
656 );
657 write!(f, "command:{id}?{args}")
658 }
659 }
660 }
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666 use crate::tests::*;
667
668 #[test]
669 fn test() {
670 snapshot_testing("hover", &|ctx, path| {
671 let source = ctx.source_by_path(&path).unwrap();
672
673 let request = HoverRequest {
674 path: path.clone(),
675 position: find_test_position(&source),
676 };
677
678 let result = request.request(ctx);
679 let content = HoverDisplay(result.as_ref())
680 .to_string()
681 .replace("\n---\n", "\n\n======\n\n");
682 let content = JsonRepr::md_content(&content);
683 assert_snapshot!(content);
684 });
685 }
686
687 struct HoverDisplay<'a>(Option<&'a Hover>);
688
689 impl fmt::Display for HoverDisplay<'_> {
690 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691 let Some(Hover { range, contents }) = self.0 else {
692 return write!(f, "No hover information");
693 };
694
695 if let Some(range) = range {
697 writeln!(f, "Range: {}\n", JsonRepr::range(range))?;
698 } else {
699 writeln!(f, "No range")?;
700 };
701
702 match contents {
704 HoverContents::Markup(content) => {
705 writeln!(f, "{}", content.value)?;
706 }
707 HoverContents::Scalar(MarkedString::String(content)) => {
708 writeln!(f, "{content}")?;
709 }
710 HoverContents::Scalar(MarkedString::LanguageString(lang_str)) => {
711 writeln!(f, "=== {} ===\n{}", lang_str.language, lang_str.value)?
712 }
713 HoverContents::Array(contents) => {
714 let content = contents
716 .iter()
717 .map(|content| match content {
718 MarkedString::String(text) => text.to_string(),
719 MarkedString::LanguageString(lang_str) => {
720 format!("=== {} ===\n{}", lang_str.language, lang_str.value)
721 }
722 })
723 .collect::<Vec<_>>()
724 .join("\n\n=====\n\n");
725 writeln!(f, "{content}")?;
726 }
727 }
728
729 Ok(())
730 }
731 }
732}