From d7928a8ea347df89f501e5b3ecbb52402cae8edb Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 26 Sep 2023 17:12:18 +0200 Subject: Extract IDE crate --- Cargo.lock | 13 +- crates/typst-docs/src/html.rs | 4 +- crates/typst-docs/src/lib.rs | 2 +- crates/typst-ide/Cargo.toml | 25 + crates/typst-ide/src/analyze.rs | 110 +++ crates/typst-ide/src/complete.rs | 1285 ++++++++++++++++++++++++++++++++++ crates/typst-ide/src/jump.rs | 169 +++++ crates/typst-ide/src/lib.rs | 94 +++ crates/typst-ide/src/tooltip.rs | 245 +++++++ crates/typst-library/src/text/raw.rs | 2 +- crates/typst-syntax/src/highlight.rs | 457 ++++++++++++ crates/typst-syntax/src/lib.rs | 2 + crates/typst/Cargo.toml | 1 - crates/typst/src/eval/cast.rs | 10 +- crates/typst/src/eval/mod.rs | 7 +- crates/typst/src/ide/analyze.rs | 111 --- crates/typst/src/ide/complete.rs | 1285 ---------------------------------- crates/typst/src/ide/highlight.rs | 458 ------------ crates/typst/src/ide/jump.rs | 170 ----- crates/typst/src/ide/mod.rs | 97 --- crates/typst/src/ide/tooltip.rs | 246 ------- crates/typst/src/lib.rs | 1 - 22 files changed, 2410 insertions(+), 2384 deletions(-) create mode 100644 crates/typst-ide/Cargo.toml create mode 100644 crates/typst-ide/src/analyze.rs create mode 100644 crates/typst-ide/src/complete.rs create mode 100644 crates/typst-ide/src/jump.rs create mode 100644 crates/typst-ide/src/lib.rs create mode 100644 crates/typst-ide/src/tooltip.rs create mode 100644 crates/typst-syntax/src/highlight.rs delete mode 100644 crates/typst/src/ide/analyze.rs delete mode 100644 crates/typst/src/ide/complete.rs delete mode 100644 crates/typst/src/ide/highlight.rs delete mode 100644 crates/typst/src/ide/jump.rs delete mode 100644 crates/typst/src/ide/mod.rs delete mode 100644 crates/typst/src/ide/tooltip.rs diff --git a/Cargo.lock b/Cargo.lock index a01fb639..0d62f626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2739,7 +2739,6 @@ dependencies = [ "ecow", "flate2", "fontdb", - "if_chain", "image", "indexmap 2.0.0", "log", @@ -2841,6 +2840,18 @@ dependencies = [ "yaml-front-matter", ] +[[package]] +name = "typst-ide" +version = "0.8.0" +dependencies = [ + "comemo", + "ecow", + "if_chain", + "serde", + "typst", + "unscanny", +] + [[package]] name = "typst-library" version = "0.8.0" diff --git a/crates/typst-docs/src/html.rs b/crates/typst-docs/src/html.rs index c64649e3..ea81fa5c 100644 --- a/crates/typst-docs/src/html.rs +++ b/crates/typst-docs/src/html.rs @@ -215,7 +215,7 @@ impl<'a> Handler<'a> { }; let root = parser(&code[1..code.len() - 1]); - let html = typst::ide::highlight_html(&root); + let html = typst::syntax::highlight_html(&root); *event = md::Event::Html(html.into()); } @@ -370,7 +370,7 @@ fn code_block(resolver: &dyn Resolver, lang: &str, text: &str) -> Html { } let root = typst::syntax::parse(&display); - let highlighted = Html::new(typst::ide::highlight_html(&root)); + let highlighted = Html::new(typst::syntax::highlight_html(&root)); if lang == "typ" { return Html::new(format!("
{}
", highlighted.as_str())); } diff --git a/crates/typst-docs/src/lib.rs b/crates/typst-docs/src/lib.rs index 3018c244..fa74a56e 100644 --- a/crates/typst-docs/src/lib.rs +++ b/crates/typst-docs/src/lib.rs @@ -382,7 +382,7 @@ fn param_model(resolver: &dyn Resolver, info: &ParamInfo) -> ParamModel { strings, default: info.default.map(|default| { let node = typst::syntax::parse_code(&default().repr()); - Html::new(typst::ide::highlight_html(&node)) + Html::new(typst::syntax::highlight_html(&node)) }), positional: info.positional, named: info.named, 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 { + match node.cast::() { + 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 { + 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)>, 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)> { + 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, + /// An optional short description, at most one sentence. + pub detail: Option, +} + +/// 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::(); + 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::(); + 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::()); + 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::()); + 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::(); + if let Some(grand) = parent.parent(); + if let Some(expr) = grand.get().cast::(); + 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::(); + then { + if let Some(next) = deciding.next_leaf() { + ctx.from = ctx.cursor.min(next.offset()); + } + + named_param_value_completions(ctx, callee, ¶m); + 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(¶m.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(¶m.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, + seen_casts: HashSet, +} + +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 { + 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, + 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::() { + self.snippet_completion("none", "none", "Nothing.") + } else if *ty == Type::of::() { + self.snippet_completion("auto", "auto", "A smart default."); + } else if *ty == Type::of::() { + self.snippet_completion("false", "false", "No / Disabled."); + self.snippet_completion("true", "true", "Yes / Enabled."); + } else if *ty == Type::of::() { + 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::() { + 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::() { + for ident in v.kind().idents() { + defined.insert(ident.get().clone()); + } + } + + if let Some(v) = node.cast::() { + let imports = v.imports(); + match imports { + None | Some(ast::Imports::Wildcard) => { + if let Some(value) = node + .children() + .find(|child| child.is::()) + .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::() { + 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 { + 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 { + 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 { + 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 { + 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) -> 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 { + 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 { + let mut ancestor = leaf; + while !ancestor.is::() { + ancestor = ancestor.parent()?; + } + + let expr = ancestor.cast::()?; + 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 = 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 { + // Find the closure to analyze. + let mut ancestor = leaf; + while !ancestor.is::() { + ancestor = ancestor.parent()?; + } + let closure = ancestor.cast::()?.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 { + 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 { + 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 { + 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(plain_docs_sentence(param.docs))); + } + } + + // 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 +} diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index 688e69d8..dfd71275 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -423,7 +423,7 @@ fn highlight_themed( for child in node.children() { let mut scopes = scopes.clone(); - if let Some(tag) = typst::ide::highlight(&child) { + if let Some(tag) = typst::syntax::highlight(&child) { scopes.push(syntect::parsing::Scope::new(tag.tm_scope()).unwrap()) } highlight_themed(&child, scopes, highlighter, f); diff --git a/crates/typst-syntax/src/highlight.rs b/crates/typst-syntax/src/highlight.rs new file mode 100644 index 00000000..e8ae613b --- /dev/null +++ b/crates/typst-syntax/src/highlight.rs @@ -0,0 +1,457 @@ +use crate::{ast, LinkedNode, SyntaxKind, SyntaxNode}; + +/// A syntax highlighting tag. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum Tag { + /// A line or block comment. + Comment, + /// Punctuation in code. + Punctuation, + /// An escape sequence or shorthand. + Escape, + /// Strong markup. + Strong, + /// Emphasized markup. + Emph, + /// A hyperlink. + Link, + /// Raw text. + Raw, + /// A label. + Label, + /// A reference to a label. + Ref, + /// A section heading. + Heading, + /// A marker of a list, enumeration, or term list. + ListMarker, + /// A term in a term list. + ListTerm, + /// The delimiters of an equation. + MathDelimiter, + /// An operator with special meaning in an equation. + MathOperator, + /// A keyword. + Keyword, + /// An operator in code. + Operator, + /// A numeric literal. + Number, + /// A string literal. + String, + /// A function or method name. + Function, + /// An interpolated variable in markup or math. + Interpolated, + /// A syntax error. + Error, +} + +impl Tag { + /// The list of all tags. + pub const LIST: &'static [Tag] = &[ + Self::Comment, + Self::Punctuation, + Self::Escape, + Self::Strong, + Self::Emph, + Self::Link, + Self::Raw, + Self::MathDelimiter, + Self::MathOperator, + Self::Heading, + Self::ListMarker, + Self::ListTerm, + Self::Label, + Self::Ref, + Self::Keyword, + Self::Operator, + Self::Number, + Self::String, + Self::Function, + Self::Interpolated, + Self::Error, + ]; + + /// Return the recommended TextMate grammar scope for the given highlighting + /// tag. + pub fn tm_scope(&self) -> &'static str { + match self { + Self::Comment => "comment.typst", + Self::Punctuation => "punctuation.typst", + Self::Escape => "constant.character.escape.typst", + Self::Strong => "markup.bold.typst", + Self::Emph => "markup.italic.typst", + Self::Link => "markup.underline.link.typst", + Self::Raw => "markup.raw.typst", + Self::MathDelimiter => "punctuation.definition.math.typst", + Self::MathOperator => "keyword.operator.math.typst", + Self::Heading => "markup.heading.typst", + Self::ListMarker => "punctuation.definition.list.typst", + Self::ListTerm => "markup.list.term.typst", + Self::Label => "entity.name.label.typst", + Self::Ref => "markup.other.reference.typst", + Self::Keyword => "keyword.typst", + Self::Operator => "keyword.operator.typst", + Self::Number => "constant.numeric.typst", + Self::String => "string.quoted.double.typst", + Self::Function => "entity.name.function.typst", + Self::Interpolated => "meta.interpolation.typst", + Self::Error => "invalid.typst", + } + } + + /// The recommended CSS class for the highlighting tag. + pub fn css_class(self) -> &'static str { + match self { + Self::Comment => "typ-comment", + Self::Punctuation => "typ-punct", + Self::Escape => "typ-escape", + Self::Strong => "typ-strong", + Self::Emph => "typ-emph", + Self::Link => "typ-link", + Self::Raw => "typ-raw", + Self::Label => "typ-label", + Self::Ref => "typ-ref", + Self::Heading => "typ-heading", + Self::ListMarker => "typ-marker", + Self::ListTerm => "typ-term", + Self::MathDelimiter => "typ-math-delim", + Self::MathOperator => "typ-math-op", + Self::Keyword => "typ-key", + Self::Operator => "typ-op", + Self::Number => "typ-num", + Self::String => "typ-str", + Self::Function => "typ-func", + Self::Interpolated => "typ-pol", + Self::Error => "typ-error", + } + } +} + +/// Determine the highlight tag of a linked syntax node. +/// +/// Returns `None` if the node should not be highlighted. +pub fn highlight(node: &LinkedNode) -> Option { + match node.kind() { + SyntaxKind::Markup + if node.parent_kind() == Some(SyntaxKind::TermItem) + && node.next_sibling_kind() == Some(SyntaxKind::Colon) => + { + Some(Tag::ListTerm) + } + SyntaxKind::Markup => None, + SyntaxKind::Text => None, + SyntaxKind::Space => None, + SyntaxKind::Linebreak => Some(Tag::Escape), + SyntaxKind::Parbreak => None, + SyntaxKind::Escape => Some(Tag::Escape), + SyntaxKind::Shorthand => Some(Tag::Escape), + SyntaxKind::SmartQuote => None, + SyntaxKind::Strong => Some(Tag::Strong), + SyntaxKind::Emph => Some(Tag::Emph), + SyntaxKind::Raw => Some(Tag::Raw), + SyntaxKind::Link => Some(Tag::Link), + SyntaxKind::Label => Some(Tag::Label), + SyntaxKind::Ref => Some(Tag::Ref), + SyntaxKind::RefMarker => None, + SyntaxKind::Heading => Some(Tag::Heading), + SyntaxKind::HeadingMarker => None, + SyntaxKind::ListItem => None, + SyntaxKind::ListMarker => Some(Tag::ListMarker), + SyntaxKind::EnumItem => None, + SyntaxKind::EnumMarker => Some(Tag::ListMarker), + SyntaxKind::TermItem => None, + SyntaxKind::TermMarker => Some(Tag::ListMarker), + SyntaxKind::Equation => None, + + SyntaxKind::Math => None, + SyntaxKind::MathIdent => highlight_ident(node), + SyntaxKind::MathAlignPoint => Some(Tag::MathOperator), + SyntaxKind::MathDelimited => None, + SyntaxKind::MathAttach => None, + SyntaxKind::MathFrac => None, + SyntaxKind::MathRoot => None, + SyntaxKind::MathPrimes => None, + + SyntaxKind::Hashtag => highlight_hashtag(node), + SyntaxKind::LeftBrace => Some(Tag::Punctuation), + SyntaxKind::RightBrace => Some(Tag::Punctuation), + SyntaxKind::LeftBracket => Some(Tag::Punctuation), + SyntaxKind::RightBracket => Some(Tag::Punctuation), + SyntaxKind::LeftParen => Some(Tag::Punctuation), + SyntaxKind::RightParen => Some(Tag::Punctuation), + SyntaxKind::Comma => Some(Tag::Punctuation), + SyntaxKind::Semicolon => Some(Tag::Punctuation), + SyntaxKind::Colon => Some(Tag::Punctuation), + SyntaxKind::Star => match node.parent_kind() { + Some(SyntaxKind::Strong) => None, + _ => Some(Tag::Operator), + }, + SyntaxKind::Underscore => match node.parent_kind() { + Some(SyntaxKind::MathAttach) => Some(Tag::MathOperator), + _ => None, + }, + SyntaxKind::Dollar => Some(Tag::MathDelimiter), + SyntaxKind::Plus => Some(Tag::Operator), + SyntaxKind::Minus => Some(Tag::Operator), + SyntaxKind::Slash => Some(match node.parent_kind() { + Some(SyntaxKind::MathFrac) => Tag::MathOperator, + _ => Tag::Operator, + }), + SyntaxKind::Hat => Some(Tag::MathOperator), + SyntaxKind::Prime => Some(Tag::MathOperator), + SyntaxKind::Dot => Some(Tag::Punctuation), + SyntaxKind::Eq => match node.parent_kind() { + Some(SyntaxKind::Heading) => None, + _ => Some(Tag::Operator), + }, + SyntaxKind::EqEq => Some(Tag::Operator), + SyntaxKind::ExclEq => Some(Tag::Operator), + SyntaxKind::Lt => Some(Tag::Operator), + SyntaxKind::LtEq => Some(Tag::Operator), + SyntaxKind::Gt => Some(Tag::Operator), + SyntaxKind::GtEq => Some(Tag::Operator), + SyntaxKind::PlusEq => Some(Tag::Operator), + SyntaxKind::HyphEq => Some(Tag::Operator), + SyntaxKind::StarEq => Some(Tag::Operator), + SyntaxKind::SlashEq => Some(Tag::Operator), + SyntaxKind::Dots => Some(Tag::Operator), + SyntaxKind::Arrow => Some(Tag::Operator), + SyntaxKind::Root => Some(Tag::MathOperator), + + SyntaxKind::Not => Some(Tag::Keyword), + SyntaxKind::And => Some(Tag::Keyword), + SyntaxKind::Or => Some(Tag::Keyword), + SyntaxKind::None => Some(Tag::Keyword), + SyntaxKind::Auto => Some(Tag::Keyword), + SyntaxKind::Let => Some(Tag::Keyword), + SyntaxKind::Set => Some(Tag::Keyword), + SyntaxKind::Show => Some(Tag::Keyword), + SyntaxKind::If => Some(Tag::Keyword), + SyntaxKind::Else => Some(Tag::Keyword), + SyntaxKind::For => Some(Tag::Keyword), + SyntaxKind::In => Some(Tag::Keyword), + SyntaxKind::While => Some(Tag::Keyword), + SyntaxKind::Break => Some(Tag::Keyword), + SyntaxKind::Continue => Some(Tag::Keyword), + SyntaxKind::Return => Some(Tag::Keyword), + SyntaxKind::Import => Some(Tag::Keyword), + SyntaxKind::Include => Some(Tag::Keyword), + SyntaxKind::As => Some(Tag::Keyword), + + SyntaxKind::Code => None, + SyntaxKind::Ident => highlight_ident(node), + SyntaxKind::Bool => Some(Tag::Keyword), + SyntaxKind::Int => Some(Tag::Number), + SyntaxKind::Float => Some(Tag::Number), + SyntaxKind::Numeric => Some(Tag::Number), + SyntaxKind::Str => Some(Tag::String), + SyntaxKind::CodeBlock => None, + SyntaxKind::ContentBlock => None, + SyntaxKind::Parenthesized => None, + SyntaxKind::Array => None, + SyntaxKind::Dict => None, + SyntaxKind::Named => None, + SyntaxKind::Keyed => None, + SyntaxKind::Unary => None, + SyntaxKind::Binary => None, + SyntaxKind::FieldAccess => None, + SyntaxKind::FuncCall => None, + SyntaxKind::Args => None, + SyntaxKind::Spread => None, + SyntaxKind::Closure => None, + SyntaxKind::Params => None, + SyntaxKind::LetBinding => None, + SyntaxKind::SetRule => None, + SyntaxKind::ShowRule => None, + SyntaxKind::Conditional => None, + SyntaxKind::WhileLoop => None, + SyntaxKind::ForLoop => None, + SyntaxKind::ModuleImport => None, + SyntaxKind::ImportItems => None, + SyntaxKind::RenamedImportItem => None, + SyntaxKind::ModuleInclude => None, + SyntaxKind::LoopBreak => None, + SyntaxKind::LoopContinue => None, + SyntaxKind::FuncReturn => None, + SyntaxKind::Destructuring => None, + SyntaxKind::DestructAssignment => None, + + SyntaxKind::LineComment => Some(Tag::Comment), + SyntaxKind::BlockComment => Some(Tag::Comment), + SyntaxKind::Error => Some(Tag::Error), + SyntaxKind::Eof => None, + } +} + +/// Highlight an identifier based on context. +fn highlight_ident(node: &LinkedNode) -> Option { + // Are we directly before an argument list? + let next_leaf = node.next_leaf(); + if let Some(next) = &next_leaf { + if node.range().end == next.offset() + && ((next.kind() == SyntaxKind::LeftParen + && matches!( + next.parent_kind(), + Some(SyntaxKind::Args | SyntaxKind::Params) + )) + || (next.kind() == SyntaxKind::LeftBracket + && next.parent_kind() == Some(SyntaxKind::ContentBlock))) + { + return Some(Tag::Function); + } + } + + // Are we in math? + if node.kind() == SyntaxKind::MathIdent { + return Some(Tag::Interpolated); + } + + // Find the first non-field access ancestor. + let mut ancestor = node; + while ancestor.parent_kind() == Some(SyntaxKind::FieldAccess) { + ancestor = ancestor.parent()?; + } + + // Are we directly before or behind a show rule colon? + if ancestor.parent_kind() == Some(SyntaxKind::ShowRule) + && (next_leaf.map(|leaf| leaf.kind()) == Some(SyntaxKind::Colon) + || node.prev_leaf().map(|leaf| leaf.kind()) == Some(SyntaxKind::Colon)) + { + return Some(Tag::Function); + } + + // Are we (or an ancestor field access) directly after a hashtag. + if ancestor.prev_leaf().map(|leaf| leaf.kind()) == Some(SyntaxKind::Hashtag) { + return Some(Tag::Interpolated); + } + + // Are we behind a dot, that is behind another identifier? + let prev = node.prev_leaf()?; + if prev.kind() == SyntaxKind::Dot { + let prev_prev = prev.prev_leaf()?; + if is_ident(&prev_prev) { + return highlight_ident(&prev_prev); + } + } + + None +} + +/// Highlight a hashtag based on context. +fn highlight_hashtag(node: &LinkedNode) -> Option { + let next = node.next_sibling()?; + let expr = next.cast::()?; + if !expr.hashtag() { + return None; + } + highlight(&next.leftmost_leaf()?) +} + +/// Whether the node is one of the two identifier nodes. +fn is_ident(node: &LinkedNode) -> bool { + matches!(node.kind(), SyntaxKind::Ident | SyntaxKind::MathIdent) +} + +/// Highlight a node to an HTML `code` element. +/// +/// This uses these [CSS classes for categories](Tag::css_class). +pub fn highlight_html(root: &SyntaxNode) -> String { + let mut buf = String::from(""); + let node = LinkedNode::new(root); + highlight_html_impl(&mut buf, &node); + buf.push_str(""); + buf +} + +/// Highlight one source node, emitting HTML. +fn highlight_html_impl(html: &mut String, node: &LinkedNode) { + let mut span = false; + if let Some(tag) = highlight(node) { + if tag != Tag::Error { + span = true; + html.push_str(""); + } + } + + let text = node.text(); + if !text.is_empty() { + for c in text.chars() { + match c { + '<' => html.push_str("<"), + '>' => html.push_str(">"), + '&' => html.push_str("&"), + '\'' => html.push_str("'"), + '"' => html.push_str("""), + _ => html.push(c), + } + } + } else { + for child in node.children() { + highlight_html_impl(html, &child); + } + } + + if span { + html.push_str(""); + } +} + +#[cfg(test)] +mod tests { + use std::ops::Range; + + use super::*; + + #[test] + fn test_highlighting() { + use Tag::*; + + #[track_caller] + fn test(text: &str, goal: &[(Range, Tag)]) { + let mut vec = vec![]; + let root = crate::parse(text); + highlight_tree(&mut vec, &LinkedNode::new(&root)); + assert_eq!(vec, goal); + } + + fn highlight_tree(tags: &mut Vec<(Range, Tag)>, node: &LinkedNode) { + if let Some(tag) = highlight(node) { + tags.push((node.range(), tag)); + } + + for child in node.children() { + highlight_tree(tags, &child); + } + } + + test("= *AB*", &[(0..6, Heading), (2..6, Strong)]); + + test( + "#f(x + 1)", + &[ + (0..1, Function), + (1..2, Function), + (2..3, Punctuation), + (5..6, Operator), + (7..8, Number), + (8..9, Punctuation), + ], + ); + + test( + "#let f(x) = x", + &[ + (0..1, Keyword), + (1..4, Keyword), + (5..6, Function), + (6..7, Punctuation), + (8..9, Punctuation), + (10..11, Operator), + ], + ); + } +} diff --git a/crates/typst-syntax/src/lib.rs b/crates/typst-syntax/src/lib.rs index dec9a751..4ee37096 100644 --- a/crates/typst-syntax/src/lib.rs +++ b/crates/typst-syntax/src/lib.rs @@ -3,6 +3,7 @@ pub mod ast; mod file; +mod highlight; mod kind; mod lexer; mod node; @@ -12,6 +13,7 @@ mod source; mod span; pub use self::file::{FileId, PackageSpec, PackageVersion, VirtualPath}; +pub use self::highlight::{highlight, highlight_html, Tag}; pub use self::kind::SyntaxKind; pub use self::lexer::{is_id_continue, is_id_start, is_ident, is_newline}; pub use self::node::{LinkedChildren, LinkedNode, SyntaxError, SyntaxNode}; diff --git a/crates/typst/Cargo.toml b/crates/typst/Cargo.toml index 0775f67b..630e7466 100644 --- a/crates/typst/Cargo.toml +++ b/crates/typst/Cargo.toml @@ -25,7 +25,6 @@ comemo = "0.3" ecow = { version = "0.1.2", features = ["serde"] } flate2 = "1" fontdb = { version = "0.14", default-features = false } -if_chain = "1" image = { version = "0.24", default-features = false, features = ["png", "jpeg", "gif"] } indexmap = { version = "2", features = ["serde"] } log = "0.4" diff --git a/crates/typst/src/eval/cast.rs b/crates/typst/src/eval/cast.rs index 14413a61..0f9f6355 100644 --- a/crates/typst/src/eval/cast.rs +++ b/crates/typst/src/eval/cast.rs @@ -253,14 +253,12 @@ impl CastInfo { msg.push_str(", found "); write!(msg, "{}", found.ty()).unwrap(); } - if_chain::if_chain! { - if let Value::Int(i) = found; - if parts.iter().any(|p| p == "length"); - if !matching_type; - then { + + if let Value::Int(i) = found { + if parts.iter().any(|p| p == "length") && !matching_type { write!(msg, ": a length needs a unit - did you mean {i}pt?").unwrap(); } - }; + } msg.into() } diff --git a/crates/typst/src/eval/mod.rs b/crates/typst/src/eval/mod.rs index d39a705e..424e15ee 100644 --- a/crates/typst/src/eval/mod.rs +++ b/crates/typst/src/eval/mod.rs @@ -50,10 +50,12 @@ pub use self::cast::{ pub use self::datetime::Datetime; pub use self::dict::{dict, Dict}; pub use self::duration::Duration; +pub use self::fields::fields_on; pub use self::func::{ func, CapturesVisitor, Func, NativeFunc, NativeFuncData, ParamInfo, }; pub use self::library::{set_lang_items, LangItems, Library}; +pub use self::methods::mutable_methods_on; pub use self::module::Module; pub use self::none::NoneValue; pub use self::plugin::Plugin; @@ -64,9 +66,6 @@ pub use self::tracer::Tracer; pub use self::ty::{scope, ty, NativeType, NativeTypeData, Type}; pub use self::value::{Dynamic, Value}; -pub(crate) use self::fields::fields_on; -pub(crate) use self::methods::mutable_methods_on; - use std::collections::HashSet; use std::mem; @@ -1829,7 +1828,7 @@ impl Eval for ast::ModuleInclude<'_> { } /// Process an import of a module relative to the current location. -pub(crate) fn import( +pub fn import( vm: &mut Vm, source: Value, span: Span, diff --git a/crates/typst/src/ide/analyze.rs b/crates/typst/src/ide/analyze.rs deleted file mode 100644 index 769e80ad..00000000 --- a/crates/typst/src/ide/analyze.rs +++ /dev/null @@ -1,111 +0,0 @@ -use comemo::Track; -use ecow::{eco_vec, EcoString, EcoVec}; - -use crate::doc::Frame; -use crate::eval::{Route, Scopes, Tracer, Value, Vm}; -use crate::model::{DelayedErrors, Introspector, Label, Locator, Vt}; -use crate::syntax::{ast, LinkedNode, Span, SyntaxKind}; -use crate::World; - -/// Try to determine a set of possible values for an expression. -pub fn analyze_expr(world: &dyn World, node: &LinkedNode) -> EcoVec { - match node.cast::() { - 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()); - crate::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 { - 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()))); - crate::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)>, 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/src/ide/complete.rs b/crates/typst/src/ide/complete.rs deleted file mode 100644 index 27623fc2..00000000 --- a/crates/typst/src/ide/complete.rs +++ /dev/null @@ -1,1285 +0,0 @@ -use std::cmp::Reverse; -use std::collections::{BTreeSet, HashSet}; - -use ecow::{eco_format, EcoString}; -use if_chain::if_chain; -use serde::{Deserialize, Serialize}; -use unscanny::Scanner; - -use super::analyze::analyze_labels; -use super::{analyze_expr, analyze_import, plain_docs_sentence, summarize_font_family}; -use crate::doc::Frame; -use crate::eval::{ - format_str, AutoValue, CastInfo, Func, Library, NoneValue, Scope, Type, Value, -}; -use crate::geom::Color; -use crate::syntax::{ - ast, is_id_continue, is_id_start, is_ident, LinkedNode, Source, SyntaxKind, -}; -use crate::util::separated_list; -use crate::World; - -/// 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)> { - 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, - /// An optional short description, at most one sentence. - pub detail: Option, -} - -/// 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::(); - 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::(); - 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 crate::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 crate::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::()); - 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::()); - 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::(); - if let Some(grand) = parent.parent(); - if let Some(expr) = grand.get().cast::(); - 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::(); - then { - if let Some(next) = deciding.next_leaf() { - ctx.from = ctx.cursor.min(next.offset()); - } - - named_param_value_completions(ctx, callee, ¶m); - 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(¶m.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(¶m.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, - seen_casts: HashSet, -} - -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 { - 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, - 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(crate::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::() { - self.snippet_completion("none", "none", "Nothing.") - } else if *ty == Type::of::() { - self.snippet_completion("auto", "auto", "A smart default."); - } else if *ty == Type::of::() { - self.snippet_completion("false", "false", "No / Disabled."); - self.snippet_completion("true", "true", "Yes / Enabled."); - } else if *ty == Type::of::() { - 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::() { - 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::() { - for ident in v.kind().idents() { - defined.insert(ident.get().clone()); - } - } - - if let Some(v) = node.cast::() { - let imports = v.imports(); - match imports { - None | Some(ast::Imports::Wildcard) => { - if let Some(value) = node - .children() - .find(|child| child.is::()) - .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::() { - 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/src/ide/highlight.rs b/crates/typst/src/ide/highlight.rs deleted file mode 100644 index 197223f8..00000000 --- a/crates/typst/src/ide/highlight.rs +++ /dev/null @@ -1,458 +0,0 @@ -use crate::syntax::{ast, LinkedNode, SyntaxKind, SyntaxNode}; - -/// A syntax highlighting tag. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -pub enum Tag { - /// A line or block comment. - Comment, - /// Punctuation in code. - Punctuation, - /// An escape sequence or shorthand. - Escape, - /// Strong markup. - Strong, - /// Emphasized markup. - Emph, - /// A hyperlink. - Link, - /// Raw text. - Raw, - /// A label. - Label, - /// A reference to a label. - Ref, - /// A section heading. - Heading, - /// A marker of a list, enumeration, or term list. - ListMarker, - /// A term in a term list. - ListTerm, - /// The delimiters of an equation. - MathDelimiter, - /// An operator with special meaning in an equation. - MathOperator, - /// A keyword. - Keyword, - /// An operator in code. - Operator, - /// A numeric literal. - Number, - /// A string literal. - String, - /// A function or method name. - Function, - /// An interpolated variable in markup or math. - Interpolated, - /// A syntax error. - Error, -} - -impl Tag { - /// The list of all tags. - pub const LIST: &'static [Tag] = &[ - Self::Comment, - Self::Punctuation, - Self::Escape, - Self::Strong, - Self::Emph, - Self::Link, - Self::Raw, - Self::MathDelimiter, - Self::MathOperator, - Self::Heading, - Self::ListMarker, - Self::ListTerm, - Self::Label, - Self::Ref, - Self::Keyword, - Self::Operator, - Self::Number, - Self::String, - Self::Function, - Self::Interpolated, - Self::Error, - ]; - - /// Return the recommended TextMate grammar scope for the given highlighting - /// tag. - pub fn tm_scope(&self) -> &'static str { - match self { - Self::Comment => "comment.typst", - Self::Punctuation => "punctuation.typst", - Self::Escape => "constant.character.escape.typst", - Self::Strong => "markup.bold.typst", - Self::Emph => "markup.italic.typst", - Self::Link => "markup.underline.link.typst", - Self::Raw => "markup.raw.typst", - Self::MathDelimiter => "punctuation.definition.math.typst", - Self::MathOperator => "keyword.operator.math.typst", - Self::Heading => "markup.heading.typst", - Self::ListMarker => "punctuation.definition.list.typst", - Self::ListTerm => "markup.list.term.typst", - Self::Label => "entity.name.label.typst", - Self::Ref => "markup.other.reference.typst", - Self::Keyword => "keyword.typst", - Self::Operator => "keyword.operator.typst", - Self::Number => "constant.numeric.typst", - Self::String => "string.quoted.double.typst", - Self::Function => "entity.name.function.typst", - Self::Interpolated => "meta.interpolation.typst", - Self::Error => "invalid.typst", - } - } - - /// The recommended CSS class for the highlighting tag. - pub fn css_class(self) -> &'static str { - match self { - Self::Comment => "typ-comment", - Self::Punctuation => "typ-punct", - Self::Escape => "typ-escape", - Self::Strong => "typ-strong", - Self::Emph => "typ-emph", - Self::Link => "typ-link", - Self::Raw => "typ-raw", - Self::Label => "typ-label", - Self::Ref => "typ-ref", - Self::Heading => "typ-heading", - Self::ListMarker => "typ-marker", - Self::ListTerm => "typ-term", - Self::MathDelimiter => "typ-math-delim", - Self::MathOperator => "typ-math-op", - Self::Keyword => "typ-key", - Self::Operator => "typ-op", - Self::Number => "typ-num", - Self::String => "typ-str", - Self::Function => "typ-func", - Self::Interpolated => "typ-pol", - Self::Error => "typ-error", - } - } -} - -/// Determine the highlight tag of a linked syntax node. -/// -/// Returns `None` if the node should not be highlighted. -pub fn highlight(node: &LinkedNode) -> Option { - match node.kind() { - SyntaxKind::Markup - if node.parent_kind() == Some(SyntaxKind::TermItem) - && node.next_sibling_kind() == Some(SyntaxKind::Colon) => - { - Some(Tag::ListTerm) - } - SyntaxKind::Markup => None, - SyntaxKind::Text => None, - SyntaxKind::Space => None, - SyntaxKind::Linebreak => Some(Tag::Escape), - SyntaxKind::Parbreak => None, - SyntaxKind::Escape => Some(Tag::Escape), - SyntaxKind::Shorthand => Some(Tag::Escape), - SyntaxKind::SmartQuote => None, - SyntaxKind::Strong => Some(Tag::Strong), - SyntaxKind::Emph => Some(Tag::Emph), - SyntaxKind::Raw => Some(Tag::Raw), - SyntaxKind::Link => Some(Tag::Link), - SyntaxKind::Label => Some(Tag::Label), - SyntaxKind::Ref => Some(Tag::Ref), - SyntaxKind::RefMarker => None, - SyntaxKind::Heading => Some(Tag::Heading), - SyntaxKind::HeadingMarker => None, - SyntaxKind::ListItem => None, - SyntaxKind::ListMarker => Some(Tag::ListMarker), - SyntaxKind::EnumItem => None, - SyntaxKind::EnumMarker => Some(Tag::ListMarker), - SyntaxKind::TermItem => None, - SyntaxKind::TermMarker => Some(Tag::ListMarker), - SyntaxKind::Equation => None, - - SyntaxKind::Math => None, - SyntaxKind::MathIdent => highlight_ident(node), - SyntaxKind::MathAlignPoint => Some(Tag::MathOperator), - SyntaxKind::MathDelimited => None, - SyntaxKind::MathAttach => None, - SyntaxKind::MathFrac => None, - SyntaxKind::MathRoot => None, - SyntaxKind::MathPrimes => None, - - SyntaxKind::Hashtag => highlight_hashtag(node), - SyntaxKind::LeftBrace => Some(Tag::Punctuation), - SyntaxKind::RightBrace => Some(Tag::Punctuation), - SyntaxKind::LeftBracket => Some(Tag::Punctuation), - SyntaxKind::RightBracket => Some(Tag::Punctuation), - SyntaxKind::LeftParen => Some(Tag::Punctuation), - SyntaxKind::RightParen => Some(Tag::Punctuation), - SyntaxKind::Comma => Some(Tag::Punctuation), - SyntaxKind::Semicolon => Some(Tag::Punctuation), - SyntaxKind::Colon => Some(Tag::Punctuation), - SyntaxKind::Star => match node.parent_kind() { - Some(SyntaxKind::Strong) => None, - _ => Some(Tag::Operator), - }, - SyntaxKind::Underscore => match node.parent_kind() { - Some(SyntaxKind::MathAttach) => Some(Tag::MathOperator), - _ => None, - }, - SyntaxKind::Dollar => Some(Tag::MathDelimiter), - SyntaxKind::Plus => Some(Tag::Operator), - SyntaxKind::Minus => Some(Tag::Operator), - SyntaxKind::Slash => Some(match node.parent_kind() { - Some(SyntaxKind::MathFrac) => Tag::MathOperator, - _ => Tag::Operator, - }), - SyntaxKind::Hat => Some(Tag::MathOperator), - SyntaxKind::Prime => Some(Tag::MathOperator), - SyntaxKind::Dot => Some(Tag::Punctuation), - SyntaxKind::Eq => match node.parent_kind() { - Some(SyntaxKind::Heading) => None, - _ => Some(Tag::Operator), - }, - SyntaxKind::EqEq => Some(Tag::Operator), - SyntaxKind::ExclEq => Some(Tag::Operator), - SyntaxKind::Lt => Some(Tag::Operator), - SyntaxKind::LtEq => Some(Tag::Operator), - SyntaxKind::Gt => Some(Tag::Operator), - SyntaxKind::GtEq => Some(Tag::Operator), - SyntaxKind::PlusEq => Some(Tag::Operator), - SyntaxKind::HyphEq => Some(Tag::Operator), - SyntaxKind::StarEq => Some(Tag::Operator), - SyntaxKind::SlashEq => Some(Tag::Operator), - SyntaxKind::Dots => Some(Tag::Operator), - SyntaxKind::Arrow => Some(Tag::Operator), - SyntaxKind::Root => Some(Tag::MathOperator), - - SyntaxKind::Not => Some(Tag::Keyword), - SyntaxKind::And => Some(Tag::Keyword), - SyntaxKind::Or => Some(Tag::Keyword), - SyntaxKind::None => Some(Tag::Keyword), - SyntaxKind::Auto => Some(Tag::Keyword), - SyntaxKind::Let => Some(Tag::Keyword), - SyntaxKind::Set => Some(Tag::Keyword), - SyntaxKind::Show => Some(Tag::Keyword), - SyntaxKind::If => Some(Tag::Keyword), - SyntaxKind::Else => Some(Tag::Keyword), - SyntaxKind::For => Some(Tag::Keyword), - SyntaxKind::In => Some(Tag::Keyword), - SyntaxKind::While => Some(Tag::Keyword), - SyntaxKind::Break => Some(Tag::Keyword), - SyntaxKind::Continue => Some(Tag::Keyword), - SyntaxKind::Return => Some(Tag::Keyword), - SyntaxKind::Import => Some(Tag::Keyword), - SyntaxKind::Include => Some(Tag::Keyword), - SyntaxKind::As => Some(Tag::Keyword), - - SyntaxKind::Code => None, - SyntaxKind::Ident => highlight_ident(node), - SyntaxKind::Bool => Some(Tag::Keyword), - SyntaxKind::Int => Some(Tag::Number), - SyntaxKind::Float => Some(Tag::Number), - SyntaxKind::Numeric => Some(Tag::Number), - SyntaxKind::Str => Some(Tag::String), - SyntaxKind::CodeBlock => None, - SyntaxKind::ContentBlock => None, - SyntaxKind::Parenthesized => None, - SyntaxKind::Array => None, - SyntaxKind::Dict => None, - SyntaxKind::Named => None, - SyntaxKind::Keyed => None, - SyntaxKind::Unary => None, - SyntaxKind::Binary => None, - SyntaxKind::FieldAccess => None, - SyntaxKind::FuncCall => None, - SyntaxKind::Args => None, - SyntaxKind::Spread => None, - SyntaxKind::Closure => None, - SyntaxKind::Params => None, - SyntaxKind::LetBinding => None, - SyntaxKind::SetRule => None, - SyntaxKind::ShowRule => None, - SyntaxKind::Conditional => None, - SyntaxKind::WhileLoop => None, - SyntaxKind::ForLoop => None, - SyntaxKind::ModuleImport => None, - SyntaxKind::ImportItems => None, - SyntaxKind::RenamedImportItem => None, - SyntaxKind::ModuleInclude => None, - SyntaxKind::LoopBreak => None, - SyntaxKind::LoopContinue => None, - SyntaxKind::FuncReturn => None, - SyntaxKind::Destructuring => None, - SyntaxKind::DestructAssignment => None, - - SyntaxKind::LineComment => Some(Tag::Comment), - SyntaxKind::BlockComment => Some(Tag::Comment), - SyntaxKind::Error => Some(Tag::Error), - SyntaxKind::Eof => None, - } -} - -/// Highlight an identifier based on context. -fn highlight_ident(node: &LinkedNode) -> Option { - // Are we directly before an argument list? - let next_leaf = node.next_leaf(); - if let Some(next) = &next_leaf { - if node.range().end == next.offset() - && ((next.kind() == SyntaxKind::LeftParen - && matches!( - next.parent_kind(), - Some(SyntaxKind::Args | SyntaxKind::Params) - )) - || (next.kind() == SyntaxKind::LeftBracket - && next.parent_kind() == Some(SyntaxKind::ContentBlock))) - { - return Some(Tag::Function); - } - } - - // Are we in math? - if node.kind() == SyntaxKind::MathIdent { - return Some(Tag::Interpolated); - } - - // Find the first non-field access ancestor. - let mut ancestor = node; - while ancestor.parent_kind() == Some(SyntaxKind::FieldAccess) { - ancestor = ancestor.parent()?; - } - - // Are we directly before or behind a show rule colon? - if ancestor.parent_kind() == Some(SyntaxKind::ShowRule) - && (next_leaf.map(|leaf| leaf.kind()) == Some(SyntaxKind::Colon) - || node.prev_leaf().map(|leaf| leaf.kind()) == Some(SyntaxKind::Colon)) - { - return Some(Tag::Function); - } - - // Are we (or an ancestor field access) directly after a hashtag. - if ancestor.prev_leaf().map(|leaf| leaf.kind()) == Some(SyntaxKind::Hashtag) { - return Some(Tag::Interpolated); - } - - // Are we behind a dot, that is behind another identifier? - let prev = node.prev_leaf()?; - if prev.kind() == SyntaxKind::Dot { - let prev_prev = prev.prev_leaf()?; - if is_ident(&prev_prev) { - return highlight_ident(&prev_prev); - } - } - - None -} - -/// Highlight a hashtag based on context. -fn highlight_hashtag(node: &LinkedNode) -> Option { - let next = node.next_sibling()?; - let expr = next.cast::()?; - if !expr.hashtag() { - return None; - } - highlight(&next.leftmost_leaf()?) -} - -/// Whether the node is one of the two identifier nodes. -fn is_ident(node: &LinkedNode) -> bool { - matches!(node.kind(), SyntaxKind::Ident | SyntaxKind::MathIdent) -} - -/// Highlight a node to an HTML `code` element. -/// -/// This uses these [CSS classes for categories](Tag::css_class). -pub fn highlight_html(root: &SyntaxNode) -> String { - let mut buf = String::from(""); - let node = LinkedNode::new(root); - highlight_html_impl(&mut buf, &node); - buf.push_str(""); - buf -} - -/// Highlight one source node, emitting HTML. -fn highlight_html_impl(html: &mut String, node: &LinkedNode) { - let mut span = false; - if let Some(tag) = highlight(node) { - if tag != Tag::Error { - span = true; - html.push_str(""); - } - } - - let text = node.text(); - if !text.is_empty() { - for c in text.chars() { - match c { - '<' => html.push_str("<"), - '>' => html.push_str(">"), - '&' => html.push_str("&"), - '\'' => html.push_str("'"), - '"' => html.push_str("""), - _ => html.push(c), - } - } - } else { - for child in node.children() { - highlight_html_impl(html, &child); - } - } - - if span { - html.push_str(""); - } -} - -#[cfg(test)] -mod tests { - use std::ops::Range; - - use super::*; - use crate::syntax::parse; - - #[test] - fn test_highlighting() { - use Tag::*; - - #[track_caller] - fn test(text: &str, goal: &[(Range, Tag)]) { - let mut vec = vec![]; - let root = parse(text); - highlight_tree(&mut vec, &LinkedNode::new(&root)); - assert_eq!(vec, goal); - } - - fn highlight_tree(tags: &mut Vec<(Range, Tag)>, node: &LinkedNode) { - if let Some(tag) = highlight(node) { - tags.push((node.range(), tag)); - } - - for child in node.children() { - highlight_tree(tags, &child); - } - } - - test("= *AB*", &[(0..6, Heading), (2..6, Strong)]); - - test( - "#f(x + 1)", - &[ - (0..1, Function), - (1..2, Function), - (2..3, Punctuation), - (5..6, Operator), - (7..8, Number), - (8..9, Punctuation), - ], - ); - - test( - "#let f(x) = x", - &[ - (0..1, Keyword), - (1..4, Keyword), - (5..6, Function), - (6..7, Punctuation), - (8..9, Punctuation), - (10..11, Operator), - ], - ); - } -} diff --git a/crates/typst/src/ide/jump.rs b/crates/typst/src/ide/jump.rs deleted file mode 100644 index 34d51c07..00000000 --- a/crates/typst/src/ide/jump.rs +++ /dev/null @@ -1,170 +0,0 @@ -use std::num::NonZeroUsize; - -use ecow::EcoString; - -use crate::doc::{Destination, Frame, FrameItem, Meta, Position}; -use crate::geom::{Geometry, Point, Size}; -use crate::model::Introspector; -use crate::syntax::{FileId, LinkedNode, Source, Span, SyntaxKind}; -use crate::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 { - 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 { - 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 { - 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 { - 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/src/ide/mod.rs b/crates/typst/src/ide/mod.rs deleted file mode 100644 index 4b08b66b..00000000 --- a/crates/typst/src/ide/mod.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Capabilities for IDE support. - -mod analyze; -mod complete; -mod highlight; -mod jump; -mod tooltip; - -pub use self::analyze::analyze_labels; -pub use self::complete::{autocomplete, Completion, CompletionKind}; -pub use self::highlight::{highlight, highlight_html, Tag}; -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 self::analyze::*; -use crate::font::{FontInfo, FontStyle}; - -/// 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) -> 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 s = if count == 1 { "" } else { "s" }; - let mut detail = eco_format!("{count} variant{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/src/ide/tooltip.rs b/crates/typst/src/ide/tooltip.rs deleted file mode 100644 index b9dd17f7..00000000 --- a/crates/typst/src/ide/tooltip.rs +++ /dev/null @@ -1,246 +0,0 @@ -use std::fmt::Write; - -use ecow::{eco_format, EcoString}; - -use if_chain::if_chain; - -use super::analyze::analyze_labels; -use super::{analyze_expr, plain_docs_sentence, summarize_font_family}; -use crate::doc::Frame; -use crate::eval::{CapturesVisitor, CastInfo, Tracer, Value}; -use crate::geom::{round_2, Length, Numeric}; -use crate::syntax::ast::{self, AstNode}; -use crate::syntax::{LinkedNode, Source, SyntaxKind}; -use crate::util::{pretty_comma_list, separated_list}; -use crate::World; - -/// Describe the item under the cursor. -pub fn tooltip( - world: &dyn World, - frames: &[Frame], - 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(|| 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 { - let mut ancestor = leaf; - while !ancestor.is::() { - ancestor = ancestor.parent()?; - } - - let expr = ancestor.cast::()?; - 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 = 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 { - // Find the closure to analyze. - let mut ancestor = leaf; - while !ancestor.is::() { - ancestor = ancestor.parent()?; - } - let closure = ancestor.cast::()?.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 { - 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 { - 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 { - 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(plain_docs_sentence(param.docs))); - } - } - - // 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 -} diff --git a/crates/typst/src/lib.rs b/crates/typst/src/lib.rs index 608abbcc..24a6311e 100644 --- a/crates/typst/src/lib.rs +++ b/crates/typst/src/lib.rs @@ -46,7 +46,6 @@ pub mod doc; pub mod export; pub mod font; pub mod geom; -pub mod ide; pub mod image; pub mod model; -- cgit v1.2.3