use std::fmt::Write;
use ecow::{EcoString, eco_format};
use typst::engine::Sink;
use typst::foundations::{
AsOutput, Capturer, CastInfo, Func, ParamInfo, Repr, Value, repr,
};
use typst::layout::Length;
use typst::syntax::ast::AstNode;
use typst::syntax::{LinkedNode, Side, Source, SyntaxKind, ast};
use typst::utils::{Numeric, round_with_precision};
use typst_eval::CapturesVisitor;
use crate::analyze::analyze_expr_with_fallback;
use crate::docs::{find_param_docs, find_value_docs};
use crate::utils::summarize_font_family;
use crate::{IdeWorld, analyze_expr, analyze_import, analyze_labels};
pub fn tooltip(
world: &dyn IdeWorld,
output: Option<impl AsOutput>,
source: &Source,
cursor: usize,
side: Side,
) -> Option<Tooltip> {
let leaf = LinkedNode::new(source.root()).leaf_at(cursor, side)?;
if leaf.kind().is_trivia() {
return None;
}
named_param_tooltip(world, &leaf)
.or_else(|| font_tooltip(world, &leaf))
.or_else(|| output.and_then(|output| label_tooltip(output, &leaf)))
.or_else(|| import_tooltip(world, &leaf))
.or_else(|| expr_tooltip(world, &leaf))
.or_else(|| closure_tooltip(&leaf))
}
#[derive(Debug, Clone, PartialEq)]
pub enum Tooltip {
Text(EcoString),
Code(EcoString),
}
fn expr_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
let mut ancestor = leaf;
while !ancestor.is::<ast::Expr>() {
ancestor = ancestor.parent()?;
}
let expr = ancestor.cast::<ast::Expr>()?;
let analyze = expr.hash()
|| matches!(
expr,
ast::Expr::MathIdent(_)
| ast::Expr::MathFieldAccess(_)
| ast::Expr::MathCall(_)
);
if !analyze {
return None;
}
let values = analyze_expr(world, ancestor);
if let [(value, _), rest @ ..] = values.as_slice()
&& rest.iter().all(|(v, _)| value == v)
{
if let Some(docs) = find_value_docs(world, value) {
return Some(Tooltip::Text(docs.summary()));
}
if let &Value::Length(length) = value
&& let Some(tooltip) = length_tooltip(length)
{
return Some(tooltip);
}
}
if expr.is_literal() {
return None;
}
let mut last = None;
let mut pieces: Vec<EcoString> = vec![];
let mut iter = values.iter();
for (value, _) in (&mut iter).take(Sink::MAX_VALUES - 1) {
if let Some((prev, count)) = &mut last {
if *prev == value {
*count += 1;
continue;
} else if *count > 1 {
write!(pieces.last_mut().unwrap(), " (×{count})").unwrap();
}
}
pieces.push(value.repr());
last = Some((value, 1));
}
if let Some((_, count)) = last
&& count > 1
{
write!(pieces.last_mut().unwrap(), " (×{count})").unwrap();
}
if iter.next().is_some() {
pieces.push("...".into());
}
let tooltip = repr::pretty_comma_list(&pieces, false);
(!tooltip.is_empty()).then(|| Tooltip::Code(tooltip.into()))
}
fn import_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
if leaf.kind() == SyntaxKind::Star
&& let Some(parent) = leaf.parent()
&& let Some(import) = parent.cast::<ast::ModuleImport>()
&& let Some(node) = parent.find(import.source().span())
&& let Some(value) = analyze_import(world, &node)
&& let Some(scope) = value.scope()
{
let names: Vec<_> =
scope.iter().map(|(name, ..)| eco_format!("`{name}`")).collect();
let list = repr::separated_list(&names, "and");
return Some(Tooltip::Text(eco_format!("This star imports {list}")));
}
None
}
fn closure_tooltip(leaf: &LinkedNode) -> Option<Tooltip> {
if !matches!(leaf.kind(), SyntaxKind::Eq | SyntaxKind::Arrow) {
return None;
}
let parent = leaf.parent()?;
if parent.kind() != SyntaxKind::Closure {
return None;
}
let mut visitor = CapturesVisitor::new(None, Capturer::Function);
visitor.visit(parent);
let captures = visitor.finish();
let mut names: Vec<_> =
captures.iter().map(|(name, ..)| eco_format!("`{name}`")).collect();
if names.is_empty() {
return None;
}
names.sort();
let tooltip = repr::separated_list(&names, "and");
Some(Tooltip::Text(eco_format!("This closure captures {tooltip}")))
}
fn length_tooltip(length: Length) -> Option<Tooltip> {
length.em.is_zero().then(|| {
Tooltip::Code(eco_format!(
"{}pt = {}mm = {}cm = {}in",
round_with_precision(length.abs.to_pt(), 2),
round_with_precision(length.abs.to_mm(), 2),
round_with_precision(length.abs.to_cm(), 2),
round_with_precision(length.abs.to_inches(), 2),
))
})
}
fn label_tooltip(output: impl AsOutput, leaf: &LinkedNode) -> Option<Tooltip> {
let target = match leaf.kind() {
SyntaxKind::RefMarker => leaf.leaf_text().trim_start_matches('@'),
SyntaxKind::Label => {
leaf.leaf_text().trim_start_matches('<').trim_end_matches('>')
}
_ => return None,
};
for (label, detail) in analyze_labels(output).0 {
if label.resolve().as_str() == target {
return Some(Tooltip::Text(detail?));
}
}
None
}
fn named_param_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
let (func, named) =
if let Some(parent) = leaf.parent()
&& let Some(named) = parent.cast::<ast::Named>()
&& let Some(grand) = parent.parent()
&& matches!(grand.kind(), SyntaxKind::Args | SyntaxKind::MathArgs)
&& let Some(grand_grand) = grand.parent()
&& let Some(expr) = grand_grand.cast::<ast::Expr>()
&& let Some(callee_span) = match expr {
ast::Expr::FuncCall(call) => Some(call.callee().span()),
ast::Expr::MathCall(call) => Some(call.callee().span()),
ast::Expr::SetRule(set) => Some(set.target().span()),
_ => None,
}
&& let Some(callee) = grand_grand.find(callee_span)
&& let Some(value) = analyze_expr_with_fallback(world, &callee)
&& let Ok(func) = value.cast::<Func>()
{ (func, named) }
else { return None; };
if leaf.index() == 0
&& let Some(ident) = leaf.cast::<ast::Ident>()
&& let Some(param) = func.param(&ident)
&& let Some(docs) = find_param_docs(world, ¶m)
{
return Some(Tooltip::Text(docs.summary()));
}
if let Some(string) = leaf.cast::<ast::Str>()
&& let Some(param) = func.param(&named.name())
&& let ParamInfo::Native(param) = param
&& let Some(docs) = find_string_doc(¶m.input, &string.get())
{
return Some(Tooltip::Text(docs.into()));
}
None
}
fn find_string_doc(info: &CastInfo, string: &str) -> Option<&'static str> {
match info {
CastInfo::Value(Value::Str(s), docs) if s.as_str() == string => Some(docs),
CastInfo::Union(options) => {
options.iter().find_map(|option| find_string_doc(option, string))
}
_ => None,
}
}
fn font_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
if let Some(string) = leaf.cast::<ast::Str>()
&& let lower = string.get().to_lowercase()
&& let Some(parent) = leaf.parent()
&& let Some(named) = parent.cast::<ast::Named>()
&& named.name().as_str() == "font"
&& let book = world.book()
&& let Some((_, iter)) = book
.families()
.find(|&(family, _)| family.to_lowercase().as_str() == lower.as_str())
{
let detail = summarize_font_family(iter.filter_map(|id| book.info(id)));
return Some(Tooltip::Text(detail));
}
None
}
#[cfg(test)]
mod tests {
use std::borrow::Borrow;
use typst::syntax::Side;
use typst_layout::PagedDocument;
use super::{Tooltip, tooltip};
use crate::tests::{FilePos, TestWorld, WorldLike};
type Response = Option<Tooltip>;
trait ResponseExt {
fn must_be_none(&self) -> &Self;
fn must_be_text(&self, text: &str) -> &Self;
fn must_be_code(&self, code: &str) -> &Self;
}
impl ResponseExt for Response {
#[track_caller]
fn must_be_none(&self) -> &Self {
assert_eq!(*self, None);
self
}
#[track_caller]
fn must_be_text(&self, text: &str) -> &Self {
assert_eq!(*self, Some(Tooltip::Text(text.into())));
self
}
#[track_caller]
fn must_be_code(&self, code: &str) -> &Self {
assert_eq!(*self, Some(Tooltip::Code(code.into())));
self
}
}
#[track_caller]
fn test(world: impl WorldLike, pos: impl FilePos, side: Side) -> Response {
let world = world.acquire();
let world = world.borrow();
let (source, cursor) = pos.resolve(world);
let doc = typst::compile::<PagedDocument>(world).output.ok();
tooltip(world, doc.as_ref(), &source, cursor, side)
}
#[test]
fn test_tooltip() {
test("#let x = 1 + 2", -1, Side::After).must_be_none();
test("#let x = 1 + 2", 5, Side::After).must_be_code("3");
test("#let x = 1 + 2", 6, Side::Before).must_be_code("3");
}
#[test]
fn test_tooltip_math_literals() {
let world = "$x'^2 &!= \\u{3C0} \"is\" pi #true$";
test(world, 0 , Side::After).must_be_none();
test(world, 1 , Side::After).must_be_none();
test(world, 2 , Side::After).must_be_none();
test(world, 3 , Side::After).must_be_none();
test(world, 4 , Side::After).must_be_none();
test(world, 5 , Side::After).must_be_none();
test(world, 6 , Side::After).must_be_none();
test(world, 7 , Side::After).must_be_none();
test(world, 10 , Side::After).must_be_none();
test(world, 11 , Side::After).must_be_none();
test(world, 12 , Side::After).must_be_none();
test(world, 13 , Side::After).must_be_none();
test(world, 19 , Side::After).must_be_none();
test(world, 20 , Side::After).must_be_none();
test(world, 24 , Side::After)
.must_be_code("symbol(\"Ï€\", (\"alt\", \"Ï–\"))");
test(world, 27 , Side::After).must_be_none();
test(world, 28 , Side::After).must_be_none();
}
#[test]
fn test_tooltip_math_field_access() {
test("$pi.alt$", 1, Side::After).must_be_code("symbol(\"Ï€\", (\"alt\", \"Ï–\"))");
test("$pi.alt$", 3, Side::After).must_be_code("symbol(\"Ï–\")");
test("$pi.alt$", 4, Side::After).must_be_code("symbol(\"Ï–\")");
}
#[test]
fn test_tooltip_set() {
let box_desc = "An inline-level container that sizes content.";
let fill_desc = "The box's background color.";
let red = "rgb(\"#ff4136\")";
test("#set box(fill: red,)", 0 , Side::After).must_be_none();
test("#set box(fill: red,)", 1 , Side::After).must_be_none();
test("#set box(fill: red,)", 5 , Side::After).must_be_text(box_desc);
test("#set box(fill: red,)", 8 , Side::After).must_be_none();
test("#set box(fill: red,)", 9 , Side::After).must_be_text(fill_desc);
test("#set box(fill: red,)", 13 , Side::After).must_be_none();
test("#set box(fill: red,)", 15 , Side::After).must_be_code(red);
test("#set box(fill: red,)", 18 , Side::After).must_be_none();
test("#set box(fill: red,)", 19 , Side::After).must_be_none();
}
#[test]
fn test_tooltip_function_call() {
let box_desc = "An inline-level container that sizes content.";
test("#box", 0 , Side::After).must_be_none();
test("#box", 1 , Side::After).must_be_text(box_desc);
test("#(box)", 0 , Side::After).must_be_none();
test("#(box)", 1 , Side::After).must_be_text(box_desc);
test("#(box)", 2 , Side::After).must_be_text(box_desc);
test("#(box)", 5 , Side::After).must_be_text(box_desc);
test("#box()", 1 , Side::After).must_be_text(box_desc);
test("#box()", 4 , Side::After).must_be_code("box()");
test("#box()", 5 , Side::After).must_be_code("box()");
test("#std.box()", 1 , Side::After).must_be_code("<module global>");
test("#std.box()", 4 , Side::After).must_be_text(box_desc);
test("#std.box()", 5 , Side::After).must_be_text(box_desc);
test("#std.box()", 8 , Side::After).must_be_code("box()");
test("#box([],)", 4 , Side::After).must_be_code("box(body: [])");
test("#box([],)", 5 , Side::After).must_be_code("[]");
test("#box([],)", 6 , Side::After).must_be_code("[]");
test("#box([],)", 7 , Side::After).must_be_code("box(body: [])");
test("#box([],)", 8 , Side::After).must_be_code("box(body: [])");
test("#box[]", 4 , Side::After).must_be_code("[]");
test("#box[]", 5 , Side::After).must_be_code("[]");
let fill_desc = "The box's background color.";
let red_box = "box(fill: rgb(\"#ff4136\"))";
test("#box(fill:red,)", 5 , Side::After).must_be_text(fill_desc);
test("#box(fill:red,)", 9 , Side::After).must_be_code(red_box);
test("#box(fill:red,)", 10 , Side::After).must_be_code("rgb(\"#ff4136\")");
test("#box(fill:red,)", 13 , Side::After).must_be_code(red_box);
test("#box(..none,)", 5 , Side::After).must_be_code("box()");
test("#box(..none,)", 7 , Side::After).must_be_none();
test("#box(..none,)", 11 , Side::After).must_be_code("box()");
test("#box(..([],))", 5 , Side::After).must_be_code("box(body: [])");
test("#box(..([],))", 7 , Side::After).must_be_code("([],)");
}
#[test]
fn test_tooltip_math_function_call() {
test("$f(x)$", 1 , Side::After).must_be_none();
test("$f(x)$", 2 , Side::After).must_be_none();
test("$f(x)$", 3 , Side::After).must_be_none();
test("$sin()$", 1 , Side::After)
.must_be_code("op(text: [sin], limits: false)");
let vec_z = "vec(children: ([ℤ], []))";
test("$vec(ZZ, ,)$", 1 , Side::After).must_be_text("A column vector.");
test("$vec(ZZ, ,)$", 4 , Side::After).must_be_code(vec_z);
test("$vec(ZZ, ,)$", 5 , Side::After).must_be_code("symbol(\"ℤ\")");
test("$vec(ZZ, ,)$", 7 , Side::After).must_be_code(vec_z);
test("$vec(ZZ, ,)$", 8 , Side::After).must_be_none();
test("$vec(ZZ, ,)$", 9 , Side::After).must_be_code(vec_z);
test("$vec(ZZ, ,)$", 10 , Side::After).must_be_code(vec_z);
let vec_gap = "vec(gap: 0% + 1em, children: ())";
test("$vec(gap:#1em)$", 5 , Side::After)
.must_be_text("The gap between elements.");
test("$vec(gap:#1em)$", 8 , Side::After).must_be_code(vec_gap);
test("$vec(gap:#1em)$", 10 , Side::After).must_be_none();
let mat = "mat(rows: (([1],), ([2],)))";
test("$mat(1; ..#([2],))$", 1 , Side::After).must_be_text("A matrix.");
test("$mat(1; ..#([2],))$", 5 , Side::After).must_be_none();
test("$mat(1; ..#([2],))$", 6 , Side::After).must_be_code(mat);
test("$mat(1; ..#([2],))$", 8 , Side::After).must_be_code(mat);
test("$mat(1; ..#([2],))$", 10 , Side::After).must_be_code(mat);
test("$mat(1; ..#([2],))$", 11 , Side::After).must_be_code("([2],)");
test("$hat(i,size:#1em)$", 1, Side::After).must_be_code("symbol(\"^\")");
test("$hat(i,size:#1em)$", 7, Side::After)
.must_be_text("The size of the accent, relative to the width of the base.");
let box_desc = "An inline-level container that sizes content.";
test("$std.box()$", 1 , Side::After).must_be_code("<module global>");
test("$std.box()$", 4 , Side::After).must_be_text(box_desc);
test("$std.box()$", 5 , Side::After).must_be_text(box_desc);
test("$std.box()$", 8 , Side::After).must_be_code("box()");
}
#[test]
fn test_tooltip_empty_contextual() {
test("#{context}", -1, Side::Before).must_be_code("context()");
}
#[test]
fn test_tooltip_closure() {
test("#let f(x) = x + y", 11, Side::Before)
.must_be_text("This closure captures `y`");
test("#let y = 10; #let f(x) = x + y", 24, Side::Before)
.must_be_text("This closure captures `y`");
test("#let f(x) = x + y + z + a", 11, Side::Before)
.must_be_text("This closure captures `a`, `y`, and `z`");
test("#let f(x) = x + y + z + y", 11, Side::Before)
.must_be_text("This closure captures `y` and `z`");
test("#let f = (x) => x + y", 15, Side::Before)
.must_be_text("This closure captures `y`");
test("#let f = (x) => x + y + f", 13, Side::After)
.must_be_text("This closure captures `f` and `y`");
}
#[test]
fn test_tooltip_import() {
let world = TestWorld::new("#import \"other.typ\": a, b")
.with_source("other.typ", "#let (a, b, c) = (1, 2, 3)");
test(&world, -5, Side::After).must_be_code("1");
}
#[test]
fn test_tooltip_star_import() {
let world = TestWorld::new("#import \"other.typ\": *")
.with_source("other.typ", "#let (a, b, c) = (1, 2, 3)");
test(&world, -2, Side::Before).must_be_none();
test(&world, -2, Side::After).must_be_text("This star imports `a`, `b`, and `c`");
}
#[test]
fn test_tooltip_field_call() {
let world = TestWorld::new("#import \"other.typ\"\n#other.f()")
.with_source("other.typ", "#let f = (x) => 1");
test(&world, -4, Side::After).must_be_code("(..) => ..");
}
#[test]
fn test_tooltip_reference() {
test("#figure(caption: [Hi])[]<f> @f", -1, Side::Before).must_be_text("Hi");
}
#[test]
fn test_tooltip_user_function() {
let world = TestWorld::new("#import \"lib.typ\"\n#lib.foo(none, tree: 2)")
.with_source("lib.typ", crate::tests::EXAMPLE_CLOSURE);
test(&world, -17, Side::After).must_be_text("A useful function.");
test(&world, -7, Side::After).must_be_text("Tree with three slashes.");
}
#[test]
fn test_tooltip_user_function_in_math() {
let world = TestWorld::new("#import \"lib.typ\"\n$lib.foo(none, tree: 2)$")
.with_source("lib.typ", crate::tests::EXAMPLE_CLOSURE);
test(&world, -18, Side::After).must_be_text("A useful function.");
test(&world, -8, Side::After).must_be_text("Tree with three slashes.");
}
}