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