diff --git a/Cargo.lock b/Cargo.lock index 339d492fb..6912417d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3664,6 +3664,7 @@ dependencies = [ "ena", "fxhash", "hex", + "if_chain", "indexmap 2.2.5", "insta", "itertools 0.12.1", diff --git a/crates/tinymist-query/Cargo.toml b/crates/tinymist-query/Cargo.toml index 55a8dca04..437135f36 100644 --- a/crates/tinymist-query/Cargo.toml +++ b/crates/tinymist-query/Cargo.toml @@ -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 diff --git a/crates/tinymist-query/src/goto_definition.rs b/crates/tinymist-query/src/goto_definition.rs index ee0f61474..96c05cb00 100644 --- a/crates/tinymist-query/src/goto_definition.rs +++ b/crates/tinymist-query/src/goto_definition.rs @@ -72,6 +72,7 @@ impl SyntaxRequest for GotoDefinitionRequest { } pub(crate) struct DefinitionLink { + pub kind: LexicalKind, pub value: Option, pub fid: TypstFileId, pub name: String, @@ -97,6 +98,7 @@ pub(crate) fn find_definition( let e = parent.cast::()?; 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()), diff --git a/crates/tinymist-query/src/hover.rs b/crates/tinymist-query/src/hover.rs index cac8b76fb..57c39df6e 100644 --- a/crates/tinymist-query/src/hover.rs +++ b/crates/tinymist-query/src/hover.rs @@ -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 { + 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) + } +} diff --git a/crates/tinymist-query/src/inlay_hint.rs b/crates/tinymist-query/src/inlay_hint.rs index 936ab132d..5daad0fd6 100644 --- a/crates/tinymist-query/src/inlay_hint.rs +++ b/crates/tinymist-query/src/inlay_hint.rs @@ -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 { pub struct ParamSpec { /// The parameter's name. pub name: Cow<'static, str>, + /// The parameter's default name. + pub expr: Option, /// Creates an instance of the parameter's default value. pub default: Option Value>, /// Is the parameter positional? @@ -503,6 +506,7 @@ impl ParamSpec { fn from_static(s: &ParamInfo) -> Arc { 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>, - named: HashMap, Arc>, +pub struct Signature { + pub pos: Vec>, + pub named: HashMap, Arc>, has_fill_or_size_or_stroke: bool, - rest: Option>, + pub rest: Option>, _broken: bool, } #[comemo::memoize] -fn analyze_signature(func: Func) -> Arc { +pub(crate) fn analyze_signature(func: Func) -> Arc { use typst::foundations::func::Repr; let params = match func.inner() { Repr::With(..) => unreachable!(), @@ -595,6 +599,7 @@ fn analyze_closure_signature(c: Arc>) -> Vec> { 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>) -> Vec> { 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>) -> Vec> { 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 { 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::*; diff --git a/crates/tinymist-query/src/lib.rs b/crates/tinymist-query/src/lib.rs index 059c00a0d..3ee6bd96c 100644 --- a/crates/tinymist-query/src/lib.rs +++ b/crates/tinymist-query/src/lib.rs @@ -1,6 +1,7 @@ mod adt; pub mod analysis; pub mod syntax; +mod upstream; pub(crate) mod diagnostics; diff --git a/crates/tinymist-query/src/lsp_typst_boundary.rs b/crates/tinymist-query/src/lsp_typst_boundary.rs index 48afbf77c..8d455cf07 100644 --- a/crates/tinymist-query/src/lsp_typst_boundary.rs +++ b/crates/tinymist-query/src/lsp_typst_boundary.rs @@ -17,7 +17,7 @@ pub type TypstSpan = typst::syntax::Span; pub type LspRange = lsp_types::Range; pub type TypstRange = std::ops::Range; -pub type TypstTooltip = typst_ide::Tooltip; +pub type TypstTooltip = crate::upstream::Tooltip; pub type LspHoverContents = lsp_types::HoverContents; pub type LspDiagnostic = lsp_types::Diagnostic; diff --git a/crates/tinymist-query/src/syntax/lexical_hierarchy.rs b/crates/tinymist-query/src/syntax/lexical_hierarchy.rs index 23986ef26..7964c2e8d 100644 --- a/crates/tinymist-query/src/syntax/lexical_hierarchy.rs +++ b/crates/tinymist-query/src/syntax/lexical_hierarchy.rs @@ -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::().is_some()); diff --git a/crates/tinymist-query/src/upstream/mod.rs b/crates/tinymist-query/src/upstream/mod.rs new file mode 100644 index 000000000..cc250cdc9 --- /dev/null +++ b/crates/tinymist-query/src/upstream/mod.rs @@ -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) -> 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 +} diff --git a/crates/tinymist-query/src/upstream/tooltip.rs b/crates/tinymist-query/src/upstream/tooltip.rs new file mode 100644 index 000000000..89f2c14e8 --- /dev/null +++ b/crates/tinymist-query/src/upstream/tooltip.rs @@ -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 { + 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 { + let mut ancestor = leaf; + while !ancestor.is::() { + ancestor = ancestor.parent()?; + } + + let expr = ancestor.cast::()?; + 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 = 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 { + // 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 { + 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 { + 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 { + 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::(); + 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::(); + 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::(); + 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::(); + 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 +} + +/// 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 { + if_chain! { + // Ensure that we are on top of a string. + if let Some(string) = leaf.cast::(); + 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::(); + 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 +}