tinymist_query/
hover.rs

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/// 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
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        // the typst's cursor is 1-based, so we need to add 1 to the offset
39        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            // Neovim shows ugly hover if the hover content is in array, so we join them
75            // manually with divider bars.
76            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    /// Static analysis results
102    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    /// Dynamic analysis results
113    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    /// Definition analysis results
123    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                    // fallback: no additional information
150                    self.def.push(format!("Bibliography: `{}`", def.name()));
151                }
152            }
153            _ => {
154                let sym_docs = self.ctx.def_docs(&def);
155
156                // todo: hover with `with_stack`
157
158                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        // Check if we're in a string literal that's part of an import
223        if !matches!(node.kind(), SyntaxKind::Str) {
224            return None;
225        }
226
227        // Navigate up to find the ModuleImport node
228        let import_node = node.parent()?.cast::<ast::ModuleImport>()?;
229
230        // Check if this is a package import
231        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    /// Get package information for hover content
245    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        // Get all matching packages
253        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        // Add non-preview packages
263        #[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        // Sort by version descending
275        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            // Add links
284            let mut links_line = Vec::new();
285
286            if package_spec.is_preview() {
287                let package_name = &package_spec.name;
288
289                // Universe page
290                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                // Repository URL
296                if let Some(ref repo) = current_entry.package.repository {
297                    links_line.push(format!("[Repository]({repo})"));
298                }
299
300                // Homepage URL
301                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        // Package header
313        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        // Check version information and show status
319        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        // Add manifest information if available
337        if let Some(current_entry) = current_entry {
338            let pkg_info = &current_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) = &current_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        // Show version history for preview packages
363        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                    // Current version
376                    info.push_str(&format!("- **{version}** / {release_date}\n"));
377                    continue;
378                }
379                // Other versions
380                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            // open file in tab or system application
428            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        // Preview results
452        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        // https://github.com/rust-lang/rust-analyzer/blob/1a5bb27c018c947dab01ab70ffe1d267b0481a17/editors/code/src/client.ts#L59
571        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                // <https://code.visualstudio.com/api/extension-guides/command#command-uris>
588                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            // write range
636            if let Some(range) = range {
637                writeln!(f, "Range: {}\n", JsonRepr::range(range))?;
638            } else {
639                writeln!(f, "No range")?;
640            };
641
642            // write contents
643            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                    // interperse the contents with a divider
655                    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}