use std::fmt::Write;
use ecow::{eco_format, EcoString};
use if_chain::if_chain;
use typst::engine::Sink;
use typst::eval::CapturesVisitor;
use typst::foundations::{repr, Capturer, CastInfo, Repr, Value};
use typst::layout::Length;
use typst::model::Document;
use typst::syntax::{ast, LinkedNode, Side, Source, SyntaxKind};
use typst::utils::{round_with_precision, Numeric};
use typst::World;
use crate::{analyze_expr, analyze_labels, plain_docs_sentence, summarize_font_family};
pub fn tooltip(
world: &dyn World,
document: Option<&Document>,
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(|| document.and_then(|doc| label_tooltip(doc, &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 World, leaf: &LinkedNode) -> Option<Tooltip> {
let mut ancestor = leaf;
while !ancestor.is::<ast::Expr>() {
ancestor = ancestor.parent()?;
}
let expr = ancestor.cast::<ast::Expr>()?;
if !expr.hash() && !matches!(expr, ast::Expr::MathIdent(_)) {
return None;
}
let values = analyze_expr(world, ancestor);
if let [(value, _)] = values.as_slice() {
if let Some(docs) = value.docs() {
return Some(Tooltip::Text(plain_docs_sentence(docs)));
}
if let &Value::Length(length) = value {
if 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(), " (x{count})").unwrap();
}
}
pieces.push(value.repr());
last = Some((value, 1));
}
if let Some((_, count)) = last {
if count > 1 {
write!(pieces.last_mut().unwrap(), " (x{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 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(document: &Document, leaf: &LinkedNode) -> Option<Tooltip> {
let target = match leaf.kind() {
SyntaxKind::RefMarker => leaf.text().trim_start_matches('@'),
SyntaxKind::Label => leaf.text().trim_start_matches('<').trim_end_matches('>'),
_ => return None,
};
for (label, detail) in analyze_labels(document).0 {
if label.as_str() == target {
return Some(Tooltip::Text(detail?));
}
}
None
}
fn named_param_tooltip(world: &dyn World, leaf: &LinkedNode) -> Option<Tooltip> {
let (func, named) = if_chain! {
if let Some(parent) = leaf.parent();
if let Some(named) = parent.cast::<ast::Named>();
if let Some(grand) = parent.parent();
if matches!(grand.kind(), SyntaxKind::Args);
if let Some(grand_grand) = grand.parent();
if let Some(expr) = grand_grand.cast::<ast::Expr>();
if let Some(ast::Expr::Ident(callee)) = match expr {
ast::Expr::FuncCall(call) => Some(call.callee()),
ast::Expr::Set(set) => Some(set.target()),
_ => None,
};
if let Some(Value::Func(func)) = world.library().global.scope().get(&callee);
then { (func, named) }
else { return None; }
};
if_chain! {
if leaf.index() == 0;
if let Some(ident) = leaf.cast::<ast::Ident>();
if let Some(param) = func.param(&ident);
then {
return Some(Tooltip::Text(plain_docs_sentence(param.docs)));
}
}
if_chain! {
if let Some(string) = leaf.cast::<ast::Str>();
if let Some(param) = func.param(&named.name());
if let Some(docs) = find_string_doc(¶m.input, &string.get());
then {
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 World, leaf: &LinkedNode) -> Option<Tooltip> {
if_chain! {
if let Some(string) = leaf.cast::<ast::Str>();
let lower = string.get().to_lowercase();
if let Some(parent) = leaf.parent();
if let Some(named) = parent.cast::<ast::Named>();
if named.name().as_str() == "font";
if let Some((_, iter)) = world
.book()
.families()
.find(|&(family, _)| family.to_lowercase().as_str() == lower.as_str());
then {
let detail = summarize_font_family(iter);
return Some(Tooltip::Text(detail));
}
};
None
}
#[cfg(test)]
mod tests {
use typst::syntax::Side;
use super::{tooltip, Tooltip};
use crate::tests::TestWorld;
fn text(text: &str) -> Option<Tooltip> {
Some(Tooltip::Text(text.into()))
}
fn code(code: &str) -> Option<Tooltip> {
Some(Tooltip::Code(code.into()))
}
#[track_caller]
fn test(text: &str, cursor: usize, side: Side, expected: Option<Tooltip>) {
let world = TestWorld::new(text);
let doc = typst::compile(&world).output.ok();
assert_eq!(tooltip(&world, doc.as_ref(), &world.main, cursor, side), expected);
}
#[test]
fn test_tooltip() {
test("#let x = 1 + 2", 5, Side::After, code("3"));
test("#let x = 1 + 2", 6, Side::Before, code("3"));
test("#let f(x) = x + y", 11, Side::Before, text("This closure captures `y`."));
}
#[test]
fn test_empty_contextual() {
test("#{context}", 10, Side::Before, code("context()"));
}
}