tinymist_query/
hover.rs

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/// The [`textDocument/hover`] request asks the server for hover information at
17/// a given text document position.
18///
19/// [`textDocument/hover`]: https://microsoft.github.io/language-server-protocol/specification#textDocument_hover
20///
21/// Such hover information typically includes type signature information and
22/// inline documentation for the symbol at the given text document position.
23#[derive(Debug, Clone)]
24pub struct HoverRequest {
25    /// The path of the document to get hover information for.
26    pub path: PathBuf,
27    /// The position of the symbol to get hover information for.
28    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        // the typst's cursor is 1-based, so we need to add 1 to the offset
115        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            // Neovim shows ugly hover if the hover content is in array, so we join them
151            // manually with divider bars.
152            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    /// Static analysis results
178    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    /// Dynamic analysis results
189    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    /// Definition analysis results
199    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                    // fallback: no additional information
226                    self.def.push(format!("Bibliography: `{}`", def.name()));
227                }
228            }
229            _ => {
230                let sym_docs = self.ctx.def_docs(&def);
231
232                // todo: hover with `with_stack`
233
234                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    /// Get package information for hover content
305    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        // Get all matching packages
313        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        // Add non-preview packages
323        #[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        // Sort by version descending
335        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            // Add links
344            let mut links_line = Vec::new();
345
346            if package_spec.is_preview() {
347                let package_name = &package_spec.name;
348
349                // Universe page
350                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                // Repository URL
356                if let Some(ref repo) = current_entry.package.repository {
357                    links_line.push(format!("[Repository]({repo})"));
358                }
359
360                // Homepage URL
361                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        // Package header
373        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        // Check version information and show status
379        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        // Add manifest information if available
397        if let Some(current_entry) = current_entry {
398            let pkg_info = &current_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) = &current_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        // Show version history for preview packages
423        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                    // Current version
436                    info.push_str(&format!("- **{version}** / {release_date}\n"));
437                    continue;
438                }
439                // Other versions
440                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            // open file in tab or system application
488            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        // Preview results
512        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        // https://github.com/rust-lang/rust-analyzer/blob/1a5bb27c018c947dab01ab70ffe1d267b0481a17/editors/code/src/client.ts#L59
631        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                // <https://code.visualstudio.com/api/extension-guides/command#command-uris>
648                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            // write range
696            if let Some(range) = range {
697                writeln!(f, "Range: {}\n", JsonRepr::range(range))?;
698            } else {
699                writeln!(f, "No range")?;
700            };
701
702            // write contents
703            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                    // interperse the contents with a divider
715                    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}