summaryrefslogtreecommitdiff
path: root/crates/typst-ide
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-ide')
-rw-r--r--crates/typst-ide/Cargo.toml25
-rw-r--r--crates/typst-ide/src/analyze.rs110
-rw-r--r--crates/typst-ide/src/complete.rs1285
-rw-r--r--crates/typst-ide/src/jump.rs169
-rw-r--r--crates/typst-ide/src/lib.rs94
-rw-r--r--crates/typst-ide/src/tooltip.rs245
6 files changed, 1928 insertions, 0 deletions
diff --git a/crates/typst-ide/Cargo.toml b/crates/typst-ide/Cargo.toml
new file mode 100644
index 00000000..ba3574f1
--- /dev/null
+++ b/crates/typst-ide/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "typst-ide"
+description = "IDE functionality for Typst."
+categories = ["compilers", "science"]
+keywords = ["typst"]
+version.workspace = true
+rust-version.workspace = true
+authors.workspace = true
+edition.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license.workspace = true
+
+[lib]
+test = false
+doctest = false
+bench = false
+
+[dependencies]
+typst = { path = "../typst" }
+comemo = "0.3"
+ecow = { version = "0.1.2", features = ["serde"] }
+if_chain = "1"
+serde = { version = "1.0.184", features = ["derive"] }
+unscanny = "0.1"
diff --git a/crates/typst-ide/src/analyze.rs b/crates/typst-ide/src/analyze.rs
new file mode 100644
index 00000000..32f3df0d
--- /dev/null
+++ b/crates/typst-ide/src/analyze.rs
@@ -0,0 +1,110 @@
+use comemo::Track;
+use ecow::{eco_vec, EcoString, EcoVec};
+use typst::doc::Frame;
+use typst::eval::{Route, Scopes, Tracer, Value, Vm};
+use typst::model::{DelayedErrors, Introspector, Label, Locator, Vt};
+use typst::syntax::{ast, LinkedNode, Span, SyntaxKind};
+use typst::World;
+
+/// Try to determine a set of possible values for an expression.
+pub fn analyze_expr(world: &dyn World, node: &LinkedNode) -> EcoVec<Value> {
+ match node.cast::<ast::Expr>() {
+ Some(ast::Expr::None(_)) => eco_vec![Value::None],
+ Some(ast::Expr::Auto(_)) => eco_vec![Value::Auto],
+ Some(ast::Expr::Bool(v)) => eco_vec![Value::Bool(v.get())],
+ Some(ast::Expr::Int(v)) => eco_vec![Value::Int(v.get())],
+ Some(ast::Expr::Float(v)) => eco_vec![Value::Float(v.get())],
+ Some(ast::Expr::Numeric(v)) => eco_vec![Value::numeric(v.get())],
+ Some(ast::Expr::Str(v)) => eco_vec![Value::Str(v.get().into())],
+
+ Some(ast::Expr::FieldAccess(access)) => {
+ let Some(child) = node.children().next() else { return eco_vec![] };
+ analyze_expr(world, &child)
+ .into_iter()
+ .filter_map(|target| target.field(&access.field()).ok())
+ .collect()
+ }
+
+ Some(_) => {
+ if let Some(parent) = node.parent() {
+ if parent.kind() == SyntaxKind::FieldAccess && node.index() > 0 {
+ return analyze_expr(world, parent);
+ }
+ }
+
+ let mut tracer = Tracer::new();
+ tracer.inspect(node.span());
+ typst::compile(world, &mut tracer).ok();
+ tracer.values()
+ }
+
+ _ => eco_vec![],
+ }
+}
+
+/// Try to load a module from the current source file.
+pub fn analyze_import(world: &dyn World, source: &LinkedNode) -> Option<Value> {
+ let id = source.span().id()?;
+ let source = analyze_expr(world, source).into_iter().next()?;
+ if source.scope().is_some() {
+ return Some(source);
+ }
+
+ let mut locator = Locator::default();
+ let introspector = Introspector::default();
+ let mut delayed = DelayedErrors::new();
+ let mut tracer = Tracer::new();
+ let vt = Vt {
+ world: world.track(),
+ introspector: introspector.track(),
+ locator: &mut locator,
+ delayed: delayed.track_mut(),
+ tracer: tracer.track_mut(),
+ };
+
+ let route = Route::default();
+ let mut vm = Vm::new(vt, route.track(), Some(id), Scopes::new(Some(world.library())));
+ typst::eval::import(&mut vm, source, Span::detached(), true)
+ .ok()
+ .map(Value::Module)
+}
+
+/// Find all labels and details for them.
+///
+/// Returns:
+/// - All labels and descriptions for them, if available
+/// - A split offset: All labels before this offset belong to nodes, all after
+/// belong to a bibliography.
+pub fn analyze_labels(
+ world: &dyn World,
+ frames: &[Frame],
+) -> (Vec<(Label, Option<EcoString>)>, usize) {
+ let mut output = vec![];
+ let introspector = Introspector::new(frames);
+ let items = &world.library().items;
+
+ // Labels in the document.
+ for elem in introspector.all() {
+ let Some(label) = elem.label().cloned() else { continue };
+ let details = elem
+ .field("caption")
+ .or_else(|| elem.field("body"))
+ .and_then(|field| match field {
+ Value::Content(content) => Some(content),
+ _ => None,
+ })
+ .as_ref()
+ .unwrap_or(elem)
+ .plain_text();
+ output.push((label, Some(details)));
+ }
+
+ let split = output.len();
+
+ // Bibliography keys.
+ for (key, detail) in (items.bibliography_keys)(introspector.track()) {
+ output.push((Label(key), detail));
+ }
+
+ (output, split)
+}
diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs
new file mode 100644
index 00000000..dcf98a31
--- /dev/null
+++ b/crates/typst-ide/src/complete.rs
@@ -0,0 +1,1285 @@
+use std::cmp::Reverse;
+use std::collections::{BTreeSet, HashSet};
+
+use ecow::{eco_format, EcoString};
+use if_chain::if_chain;
+use serde::{Deserialize, Serialize};
+use typst::doc::Frame;
+use typst::eval::{
+ format_str, AutoValue, CastInfo, Func, Library, NoneValue, Scope, Type, Value,
+};
+use typst::geom::Color;
+use typst::syntax::{
+ ast, is_id_continue, is_id_start, is_ident, LinkedNode, Source, SyntaxKind,
+};
+use typst::util::separated_list;
+use typst::World;
+use unscanny::Scanner;
+
+use super::analyze::analyze_labels;
+use super::{analyze_expr, analyze_import, plain_docs_sentence, summarize_font_family};
+
+/// Autocomplete a cursor position in a source file.
+///
+/// Returns the position from which the completions apply and a list of
+/// completions.
+///
+/// When `explicit` is `true`, the user requested the completion by pressing
+/// control and space or something similar.
+pub fn autocomplete(
+ world: &dyn World,
+ frames: &[Frame],
+ source: &Source,
+ cursor: usize,
+ explicit: bool,
+) -> Option<(usize, Vec<Completion>)> {
+ let mut ctx = CompletionContext::new(world, frames, source, cursor, explicit)?;
+
+ let _ = complete_comments(&mut ctx)
+ || complete_field_accesses(&mut ctx)
+ || complete_imports(&mut ctx)
+ || complete_rules(&mut ctx)
+ || complete_params(&mut ctx)
+ || complete_markup(&mut ctx)
+ || complete_math(&mut ctx)
+ || complete_code(&mut ctx);
+
+ Some((ctx.from, ctx.completions))
+}
+
+/// An autocompletion option.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Completion {
+ /// The kind of item this completes to.
+ pub kind: CompletionKind,
+ /// The label the completion is shown with.
+ pub label: EcoString,
+ /// The completed version of the input, possibly described with snippet
+ /// syntax like `${lhs} + ${rhs}`.
+ ///
+ /// Should default to the `label` if `None`.
+ pub apply: Option<EcoString>,
+ /// An optional short description, at most one sentence.
+ pub detail: Option<EcoString>,
+}
+
+/// A kind of item that can be completed.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum CompletionKind {
+ /// A syntactical structure.
+ Syntax,
+ /// A function.
+ Func,
+ /// A type.
+ Type,
+ /// A function parameter.
+ Param,
+ /// A constant.
+ Constant,
+ /// A symbol.
+ Symbol(char),
+}
+
+/// Complete in comments. Or rather, don't!
+fn complete_comments(ctx: &mut CompletionContext) -> bool {
+ matches!(ctx.leaf.kind(), SyntaxKind::LineComment | SyntaxKind::BlockComment)
+}
+
+/// Complete in markup mode.
+fn complete_markup(ctx: &mut CompletionContext) -> bool {
+ // Bail if we aren't even in markup.
+ if !matches!(
+ ctx.leaf.parent_kind(),
+ None | Some(SyntaxKind::Markup) | Some(SyntaxKind::Ref)
+ ) {
+ return false;
+ }
+
+ // Start of an interpolated identifier: "#|".
+ if ctx.leaf.kind() == SyntaxKind::Hashtag {
+ ctx.from = ctx.cursor;
+ code_completions(ctx, true);
+ return true;
+ }
+
+ // An existing identifier: "#pa|".
+ if ctx.leaf.kind() == SyntaxKind::Ident {
+ ctx.from = ctx.leaf.offset();
+ code_completions(ctx, true);
+ return true;
+ }
+
+ // Start of an reference: "@|" or "@he|".
+ if ctx.leaf.kind() == SyntaxKind::RefMarker {
+ ctx.from = ctx.leaf.offset() + 1;
+ ctx.label_completions();
+ return true;
+ }
+
+ // Behind a half-completed binding: "#let x = |".
+ if_chain! {
+ if let Some(prev) = ctx.leaf.prev_leaf();
+ if prev.kind() == SyntaxKind::Eq;
+ if prev.parent_kind() == Some(SyntaxKind::LetBinding);
+ then {
+ ctx.from = ctx.cursor;
+ code_completions(ctx, false);
+ return true;
+ }
+ }
+
+ // Directly after a raw block.
+ let mut s = Scanner::new(ctx.text);
+ s.jump(ctx.leaf.offset());
+ if s.eat_if("```") {
+ s.eat_while('`');
+ let start = s.cursor();
+ if s.eat_if(is_id_start) {
+ s.eat_while(is_id_continue);
+ }
+ if s.cursor() == ctx.cursor {
+ ctx.from = start;
+ ctx.raw_completions();
+ }
+ return true;
+ }
+
+ // Anywhere: "|".
+ if ctx.explicit {
+ ctx.from = ctx.cursor;
+ markup_completions(ctx);
+ return true;
+ }
+
+ false
+}
+
+/// Add completions for markup snippets.
+#[rustfmt::skip]
+fn markup_completions(ctx: &mut CompletionContext) {
+ ctx.snippet_completion(
+ "expression",
+ "#${}",
+ "Variables, function calls, blocks, and more.",
+ );
+
+ ctx.snippet_completion(
+ "linebreak",
+ "\\\n${}",
+ "Inserts a forced linebreak.",
+ );
+
+ ctx.snippet_completion(
+ "strong text",
+ "*${strong}*",
+ "Strongly emphasizes content by increasing the font weight.",
+ );
+
+ ctx.snippet_completion(
+ "emphasized text",
+ "_${emphasized}_",
+ "Emphasizes content by setting it in italic font style.",
+ );
+
+ ctx.snippet_completion(
+ "raw text",
+ "`${text}`",
+ "Displays text verbatim, in monospace.",
+ );
+
+ ctx.snippet_completion(
+ "code listing",
+ "```${lang}\n${code}\n```",
+ "Inserts computer code with syntax highlighting.",
+ );
+
+ ctx.snippet_completion(
+ "hyperlink",
+ "https://${example.com}",
+ "Links to a URL.",
+ );
+
+ ctx.snippet_completion(
+ "label",
+ "<${name}>",
+ "Makes the preceding element referenceable.",
+ );
+
+ ctx.snippet_completion(
+ "reference",
+ "@${name}",
+ "Inserts a reference to a label.",
+ );
+
+ ctx.snippet_completion(
+ "heading",
+ "= ${title}",
+ "Inserts a section heading.",
+ );
+
+ ctx.snippet_completion(
+ "list item",
+ "- ${item}",
+ "Inserts an item of a bullet list.",
+ );
+
+ ctx.snippet_completion(
+ "enumeration item",
+ "+ ${item}",
+ "Inserts an item of a numbered list.",
+ );
+
+ ctx.snippet_completion(
+ "enumeration item (numbered)",
+ "${number}. ${item}",
+ "Inserts an explicitly numbered list item.",
+ );
+
+ ctx.snippet_completion(
+ "term list item",
+ "/ ${term}: ${description}",
+ "Inserts an item of a term list.",
+ );
+
+ ctx.snippet_completion(
+ "math (inline)",
+ "$${x}$",
+ "Inserts an inline-level mathematical equation.",
+ );
+
+ ctx.snippet_completion(
+ "math (block)",
+ "$ ${sum_x^2} $",
+ "Inserts a block-level mathematical equation.",
+ );
+}
+
+/// Complete in math mode.
+fn complete_math(ctx: &mut CompletionContext) -> bool {
+ if !matches!(
+ ctx.leaf.parent_kind(),
+ Some(SyntaxKind::Equation)
+ | Some(SyntaxKind::Math)
+ | Some(SyntaxKind::MathFrac)
+ | Some(SyntaxKind::MathAttach)
+ ) {
+ return false;
+ }
+
+ // Start of an interpolated identifier: "#|".
+ if ctx.leaf.kind() == SyntaxKind::Hashtag {
+ ctx.from = ctx.cursor;
+ code_completions(ctx, true);
+ return true;
+ }
+
+ // Behind existing atom or identifier: "$a|$" or "$abc|$".
+ if matches!(ctx.leaf.kind(), SyntaxKind::Text | SyntaxKind::MathIdent) {
+ ctx.from = ctx.leaf.offset();
+ math_completions(ctx);
+ return true;
+ }
+
+ // Anywhere: "$|$".
+ if ctx.explicit {
+ ctx.from = ctx.cursor;
+ math_completions(ctx);
+ return true;
+ }
+
+ false
+}
+
+/// Add completions for math snippets.
+#[rustfmt::skip]
+fn math_completions(ctx: &mut CompletionContext) {
+ ctx.scope_completions(true, |_| true);
+
+ ctx.snippet_completion(
+ "subscript",
+ "${x}_${2:2}",
+ "Sets something in subscript.",
+ );
+
+ ctx.snippet_completion(
+ "superscript",
+ "${x}^${2:2}",
+ "Sets something in superscript.",
+ );
+
+ ctx.snippet_completion(
+ "fraction",
+ "${x}/${y}",
+ "Inserts a fraction.",
+ );
+}
+
+/// Complete field accesses.
+fn complete_field_accesses(ctx: &mut CompletionContext) -> bool {
+ // Behind an expression plus dot: "emoji.|".
+ if_chain! {
+ if ctx.leaf.kind() == SyntaxKind::Dot
+ || (ctx.leaf.kind() == SyntaxKind::Text
+ && ctx.leaf.text() == ".");
+ if ctx.leaf.range().end == ctx.cursor;
+ if let Some(prev) = ctx.leaf.prev_sibling();
+ if prev.is::<ast::Expr>();
+ if prev.parent_kind() != Some(SyntaxKind::Markup) ||
+ prev.prev_sibling_kind() == Some(SyntaxKind::Hashtag);
+ if let Some(value) = analyze_expr(ctx.world, &prev).into_iter().next();
+ then {
+ ctx.from = ctx.cursor;
+ field_access_completions(ctx, &value);
+ return true;
+ }
+ }
+
+ // Behind a started field access: "emoji.fa|".
+ if_chain! {
+ if ctx.leaf.kind() == SyntaxKind::Ident;
+ if let Some(prev) = ctx.leaf.prev_sibling();
+ if prev.kind() == SyntaxKind::Dot;
+ if let Some(prev_prev) = prev.prev_sibling();
+ if prev_prev.is::<ast::Expr>();
+ if let Some(value) = analyze_expr(ctx.world, &prev_prev).into_iter().next();
+ then {
+ ctx.from = ctx.leaf.offset();
+ field_access_completions(ctx, &value);
+ return true;
+ }
+ }
+
+ false
+}
+
+/// Add completions for all fields on a value.
+fn field_access_completions(ctx: &mut CompletionContext, value: &Value) {
+ for (name, value) in value.ty().scope().iter() {
+ ctx.value_completion(Some(name.clone()), value, true, None);
+ }
+
+ if let Some(scope) = value.scope() {
+ for (name, value) in scope.iter() {
+ ctx.value_completion(Some(name.clone()), value, true, None);
+ }
+ }
+
+ for &(method, args) in typst::eval::mutable_methods_on(value.ty()) {
+ ctx.completions.push(Completion {
+ kind: CompletionKind::Func,
+ label: method.into(),
+ apply: Some(if args {
+ eco_format!("{method}(${{}})")
+ } else {
+ eco_format!("{method}()${{}}")
+ }),
+ detail: None,
+ })
+ }
+
+ for &field in typst::eval::fields_on(value.ty()) {
+ // Complete the field name along with its value. Notes:
+ // 1. No parentheses since function fields cannot currently be called
+ // with method syntax;
+ // 2. We can unwrap the field's value since it's a field belonging to
+ // this value's type, so accessing it should not fail.
+ ctx.value_completion(
+ Some(field.into()),
+ &value.field(field).unwrap(),
+ false,
+ None,
+ );
+ }
+
+ match value {
+ Value::Symbol(symbol) => {
+ for modifier in symbol.modifiers() {
+ if let Ok(modified) = symbol.clone().modified(modifier) {
+ ctx.completions.push(Completion {
+ kind: CompletionKind::Symbol(modified.get()),
+ label: modifier.into(),
+ apply: None,
+ detail: None,
+ });
+ }
+ }
+ }
+ Value::Content(content) => {
+ for (name, value) in content.fields() {
+ ctx.value_completion(Some(name.into()), &value, false, None);
+ }
+ }
+ Value::Dict(dict) => {
+ for (name, value) in dict.iter() {
+ ctx.value_completion(Some(name.clone().into()), value, false, None);
+ }
+ }
+ Value::Plugin(plugin) => {
+ for name in plugin.iter() {
+ ctx.completions.push(Completion {
+ kind: CompletionKind::Func,
+ label: name.clone(),
+ apply: None,
+ detail: None,
+ })
+ }
+ }
+ _ => {}
+ }
+}
+
+/// Complete imports.
+fn complete_imports(ctx: &mut CompletionContext) -> bool {
+ // In an import path for a package:
+ // "#import "@|",
+ if_chain! {
+ if matches!(
+ ctx.leaf.parent_kind(),
+ Some(SyntaxKind::ModuleImport | SyntaxKind::ModuleInclude)
+ );
+ if let Some(ast::Expr::Str(str)) = ctx.leaf.cast();
+ let value = str.get();
+ if value.starts_with('@');
+ then {
+ let all_versions = value.contains(':');
+ ctx.from = ctx.leaf.offset();
+ ctx.package_completions(all_versions);
+ return true;
+ }
+ }
+
+ // Behind an import list:
+ // "#import "path.typ": |",
+ // "#import "path.typ": a, b, |".
+ if_chain! {
+ if let Some(prev) = ctx.leaf.prev_sibling();
+ if let Some(ast::Expr::Import(import)) = prev.get().cast();
+ if let Some(ast::Imports::Items(items)) = import.imports();
+ if let Some(source) = prev.children().find(|child| child.is::<ast::Expr>());
+ then {
+ ctx.from = ctx.cursor;
+ import_item_completions(ctx, items, &source);
+ return true;
+ }
+ }
+
+ // Behind a half-started identifier in an import list:
+ // "#import "path.typ": thi|",
+ if_chain! {
+ if ctx.leaf.kind() == SyntaxKind::Ident;
+ if let Some(parent) = ctx.leaf.parent();
+ if parent.kind() == SyntaxKind::ImportItems;
+ if let Some(grand) = parent.parent();
+ if let Some(ast::Expr::Import(import)) = grand.get().cast();
+ if let Some(ast::Imports::Items(items)) = import.imports();
+ if let Some(source) = grand.children().find(|child| child.is::<ast::Expr>());
+ then {
+ ctx.from = ctx.leaf.offset();
+ import_item_completions(ctx, items, &source);
+ return true;
+ }
+ }
+
+ false
+}
+
+/// Add completions for all exports of a module.
+fn import_item_completions<'a>(
+ ctx: &mut CompletionContext<'a>,
+ existing: ast::ImportItems<'a>,
+ source: &LinkedNode,
+) {
+ let Some(value) = analyze_import(ctx.world, source) else { return };
+ let Some(scope) = value.scope() else { return };
+
+ if existing.iter().next().is_none() {
+ ctx.snippet_completion("*", "*", "Import everything.");
+ }
+
+ for (name, value) in scope.iter() {
+ if existing.iter().all(|item| item.original_name().as_str() != name) {
+ ctx.value_completion(Some(name.clone()), value, false, None);
+ }
+ }
+}
+
+/// Complete set and show rules.
+fn complete_rules(ctx: &mut CompletionContext) -> bool {
+ // We don't want to complete directly behind the keyword.
+ if !ctx.leaf.kind().is_trivia() {
+ return false;
+ }
+
+ let Some(prev) = ctx.leaf.prev_leaf() else { return false };
+
+ // Behind the set keyword: "set |".
+ if matches!(prev.kind(), SyntaxKind::Set) {
+ ctx.from = ctx.cursor;
+ set_rule_completions(ctx);
+ return true;
+ }
+
+ // Behind the show keyword: "show |".
+ if matches!(prev.kind(), SyntaxKind::Show) {
+ ctx.from = ctx.cursor;
+ show_rule_selector_completions(ctx);
+ return true;
+ }
+
+ // Behind a half-completed show rule: "show strong: |".
+ if_chain! {
+ if let Some(prev) = ctx.leaf.prev_leaf();
+ if matches!(prev.kind(), SyntaxKind::Colon);
+ if matches!(prev.parent_kind(), Some(SyntaxKind::ShowRule));
+ then {
+ ctx.from = ctx.cursor;
+ show_rule_recipe_completions(ctx);
+ return true;
+ }
+ }
+
+ false
+}
+
+/// Add completions for all functions from the global scope.
+fn set_rule_completions(ctx: &mut CompletionContext) {
+ ctx.scope_completions(true, |value| {
+ matches!(
+ value,
+ Value::Func(func) if func.params()
+ .unwrap_or_default()
+ .iter()
+ .any(|param| param.settable),
+ )
+ });
+}
+
+/// Add completions for selectors.
+fn show_rule_selector_completions(ctx: &mut CompletionContext) {
+ ctx.scope_completions(
+ false,
+ |value| matches!(value, Value::Func(func) if func.element().is_some()),
+ );
+
+ ctx.enrich("", ": ");
+
+ ctx.snippet_completion(
+ "text selector",
+ "\"${text}\": ${}",
+ "Replace occurrences of specific text.",
+ );
+
+ ctx.snippet_completion(
+ "regex selector",
+ "regex(\"${regex}\"): ${}",
+ "Replace matches of a regular expression.",
+ );
+}
+
+/// Add completions for recipes.
+fn show_rule_recipe_completions(ctx: &mut CompletionContext) {
+ ctx.snippet_completion(
+ "replacement",
+ "[${content}]",
+ "Replace the selected element with content.",
+ );
+
+ ctx.snippet_completion(
+ "replacement (string)",
+ "\"${text}\"",
+ "Replace the selected element with a string of text.",
+ );
+
+ ctx.snippet_completion(
+ "transformation",
+ "element => [${content}]",
+ "Transform the element with a function.",
+ );
+
+ ctx.scope_completions(false, |value| matches!(value, Value::Func(_)));
+}
+
+/// Complete call and set rule parameters.
+fn complete_params(ctx: &mut CompletionContext) -> bool {
+ // Ensure that we are in a function call or set rule's argument list.
+ let (callee, set, args) = if_chain! {
+ if let Some(parent) = ctx.leaf.parent();
+ if let Some(parent) = match parent.kind() {
+ SyntaxKind::Named => parent.parent(),
+ _ => Some(parent),
+ };
+ if let Some(args) = parent.get().cast::<ast::Args>();
+ if let Some(grand) = parent.parent();
+ if let Some(expr) = grand.get().cast::<ast::Expr>();
+ let set = matches!(expr, ast::Expr::Set(_));
+ if let Some(callee) = match expr {
+ ast::Expr::FuncCall(call) => Some(call.callee()),
+ ast::Expr::Set(set) => Some(set.target()),
+ _ => None,
+ };
+ then {
+ (callee, set, args)
+ } else {
+ return false;
+ }
+ };
+
+ // Find the piece of syntax that decides what we're completing.
+ let mut deciding = ctx.leaf.clone();
+ while !matches!(
+ deciding.kind(),
+ SyntaxKind::LeftParen | SyntaxKind::Comma | SyntaxKind::Colon
+ ) {
+ let Some(prev) = deciding.prev_leaf() else { break };
+ deciding = prev;
+ }
+
+ // Parameter values: "func(param:|)", "func(param: |)".
+ if_chain! {
+ if deciding.kind() == SyntaxKind::Colon;
+ if let Some(prev) = deciding.prev_leaf();
+ if let Some(param) = prev.get().cast::<ast::Ident>();
+ then {
+ if let Some(next) = deciding.next_leaf() {
+ ctx.from = ctx.cursor.min(next.offset());
+ }
+
+ named_param_value_completions(ctx, callee, &param);
+ return true;
+ }
+ }
+
+ // Parameters: "func(|)", "func(hi|)", "func(12,|)".
+ if_chain! {
+ if matches!(deciding.kind(), SyntaxKind::LeftParen | SyntaxKind::Comma);
+ if deciding.kind() != SyntaxKind::Comma || deciding.range().end < ctx.cursor;
+ then {
+ if let Some(next) = deciding.next_leaf() {
+ ctx.from = ctx.cursor.min(next.offset());
+ }
+
+ // Exclude arguments which are already present.
+ let exclude: Vec<_> = args
+ .items()
+ .filter_map(|arg| match arg {
+ ast::Arg::Named(named) => Some(named.name()),
+ _ => None,
+ })
+ .collect();
+
+ param_completions(ctx, callee, set, &exclude);
+ return true;
+ }
+ }
+
+ false
+}
+
+/// Add completions for the parameters of a function.
+fn param_completions<'a>(
+ ctx: &mut CompletionContext<'a>,
+ callee: ast::Expr<'a>,
+ set: bool,
+ exclude: &[ast::Ident<'a>],
+) {
+ let Some(func) = resolve_global_callee(ctx, callee) else { return };
+ let Some(params) = func.params() else { return };
+
+ for param in params {
+ if exclude.iter().any(|ident| ident.as_str() == param.name) {
+ continue;
+ }
+
+ if set && !param.settable {
+ continue;
+ }
+
+ if param.named {
+ ctx.completions.push(Completion {
+ kind: CompletionKind::Param,
+ label: param.name.into(),
+ apply: Some(eco_format!("{}: ${{}}", param.name)),
+ detail: Some(plain_docs_sentence(param.docs)),
+ });
+ }
+
+ if param.positional {
+ ctx.cast_completions(&param.input);
+ }
+ }
+
+ if ctx.before.ends_with(',') {
+ ctx.enrich(" ", "");
+ }
+}
+
+/// Add completions for the values of a named function parameter.
+fn named_param_value_completions<'a>(
+ ctx: &mut CompletionContext<'a>,
+ callee: ast::Expr<'a>,
+ name: &str,
+) {
+ let Some(func) = resolve_global_callee(ctx, callee) else { return };
+ let Some(param) = func.param(name) else { return };
+ if !param.named {
+ return;
+ }
+
+ ctx.cast_completions(&param.input);
+ if name == "font" {
+ ctx.font_completions();
+ }
+
+ if ctx.before.ends_with(':') {
+ ctx.enrich(" ", "");
+ }
+}
+
+/// Resolve a callee expression to a global function.
+fn resolve_global_callee<'a>(
+ ctx: &CompletionContext<'a>,
+ callee: ast::Expr<'a>,
+) -> Option<&'a Func> {
+ let value = match callee {
+ ast::Expr::Ident(ident) => ctx.global.get(&ident)?,
+ ast::Expr::FieldAccess(access) => match access.target() {
+ ast::Expr::Ident(target) => match ctx.global.get(&target)? {
+ Value::Module(module) => module.field(&access.field()).ok()?,
+ Value::Func(func) => func.field(&access.field()).ok()?,
+ _ => return None,
+ },
+ _ => return None,
+ },
+ _ => return None,
+ };
+
+ match value {
+ Value::Func(func) => Some(func),
+ _ => None,
+ }
+}
+
+/// Complete in code mode.
+fn complete_code(ctx: &mut CompletionContext) -> bool {
+ if matches!(
+ ctx.leaf.parent_kind(),
+ None | Some(SyntaxKind::Markup)
+ | Some(SyntaxKind::Math)
+ | Some(SyntaxKind::MathFrac)
+ | Some(SyntaxKind::MathAttach)
+ | Some(SyntaxKind::MathRoot)
+ ) {
+ return false;
+ }
+
+ // An existing identifier: "{ pa| }".
+ if ctx.leaf.kind() == SyntaxKind::Ident {
+ ctx.from = ctx.leaf.offset();
+ code_completions(ctx, false);
+ return true;
+ }
+
+ // Anywhere: "{ | }".
+ // But not within or after an expression.
+ if ctx.explicit
+ && (ctx.leaf.kind().is_trivia()
+ || matches!(ctx.leaf.kind(), SyntaxKind::LeftParen | SyntaxKind::LeftBrace))
+ {
+ ctx.from = ctx.cursor;
+ code_completions(ctx, false);
+ return true;
+ }
+
+ false
+}
+
+/// Add completions for expression snippets.
+#[rustfmt::skip]
+fn code_completions(ctx: &mut CompletionContext, hashtag: bool) {
+ ctx.scope_completions(true, |value| !hashtag || {
+ matches!(value, Value::Symbol(_) | Value::Func(_) | Value::Type(_) | Value::Module(_))
+ });
+
+ ctx.snippet_completion(
+ "function call",
+ "${function}(${arguments})[${body}]",
+ "Evaluates a function.",
+ );
+
+ ctx.snippet_completion(
+ "code block",
+ "{ ${} }",
+ "Inserts a nested code block.",
+ );
+
+ ctx.snippet_completion(
+ "content block",
+ "[${content}]",
+ "Switches into markup mode.",
+ );
+
+ ctx.snippet_completion(
+ "set rule",
+ "set ${}",
+ "Sets style properties on an element.",
+ );
+
+ ctx.snippet_completion(
+ "show rule",
+ "show ${}",
+ "Redefines the look of an element.",
+ );
+
+ ctx.snippet_completion(
+ "show rule (everything)",
+ "show: ${}",
+ "Transforms everything that follows.",
+ );
+
+ ctx.snippet_completion(
+ "let binding",
+ "let ${name} = ${value}",
+ "Saves a value in a variable.",
+ );
+
+ ctx.snippet_completion(
+ "let binding (function)",
+ "let ${name}(${params}) = ${output}",
+ "Defines a function.",
+ );
+
+ ctx.snippet_completion(
+ "if conditional",
+ "if ${1 < 2} {\n\t${}\n}",
+ "Computes or inserts something conditionally.",
+ );
+
+ ctx.snippet_completion(
+ "if-else conditional",
+ "if ${1 < 2} {\n\t${}\n} else {\n\t${}\n}",
+ "Computes or inserts different things based on a condition.",
+ );
+
+ ctx.snippet_completion(
+ "while loop",
+ "while ${1 < 2} {\n\t${}\n}",
+ "Computes or inserts something while a condition is met.",
+ );
+
+ ctx.snippet_completion(
+ "for loop",
+ "for ${value} in ${(1, 2, 3)} {\n\t${}\n}",
+ "Computes or inserts something for each value in a collection.",
+ );
+
+ ctx.snippet_completion(
+ "for loop (with key)",
+ "for (${key}, ${value}) in ${(a: 1, b: 2)} {\n\t${}\n}",
+ "Computes or inserts something for each key and value in a collection.",
+ );
+
+ ctx.snippet_completion(
+ "break",
+ "break",
+ "Exits early from a loop.",
+ );
+
+ ctx.snippet_completion(
+ "continue",
+ "continue",
+ "Continues with the next iteration of a loop.",
+ );
+
+ ctx.snippet_completion(
+ "return",
+ "return ${output}",
+ "Returns early from a function.",
+ );
+
+ ctx.snippet_completion(
+ "import (file)",
+ "import \"${file}.typ\": ${items}",
+ "Imports variables from another file.",
+ );
+
+ ctx.snippet_completion(
+ "import (package)",
+ "import \"@${}\": ${items}",
+ "Imports variables from another file.",
+ );
+
+ ctx.snippet_completion(
+ "include (file)",
+ "include \"${file}.typ\"",
+ "Includes content from another file.",
+ );
+
+ ctx.snippet_completion(
+ "include (package)",
+ "include \"@${}\"",
+ "Includes content from another file.",
+ );
+
+ ctx.snippet_completion(
+ "array literal",
+ "(${1, 2, 3})",
+ "Creates a sequence of values.",
+ );
+
+ ctx.snippet_completion(
+ "dictionary literal",
+ "(${a: 1, b: 2})",
+ "Creates a mapping from names to value.",
+ );
+
+ if !hashtag {
+ ctx.snippet_completion(
+ "function",
+ "(${params}) => ${output}",
+ "Creates an unnamed function.",
+ );
+ }
+}
+
+/// Context for autocompletion.
+struct CompletionContext<'a> {
+ world: &'a (dyn World + 'a),
+ frames: &'a [Frame],
+ library: &'a Library,
+ global: &'a Scope,
+ math: &'a Scope,
+ text: &'a str,
+ before: &'a str,
+ after: &'a str,
+ leaf: LinkedNode<'a>,
+ cursor: usize,
+ explicit: bool,
+ from: usize,
+ completions: Vec<Completion>,
+ seen_casts: HashSet<u128>,
+}
+
+impl<'a> CompletionContext<'a> {
+ /// Create a new autocompletion context.
+ fn new(
+ world: &'a (dyn World + 'a),
+ frames: &'a [Frame],
+ source: &'a Source,
+ cursor: usize,
+ explicit: bool,
+ ) -> Option<Self> {
+ let text = source.text();
+ let library = world.library();
+ let leaf = LinkedNode::new(source.root()).leaf_at(cursor)?;
+ Some(Self {
+ world,
+ frames,
+ library,
+ global: library.global.scope(),
+ math: library.math.scope(),
+ text,
+ before: &text[..cursor],
+ after: &text[cursor..],
+ leaf,
+ cursor,
+ explicit,
+ from: cursor,
+ completions: vec![],
+ seen_casts: HashSet::new(),
+ })
+ }
+
+ /// Add a prefix and suffix to all applications.
+ fn enrich(&mut self, prefix: &str, suffix: &str) {
+ for Completion { label, apply, .. } in &mut self.completions {
+ let current = apply.as_ref().unwrap_or(label);
+ *apply = Some(eco_format!("{prefix}{current}{suffix}"));
+ }
+ }
+
+ /// Add a snippet completion.
+ fn snippet_completion(
+ &mut self,
+ label: &'static str,
+ snippet: &'static str,
+ docs: &'static str,
+ ) {
+ self.completions.push(Completion {
+ kind: CompletionKind::Syntax,
+ label: label.into(),
+ apply: Some(snippet.into()),
+ detail: Some(docs.into()),
+ });
+ }
+
+ /// Add completions for all font families.
+ fn font_completions(&mut self) {
+ let equation = self.before[self.cursor.saturating_sub(25)..].contains("equation");
+ for (family, iter) in self.world.book().families() {
+ let detail = summarize_font_family(iter);
+ if !equation || family.contains("Math") {
+ self.value_completion(
+ None,
+ &Value::Str(family.into()),
+ false,
+ Some(detail.as_str()),
+ );
+ }
+ }
+ }
+
+ /// Add completions for all available packages.
+ fn package_completions(&mut self, all_versions: bool) {
+ let mut packages: Vec<_> = self.world.packages().iter().collect();
+ packages.sort_by_key(|(spec, _)| (&spec.name, Reverse(spec.version)));
+ if !all_versions {
+ packages.dedup_by_key(|(spec, _)| &spec.name);
+ }
+ for (package, description) in packages {
+ self.value_completion(
+ None,
+ &Value::Str(format_str!("{package}")),
+ false,
+ description.as_deref(),
+ );
+ }
+ }
+
+ /// Add completions for raw block tags.
+ fn raw_completions(&mut self) {
+ for (name, mut tags) in (self.library.items.raw_languages)() {
+ let lower = name.to_lowercase();
+ if !tags.contains(&lower.as_str()) {
+ tags.push(lower.as_str());
+ }
+
+ tags.retain(|tag| is_ident(tag));
+ if tags.is_empty() {
+ continue;
+ }
+
+ self.completions.push(Completion {
+ kind: CompletionKind::Constant,
+ label: name.into(),
+ apply: Some(tags[0].into()),
+ detail: Some(separated_list(&tags, " or ").into()),
+ });
+ }
+ }
+
+ /// Add completions for all labels.
+ fn label_completions(&mut self) {
+ for (label, detail) in analyze_labels(self.world, self.frames).0 {
+ self.completions.push(Completion {
+ kind: CompletionKind::Constant,
+ label: label.0,
+ apply: None,
+ detail,
+ });
+ }
+ }
+
+ /// Add a completion for a specific value.
+ fn value_completion(
+ &mut self,
+ label: Option<EcoString>,
+ value: &Value,
+ parens: bool,
+ docs: Option<&str>,
+ ) {
+ let at = label.as_deref().map_or(false, |field| !is_ident(field));
+ let label = label.unwrap_or_else(|| value.repr().into());
+
+ let detail = docs.map(Into::into).or_else(|| match value {
+ Value::Symbol(_) => None,
+ Value::Func(func) => func.docs().map(plain_docs_sentence),
+ v => {
+ let repr = v.repr();
+ (repr.as_str() != label).then(|| repr.into())
+ }
+ });
+
+ let mut apply = None;
+ if parens && matches!(value, Value::Func(_)) {
+ if let Value::Func(func) = value {
+ if func
+ .params()
+ .is_some_and(|params| params.iter().all(|param| param.name == "self"))
+ {
+ apply = Some(eco_format!("{label}()${{}}"));
+ } else {
+ apply = Some(eco_format!("{label}(${{}})"));
+ }
+ }
+ } else if at {
+ apply = Some(eco_format!("at(\"{label}\")"));
+ } else if label.starts_with('"') && self.after.starts_with('"') {
+ if let Some(trimmed) = label.strip_suffix('"') {
+ apply = Some(trimmed.into());
+ }
+ }
+
+ self.completions.push(Completion {
+ kind: match value {
+ Value::Func(_) => CompletionKind::Func,
+ Value::Type(_) => CompletionKind::Type,
+ Value::Symbol(s) => CompletionKind::Symbol(s.get()),
+ _ => CompletionKind::Constant,
+ },
+ label,
+ apply,
+ detail,
+ });
+ }
+
+ /// Add completions for a castable.
+ fn cast_completions(&mut self, cast: &'a CastInfo) {
+ // Prevent duplicate completions from appearing.
+ if !self.seen_casts.insert(typst::util::hash128(cast)) {
+ return;
+ }
+
+ match cast {
+ CastInfo::Any => {}
+ CastInfo::Value(value, docs) => {
+ self.value_completion(None, value, true, Some(docs));
+ }
+ CastInfo::Type(ty) => {
+ if *ty == Type::of::<NoneValue>() {
+ self.snippet_completion("none", "none", "Nothing.")
+ } else if *ty == Type::of::<AutoValue>() {
+ self.snippet_completion("auto", "auto", "A smart default.");
+ } else if *ty == Type::of::<bool>() {
+ self.snippet_completion("false", "false", "No / Disabled.");
+ self.snippet_completion("true", "true", "Yes / Enabled.");
+ } else if *ty == Type::of::<Color>() {
+ self.snippet_completion(
+ "luma()",
+ "luma(${v})",
+ "A custom grayscale color.",
+ );
+ self.snippet_completion(
+ "rgb()",
+ "rgb(${r}, ${g}, ${b}, ${a})",
+ "A custom RGBA color.",
+ );
+ self.snippet_completion(
+ "cmyk()",
+ "cmyk(${c}, ${m}, ${y}, ${k})",
+ "A custom CMYK color.",
+ );
+ self.scope_completions(false, |value| value.ty() == *ty);
+ } else if *ty == Type::of::<Func>() {
+ self.snippet_completion(
+ "function",
+ "(${params}) => ${output}",
+ "A custom function.",
+ );
+ } else {
+ self.completions.push(Completion {
+ kind: CompletionKind::Syntax,
+ label: ty.long_name().into(),
+ apply: Some(eco_format!("${{{ty}}}")),
+ detail: Some(eco_format!("A value of type {ty}.")),
+ });
+ self.scope_completions(false, |value| value.ty() == *ty);
+ }
+ }
+ CastInfo::Union(union) => {
+ for info in union {
+ self.cast_completions(info);
+ }
+ }
+ }
+ }
+
+ /// Add completions for definitions that are available at the cursor.
+ ///
+ /// Filters the global/math scope with the given filter.
+ fn scope_completions(&mut self, parens: bool, filter: impl Fn(&Value) -> bool) {
+ let mut defined = BTreeSet::new();
+
+ let mut ancestor = Some(self.leaf.clone());
+ while let Some(node) = &ancestor {
+ let mut sibling = Some(node.clone());
+ while let Some(node) = &sibling {
+ if let Some(v) = node.cast::<ast::LetBinding>() {
+ for ident in v.kind().idents() {
+ defined.insert(ident.get().clone());
+ }
+ }
+
+ if let Some(v) = node.cast::<ast::ModuleImport>() {
+ let imports = v.imports();
+ match imports {
+ None | Some(ast::Imports::Wildcard) => {
+ if let Some(value) = node
+ .children()
+ .find(|child| child.is::<ast::Expr>())
+ .and_then(|source| analyze_import(self.world, &source))
+ {
+ if imports.is_none() {
+ defined.extend(value.name().map(Into::into));
+ } else if let Some(scope) = value.scope() {
+ for (name, _) in scope.iter() {
+ defined.insert(name.clone());
+ }
+ }
+ }
+ }
+ Some(ast::Imports::Items(items)) => {
+ for item in items.iter() {
+ defined.insert(item.bound_name().get().clone());
+ }
+ }
+ }
+ }
+
+ sibling = node.prev_sibling();
+ }
+
+ if let Some(parent) = node.parent() {
+ if let Some(v) = parent.cast::<ast::ForLoop>() {
+ if node.prev_sibling_kind() != Some(SyntaxKind::In) {
+ let pattern = v.pattern();
+ for ident in pattern.idents() {
+ defined.insert(ident.get().clone());
+ }
+ }
+ }
+
+ ancestor = Some(parent.clone());
+ continue;
+ }
+
+ break;
+ }
+
+ let in_math = matches!(
+ self.leaf.parent_kind(),
+ Some(SyntaxKind::Equation)
+ | Some(SyntaxKind::Math)
+ | Some(SyntaxKind::MathFrac)
+ | Some(SyntaxKind::MathAttach)
+ );
+
+ let scope = if in_math { self.math } else { self.global };
+ for (name, value) in scope.iter() {
+ if filter(value) && !defined.contains(name) {
+ self.value_completion(Some(name.clone()), value, parens, None);
+ }
+ }
+
+ for name in defined {
+ if !name.is_empty() {
+ self.completions.push(Completion {
+ kind: CompletionKind::Constant,
+ label: name,
+ apply: None,
+ detail: None,
+ });
+ }
+ }
+ }
+}
diff --git a/crates/typst-ide/src/jump.rs b/crates/typst-ide/src/jump.rs
new file mode 100644
index 00000000..a33e743c
--- /dev/null
+++ b/crates/typst-ide/src/jump.rs
@@ -0,0 +1,169 @@
+use std::num::NonZeroUsize;
+
+use ecow::EcoString;
+use typst::doc::{Destination, Frame, FrameItem, Meta, Position};
+use typst::geom::{Geometry, Point, Size};
+use typst::model::Introspector;
+use typst::syntax::{FileId, LinkedNode, Source, Span, SyntaxKind};
+use typst::World;
+
+/// Where to [jump](jump_from_click) to.
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub enum Jump {
+ /// Jump to a position in a source file.
+ Source(FileId, usize),
+ /// Jump to an external URL.
+ Url(EcoString),
+ /// Jump to a point on a page.
+ Position(Position),
+}
+
+impl Jump {
+ fn from_span(world: &dyn World, span: Span) -> Option<Self> {
+ let id = span.id()?;
+ let source = world.source(id).ok()?;
+ let node = source.find(span)?;
+ Some(Self::Source(id, node.offset()))
+ }
+}
+
+/// Determine where to jump to based on a click in a frame.
+pub fn jump_from_click(
+ world: &dyn World,
+ frames: &[Frame],
+ frame: &Frame,
+ click: Point,
+) -> Option<Jump> {
+ let mut introspector = None;
+
+ // Try to find a link first.
+ for (pos, item) in frame.items() {
+ if let FrameItem::Meta(Meta::Link(dest), size) = item {
+ if is_in_rect(*pos, *size, click) {
+ return Some(match dest {
+ Destination::Url(url) => Jump::Url(url.clone()),
+ Destination::Position(pos) => Jump::Position(*pos),
+ Destination::Location(loc) => Jump::Position(
+ introspector
+ .get_or_insert_with(|| Introspector::new(frames))
+ .position(*loc),
+ ),
+ });
+ }
+ }
+ }
+
+ // If there's no link, search for a jump target.
+ for (mut pos, item) in frame.items().rev() {
+ match item {
+ FrameItem::Group(group) => {
+ // TODO: Handle transformation.
+ if let Some(span) =
+ jump_from_click(world, frames, &group.frame, click - pos)
+ {
+ return Some(span);
+ }
+ }
+
+ FrameItem::Text(text) => {
+ for glyph in &text.glyphs {
+ let width = glyph.x_advance.at(text.size);
+ if is_in_rect(
+ Point::new(pos.x, pos.y - text.size),
+ Size::new(width, text.size),
+ click,
+ ) {
+ let (span, span_offset) = glyph.span;
+ let Some(id) = span.id() else { continue };
+ let source = world.source(id).ok()?;
+ let node = source.find(span)?;
+ let pos = if node.kind() == SyntaxKind::Text {
+ let range = node.range();
+ let mut offset = range.start + usize::from(span_offset);
+ if (click.x - pos.x) > width / 2.0 {
+ offset += glyph.range().len();
+ }
+ offset.min(range.end)
+ } else {
+ node.offset()
+ };
+ return Some(Jump::Source(source.id(), pos));
+ }
+
+ pos.x += width;
+ }
+ }
+
+ FrameItem::Shape(shape, span) => {
+ let Geometry::Rect(size) = shape.geometry else { continue };
+ if is_in_rect(pos, size, click) {
+ return Jump::from_span(world, *span);
+ }
+ }
+
+ FrameItem::Image(_, size, span) if is_in_rect(pos, *size, click) => {
+ return Jump::from_span(world, *span);
+ }
+
+ _ => {}
+ }
+ }
+
+ None
+}
+
+/// Find the output location in the document for a cursor position.
+pub fn jump_from_cursor(
+ frames: &[Frame],
+ source: &Source,
+ cursor: usize,
+) -> Option<Position> {
+ let node = LinkedNode::new(source.root()).leaf_at(cursor)?;
+ if node.kind() != SyntaxKind::Text {
+ return None;
+ }
+
+ let span = node.span();
+ for (i, frame) in frames.iter().enumerate() {
+ if let Some(pos) = find_in_frame(frame, span) {
+ return Some(Position {
+ page: NonZeroUsize::new(i + 1).unwrap(),
+ point: pos,
+ });
+ }
+ }
+
+ None
+}
+
+/// Find the position of a span in a frame.
+fn find_in_frame(frame: &Frame, span: Span) -> Option<Point> {
+ for (mut pos, item) in frame.items() {
+ if let FrameItem::Group(group) = item {
+ // TODO: Handle transformation.
+ if let Some(point) = find_in_frame(&group.frame, span) {
+ return Some(point + pos);
+ }
+ }
+
+ if let FrameItem::Text(text) = item {
+ for glyph in &text.glyphs {
+ if glyph.span.0 == span {
+ return Some(pos);
+ }
+ pos.x += glyph.x_advance.at(text.size);
+ }
+ }
+ }
+
+ None
+}
+
+/// Whether a rectangle with the given size at the given position contains the
+/// click position.
+fn is_in_rect(pos: Point, size: Size, click: Point) -> bool {
+ pos.x <= click.x
+ && pos.x + size.x >= click.x
+ && pos.y <= click.y
+ && pos.y + size.y >= click.y
+}
diff --git a/crates/typst-ide/src/lib.rs b/crates/typst-ide/src/lib.rs
new file mode 100644
index 00000000..3ab367dc
--- /dev/null
+++ b/crates/typst-ide/src/lib.rs
@@ -0,0 +1,94 @@
+//! Capabilities for IDE support.
+
+mod analyze;
+mod complete;
+mod jump;
+mod tooltip;
+
+pub use self::analyze::analyze_labels;
+pub use self::complete::{autocomplete, Completion, CompletionKind};
+pub use self::jump::{jump_from_click, jump_from_cursor, Jump};
+pub use self::tooltip::{tooltip, Tooltip};
+
+use std::fmt::Write;
+
+use ecow::{eco_format, EcoString};
+use typst::font::{FontInfo, FontStyle};
+
+use self::analyze::*;
+
+/// Extract the first sentence of plain text of a piece of documentation.
+///
+/// Removes Markdown formatting.
+fn plain_docs_sentence(docs: &str) -> EcoString {
+ let mut s = unscanny::Scanner::new(docs);
+ let mut output = EcoString::new();
+ let mut link = false;
+ while let Some(c) = s.eat() {
+ match c {
+ '`' => {
+ let mut raw = s.eat_until('`');
+ if (raw.starts_with('{') && raw.ends_with('}'))
+ || (raw.starts_with('[') && raw.ends_with(']'))
+ {
+ raw = &raw[1..raw.len() - 1];
+ }
+
+ s.eat();
+ output.push('`');
+ output.push_str(raw);
+ output.push('`');
+ }
+ '[' => link = true,
+ ']' if link => {
+ if s.eat_if('(') {
+ s.eat_until(')');
+ s.eat();
+ } else if s.eat_if('[') {
+ s.eat_until(']');
+ s.eat();
+ }
+ link = false
+ }
+ '*' | '_' => {}
+ '.' => {
+ output.push('.');
+ break;
+ }
+ _ => output.push(c),
+ }
+ }
+
+ output
+}
+
+/// 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| 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/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs
new file mode 100644
index 00000000..8a418e0e
--- /dev/null
+++ b/crates/typst-ide/src/tooltip.rs
@@ -0,0 +1,245 @@
+use std::fmt::Write;
+
+use ecow::{eco_format, EcoString};
+use if_chain::if_chain;
+use typst::doc::Frame;
+use typst::eval::{CapturesVisitor, CastInfo, Tracer, Value};
+use typst::geom::{round_2, Length, Numeric};
+use typst::syntax::ast::{self, AstNode};
+use typst::syntax::{LinkedNode, Source, SyntaxKind};
+use typst::util::{pretty_comma_list, separated_list};
+use typst::World;
+
+use super::analyze::analyze_labels;
+use super::{analyze_expr, plain_docs_sentence, summarize_font_family};
+
+/// Describe the item under the cursor.
+pub fn tooltip(
+ world: &dyn World,
+ frames: &[Frame],
+ 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(|| ref_tooltip(world, frames, &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.
+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.hashtag() && !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(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().into());
+ 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 = 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> {
+ // Find the closure to analyze.
+ let mut ancestor = leaf;
+ while !ancestor.is::<ast::Closure>() {
+ ancestor = ancestor.parent()?;
+ }
+ let closure = ancestor.cast::<ast::Closure>()?.to_untyped();
+
+ // Analyze the closure's captures.
+ let mut visitor = CapturesVisitor::new(None);
+ visitor.visit(closure);
+
+ 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 = 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.
+fn ref_tooltip(
+ world: &dyn World,
+ frames: &[Frame],
+ leaf: &LinkedNode,
+) -> Option<Tooltip> {
+ if leaf.kind() != SyntaxKind::RefMarker {
+ return None;
+ }
+
+ let target = leaf.text().trim_start_matches('@');
+ for (label, detail) in analyze_labels(world, frames).0 {
+ if label.0 == 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(plain_docs_sentence(param.docs)));
+ }
+ }
+
+ // 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
+}