Skip to main content

typst_ide/
tooltip.rs

1use std::fmt::Write;
2
3use ecow::{EcoString, eco_format};
4use typst::engine::Sink;
5use typst::foundations::{
6    AsOutput, Capturer, CastInfo, Func, ParamInfo, Repr, Value, repr,
7};
8use typst::layout::Length;
9use typst::syntax::ast::AstNode;
10use typst::syntax::{LinkedNode, Side, Source, SyntaxKind, ast};
11use typst::utils::{Numeric, round_with_precision};
12use typst_eval::CapturesVisitor;
13
14use crate::analyze::analyze_expr_with_fallback;
15use crate::docs::{find_param_docs, find_value_docs};
16use crate::utils::summarize_font_family;
17use crate::{IdeWorld, analyze_expr, analyze_import, analyze_labels};
18
19/// Describe the item under the cursor.
20///
21/// Passing a `document` (from a previous compilation) is optional, but enhances
22/// the tooltips. Label tooltips, for instance, are only generated when the
23/// document is available.
24pub fn tooltip(
25    world: &dyn IdeWorld,
26    output: Option<impl AsOutput>,
27    source: &Source,
28    cursor: usize,
29    side: Side,
30) -> Option<Tooltip> {
31    let leaf = LinkedNode::new(source.root()).leaf_at(cursor, side)?;
32    if leaf.kind().is_trivia() {
33        return None;
34    }
35
36    named_param_tooltip(world, &leaf)
37        .or_else(|| font_tooltip(world, &leaf))
38        .or_else(|| output.and_then(|output| label_tooltip(output, &leaf)))
39        .or_else(|| import_tooltip(world, &leaf))
40        .or_else(|| expr_tooltip(world, &leaf))
41        .or_else(|| closure_tooltip(&leaf))
42}
43
44/// A hover tooltip.
45#[derive(Debug, Clone, PartialEq)]
46pub enum Tooltip {
47    /// A string of text.
48    Text(EcoString),
49    /// A string of Typst code.
50    Code(EcoString),
51}
52
53/// Tooltip for a hovered expression.
54fn expr_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
55    let mut ancestor = leaf;
56    while !ancestor.is::<ast::Expr>() {
57        ancestor = ancestor.parent()?;
58    }
59
60    let expr = ancestor.cast::<ast::Expr>()?;
61    // We only analyze embeddable code expressions or math expressions that
62    // access variables.
63    let analyze = expr.hash()
64        || matches!(
65            expr,
66            ast::Expr::MathIdent(_)
67                | ast::Expr::MathFieldAccess(_)
68                | ast::Expr::MathCall(_)
69        );
70    if !analyze {
71        return None;
72    }
73
74    let values = analyze_expr(world, ancestor);
75
76    if let [(value, _), rest @ ..] = values.as_slice()
77        && rest.iter().all(|(v, _)| value == v)
78    {
79        if let Some(docs) = find_value_docs(world, value) {
80            return Some(Tooltip::Text(docs.summary()));
81        }
82
83        if let &Value::Length(length) = value
84            && let Some(tooltip) = length_tooltip(length)
85        {
86            return Some(tooltip);
87        }
88    }
89
90    if expr.is_literal() {
91        return None;
92    }
93
94    let mut last = None;
95    let mut pieces: Vec<EcoString> = vec![];
96    let mut iter = values.iter();
97    for (value, _) in (&mut iter).take(Sink::MAX_VALUES - 1) {
98        if let Some((prev, count)) = &mut last {
99            if *prev == value {
100                *count += 1;
101                continue;
102            } else if *count > 1 {
103                write!(pieces.last_mut().unwrap(), " (×{count})").unwrap();
104            }
105        }
106        pieces.push(value.repr());
107        last = Some((value, 1));
108    }
109
110    if let Some((_, count)) = last
111        && count > 1
112    {
113        write!(pieces.last_mut().unwrap(), " (×{count})").unwrap();
114    }
115
116    if iter.next().is_some() {
117        pieces.push("...".into());
118    }
119
120    let tooltip = repr::pretty_comma_list(&pieces, false);
121    (!tooltip.is_empty()).then(|| Tooltip::Code(tooltip.into()))
122}
123
124/// Tooltips for imports.
125fn import_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
126    if leaf.kind() == SyntaxKind::Star
127        && let Some(parent) = leaf.parent()
128        && let Some(import) = parent.cast::<ast::ModuleImport>()
129        && let Some(node) = parent.find(import.source().span())
130        && let Some(value) = analyze_import(world, &node)
131        && let Some(scope) = value.scope()
132    {
133        let names: Vec<_> =
134            scope.iter().map(|(name, ..)| eco_format!("`{name}`")).collect();
135        let list = repr::separated_list(&names, "and");
136        return Some(Tooltip::Text(eco_format!("This star imports {list}")));
137    }
138
139    None
140}
141
142/// Tooltip for a hovered closure.
143fn closure_tooltip(leaf: &LinkedNode) -> Option<Tooltip> {
144    // Only show this tooltip when hovering over the equals sign or arrow of
145    // the closure. Showing it across the whole subtree is too noisy.
146    if !matches!(leaf.kind(), SyntaxKind::Eq | SyntaxKind::Arrow) {
147        return None;
148    }
149
150    // Find the closure to analyze.
151    let parent = leaf.parent()?;
152    if parent.kind() != SyntaxKind::Closure {
153        return None;
154    }
155
156    // Analyze the closure's captures.
157    let mut visitor = CapturesVisitor::new(None, Capturer::Function);
158    visitor.visit(parent);
159
160    let captures = visitor.finish();
161    let mut names: Vec<_> =
162        captures.iter().map(|(name, ..)| eco_format!("`{name}`")).collect();
163    if names.is_empty() {
164        return None;
165    }
166
167    names.sort();
168
169    let tooltip = repr::separated_list(&names, "and");
170    Some(Tooltip::Text(eco_format!("This closure captures {tooltip}")))
171}
172
173/// Tooltip text for a hovered length.
174fn length_tooltip(length: Length) -> Option<Tooltip> {
175    length.em.is_zero().then(|| {
176        Tooltip::Code(eco_format!(
177            "{}pt = {}mm = {}cm = {}in",
178            round_with_precision(length.abs.to_pt(), 2),
179            round_with_precision(length.abs.to_mm(), 2),
180            round_with_precision(length.abs.to_cm(), 2),
181            round_with_precision(length.abs.to_inches(), 2),
182        ))
183    })
184}
185
186/// Tooltip for a hovered reference or label.
187fn label_tooltip(output: impl AsOutput, leaf: &LinkedNode) -> Option<Tooltip> {
188    let target = match leaf.kind() {
189        SyntaxKind::RefMarker => leaf.leaf_text().trim_start_matches('@'),
190        SyntaxKind::Label => {
191            leaf.leaf_text().trim_start_matches('<').trim_end_matches('>')
192        }
193        _ => return None,
194    };
195
196    for (label, detail) in analyze_labels(output).0 {
197        if label.resolve().as_str() == target {
198            return Some(Tooltip::Text(detail?));
199        }
200    }
201
202    None
203}
204
205/// Tooltips for components of a named parameter.
206fn named_param_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
207    let (func, named) =
208        // Ensure that we are in a named pair in the arguments to a function
209        // call or set rule.
210        if let Some(parent) = leaf.parent()
211        && let Some(named) = parent.cast::<ast::Named>()
212        && let Some(grand) = parent.parent()
213        && matches!(grand.kind(), SyntaxKind::Args | SyntaxKind::MathArgs)
214        && let Some(grand_grand) = grand.parent()
215        && let Some(expr) = grand_grand.cast::<ast::Expr>()
216        && let Some(callee_span) = match expr {
217            ast::Expr::FuncCall(call) => Some(call.callee().span()),
218            ast::Expr::MathCall(call) => Some(call.callee().span()),
219            ast::Expr::SetRule(set) => Some(set.target().span()),
220            _ => None,
221        }
222        && let Some(callee) = grand_grand.find(callee_span)
223
224        // Find metadata about the function.
225        && let Some(value) = analyze_expr_with_fallback(world, &callee)
226        && let Ok(func) = value.cast::<Func>()
227         { (func, named) }
228        else { return None; };
229
230    // Hovering over the parameter name.
231    if leaf.index() == 0
232        && let Some(ident) = leaf.cast::<ast::Ident>()
233        && let Some(param) = func.param(&ident)
234        && let Some(docs) = find_param_docs(world, &param)
235    {
236        return Some(Tooltip::Text(docs.summary()));
237    }
238
239    // Hovering over a string parameter value.
240    if let Some(string) = leaf.cast::<ast::Str>()
241        && let Some(param) = func.param(&named.name())
242        && let ParamInfo::Native(param) = param
243        && let Some(docs) = find_string_doc(&param.input, &string.get())
244    {
245        return Some(Tooltip::Text(docs.into()));
246    }
247
248    None
249}
250
251/// Find documentation for a castable string.
252fn find_string_doc(info: &CastInfo, string: &str) -> Option<&'static str> {
253    match info {
254        CastInfo::Value(Value::Str(s), docs) if s.as_str() == string => Some(docs),
255        CastInfo::Union(options) => {
256            options.iter().find_map(|option| find_string_doc(option, string))
257        }
258        _ => None,
259    }
260}
261
262/// Tooltip for font.
263fn font_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
264    // Ensure that we are on top of a string.
265    if let Some(string) = leaf.cast::<ast::Str>()
266        && let lower = string.get().to_lowercase()
267
268        // Ensure that we are in the arguments to the text function.
269        && let Some(parent) = leaf.parent()
270        && let Some(named) = parent.cast::<ast::Named>()
271        && named.name().as_str() == "font"
272
273        // Find the font family.
274        && let book = world.book()
275        && let Some((_, iter)) = book
276            .families()
277            .find(|&(family, _)| family.to_lowercase().as_str() == lower.as_str())
278    {
279        let detail = summarize_font_family(iter.filter_map(|id| book.info(id)));
280        return Some(Tooltip::Text(detail));
281    }
282
283    None
284}
285
286#[cfg(test)]
287mod tests {
288    use std::borrow::Borrow;
289
290    use typst::syntax::Side;
291    use typst_layout::PagedDocument;
292
293    use super::{Tooltip, tooltip};
294    use crate::tests::{FilePos, TestWorld, WorldLike};
295
296    type Response = Option<Tooltip>;
297
298    trait ResponseExt {
299        /// Assert that there is no tooltip.
300        fn must_be_none(&self) -> &Self;
301        /// Assert that the tooltip equals the given message.
302        fn must_be_text(&self, text: &str) -> &Self;
303        /// Assert that the tooltip equals the given code.
304        fn must_be_code(&self, code: &str) -> &Self;
305    }
306
307    impl ResponseExt for Response {
308        #[track_caller]
309        fn must_be_none(&self) -> &Self {
310            assert_eq!(*self, None);
311            self
312        }
313
314        #[track_caller]
315        fn must_be_text(&self, text: &str) -> &Self {
316            assert_eq!(*self, Some(Tooltip::Text(text.into())));
317            self
318        }
319
320        #[track_caller]
321        fn must_be_code(&self, code: &str) -> &Self {
322            assert_eq!(*self, Some(Tooltip::Code(code.into())));
323            self
324        }
325    }
326
327    #[track_caller]
328    fn test(world: impl WorldLike, pos: impl FilePos, side: Side) -> Response {
329        let world = world.acquire();
330        let world = world.borrow();
331        let (source, cursor) = pos.resolve(world);
332        let doc = typst::compile::<PagedDocument>(world).output.ok();
333        tooltip(world, doc.as_ref(), &source, cursor, side)
334    }
335
336    #[test]
337    fn test_tooltip() {
338        test("#let x = 1 + 2", -1, Side::After).must_be_none();
339        test("#let x = 1 + 2", 5, Side::After).must_be_code("3");
340        test("#let x = 1 + 2", 6, Side::Before).must_be_code("3");
341    }
342
343    /// Literal values don't get a tooltip, just idenfitifers.
344    #[test]
345    fn test_tooltip_math_literals() {
346        let world = "$x'^2 &!= \\u{3C0} \"is\" pi #true$";
347        test(world, 0 /*  $  */, Side::After).must_be_none();
348        test(world, 1 /*  x  */, Side::After).must_be_none();
349        test(world, 2 /*  '  */, Side::After).must_be_none();
350        test(world, 3 /*  ^  */, Side::After).must_be_none();
351        test(world, 4 /*  2  */, Side::After).must_be_none();
352        test(world, 5 /*     */, Side::After).must_be_none();
353        test(world, 6 /*  &  */, Side::After).must_be_none();
354        test(world, 7 /*  != */, Side::After).must_be_none();
355        test(world, 10 /* \\ */, Side::After).must_be_none();
356        test(world, 11 /* u  */, Side::After).must_be_none();
357        test(world, 12 /* {  */, Side::After).must_be_none();
358        test(world, 13 /* 3  */, Side::After).must_be_none();
359        test(world, 19 /* \" */, Side::After).must_be_none();
360        test(world, 20 /* is */, Side::After).must_be_none();
361        test(world, 24 /* pi */, Side::After)
362            .must_be_code("symbol(\"π\", (\"alt\", \"ϖ\"))");
363        test(world, 27 /* \# */, Side::After).must_be_none();
364        test(world, 28 /*true*/, Side::After).must_be_none();
365    }
366
367    #[test]
368    fn test_tooltip_math_field_access() {
369        test("$pi.alt$", 1, Side::After).must_be_code("symbol(\"π\", (\"alt\", \"ϖ\"))");
370        test("$pi.alt$", 3, Side::After).must_be_code("symbol(\"ϖ\")");
371        test("$pi.alt$", 4, Side::After).must_be_code("symbol(\"ϖ\")");
372    }
373
374    #[test]
375    fn test_tooltip_set() {
376        let box_desc = "An inline-level container that sizes content.";
377        let fill_desc = "The box's background color.";
378        let red = "rgb(\"#ff4136\")";
379        test("#set box(fill: red,)", 0 /*#*/, Side::After).must_be_none();
380        test("#set box(fill: red,)", 1 /*s*/, Side::After).must_be_none();
381        test("#set box(fill: red,)", 5 /*b*/, Side::After).must_be_text(box_desc);
382        test("#set box(fill: red,)", 8 /*(*/, Side::After).must_be_none();
383        test("#set box(fill: red,)", 9 /*f*/, Side::After).must_be_text(fill_desc);
384        test("#set box(fill: red,)", 13 /*:*/, Side::After).must_be_none();
385        test("#set box(fill: red,)", 15 /*r*/, Side::After).must_be_code(red);
386        test("#set box(fill: red,)", 18 /*,*/, Side::After).must_be_none();
387        test("#set box(fill: red,)", 19 /*)*/, Side::After).must_be_none();
388    }
389
390    #[test]
391    fn test_tooltip_function_call() {
392        let box_desc = "An inline-level container that sizes content.";
393        // Function value
394        test("#box", 0 /*#*/, Side::After).must_be_none();
395        test("#box", 1 /*b*/, Side::After).must_be_text(box_desc);
396        test("#(box)", 0 /*#*/, Side::After).must_be_none();
397        test("#(box)", 1 /*(*/, Side::After).must_be_text(box_desc);
398        test("#(box)", 2 /*b*/, Side::After).must_be_text(box_desc);
399        test("#(box)", 5 /*)*/, Side::After).must_be_text(box_desc);
400        // Basic call
401        test("#box()", 1 /*b*/, Side::After).must_be_text(box_desc);
402        test("#box()", 4 /*(*/, Side::After).must_be_code("box()");
403        test("#box()", 5 /*)*/, Side::After).must_be_code("box()");
404        // Field access call
405        test("#std.box()", 1 /*s*/, Side::After).must_be_code("<module global>");
406        test("#std.box()", 4 /*.*/, Side::After).must_be_text(box_desc);
407        test("#std.box()", 5 /*b*/, Side::After).must_be_text(box_desc);
408        test("#std.box()", 8 /*(*/, Side::After).must_be_code("box()");
409        // Positional args and trailing comma
410        test("#box([],)", 4 /*(*/, Side::After).must_be_code("box(body: [])");
411        test("#box([],)", 5 /*[*/, Side::After).must_be_code("[]");
412        test("#box([],)", 6 /*]*/, Side::After).must_be_code("[]");
413        test("#box([],)", 7 /*,*/, Side::After).must_be_code("box(body: [])");
414        test("#box([],)", 8 /*)*/, Side::After).must_be_code("box(body: [])");
415        // Content args
416        test("#box[]", 4 /*[*/, Side::After).must_be_code("[]");
417        test("#box[]", 5 /*]*/, Side::After).must_be_code("[]");
418        // Named args
419        let fill_desc = "The box's background color.";
420        let red_box = "box(fill: rgb(\"#ff4136\"))";
421        test("#box(fill:red,)", 5 /*f*/, Side::After).must_be_text(fill_desc);
422        test("#box(fill:red,)", 9 /*:*/, Side::After).must_be_code(red_box);
423        test("#box(fill:red,)", 10 /*r*/, Side::After).must_be_code("rgb(\"#ff4136\")");
424        test("#box(fill:red,)", 13 /*,*/, Side::After).must_be_code(red_box);
425        // Spread args
426        test("#box(..none,)", 5 /*.*/, Side::After).must_be_code("box()");
427        test("#box(..none,)", 7 /*(*/, Side::After).must_be_none();
428        test("#box(..none,)", 11 /*,*/, Side::After).must_be_code("box()");
429        test("#box(..([],))", 5 /*.*/, Side::After).must_be_code("box(body: [])");
430        test("#box(..([],))", 7 /*(*/, Side::After).must_be_code("([],)");
431    }
432
433    #[test]
434    fn test_tooltip_math_function_call() {
435        // Content plus parens
436        test("$f(x)$", 1 /*f*/, Side::After).must_be_none();
437        test("$f(x)$", 2 /*(*/, Side::After).must_be_none();
438        test("$f(x)$", 3 /*x*/, Side::After).must_be_none();
439        test("$sin()$", 1 /*s*/, Side::After)
440            .must_be_code("op(text: [sin], limits: false)");
441        // Actual function call + empty arg + trailing comma
442        let vec_z = "vec(children: ([ℤ], []))";
443        test("$vec(ZZ, ,)$", 1 /*v*/, Side::After).must_be_text("A column vector.");
444        test("$vec(ZZ, ,)$", 4 /*(*/, Side::After).must_be_code(vec_z);
445        test("$vec(ZZ, ,)$", 5 /*Z*/, Side::After).must_be_code("symbol(\"ℤ\")");
446        test("$vec(ZZ, ,)$", 7 /*,*/, Side::After).must_be_code(vec_z);
447        test("$vec(ZZ, ,)$", 8 /* */, Side::After).must_be_none();
448        test("$vec(ZZ, ,)$", 9 /*,*/, Side::After).must_be_code(vec_z);
449        test("$vec(ZZ, ,)$", 10 /*)*/, Side::After).must_be_code(vec_z);
450        // Named args
451        let vec_gap = "vec(gap: 0% + 1em, children: ())";
452        test("$vec(gap:#1em)$", 5 /*g*/, Side::After)
453            .must_be_text("The gap between elements.");
454        test("$vec(gap:#1em)$", 8 /*:*/, Side::After).must_be_code(vec_gap);
455        test("$vec(gap:#1em)$", 10 /*1*/, Side::After).must_be_none();
456        // 2d args and spread args
457        let mat = "mat(rows: (([1],), ([2],)))";
458        test("$mat(1; ..#([2],))$", 1 /*m*/, Side::After).must_be_text("A matrix.");
459        test("$mat(1; ..#([2],))$", 5 /*1*/, Side::After).must_be_none();
460        test("$mat(1; ..#([2],))$", 6 /*;*/, Side::After).must_be_code(mat);
461        test("$mat(1; ..#([2],))$", 8 /*.*/, Side::After).must_be_code(mat);
462        test("$mat(1; ..#([2],))$", 10 /*#*/, Side::After).must_be_code(mat);
463        test("$mat(1; ..#([2],))$", 11 /*(*/, Side::After).must_be_code("([2],)");
464        // Symbol function and named arg
465        test("$hat(i,size:#1em)$", 1, Side::After).must_be_code("symbol(\"^\")");
466        test("$hat(i,size:#1em)$", 7, Side::After)
467            .must_be_text("The size of the accent, relative to the width of the base.");
468        // Field access call
469        let box_desc = "An inline-level container that sizes content.";
470        test("$std.box()$", 1 /*s*/, Side::After).must_be_code("<module global>");
471        test("$std.box()$", 4 /*.*/, Side::After).must_be_text(box_desc);
472        test("$std.box()$", 5 /*b*/, Side::After).must_be_text(box_desc);
473        test("$std.box()$", 8 /*(*/, Side::After).must_be_code("box()");
474    }
475
476    #[test]
477    fn test_tooltip_empty_contextual() {
478        test("#{context}", -1, Side::Before).must_be_code("context()");
479    }
480
481    #[test]
482    fn test_tooltip_closure() {
483        test("#let f(x) = x + y", 11, Side::Before)
484            .must_be_text("This closure captures `y`");
485        // Same tooltip if `y` is defined first.
486        test("#let y = 10; #let f(x) = x + y", 24, Side::Before)
487            .must_be_text("This closure captures `y`");
488        // Names are sorted.
489        test("#let f(x) = x + y + z + a", 11, Side::Before)
490            .must_be_text("This closure captures `a`, `y`, and `z`");
491        // Names are de-duplicated.
492        test("#let f(x) = x + y + z + y", 11, Side::Before)
493            .must_be_text("This closure captures `y` and `z`");
494        // With arrow syntax.
495        test("#let f = (x) => x + y", 15, Side::Before)
496            .must_be_text("This closure captures `y`");
497        // No recursion with arrow syntax.
498        test("#let f = (x) => x + y + f", 13, Side::After)
499            .must_be_text("This closure captures `f` and `y`");
500    }
501
502    #[test]
503    fn test_tooltip_import() {
504        let world = TestWorld::new("#import \"other.typ\": a, b")
505            .with_source("other.typ", "#let (a, b, c) = (1, 2, 3)");
506        test(&world, -5, Side::After).must_be_code("1");
507    }
508
509    #[test]
510    fn test_tooltip_star_import() {
511        let world = TestWorld::new("#import \"other.typ\": *")
512            .with_source("other.typ", "#let (a, b, c) = (1, 2, 3)");
513        test(&world, -2, Side::Before).must_be_none();
514        test(&world, -2, Side::After).must_be_text("This star imports `a`, `b`, and `c`");
515    }
516
517    #[test]
518    fn test_tooltip_field_call() {
519        let world = TestWorld::new("#import \"other.typ\"\n#other.f()")
520            .with_source("other.typ", "#let f = (x) => 1");
521        test(&world, -4, Side::After).must_be_code("(..) => ..");
522    }
523
524    #[test]
525    fn test_tooltip_reference() {
526        test("#figure(caption: [Hi])[]<f> @f", -1, Side::Before).must_be_text("Hi");
527    }
528
529    #[test]
530    fn test_tooltip_user_function() {
531        let world = TestWorld::new("#import \"lib.typ\"\n#lib.foo(none, tree: 2)")
532            .with_source("lib.typ", crate::tests::EXAMPLE_CLOSURE);
533        // Function itself.
534        test(&world, -17, Side::After).must_be_text("A useful function.");
535        // Named parameter.
536        test(&world, -7, Side::After).must_be_text("Tree with three slashes.");
537    }
538
539    #[test]
540    fn test_tooltip_user_function_in_math() {
541        let world = TestWorld::new("#import \"lib.typ\"\n$lib.foo(none, tree: 2)$")
542            .with_source("lib.typ", crate::tests::EXAMPLE_CLOSURE);
543        // Function itself.
544        test(&world, -18, Side::After).must_be_text("A useful function.");
545        // Named parameter.
546        test(&world, -8, Side::After).must_be_text("Tree with three slashes.");
547    }
548}