typst_ide/
tooltip.rs

1use std::fmt::Write;
2
3use ecow::{EcoString, eco_format};
4use typst::engine::Sink;
5use typst::foundations::{Binding, Capturer, CastInfo, Repr, Value, repr};
6use typst::layout::{Length, PagedDocument};
7use typst::syntax::ast::AstNode;
8use typst::syntax::{LinkedNode, Side, Source, SyntaxKind, ast};
9use typst::utils::{Numeric, round_with_precision};
10use typst_eval::CapturesVisitor;
11
12use crate::utils::{plain_docs_sentence, summarize_font_family};
13use crate::{IdeWorld, analyze_expr, analyze_import, analyze_labels};
14
15/// Describe the item under the cursor.
16///
17/// Passing a `document` (from a previous compilation) is optional, but enhances
18/// the tooltips. Label tooltips, for instance, are only generated when the
19/// document is available.
20pub fn tooltip(
21    world: &dyn IdeWorld,
22    document: Option<&PagedDocument>,
23    source: &Source,
24    cursor: usize,
25    side: Side,
26) -> Option<Tooltip> {
27    let leaf = LinkedNode::new(source.root()).leaf_at(cursor, side)?;
28    if leaf.kind().is_trivia() {
29        return None;
30    }
31
32    named_param_tooltip(world, &leaf)
33        .or_else(|| font_tooltip(world, &leaf))
34        .or_else(|| document.and_then(|doc| label_tooltip(doc, &leaf)))
35        .or_else(|| import_tooltip(world, &leaf))
36        .or_else(|| expr_tooltip(world, &leaf))
37        .or_else(|| closure_tooltip(&leaf))
38}
39
40/// A hover tooltip.
41#[derive(Debug, Clone, PartialEq)]
42pub enum Tooltip {
43    /// A string of text.
44    Text(EcoString),
45    /// A string of Typst code.
46    Code(EcoString),
47}
48
49/// Tooltip for a hovered expression.
50fn expr_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
51    let mut ancestor = leaf;
52    while !ancestor.is::<ast::Expr>() {
53        ancestor = ancestor.parent()?;
54    }
55
56    let expr = ancestor.cast::<ast::Expr>()?;
57    if !expr.hash() && !matches!(expr, ast::Expr::MathIdent(_)) {
58        return None;
59    }
60
61    let values = analyze_expr(world, ancestor);
62
63    if let [(value, _)] = values.as_slice() {
64        if let Some(docs) = value.docs() {
65            return Some(Tooltip::Text(plain_docs_sentence(docs)));
66        }
67
68        if let &Value::Length(length) = value
69            && let Some(tooltip) = length_tooltip(length)
70        {
71            return Some(tooltip);
72        }
73    }
74
75    if expr.is_literal() {
76        return None;
77    }
78
79    let mut last = None;
80    let mut pieces: Vec<EcoString> = vec![];
81    let mut iter = values.iter();
82    for (value, _) in (&mut iter).take(Sink::MAX_VALUES - 1) {
83        if let Some((prev, count)) = &mut last {
84            if *prev == value {
85                *count += 1;
86                continue;
87            } else if *count > 1 {
88                write!(pieces.last_mut().unwrap(), " (×{count})").unwrap();
89            }
90        }
91        pieces.push(value.repr());
92        last = Some((value, 1));
93    }
94
95    if let Some((_, count)) = last
96        && count > 1
97    {
98        write!(pieces.last_mut().unwrap(), " (×{count})").unwrap();
99    }
100
101    if iter.next().is_some() {
102        pieces.push("...".into());
103    }
104
105    let tooltip = repr::pretty_comma_list(&pieces, false);
106    (!tooltip.is_empty()).then(|| Tooltip::Code(tooltip.into()))
107}
108
109/// Tooltips for imports.
110fn import_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
111    if leaf.kind() == SyntaxKind::Star
112        && let Some(parent) = leaf.parent()
113        && let Some(import) = parent.cast::<ast::ModuleImport>()
114        && let Some(node) = parent.find(import.source().span())
115        && let Some(value) = analyze_import(world, &node)
116        && let Some(scope) = value.scope()
117    {
118        let names: Vec<_> =
119            scope.iter().map(|(name, ..)| eco_format!("`{name}`")).collect();
120        let list = repr::separated_list(&names, "and");
121        return Some(Tooltip::Text(eco_format!("This star imports {list}")));
122    }
123
124    None
125}
126
127/// Tooltip for a hovered closure.
128fn closure_tooltip(leaf: &LinkedNode) -> Option<Tooltip> {
129    // Only show this tooltip when hovering over the equals sign or arrow of
130    // the closure. Showing it across the whole subtree is too noisy.
131    if !matches!(leaf.kind(), SyntaxKind::Eq | SyntaxKind::Arrow) {
132        return None;
133    }
134
135    // Find the closure to analyze.
136    let parent = leaf.parent()?;
137    if parent.kind() != SyntaxKind::Closure {
138        return None;
139    }
140
141    // Analyze the closure's captures.
142    let mut visitor = CapturesVisitor::new(None, Capturer::Function);
143    visitor.visit(parent);
144
145    let captures = visitor.finish();
146    let mut names: Vec<_> =
147        captures.iter().map(|(name, ..)| eco_format!("`{name}`")).collect();
148    if names.is_empty() {
149        return None;
150    }
151
152    names.sort();
153
154    let tooltip = repr::separated_list(&names, "and");
155    Some(Tooltip::Text(eco_format!("This closure captures {tooltip}")))
156}
157
158/// Tooltip text for a hovered length.
159fn length_tooltip(length: Length) -> Option<Tooltip> {
160    length.em.is_zero().then(|| {
161        Tooltip::Code(eco_format!(
162            "{}pt = {}mm = {}cm = {}in",
163            round_with_precision(length.abs.to_pt(), 2),
164            round_with_precision(length.abs.to_mm(), 2),
165            round_with_precision(length.abs.to_cm(), 2),
166            round_with_precision(length.abs.to_inches(), 2),
167        ))
168    })
169}
170
171/// Tooltip for a hovered reference or label.
172fn label_tooltip(document: &PagedDocument, leaf: &LinkedNode) -> Option<Tooltip> {
173    let target = match leaf.kind() {
174        SyntaxKind::RefMarker => leaf.text().trim_start_matches('@'),
175        SyntaxKind::Label => leaf.text().trim_start_matches('<').trim_end_matches('>'),
176        _ => return None,
177    };
178
179    for (label, detail) in analyze_labels(document).0 {
180        if label.resolve().as_str() == target {
181            return Some(Tooltip::Text(detail?));
182        }
183    }
184
185    None
186}
187
188/// Tooltips for components of a named parameter.
189fn named_param_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
190    let (func, named) =
191        // Ensure that we are in a named pair in the arguments to a function
192        // call or set rule.
193        if let Some(parent) = leaf.parent()
194        && let Some(named) = parent.cast::<ast::Named>()
195        && let Some(grand) = parent.parent()
196        && matches!(grand.kind(), SyntaxKind::Args)
197        && let Some(grand_grand) = grand.parent()
198        && let Some(expr) = grand_grand.cast::<ast::Expr>()
199        && let Some(ast::Expr::Ident(callee)) = match expr {
200            ast::Expr::FuncCall(call) => Some(call.callee()),
201            ast::Expr::SetRule(set) => Some(set.target()),
202            _ => None,
203        }
204
205        // Find metadata about the function.
206        && let Some(Value::Func(func)) = world
207            .library()
208            .global
209            .scope()
210            .get(&callee)
211            .map(Binding::read)
212         { (func, named) }
213        else { return None; };
214
215    // Hovering over the parameter name.
216    if leaf.index() == 0
217        && let Some(ident) = leaf.cast::<ast::Ident>()
218        && let Some(param) = func.param(&ident)
219    {
220        return Some(Tooltip::Text(plain_docs_sentence(param.docs)));
221    }
222
223    // Hovering over a string parameter value.
224    if let Some(string) = leaf.cast::<ast::Str>()
225        && let Some(param) = func.param(&named.name())
226        && let Some(docs) = find_string_doc(&param.input, &string.get())
227    {
228        return Some(Tooltip::Text(docs.into()));
229    }
230
231    None
232}
233
234/// Find documentation for a castable string.
235fn find_string_doc(info: &CastInfo, string: &str) -> Option<&'static str> {
236    match info {
237        CastInfo::Value(Value::Str(s), docs) if s.as_str() == string => Some(docs),
238        CastInfo::Union(options) => {
239            options.iter().find_map(|option| find_string_doc(option, string))
240        }
241        _ => None,
242    }
243}
244
245/// Tooltip for font.
246fn font_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
247    // Ensure that we are on top of a string.
248    if let Some(string) = leaf.cast::<ast::Str>()
249        && let lower = string.get().to_lowercase()
250
251        // Ensure that we are in the arguments to the text function.
252        && let Some(parent) = leaf.parent()
253        && let Some(named) = parent.cast::<ast::Named>()
254        && named.name().as_str() == "font"
255
256        // Find the font family.
257        && let Some((_, iter)) = world
258            .book()
259            .families()
260            .find(|&(family, _)| family.to_lowercase().as_str() == lower.as_str())
261    {
262        let detail = summarize_font_family(iter.collect());
263        return Some(Tooltip::Text(detail));
264    }
265
266    None
267}
268
269#[cfg(test)]
270mod tests {
271    use std::borrow::Borrow;
272
273    use typst::syntax::Side;
274
275    use super::{Tooltip, tooltip};
276    use crate::tests::{FilePos, TestWorld, WorldLike};
277
278    type Response = Option<Tooltip>;
279
280    trait ResponseExt {
281        fn must_be_none(&self) -> &Self;
282        fn must_be_text(&self, text: &str) -> &Self;
283        fn must_be_code(&self, code: &str) -> &Self;
284    }
285
286    impl ResponseExt for Response {
287        #[track_caller]
288        fn must_be_none(&self) -> &Self {
289            assert_eq!(*self, None);
290            self
291        }
292
293        #[track_caller]
294        fn must_be_text(&self, text: &str) -> &Self {
295            assert_eq!(*self, Some(Tooltip::Text(text.into())));
296            self
297        }
298
299        #[track_caller]
300        fn must_be_code(&self, code: &str) -> &Self {
301            assert_eq!(*self, Some(Tooltip::Code(code.into())));
302            self
303        }
304    }
305
306    #[track_caller]
307    fn test(world: impl WorldLike, pos: impl FilePos, side: Side) -> Response {
308        let world = world.acquire();
309        let world = world.borrow();
310        let (source, cursor) = pos.resolve(world);
311        let doc = typst::compile(world).output.ok();
312        tooltip(world, doc.as_ref(), &source, cursor, side)
313    }
314
315    #[test]
316    fn test_tooltip() {
317        test("#let x = 1 + 2", -1, Side::After).must_be_none();
318        test("#let x = 1 + 2", 5, Side::After).must_be_code("3");
319        test("#let x = 1 + 2", 6, Side::Before).must_be_code("3");
320        test("#let x = 1 + 2", 6, Side::Before).must_be_code("3");
321    }
322
323    #[test]
324    fn test_tooltip_empty_contextual() {
325        test("#{context}", -1, Side::Before).must_be_code("context()");
326    }
327
328    #[test]
329    fn test_tooltip_closure() {
330        test("#let f(x) = x + y", 11, Side::Before)
331            .must_be_text("This closure captures `y`");
332        // Same tooltip if `y` is defined first.
333        test("#let y = 10; #let f(x) = x + y", 24, Side::Before)
334            .must_be_text("This closure captures `y`");
335        // Names are sorted.
336        test("#let f(x) = x + y + z + a", 11, Side::Before)
337            .must_be_text("This closure captures `a`, `y`, and `z`");
338        // Names are de-duplicated.
339        test("#let f(x) = x + y + z + y", 11, Side::Before)
340            .must_be_text("This closure captures `y` and `z`");
341        // With arrow syntax.
342        test("#let f = (x) => x + y", 15, Side::Before)
343            .must_be_text("This closure captures `y`");
344        // No recursion with arrow syntax.
345        test("#let f = (x) => x + y + f", 13, Side::After)
346            .must_be_text("This closure captures `f` and `y`");
347    }
348
349    #[test]
350    fn test_tooltip_import() {
351        let world = TestWorld::new("#import \"other.typ\": a, b")
352            .with_source("other.typ", "#let (a, b, c) = (1, 2, 3)");
353        test(&world, -5, Side::After).must_be_code("1");
354    }
355
356    #[test]
357    fn test_tooltip_star_import() {
358        let world = TestWorld::new("#import \"other.typ\": *")
359            .with_source("other.typ", "#let (a, b, c) = (1, 2, 3)");
360        test(&world, -2, Side::Before).must_be_none();
361        test(&world, -2, Side::After).must_be_text("This star imports `a`, `b`, and `c`");
362    }
363
364    #[test]
365    fn test_tooltip_field_call() {
366        let world = TestWorld::new("#import \"other.typ\"\n#other.f()")
367            .with_source("other.typ", "#let f = (x) => 1");
368        test(&world, -4, Side::After).must_be_code("(..) => ..");
369    }
370
371    #[test]
372    fn test_tooltip_reference() {
373        test("#figure(caption: [Hi])[]<f> @f", -1, Side::Before).must_be_text("Hi");
374    }
375}