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
15pub 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#[derive(Debug, Clone, PartialEq)]
42pub enum Tooltip {
43 Text(EcoString),
45 Code(EcoString),
47}
48
49fn 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
109fn 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
127fn closure_tooltip(leaf: &LinkedNode) -> Option<Tooltip> {
129 if !matches!(leaf.kind(), SyntaxKind::Eq | SyntaxKind::Arrow) {
132 return None;
133 }
134
135 let parent = leaf.parent()?;
137 if parent.kind() != SyntaxKind::Closure {
138 return None;
139 }
140
141 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
158fn 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
171fn 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
188fn named_param_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
190 let (func, named) =
191 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 && 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 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 if let Some(string) = leaf.cast::<ast::Str>()
225 && let Some(param) = func.param(&named.name())
226 && let Some(docs) = find_string_doc(¶m.input, &string.get())
227 {
228 return Some(Tooltip::Text(docs.into()));
229 }
230
231 None
232}
233
234fn 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
245fn font_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
247 if let Some(string) = leaf.cast::<ast::Str>()
249 && let lower = string.get().to_lowercase()
250
251 && let Some(parent) = leaf.parent()
253 && let Some(named) = parent.cast::<ast::Named>()
254 && named.name().as_str() == "font"
255
256 && 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 test("#let y = 10; #let f(x) = x + y", 24, Side::Before)
334 .must_be_text("This closure captures `y`");
335 test("#let f(x) = x + y + z + a", 11, Side::Before)
337 .must_be_text("This closure captures `a`, `y`, and `z`");
338 test("#let f(x) = x + y + z + y", 11, Side::Before)
340 .must_be_text("This closure captures `y` and `z`");
341 test("#let f = (x) => x + y", 15, Side::Before)
343 .must_be_text("This closure captures `y`");
344 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}