tinymist_query/
hover.rs

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/// The [`textDocument/hover`] request asks the server for hover information at
14/// a given text document position.
15///
16/// [`textDocument/hover`]: https://microsoft.github.io/language-server-protocol/specification#textDocument_hover
17///
18/// Such hover information typically includes type signature information and
19/// inline documentation for the symbol at the given text document position.
20#[derive(Debug, Clone)]
21pub struct HoverRequest {
22    /// The path of the document to get hover information for.
23    pub path: PathBuf,
24    /// The position of the symbol to get hover information for.
25    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        // the typst's cursor is 1-based, so we need to add 1 to the offset
36        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            // Neovim shows ugly hover if the hover content is in array, so we join them
72            // manually with divider bars.
73            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    /// Static analysis results
99    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    /// Dynamic analysis results
109    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    /// Definition analysis results
119    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                    // fallback: no additional information
146                    self.def.push(format!("Bibliography: `{}`", def.name()));
147                }
148            }
149            _ => {
150                let sym_docs = self.ctx.def_docs(&def);
151
152                // todo: hover with `with_stack`
153
154                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            // open file in tab or system application
237            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        // Preview results
261        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        // https://github.com/rust-lang/rust-analyzer/blob/1a5bb27c018c947dab01ab70ffe1d267b0481a17/editors/code/src/client.ts#L59
380        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                // <https://code.visualstudio.com/api/extension-guides/command#command-uris>
397                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            // write range
449            if let Some(range) = range {
450                writeln!(f, "Range: {}\n", JsonRepr::range(range))?;
451            } else {
452                writeln!(f, "No range")?;
453            };
454
455            // write contents
456            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                    // interperse the contents with a divider
468                    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}