feat: hover tooltip on functions (#76)

* dev: introduce upstream tooltip

* feat: basic function definition
This commit is contained in:
Myriad-Dreamin 2024-03-20 00:02:11 +08:00 committed by GitHub
parent b780a2a885
commit 14fc4819f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 490 additions and 18 deletions

View file

@ -42,6 +42,7 @@ typst-ts-core = { version = "0.4.2-rc8", default-features = false, features = [
] }
lsp-types.workspace = true
if_chain = "1"
[dev-dependencies]
once_cell.workspace = true

View file

@ -72,6 +72,7 @@ impl SyntaxRequest for GotoDefinitionRequest {
}
pub(crate) struct DefinitionLink {
pub kind: LexicalKind,
pub value: Option<Value>,
pub fid: TypstFileId,
pub name: String,
@ -97,6 +98,7 @@ pub(crate) fn find_definition(
let e = parent.cast::<ast::ModuleImport>()?;
let source = find_source_by_import(ctx.world, def_fid, e)?;
return Some(DefinitionLink {
kind: LexicalKind::Mod(LexicalModKind::PathVar),
name: String::new(),
value: None,
fid: source.id(),
@ -151,6 +153,7 @@ pub(crate) fn find_definition(
let source = ctx.source_by_id(fid).ok()?;
return Some(DefinitionLink {
kind: LexicalKind::Var(LexicalVarKind::Function),
name: name.to_owned(),
value: Some(Value::Func(f.clone())),
fid,
@ -178,6 +181,7 @@ pub(crate) fn find_definition(
| LexicalModKind::Alias { .. }
| LexicalModKind::Ident,
) => Some(DefinitionLink {
kind: def.kind.clone(),
name: def.name.clone(),
value: None,
fid: def_fid,
@ -190,18 +194,13 @@ pub(crate) fn find_definition(
let def_name = root.leaf_at(def.range.start + 1)?;
log::info!("def_name for function: {def_name:?}", def_name = def_name);
let values = analyze_expr(ctx.world, &def_name);
let Some(func) = values.into_iter().find_map(|v| match v.0 {
Value::Func(f) => Some(f),
_ => None,
}) else {
log::info!("no func found... {:?}", def.name);
return None;
};
let func = values.into_iter().find(|v| matches!(v.0, Value::Func(..)));
log::info!("okay for function: {func:?}");
Some(DefinitionLink {
kind: def.kind.clone(),
name: def.name.clone(),
value: Some(Value::Func(func.clone())),
value: func.map(|v| v.0),
fid: def_fid,
def_range: def.range.clone(),
name_range: Some(def.range.clone()),

View file

@ -1,4 +1,14 @@
use crate::{prelude::*, StatefulRequest};
use core::fmt;
use ecow::eco_format;
use crate::{
analyze_signature, find_definition,
prelude::*,
syntax::{get_deref_target, LexicalVarKind},
upstream::{expr_tooltip, tooltip, Tooltip},
StatefulRequest,
};
#[derive(Debug, Clone)]
pub struct HoverRequest {
@ -21,7 +31,8 @@ impl StatefulRequest for HoverRequest {
// the typst's cursor is 1-based, so we need to add 1 to the offset
let cursor = offset + 1;
let typst_tooltip = typst_ide::tooltip(ctx.world, doc, &source, cursor)?;
let typst_tooltip = def_tooltip(ctx, &source, cursor)
.or_else(|| tooltip(ctx.world, doc, &source, cursor))?;
let ast_node = LinkedNode::new(source.root()).leaf_at(cursor)?;
let range = ctx.to_lsp_range(ast_node.range(), &source);
@ -32,3 +43,131 @@ impl StatefulRequest for HoverRequest {
})
}
}
fn def_tooltip(ctx: &mut AnalysisContext, source: &Source, cursor: usize) -> Option<Tooltip> {
let leaf = LinkedNode::new(source.root()).leaf_at(cursor)?;
let deref_target = get_deref_target(leaf.clone())?;
let lnk = find_definition(ctx, source.clone(), deref_target.clone())?;
match lnk.kind {
crate::syntax::LexicalKind::Mod(_)
| crate::syntax::LexicalKind::Var(LexicalVarKind::Label)
| crate::syntax::LexicalKind::Var(LexicalVarKind::LabelRef)
| crate::syntax::LexicalKind::Var(LexicalVarKind::ValRef)
| crate::syntax::LexicalKind::Block
| crate::syntax::LexicalKind::Heading(..) => None,
crate::syntax::LexicalKind::Var(LexicalVarKind::Function) => {
let f = lnk.value.as_ref();
Some(Tooltip::Text(eco_format!(
r#"```typc
let {}({});
```{}"#,
lnk.name,
ParamTooltip(f),
DocTooltip(f),
)))
}
crate::syntax::LexicalKind::Var(LexicalVarKind::Variable) => {
let deref_node = deref_target.node();
let v = expr_tooltip(ctx.world, deref_node)
.map(|t| match t {
Tooltip::Text(s) => format!("\n\nValues: {}", s),
Tooltip::Code(s) => format!("\n\nValues: ```{}```", s),
})
.unwrap_or_default();
Some(Tooltip::Text(eco_format!(
r#"
```typc
let {};
```{v}"#,
lnk.name
)))
}
}
}
struct ParamTooltip<'a>(Option<&'a Value>);
impl<'a> fmt::Display for ParamTooltip<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Some(Value::Func(func)) = self.0 else {
return Ok(());
};
let sig = analyze_signature(func.clone());
let mut is_first = true;
let mut write_sep = |f: &mut fmt::Formatter<'_>| {
if is_first {
is_first = false;
return Ok(());
}
f.write_str(", ")
};
for p in &sig.pos {
write_sep(f)?;
write!(f, "{}", p.name)?;
}
if let Some(rest) = &sig.rest {
write_sep(f)?;
write!(f, "{}", rest.name)?;
}
if !sig.named.is_empty() {
let mut name_prints = vec![];
for v in sig.named.values() {
name_prints.push((v.name.clone(), v.expr.clone()))
}
name_prints.sort();
for (k, v) in name_prints {
write_sep(f)?;
let v = v.as_deref().unwrap_or("any");
let mut v = v.trim();
if v.starts_with('{') && v.ends_with('}') && v.len() > 30 {
v = "{ ... }"
}
if v.starts_with('`') && v.ends_with('`') && v.len() > 30 {
v = "raw"
}
if v.starts_with('[') && v.ends_with(']') && v.len() > 30 {
v = "content"
}
write!(f, "{k}: {v}")?;
}
}
Ok(())
}
}
struct DocTooltip<'a>(Option<&'a Value>);
impl<'a> fmt::Display for DocTooltip<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Some(Value::Func(func)) = self.0 else {
return Ok(());
};
f.write_str("\n\n")?;
use typst::foundations::func::Repr;
let mut func = func;
let docs = 'search: loop {
match func.inner() {
Repr::Native(n) => break 'search n.docs,
Repr::Element(e) => break 'search e.docs(),
Repr::With(w) => {
func = &w.0;
}
Repr::Closure(..) => {
return Ok(());
}
}
};
f.write_str(docs)
}
}

View file

@ -1,10 +1,11 @@
use core::fmt;
use std::{borrow::Cow, ops::Range};
use ecow::eco_vec;
use ecow::{eco_format, eco_vec};
use log::debug;
use lsp_types::{InlayHintKind, InlayHintLabel};
use typst::{
foundations::{Args, Closure},
foundations::{Args, CastInfo, Closure},
syntax::SyntaxNode,
util::LazyHash,
};
@ -486,6 +487,8 @@ fn analyze_call_no_cache(func: Func, args: ast::Args<'_>) -> Option<CallInfo> {
pub struct ParamSpec {
/// The parameter's name.
pub name: Cow<'static, str>,
/// The parameter's default name.
pub expr: Option<EcoString>,
/// Creates an instance of the parameter's default value.
pub default: Option<fn() -> Value>,
/// Is the parameter positional?
@ -503,6 +506,7 @@ impl ParamSpec {
fn from_static(s: &ParamInfo) -> Arc<Self> {
Arc::new(Self {
name: Cow::Borrowed(s.name),
expr: Some(eco_format!("{}", TypeExpr(&s.input))),
default: s.default,
positional: s.positional,
named: s.named,
@ -512,16 +516,16 @@ impl ParamSpec {
}
#[derive(Debug, Clone)]
struct Signature {
pos: Vec<Arc<ParamSpec>>,
named: HashMap<Cow<'static, str>, Arc<ParamSpec>>,
pub struct Signature {
pub pos: Vec<Arc<ParamSpec>>,
pub named: HashMap<Cow<'static, str>, Arc<ParamSpec>>,
has_fill_or_size_or_stroke: bool,
rest: Option<Arc<ParamSpec>>,
pub rest: Option<Arc<ParamSpec>>,
_broken: bool,
}
#[comemo::memoize]
fn analyze_signature(func: Func) -> Arc<Signature> {
pub(crate) fn analyze_signature(func: Func) -> Arc<Signature> {
use typst::foundations::func::Repr;
let params = match func.inner() {
Repr::With(..) => unreachable!(),
@ -595,6 +599,7 @@ fn analyze_closure_signature(c: Arc<LazyHash<Closure>>) -> Vec<Arc<ParamSpec>> {
ast::Param::Pos(ast::Pattern::Placeholder(..)) => {
params.push(Arc::new(ParamSpec {
name: Cow::Borrowed("_"),
expr: None,
default: None,
positional: true,
named: false,
@ -611,15 +616,18 @@ fn analyze_closure_signature(c: Arc<LazyHash<Closure>>) -> Vec<Arc<ParamSpec>> {
params.push(Arc::new(ParamSpec {
name: Cow::Owned(name.to_owned()),
expr: None,
default: None,
positional: true,
named: false,
variadic: false,
}));
}
// todo: pattern
ast::Param::Named(n) => {
params.push(Arc::new(ParamSpec {
name: Cow::Owned(n.name().as_str().to_owned()),
expr: Some(unwrap_expr(n.expr()).to_untyped().clone().into_text()),
default: None,
positional: false,
named: true,
@ -630,6 +638,7 @@ fn analyze_closure_signature(c: Arc<LazyHash<Closure>>) -> Vec<Arc<ParamSpec>> {
let ident = n.sink_ident().map(|e| e.as_str());
params.push(Arc::new(ParamSpec {
name: Cow::Owned(ident.unwrap_or_default().to_owned()),
expr: None,
default: None,
positional: false,
named: true,
@ -654,6 +663,34 @@ fn is_one_line_(src: &Source, arg_node: &LinkedNode<'_>) -> Option<bool> {
Some(ll == rl)
}
fn unwrap_expr(mut e: ast::Expr) -> ast::Expr {
while let ast::Expr::Parenthesized(p) = e {
e = p.expr();
}
e
}
struct TypeExpr<'a>(&'a CastInfo);
impl<'a> fmt::Display for TypeExpr<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self.0 {
CastInfo::Any => "any",
CastInfo::Value(.., v) => v,
CastInfo::Type(v) => {
f.write_str(v.short_name())?;
return Ok(());
}
CastInfo::Union(v) => {
let mut values = v.iter().map(|e| TypeExpr(e).to_string());
f.write_str(&values.join(" | "))?;
return Ok(());
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -1,6 +1,7 @@
mod adt;
pub mod analysis;
pub mod syntax;
mod upstream;
pub(crate) mod diagnostics;

View file

@ -17,7 +17,7 @@ pub type TypstSpan = typst::syntax::Span;
pub type LspRange = lsp_types::Range;
pub type TypstRange = std::ops::Range<usize>;
pub type TypstTooltip = typst_ide::Tooltip;
pub type TypstTooltip = crate::upstream::Tooltip;
pub type LspHoverContents = lsp_types::HoverContents;
pub type LspDiagnostic = lsp_types::Diagnostic;

View file

@ -381,6 +381,7 @@ impl LexicalHierarchyWorker {
}
}
} else {
// todo: for loop variable
match node.kind() {
SyntaxKind::LetBinding => 'let_binding: {
let name = node.children().find(|n| n.cast::<ast::Pattern>().is_some());

View file

@ -0,0 +1,38 @@
use std::fmt::Write;
use ecow::{eco_format, EcoString};
use typst::text::{FontInfo, FontStyle};
mod tooltip;
pub use tooltip::*;
/// Create a short description of a font family.
fn summarize_font_family<'a>(variants: impl Iterator<Item = &'a FontInfo>) -> EcoString {
let mut infos: Vec<_> = variants.collect();
infos.sort_by_key(|info: &&FontInfo| info.variant);
let mut has_italic = false;
let mut min_weight = u16::MAX;
let mut max_weight = 0;
for info in &infos {
let weight = info.variant.weight.to_number();
has_italic |= info.variant.style == FontStyle::Italic;
min_weight = min_weight.min(weight);
max_weight = min_weight.max(weight);
}
let count = infos.len();
let mut detail = eco_format!("{count} variant{}.", if count == 1 { "" } else { "s" });
if min_weight == max_weight {
write!(detail, " Weight {min_weight}.").unwrap();
} else {
write!(detail, " Weights {min_weight}{max_weight}.").unwrap();
}
if has_italic {
detail.push_str(" Has italics.");
}
detail
}

View file

@ -0,0 +1,255 @@
use std::fmt::Write;
use ecow::{eco_format, EcoString};
use if_chain::if_chain;
use typst::eval::{CapturesVisitor, Tracer};
use typst::foundations::{repr, Capturer, CastInfo, Repr, Value};
use typst::layout::Length;
use typst::model::Document;
use typst::syntax::{ast, LinkedNode, Source, SyntaxKind};
use typst::util::{round_2, Numeric};
use typst::World;
use super::summarize_font_family;
use crate::analysis::{analyze_expr, analyze_labels};
/// Describe the item under the cursor.
///
/// Passing a `document` (from a previous compilation) is optional, but enhances
/// the autocompletions. Label completions, for instance, are only generated
/// when the document is available.
pub fn tooltip(
world: &dyn World,
document: Option<&Document>,
source: &Source,
cursor: usize,
) -> Option<Tooltip> {
let leaf = LinkedNode::new(source.root()).leaf_at(cursor)?;
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))
}
/// A hover tooltip.
#[derive(Debug, Clone)]
pub enum Tooltip {
/// A string of text.
Text(EcoString),
/// A string of Typst code.
Code(EcoString),
}
/// Tooltip for a hovered expression.
pub 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(docs.into()));
}
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(Tracer::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()))
}
/// Tooltip for a hovered closure.
fn closure_tooltip(leaf: &LinkedNode) -> Option<Tooltip> {
// Only show this tooltip when hovering over the equals sign or arrow of
// the closure. Showing it across the whole subtree is too noisy.
if !matches!(leaf.kind(), SyntaxKind::Eq | SyntaxKind::Arrow) {
return None;
}
// Find the closure to analyze.
let parent = leaf.parent()?;
if parent.kind() != SyntaxKind::Closure {
return None;
}
// Analyze the closure's captures.
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}."
)))
}
/// Tooltip text for a hovered length.
fn length_tooltip(length: Length) -> Option<Tooltip> {
length.em.is_zero().then(|| {
Tooltip::Code(eco_format!(
"{}pt = {}mm = {}cm = {}in",
round_2(length.abs.to_pt()),
round_2(length.abs.to_mm()),
round_2(length.abs.to_cm()),
round_2(length.abs.to_inches())
))
})
}
/// Tooltip for a hovered reference or label.
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
}
/// Tooltips for components of a named parameter.
fn named_param_tooltip(world: &dyn World, leaf: &LinkedNode) -> Option<Tooltip> {
let (func, named) = if_chain! {
// Ensure that we are in a named pair in the arguments to a function
// call or set rule.
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,
};
// Find metadata about the function.
if let Some(Value::Func(func)) = world.library().global.scope().get(&callee);
then { (func, named) }
else { return None; }
};
// Hovering over the parameter name.
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(param.docs.into()));
}
}
// Hovering over a string parameter value.
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(&param.input, &string.get());
then {
return Some(Tooltip::Text(docs.into()));
}
}
None
}
/// Find documentation for a castable string.
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,
}
}
/// Tooltip for font.
fn font_tooltip(world: &dyn World, leaf: &LinkedNode) -> Option<Tooltip> {
if_chain! {
// Ensure that we are on top of a string.
if let Some(string) = leaf.cast::<ast::Str>();
let lower = string.get().to_lowercase();
// Ensure that we are in the arguments to the text function.
if let Some(parent) = leaf.parent();
if let Some(named) = parent.cast::<ast::Named>();
if named.name().as_str() == "font";
// Find the font family.
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
}