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
19pub 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#[derive(Debug, Clone, PartialEq)]
46pub enum Tooltip {
47 Text(EcoString),
49 Code(EcoString),
51}
52
53fn 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 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
124fn 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
142fn closure_tooltip(leaf: &LinkedNode) -> Option<Tooltip> {
144 if !matches!(leaf.kind(), SyntaxKind::Eq | SyntaxKind::Arrow) {
147 return None;
148 }
149
150 let parent = leaf.parent()?;
152 if parent.kind() != SyntaxKind::Closure {
153 return None;
154 }
155
156 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
173fn 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
186fn 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
205fn named_param_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
207 let (func, named) =
208 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 && 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 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, ¶m)
235 {
236 return Some(Tooltip::Text(docs.summary()));
237 }
238
239 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(¶m.input, &string.get())
244 {
245 return Some(Tooltip::Text(docs.into()));
246 }
247
248 None
249}
250
251fn 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
262fn font_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
264 if let Some(string) = leaf.cast::<ast::Str>()
266 && let lower = string.get().to_lowercase()
267
268 && let Some(parent) = leaf.parent()
270 && let Some(named) = parent.cast::<ast::Named>()
271 && named.name().as_str() == "font"
272
273 && 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 fn must_be_none(&self) -> &Self;
301 fn must_be_text(&self, text: &str) -> &Self;
303 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 #[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 , 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 , 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 , Side::After).must_be_none();
357 test(world, 12 , Side::After).must_be_none();
358 test(world, 13 , Side::After).must_be_none();
359 test(world, 19 , Side::After).must_be_none();
360 test(world, 20 , Side::After).must_be_none();
361 test(world, 24 , Side::After)
362 .must_be_code("symbol(\"π\", (\"alt\", \"ϖ\"))");
363 test(world, 27 , Side::After).must_be_none();
364 test(world, 28 , 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 , Side::After).must_be_none();
381 test("#set box(fill: red,)", 5 , 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 , 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 , 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 test("#box", 0 , Side::After).must_be_none();
395 test("#box", 1 , 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 , Side::After).must_be_text(box_desc);
399 test("#(box)", 5 , Side::After).must_be_text(box_desc);
400 test("#box()", 1 , 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 test("#std.box()", 1 , Side::After).must_be_code("<module global>");
406 test("#std.box()", 4 , Side::After).must_be_text(box_desc);
407 test("#std.box()", 5 , Side::After).must_be_text(box_desc);
408 test("#std.box()", 8 , Side::After).must_be_code("box()");
409 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 test("#box[]", 4 , Side::After).must_be_code("[]");
417 test("#box[]", 5 , Side::After).must_be_code("[]");
418 let fill_desc = "The box's background color.";
420 let red_box = "box(fill: rgb(\"#ff4136\"))";
421 test("#box(fill:red,)", 5 , 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 , Side::After).must_be_code("rgb(\"#ff4136\")");
424 test("#box(fill:red,)", 13 , Side::After).must_be_code(red_box);
425 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 test("$f(x)$", 1 , Side::After).must_be_none();
437 test("$f(x)$", 2 , Side::After).must_be_none();
438 test("$f(x)$", 3 , Side::After).must_be_none();
439 test("$sin()$", 1 , Side::After)
440 .must_be_code("op(text: [sin], limits: false)");
441 let vec_z = "vec(children: ([ℤ], []))";
443 test("$vec(ZZ, ,)$", 1 , 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 , 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 let vec_gap = "vec(gap: 0% + 1em, children: ())";
452 test("$vec(gap:#1em)$", 5 , 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 , Side::After).must_be_none();
456 let mat = "mat(rows: (([1],), ([2],)))";
458 test("$mat(1; ..#([2],))$", 1 , Side::After).must_be_text("A matrix.");
459 test("$mat(1; ..#([2],))$", 5 , 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 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 let box_desc = "An inline-level container that sizes content.";
470 test("$std.box()$", 1 , Side::After).must_be_code("<module global>");
471 test("$std.box()$", 4 , Side::After).must_be_text(box_desc);
472 test("$std.box()$", 5 , 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 test("#let y = 10; #let f(x) = x + y", 24, Side::Before)
487 .must_be_text("This closure captures `y`");
488 test("#let f(x) = x + y + z + a", 11, Side::Before)
490 .must_be_text("This closure captures `a`, `y`, and `z`");
491 test("#let f(x) = x + y + z + y", 11, Side::Before)
493 .must_be_text("This closure captures `y` and `z`");
494 test("#let f = (x) => x + y", 15, Side::Before)
496 .must_be_text("This closure captures `y`");
497 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 test(&world, -17, Side::After).must_be_text("A useful function.");
535 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 test(&world, -18, Side::After).must_be_text("A useful function.");
545 test(&world, -8, Side::After).must_be_text("Tree with three slashes.");
547 }
548}