summaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorLaurenz <laurmaedje@gmail.com>2024-11-13 12:01:38 +0100
committerLaurenz <laurmaedje@gmail.com>2024-11-13 12:03:47 +0100
commit8dbbe68527e0855b87910fe367ef29f96a670408 (patch)
treeb80403da30012f623fe1d2d298dbd1def19f90a8 /crates
parent737895d769188f6fc154523e67a9102bc24c872e (diff)
Backport IDE improvements0.12.0-with-extras
Diffstat (limited to 'crates')
-rw-r--r--crates/typst-ide/Cargo.toml1
-rw-r--r--crates/typst-ide/src/analyze.rs13
-rw-r--r--crates/typst-ide/src/complete.rs651
-rw-r--r--crates/typst-ide/src/definition.rs362
-rw-r--r--crates/typst-ide/src/jump.rs21
-rw-r--r--crates/typst-ide/src/lib.rs219
-rw-r--r--crates/typst-ide/src/matchers.rs35
-rw-r--r--crates/typst-ide/src/tests.rs184
-rw-r--r--crates/typst-ide/src/tooltip.rs102
-rw-r--r--crates/typst-ide/src/utils.rs168
-rw-r--r--crates/typst/src/lib.rs17
11 files changed, 1153 insertions, 620 deletions
diff --git a/crates/typst-ide/Cargo.toml b/crates/typst-ide/Cargo.toml
index 4e87f99b..41397df9 100644
--- a/crates/typst-ide/Cargo.toml
+++ b/crates/typst-ide/Cargo.toml
@@ -18,6 +18,7 @@ comemo = { workspace = true }
ecow = { workspace = true }
if_chain = { workspace = true }
log = { workspace = true }
+pathdiff = { workspace = true }
serde = { workspace = true }
unscanny = { workspace = true }
diff --git a/crates/typst-ide/src/analyze.rs b/crates/typst-ide/src/analyze.rs
index c3779556..6d4ba28f 100644
--- a/crates/typst-ide/src/analyze.rs
+++ b/crates/typst-ide/src/analyze.rs
@@ -6,11 +6,12 @@ use typst::foundations::{Context, Label, Scopes, Styles, Value};
use typst::introspection::Introspector;
use typst::model::{BibliographyElem, Document};
use typst::syntax::{ast, LinkedNode, Span, SyntaxKind};
-use typst::World;
+
+use crate::IdeWorld;
/// Try to determine a set of possible values for an expression.
pub fn analyze_expr(
- world: &dyn World,
+ world: &dyn IdeWorld,
node: &LinkedNode,
) -> EcoVec<(Value, Option<Styles>)> {
let Some(expr) = node.cast::<ast::Expr>() else {
@@ -38,15 +39,15 @@ pub fn analyze_expr(
}
}
- return typst::trace(world, node.span());
+ return typst::trace(world.upcast(), node.span());
}
};
eco_vec![(val, None)]
}
-/// Try to load a module from the current source file.
-pub fn analyze_import(world: &dyn World, source: &LinkedNode) -> Option<Value> {
+/// Tries to load a module from the given `source` node.
+pub fn analyze_import(world: &dyn IdeWorld, source: &LinkedNode) -> Option<Value> {
// Use span in the node for resolving imports with relative paths.
let source_span = source.span();
let (source, _) = analyze_expr(world, source).into_iter().next()?;
@@ -58,7 +59,7 @@ pub fn analyze_import(world: &dyn World, source: &LinkedNode) -> Option<Value> {
let traced = Traced::default();
let mut sink = Sink::new();
let engine = Engine {
- world: world.track(),
+ world: world.upcast().track(),
introspector: introspector.track(),
traced: traced.track(),
sink: sink.track_mut(),
diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs
index d534f55c..a2791e07 100644
--- a/crates/typst-ide/src/complete.rs
+++ b/crates/typst-ide/src/complete.rs
@@ -1,26 +1,29 @@
use std::cmp::Reverse;
-use std::collections::{BTreeSet, HashSet};
+use std::collections::{BTreeMap, HashSet};
+use std::ffi::OsStr;
use ecow::{eco_format, EcoString};
use if_chain::if_chain;
use serde::{Deserialize, Serialize};
use typst::foundations::{
- fields_on, format_str, repr, AutoValue, CastInfo, Func, Label, NoneValue, Repr,
- Scope, StyleChain, Styles, Type, Value,
+ fields_on, repr, AutoValue, CastInfo, Func, Label, NoneValue, ParamInfo, Repr,
+ StyleChain, Styles, Type, Value,
};
+use typst::layout::{Alignment, Dir};
use typst::model::Document;
+use typst::syntax::ast::AstNode;
use typst::syntax::{
- ast, is_id_continue, is_id_start, is_ident, LinkedNode, Side, Source, SyntaxKind,
+ ast, is_id_continue, is_id_start, is_ident, FileId, LinkedNode, Side, Source,
+ SyntaxKind,
};
use typst::text::RawElem;
use typst::visualize::Color;
-use typst::World;
use unscanny::Scanner;
-use crate::{
- analyze_expr, analyze_import, analyze_labels, named_items, plain_docs_sentence,
- summarize_font_family,
+use crate::utils::{
+ check_value_recursively, globals, plain_docs_sentence, summarize_font_family,
};
+use crate::{analyze_expr, analyze_import, analyze_labels, named_items, IdeWorld};
/// Autocomplete a cursor position in a source file.
///
@@ -34,13 +37,15 @@ use crate::{
/// the autocompletions. Label completions, for instance, are only generated
/// when the document is available.
pub fn autocomplete(
- world: &dyn World,
+ world: &dyn IdeWorld,
document: Option<&Document>,
source: &Source,
cursor: usize,
explicit: bool,
) -> Option<(usize, Vec<Completion>)> {
- let mut ctx = CompletionContext::new(world, document, source, cursor, explicit)?;
+ let leaf = LinkedNode::new(source.root()).leaf_at(cursor, Side::Before)?;
+ let mut ctx =
+ CompletionContext::new(world, document, source, &leaf, cursor, explicit)?;
let _ = complete_comments(&mut ctx)
|| complete_field_accesses(&mut ctx)
@@ -85,6 +90,14 @@ pub enum CompletionKind {
Param,
/// A constant.
Constant,
+ /// A file path.
+ Path,
+ /// A package.
+ Package,
+ /// A label.
+ Label,
+ /// A font family.
+ Font,
/// A symbol.
Symbol(char),
}
@@ -387,12 +400,12 @@ fn field_access_completions(
styles: &Option<Styles>,
) {
for (name, value, _) in value.ty().scope().iter() {
- ctx.value_completion(Some(name.clone()), value, true, None);
+ ctx.call_completion(name.clone(), value);
}
if let Some(scope) = value.scope() {
for (name, value, _) in scope.iter() {
- ctx.value_completion(Some(name.clone()), value, true, None);
+ ctx.call_completion(name.clone(), value);
}
}
@@ -402,12 +415,7 @@ fn field_access_completions(
// 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,
- );
+ ctx.value_completion(field, &value.field(field).unwrap());
}
match value {
@@ -425,12 +433,12 @@ fn field_access_completions(
}
Value::Content(content) => {
for (name, value) in content.fields() {
- ctx.value_completion(Some(name.into()), &value, false, None);
+ ctx.value_completion(name, &value);
}
}
Value::Dict(dict) => {
for (name, value) in dict.iter() {
- ctx.value_completion(Some(name.clone().into()), value, false, None);
+ ctx.value_completion(name.clone(), value);
}
}
Value::Func(func) => {
@@ -440,12 +448,7 @@ fn field_access_completions(
if let Some(value) = elem.field_id(param.name).and_then(|id| {
elem.field_from_styles(id, StyleChain::new(styles)).ok()
}) {
- ctx.value_completion(
- Some(param.name.into()),
- &value,
- false,
- None,
- );
+ ctx.value_completion(param.name, &value);
}
}
}
@@ -478,8 +481,8 @@ fn complete_open_labels(ctx: &mut CompletionContext) -> bool {
/// Complete imports.
fn complete_imports(ctx: &mut CompletionContext) -> bool {
- // In an import path for a package:
- // "#import "@|",
+ // In an import path for a file or package:
+ // "#import "|",
if_chain! {
if matches!(
ctx.leaf.parent_kind(),
@@ -487,11 +490,14 @@ fn complete_imports(ctx: &mut CompletionContext) -> bool {
);
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);
+ if value.starts_with('@') {
+ let all_versions = value.contains(':');
+ ctx.package_completions(all_versions);
+ } else {
+ ctx.file_completions_with_extensions(&["typ"]);
+ }
return true;
}
}
@@ -546,7 +552,7 @@ fn import_item_completions<'a>(
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);
+ ctx.value_completion(name.clone(), value);
}
}
}
@@ -650,7 +656,7 @@ fn show_rule_recipe_completions(ctx: &mut CompletionContext) {
/// 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! {
+ let (callee, set, args, args_linked) = if_chain! {
if let Some(parent) = ctx.leaf.parent();
if let Some(parent) = match parent.kind() {
SyntaxKind::Named => parent.parent(),
@@ -666,7 +672,7 @@ fn complete_params(ctx: &mut CompletionContext) -> bool {
_ => None,
};
then {
- (callee, set, args)
+ (callee, set, args, parent)
} else {
return false;
}
@@ -706,7 +712,7 @@ fn complete_params(ctx: &mut CompletionContext) -> bool {
ctx.from = ctx.cursor.min(next.offset());
}
- param_completions(ctx, callee, set, args);
+ param_completions(ctx, callee, set, args, args_linked);
return true;
}
}
@@ -720,40 +726,62 @@ fn param_completions<'a>(
callee: ast::Expr<'a>,
set: bool,
args: ast::Args<'a>,
+ args_linked: &'a LinkedNode<'a>,
) {
let Some(func) = resolve_global_callee(ctx, callee) else { return };
let Some(params) = func.params() else { return };
- // Exclude named arguments which are already present.
- let exclude: Vec<_> = args
- .items()
- .filter_map(|arg| match arg {
- ast::Arg::Named(named) => Some(named.name()),
- _ => None,
- })
- .collect();
+ // Determine which arguments are already present.
+ let mut existing_positional = 0;
+ let mut existing_named = HashSet::new();
+ for arg in args.items() {
+ match arg {
+ ast::Arg::Pos(_) => {
+ let Some(node) = args_linked.find(arg.span()) else { continue };
+ if node.range().end < ctx.cursor {
+ existing_positional += 1;
+ }
+ }
+ ast::Arg::Named(named) => {
+ existing_named.insert(named.name().as_str());
+ }
+ _ => {}
+ }
+ }
+ let mut skipped_positional = 0;
for param in params {
- if exclude.iter().any(|ident| ident.as_str() == param.name) {
+ if set && !param.settable {
continue;
}
- if set && !param.settable {
- continue;
+ if param.positional {
+ if skipped_positional < existing_positional && !param.variadic {
+ skipped_positional += 1;
+ continue;
+ }
+
+ param_value_completions(ctx, func, param);
}
if param.named {
+ if existing_named.contains(&param.name) {
+ continue;
+ }
+
+ let apply = if param.name == "caption" {
+ eco_format!("{}: [${{}}]", param.name)
+ } else {
+ eco_format!("{}: ${{}}", param.name)
+ };
+
ctx.completions.push(Completion {
kind: CompletionKind::Param,
label: param.name.into(),
- apply: Some(eco_format!("{}: ${{}}", param.name)),
+ apply: Some(apply),
detail: Some(plain_docs_sentence(param.docs)),
});
}
-
- if param.positional {
- ctx.cast_completions(&param.input);
- }
}
if ctx.before.ends_with(',') {
@@ -773,25 +801,52 @@ fn named_param_value_completions<'a>(
return;
}
- ctx.cast_completions(&param.input);
- if name == "font" {
- ctx.font_completions();
- }
+ param_value_completions(ctx, func, param);
if ctx.before.ends_with(':') {
ctx.enrich(" ", "");
}
}
+/// Add completions for the values of a parameter.
+fn param_value_completions<'a>(
+ ctx: &mut CompletionContext<'a>,
+ func: &Func,
+ param: &'a ParamInfo,
+) {
+ if param.name == "font" {
+ ctx.font_completions();
+ } else if param.name == "path" {
+ ctx.file_completions_with_extensions(match func.name() {
+ Some("image") => &["png", "jpg", "jpeg", "gif", "svg", "svgz"],
+ Some("csv") => &["csv"],
+ Some("plugin") => &["wasm"],
+ Some("cbor") => &["cbor"],
+ Some("json") => &["json"],
+ Some("toml") => &["toml"],
+ Some("xml") => &["xml"],
+ Some("yaml") => &["yml", "yaml"],
+ Some("bibliography") => &["bib", "yml", "yaml"],
+ _ => &[],
+ });
+ } else if func.name() == Some("figure") && param.name == "body" {
+ ctx.snippet_completion("image", "image(\"${}\"),", "An image in a figure.");
+ ctx.snippet_completion("table", "table(\n ${}\n),", "A table in a figure.");
+ }
+
+ ctx.cast_completions(&param.input);
+}
+
/// Resolve a callee expression to a global function.
fn resolve_global_callee<'a>(
ctx: &CompletionContext<'a>,
callee: ast::Expr<'a>,
) -> Option<&'a Func> {
+ let globals = globals(ctx.world, ctx.leaf);
let value = match callee {
- ast::Expr::Ident(ident) => ctx.global.get(&ident)?,
+ ast::Expr::Ident(ident) => globals.get(&ident)?,
ast::Expr::FieldAccess(access) => match access.target() {
- ast::Expr::Ident(target) => match ctx.global.get(&target)? {
+ ast::Expr::Ident(target) => match globals.get(&target)? {
Value::Module(module) => module.field(&access.field()).ok()?,
Value::Func(func) => func.field(&access.field()).ok()?,
_ => return None,
@@ -851,9 +906,18 @@ fn complete_code(ctx: &mut CompletionContext) -> bool {
/// Add completions for expression snippets.
#[rustfmt::skip]
fn code_completions(ctx: &mut CompletionContext, hash: bool) {
- ctx.scope_completions(true, |value| !hash || {
- matches!(value, Value::Symbol(_) | Value::Func(_) | Value::Type(_) | Value::Module(_))
- });
+ if hash {
+ ctx.scope_completions(true, |value| {
+ // If we are in markup, ignore colors, directions, and alignments.
+ // They are useless and bloat the autocomplete results.
+ let ty = value.ty();
+ ty != Type::of::<Color>()
+ && ty != Type::of::<Dir>()
+ && ty != Type::of::<Alignment>()
+ });
+ } else {
+ ctx.scope_completions(true, |_| true);
+ }
ctx.snippet_completion(
"function call",
@@ -959,25 +1023,19 @@ fn code_completions(ctx: &mut CompletionContext, hash: bool) {
ctx.snippet_completion(
"import (file)",
- "import \"${file}.typ\": ${items}",
+ "import \"${}\": ${}",
"Imports variables from another file.",
);
ctx.snippet_completion(
"import (package)",
- "import \"@${}\": ${items}",
- "Imports variables from another file.",
+ "import \"@${}\": ${}",
+ "Imports variables from a package.",
);
ctx.snippet_completion(
"include (file)",
- "include \"${file}.typ\"",
- "Includes content from another file.",
- );
-
- ctx.snippet_completion(
- "include (package)",
- "include \"@${}\"",
+ "include \"${}\"",
"Includes content from another file.",
);
@@ -1004,14 +1062,12 @@ fn code_completions(ctx: &mut CompletionContext, hash: bool) {
/// Context for autocompletion.
struct CompletionContext<'a> {
- world: &'a (dyn World + 'a),
+ world: &'a (dyn IdeWorld + 'a),
document: Option<&'a Document>,
- global: &'a Scope,
- math: &'a Scope,
text: &'a str,
before: &'a str,
after: &'a str,
- leaf: LinkedNode<'a>,
+ leaf: &'a LinkedNode<'a>,
cursor: usize,
explicit: bool,
from: usize,
@@ -1022,20 +1078,17 @@ struct CompletionContext<'a> {
impl<'a> CompletionContext<'a> {
/// Create a new autocompletion context.
fn new(
- world: &'a (dyn World + 'a),
+ world: &'a (dyn IdeWorld + 'a),
document: Option<&'a Document>,
source: &'a Source,
+ leaf: &'a LinkedNode<'a>,
cursor: usize,
explicit: bool,
) -> Option<Self> {
let text = source.text();
- let library = world.library();
- let leaf = LinkedNode::new(source.root()).leaf_at(cursor, Side::Before)?;
Some(Self {
world,
document,
- global: library.global.scope(),
- math: library.math.scope(),
text,
before: &text[..cursor],
after: &text[cursor..],
@@ -1050,7 +1103,7 @@ impl<'a> CompletionContext<'a> {
/// A small window of context before the cursor.
fn before_window(&self, size: usize) -> &str {
- Scanner::new(self.before).from(self.cursor.saturating_sub(size))
+ Scanner::new(self.before).get(self.cursor.saturating_sub(size)..self.cursor)
}
/// Add a prefix and suffix to all applications.
@@ -1082,10 +1135,9 @@ impl<'a> CompletionContext<'a> {
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,
+ self.str_completion(
+ family,
+ Some(CompletionKind::Font),
Some(detail.as_str()),
);
}
@@ -1102,15 +1154,58 @@ impl<'a> CompletionContext<'a> {
packages.dedup_by_key(|(spec, _)| (&spec.namespace, &spec.name));
}
for (package, description) in packages {
- self.value_completion(
- None,
- &Value::Str(format_str!("{package}")),
- false,
+ self.str_completion(
+ eco_format!("{package}"),
+ Some(CompletionKind::Package),
description.as_deref(),
);
}
}
+ /// Add completions for all available files.
+ fn file_completions(&mut self, mut filter: impl FnMut(FileId) -> bool) {
+ let Some(base_id) = self.leaf.span().id() else { return };
+ let Some(base_path) = base_id.vpath().as_rooted_path().parent() else { return };
+
+ let mut paths: Vec<EcoString> = self
+ .world
+ .files()
+ .iter()
+ .filter(|&&file_id| file_id != base_id && filter(file_id))
+ .filter_map(|file_id| {
+ let file_path = file_id.vpath().as_rooted_path();
+ pathdiff::diff_paths(file_path, base_path)
+ })
+ .map(|path| path.to_string_lossy().replace('\\', "/").into())
+ .collect();
+
+ paths.sort();
+
+ for path in paths {
+ self.str_completion(path, Some(CompletionKind::Path), None);
+ }
+ }
+
+ /// Add completions for all files with any of the given extensions.
+ ///
+ /// If the array is empty, all extensions are allowed.
+ fn file_completions_with_extensions(&mut self, extensions: &[&str]) {
+ if extensions.is_empty() {
+ self.file_completions(|_| true);
+ }
+ self.file_completions(|id| {
+ let ext = id
+ .vpath()
+ .as_rooted_path()
+ .extension()
+ .and_then(OsStr::to_str)
+ .map(EcoString::from)
+ .unwrap_or_default()
+ .to_lowercase();
+ extensions.contains(&ext.as_str())
+ });
+ }
+
/// Add completions for raw block tags.
fn raw_completions(&mut self) {
for (name, mut tags) in RawElem::languages() {
@@ -1154,7 +1249,7 @@ impl<'a> CompletionContext<'a> {
for (label, detail) in labels.into_iter().skip(skip).take(take) {
self.completions.push(Completion {
- kind: CompletionKind::Constant,
+ kind: CompletionKind::Label,
apply: (open || close).then(|| {
eco_format!(
"{}{}{}",
@@ -1169,18 +1264,40 @@ impl<'a> CompletionContext<'a> {
}
}
+ /// Add a completion for an arbitrary value.
+ fn value_completion(&mut self, label: impl Into<EcoString>, value: &Value) {
+ self.value_completion_full(Some(label.into()), value, false, None, None);
+ }
+
+ /// Add a completion for an arbitrary value, adding parentheses if it's a function.
+ fn call_completion(&mut self, label: impl Into<EcoString>, value: &Value) {
+ self.value_completion_full(Some(label.into()), value, true, None, None);
+ }
+
+ /// Add a completion for a specific string literal.
+ fn str_completion(
+ &mut self,
+ string: impl Into<EcoString>,
+ kind: Option<CompletionKind>,
+ detail: Option<&str>,
+ ) {
+ let string = string.into();
+ self.value_completion_full(None, &Value::Str(string.into()), false, kind, detail);
+ }
+
/// Add a completion for a specific value.
- fn value_completion(
+ fn value_completion_full(
&mut self,
label: Option<EcoString>,
value: &Value,
parens: bool,
- docs: Option<&str>,
+ kind: Option<CompletionKind>,
+ detail: Option<&str>,
) {
let at = label.as_deref().is_some_and(|field| !is_ident(field));
let label = label.unwrap_or_else(|| value.repr());
- let detail = docs.map(Into::into).or_else(|| match value {
+ let detail = detail.map(Into::into).or_else(|| match value {
Value::Symbol(_) => None,
Value::Func(func) => func.docs().map(plain_docs_sentence),
Value::Type(ty) => Some(plain_docs_sentence(ty.docs())),
@@ -1191,16 +1308,17 @@ impl<'a> CompletionContext<'a> {
});
let mut apply = None;
- if parens && matches!(value, Value::Func(_)) {
+ if parens
+ && matches!(value, Value::Func(_))
+ && !self.after.starts_with(['(', '['])
+ {
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}(${{}})"));
- }
+ apply = Some(match BracketMode::of(func) {
+ BracketMode::RoundAfter => eco_format!("{label}()${{}}"),
+ BracketMode::RoundWithin => eco_format!("{label}(${{}})"),
+ BracketMode::RoundNewline => eco_format!("{label}(\n ${{}}\n)"),
+ BracketMode::SquareWithin => eco_format!("{label}[${{}}]"),
+ });
}
} else if at {
apply = Some(eco_format!("at(\"{label}\")"));
@@ -1211,12 +1329,12 @@ impl<'a> CompletionContext<'a> {
}
self.completions.push(Completion {
- kind: match value {
+ kind: kind.unwrap_or_else(|| match value {
Value::Func(_) => CompletionKind::Func,
Value::Type(_) => CompletionKind::Type,
Value::Symbol(s) => CompletionKind::Symbol(s.get()),
_ => CompletionKind::Constant,
- },
+ }),
label,
apply,
detail,
@@ -1233,7 +1351,7 @@ impl<'a> CompletionContext<'a> {
match cast {
CastInfo::Any => {}
CastInfo::Value(value, docs) => {
- self.value_completion(None, value, true, Some(docs));
+ self.value_completion_full(None, value, false, None, Some(docs));
}
CastInfo::Type(ty) => {
if *ty == Type::of::<NoneValue>() {
@@ -1315,92 +1433,315 @@ impl<'a> CompletionContext<'a> {
///
/// 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();
- named_items(self.world, self.leaf.clone(), |name| {
- if name.value().as_ref().map_or(true, &filter) {
- defined.insert(name.name().clone());
+ // When any of the constituent parts of the value matches the filter,
+ // that's ok as well. For example, when autocompleting `#rect(fill: |)`,
+ // we propose colors, but also dictionaries and modules that contain
+ // colors.
+ let filter = |value: &Value| check_value_recursively(value, &filter);
+
+ let mut defined = BTreeMap::<EcoString, Option<Value>>::new();
+ named_items(self.world, self.leaf.clone(), |item| {
+ let name = item.name();
+ if !name.is_empty() && item.value().as_ref().map_or(true, filter) {
+ defined.insert(name.clone(), item.value());
}
+
None::<()>
});
- 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() {
+ for (name, value) in &defined {
+ if let Some(value) = value {
+ self.value_completion(name.clone(), value);
+ } else {
self.completions.push(Completion {
kind: CompletionKind::Constant,
- label: name,
+ label: name.clone(),
apply: None,
detail: None,
});
}
}
+
+ for (name, value, _) in globals(self.world, self.leaf).iter() {
+ if filter(value) && !defined.contains_key(name) {
+ self.value_completion_full(Some(name.clone()), value, parens, None, None);
+ }
+ }
+ }
+}
+
+/// What kind of parentheses to autocomplete for a function.
+enum BracketMode {
+ /// Round parenthesis, with the cursor within: `(|)`.
+ RoundWithin,
+ /// Round parenthesis, with the cursor after them: `()|`.
+ RoundAfter,
+ /// Round parenthesis, with newlines and indent.
+ RoundNewline,
+ /// Square brackets, with the cursor within: `[|]`.
+ SquareWithin,
+}
+
+impl BracketMode {
+ fn of(func: &Func) -> Self {
+ if func
+ .params()
+ .is_some_and(|params| params.iter().all(|param| param.name == "self"))
+ {
+ return Self::RoundAfter;
+ }
+
+ match func.name() {
+ Some(
+ "emph" | "footnote" | "quote" | "strong" | "highlight" | "overline"
+ | "underline" | "smallcaps" | "strike" | "sub" | "super",
+ ) => Self::SquareWithin,
+ Some("colbreak" | "parbreak" | "linebreak" | "pagebreak") => Self::RoundAfter,
+ Some("figure" | "table" | "grid" | "stack") => Self::RoundNewline,
+ _ => Self::RoundWithin,
+ }
}
}
#[cfg(test)]
mod tests {
+ use std::collections::BTreeSet;
+
+ use typst::model::Document;
+ use typst::syntax::{FileId, Source, VirtualPath};
+ use typst::World;
+
+ use super::{autocomplete, Completion};
+ use crate::tests::{SourceExt, TestWorld};
- use super::autocomplete;
- use crate::tests::TestWorld;
+ /// Quote a string.
+ macro_rules! q {
+ ($s:literal) => {
+ concat!("\"", $s, "\"")
+ };
+ }
+
+ type Response = Option<(usize, Vec<Completion>)>;
+
+ trait ResponseExt {
+ fn completions(&self) -> &[Completion];
+ fn labels(&self) -> BTreeSet<&str>;
+ fn must_include<'a>(&self, includes: impl IntoIterator<Item = &'a str>) -> &Self;
+ fn must_exclude<'a>(&self, excludes: impl IntoIterator<Item = &'a str>) -> &Self;
+ fn must_apply<'a>(&self, label: &str, apply: impl Into<Option<&'a str>>)
+ -> &Self;
+ }
+
+ impl ResponseExt for Response {
+ fn completions(&self) -> &[Completion] {
+ match self {
+ Some((_, completions)) => completions.as_slice(),
+ None => &[],
+ }
+ }
+
+ fn labels(&self) -> BTreeSet<&str> {
+ self.completions().iter().map(|c| c.label.as_str()).collect()
+ }
+
+ #[track_caller]
+ fn must_include<'a>(&self, includes: impl IntoIterator<Item = &'a str>) -> &Self {
+ let labels = self.labels();
+ for item in includes {
+ assert!(
+ labels.contains(item),
+ "{item:?} was not contained in {labels:?}",
+ );
+ }
+ self
+ }
+
+ #[track_caller]
+ fn must_exclude<'a>(&self, excludes: impl IntoIterator<Item = &'a str>) -> &Self {
+ let labels = self.labels();
+ for item in excludes {
+ assert!(
+ !labels.contains(item),
+ "{item:?} was wrongly contained in {labels:?}",
+ );
+ }
+ self
+ }
+
+ #[track_caller]
+ fn must_apply<'a>(
+ &self,
+ label: &str,
+ apply: impl Into<Option<&'a str>>,
+ ) -> &Self {
+ let Some(completion) = self.completions().iter().find(|c| c.label == label)
+ else {
+ panic!("found no completion for {label:?}");
+ };
+ assert_eq!(completion.apply.as_deref(), apply.into());
+ self
+ }
+ }
#[track_caller]
- fn test(text: &str, cursor: usize, contains: &[&str], excludes: &[&str]) {
+ fn test(text: &str, cursor: isize) -> Response {
let world = TestWorld::new(text);
+ test_with_world(&world, cursor)
+ }
+
+ #[track_caller]
+ fn test_with_world(world: &TestWorld, cursor: isize) -> Response {
let doc = typst::compile(&world).output.ok();
- let (_, completions) =
- autocomplete(&world, doc.as_ref(), &world.main, cursor, true)
- .unwrap_or_default();
+ test_full(world, &world.main, doc.as_ref(), cursor)
+ }
- let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect();
- for item in contains {
- assert!(labels.contains(item), "{item:?} was not contained in {labels:?}");
- }
- for item in excludes {
- assert!(!labels.contains(item), "{item:?} was not excluded in {labels:?}");
- }
+ #[track_caller]
+ fn test_with_path(world: &TestWorld, path: &str, cursor: isize) -> Response {
+ let doc = typst::compile(&world).output.ok();
+ let id = FileId::new(None, VirtualPath::new(path));
+ let source = world.source(id).unwrap();
+ test_full(world, &source, doc.as_ref(), cursor)
+ }
+
+ #[track_caller]
+ fn test_full(
+ world: &TestWorld,
+ source: &Source,
+ doc: Option<&Document>,
+ cursor: isize,
+ ) -> Response {
+ autocomplete(world, doc, source, source.cursor(cursor), true)
}
#[test]
- fn test_autocomplete() {
- test("#i", 2, &["int", "if conditional"], &["foo"]);
- test("#().", 4, &["insert", "remove", "len", "all"], &["foo"]);
+ fn test_autocomplete_hash_expr() {
+ test("#i", 2).must_include(["int", "if conditional"]);
}
#[test]
- fn test_autocomplete_whitespace() {
- //Check that extra space before '.' is handled correctly.
- test("#() .", 5, &[], &["insert", "remove", "len", "all"]);
- test("#{() .}", 6, &["insert", "remove", "len", "all"], &["foo"]);
+ fn test_autocomplete_array_method() {
+ test("#().", 4).must_include(["insert", "remove", "len", "all"]);
+ test("#{ let x = (1, 2, 3); x. }", -2).must_include(["at", "push", "pop"]);
+ }
- test("#() .a", 6, &[], &["insert", "remove", "len", "all"]);
- test("#{() .a}", 7, &["at", "any", "all"], &["foo"]);
+ /// Test that extra space before '.' is handled correctly.
+ #[test]
+ fn test_autocomplete_whitespace() {
+ test("#() .", 5).must_exclude(["insert", "remove", "len", "all"]);
+ test("#{() .}", 6).must_include(["insert", "remove", "len", "all"]);
+ test("#() .a", 6).must_exclude(["insert", "remove", "len", "all"]);
+ test("#{() .a}", 7).must_include(["at", "any", "all"]);
}
+ /// Test that the `before_window` doesn't slice into invalid byte
+ /// boundaries.
#[test]
fn test_autocomplete_before_window_char_boundary() {
- // Check that the `before_window` doesn't slice into invalid byte
- // boundaries.
- let s = "😀😀 #text(font: \"\")";
- test(s, s.len() - 2, &[], &[]);
+ test("😀😀 #text(font: \"\")", -2);
+ }
+
+ /// Ensure that autocompletion for `#cite(|)` completes bibligraphy labels,
+ /// but no other labels.
+ #[test]
+ fn test_autocomplete_cite_function() {
+ // First compile a working file to get a document.
+ let mut world =
+ TestWorld::new("#bibliography(\"works.bib\") <bib>").with_asset("works.bib");
+ let doc = typst::compile(&world).output.ok();
+
+ // Then, add the invalid `#cite` call. Had the document been invalid
+ // initially, we would have no populated document to autocomplete with.
+ let end = world.main.len_bytes();
+ world.main.edit(end..end, " #cite()");
+
+ test_full(&world, &world.main, doc.as_ref(), -1)
+ .must_include(["netwok", "glacier-melt", "supplement"])
+ .must_exclude(["bib"]);
}
+ /// Test what kind of brackets we autocomplete for function calls depending
+ /// on the function and existing parens.
#[test]
- fn test_autocomplete_mutable_method() {
- let s = "#{ let x = (1, 2, 3); x. }";
- test(s, s.len() - 2, &["at", "push", "pop"], &[]);
+ fn test_autocomplete_bracket_mode() {
+ test("#", 1).must_apply("list", "list(${})");
+ test("#", 1).must_apply("linebreak", "linebreak()${}");
+ test("#", 1).must_apply("strong", "strong[${}]");
+ test("#", 1).must_apply("footnote", "footnote[${}]");
+ test("#", 1).must_apply("figure", "figure(\n ${}\n)");
+ test("#", 1).must_apply("table", "table(\n ${}\n)");
+ test("#()", 1).must_apply("list", None);
+ test("#[]", 1).must_apply("strong", None);
+ }
+
+ /// Test that we only complete positional parameters if they aren't
+ /// already present.
+ #[test]
+ fn test_autocomplete_positional_param() {
+ // No string given yet.
+ test("#numbering()", -1).must_include(["string", "integer"]);
+ // String is already given.
+ test("#numbering(\"foo\", )", -1)
+ .must_include(["integer"])
+ .must_exclude(["string"]);
+ // Integer is already given, but numbering is variadic.
+ test("#numbering(\"foo\", 1, )", -1)
+ .must_include(["integer"])
+ .must_exclude(["string"]);
+ }
+
+ /// Test that autocompletion for values of known type picks up nested
+ /// values.
+ #[test]
+ fn test_autocomplete_value_filter() {
+ let world = TestWorld::new("#import \"design.typ\": clrs; #rect(fill: )")
+ .with_source(
+ "design.typ",
+ "#let clrs = (a: red, b: blue); #let nums = (a: 1, b: 2)",
+ );
+
+ test_with_world(&world, -1)
+ .must_include(["clrs", "aqua"])
+ .must_exclude(["nums", "a", "b"]);
+ }
+
+ #[test]
+ fn test_autocomplete_packages() {
+ test("#import \"@\"", -1).must_include([q!("@preview/example:0.1.0")]);
+ }
+
+ #[test]
+ fn test_autocomplete_file_path() {
+ let world = TestWorld::new("#include \"\"")
+ .with_source("utils.typ", "")
+ .with_source("content/a.typ", "#image()")
+ .with_source("content/b.typ", "#csv(\"\")")
+ .with_source("content/c.typ", "#include \"\"")
+ .with_asset_at("assets/tiger.jpg", "tiger.jpg")
+ .with_asset_at("assets/rhino.png", "rhino.png")
+ .with_asset_at("data/example.csv", "example.csv");
+
+ test_with_path(&world, "main.typ", -1)
+ .must_include([q!("content/a.typ"), q!("content/b.typ"), q!("utils.typ")])
+ .must_exclude([q!("assets/tiger.jpg")]);
+
+ test_with_path(&world, "content/c.typ", -1)
+ .must_include([q!("../main.typ"), q!("a.typ"), q!("b.typ")])
+ .must_exclude([q!("c.typ")]);
+
+ test_with_path(&world, "content/a.typ", -1)
+ .must_include([q!("../assets/tiger.jpg"), q!("../assets/rhino.png")])
+ .must_exclude([q!("../data/example.csv"), q!("b.typ")]);
+
+ test_with_path(&world, "content/b.typ", -2)
+ .must_include([q!("../data/example.csv")]);
+ }
+
+ #[test]
+ fn test_autocomplete_figure_snippets() {
+ test("#figure()", -1)
+ .must_apply("image", "image(\"${}\"),")
+ .must_apply("table", "table(\n ${}\n),");
+
+ test("#figure(cap)", -1).must_apply("caption", "caption: [${}]");
}
}
diff --git a/crates/typst-ide/src/definition.rs b/crates/typst-ide/src/definition.rs
index 4323226d..1ef8c45e 100644
--- a/crates/typst-ide/src/definition.rs
+++ b/crates/typst-ide/src/definition.rs
@@ -1,11 +1,21 @@
-use ecow::EcoString;
-use typst::foundations::{Label, Module, Selector, Value};
+use typst::foundations::{Label, Selector, Value};
use typst::model::Document;
-use typst::syntax::ast::AstNode;
-use typst::syntax::{ast, LinkedNode, Side, Source, Span, SyntaxKind};
-use typst::World;
+use typst::syntax::{ast, LinkedNode, Side, Source, Span};
-use crate::{analyze_import, deref_target, named_items, DerefTarget, NamedItem};
+use crate::utils::globals;
+use crate::{
+ analyze_expr, analyze_import, deref_target, named_items, DerefTarget, IdeWorld,
+ NamedItem,
+};
+
+/// A definition of some item.
+#[derive(Debug, Clone)]
+pub enum Definition {
+ /// The item is defined at the given span.
+ Span(Span),
+ /// The item is defined in the standard library.
+ Std(Value),
+}
/// Find the definition of the item under the cursor.
///
@@ -13,7 +23,7 @@ use crate::{analyze_import, deref_target, named_items, DerefTarget, NamedItem};
/// the definition search. Label definitions, for instance, are only generated
/// when the document is available.
pub fn definition(
- world: &dyn World,
+ world: &dyn IdeWorld,
document: Option<&Document>,
source: &Source,
cursor: usize,
@@ -22,241 +32,163 @@ pub fn definition(
let root = LinkedNode::new(source.root());
let leaf = root.leaf_at(cursor, side)?;
- let mut use_site = match deref_target(leaf.clone())? {
- DerefTarget::VarAccess(node) | DerefTarget::Callee(node) => node,
- DerefTarget::IncludePath(path) | DerefTarget::ImportPath(path) => {
- let import_item =
- analyze_import(world, &path).and_then(|v| v.cast::<Module>().ok())?;
- return Some(Definition::module(&import_item, path.span(), Span::detached()));
+ match deref_target(leaf.clone())? {
+ // Try to find a named item (defined in this file or an imported file)
+ // or fall back to a standard library item.
+ DerefTarget::VarAccess(node) | DerefTarget::Callee(node) => {
+ let name = node.cast::<ast::Ident>()?.get().clone();
+ if let Some(src) = named_items(world, node.clone(), |item: NamedItem| {
+ (*item.name() == name).then(|| Definition::Span(item.span()))
+ }) {
+ return Some(src);
+ };
+
+ if let Some((value, _)) = analyze_expr(world, &node).first() {
+ let span = match value {
+ Value::Content(content) => content.span(),
+ Value::Func(func) => func.span(),
+ _ => Span::detached(),
+ };
+ if !span.is_detached() && span != node.span() {
+ return Some(Definition::Span(span));
+ }
+ }
+
+ if let Some(value) = globals(world, &leaf).get(&name) {
+ return Some(Definition::Std(value.clone()));
+ }
}
- DerefTarget::Ref(r) => {
- let label = Label::new(r.cast::<ast::Ref>()?.target());
- let sel = Selector::Label(label);
- let elem = document?.introspector.query_first(&sel)?;
- let span = elem.span();
- return Some(Definition {
- kind: DefinitionKind::Label,
- name: label.as_str().into(),
- value: Some(Value::Label(label)),
- span,
- name_span: Span::detached(),
- });
+
+ // Try to jump to the an imported file or package.
+ DerefTarget::ImportPath(node) | DerefTarget::IncludePath(node) => {
+ let Some(Value::Module(module)) = analyze_import(world, &node) else {
+ return None;
+ };
+ let id = module.file_id()?;
+ let source = world.source(id).ok()?;
+ let span = source.root().span();
+ return Some(Definition::Span(span));
}
- DerefTarget::Label(..) | DerefTarget::Code(..) => {
- return None;
+
+ // Try to jump to the referenced content.
+ DerefTarget::Ref(node) => {
+ let label = Label::new(node.cast::<ast::Ref>()?.target());
+ let selector = Selector::Label(label);
+ let elem = document?.introspector.query_first(&selector)?;
+ return Some(Definition::Span(elem.span()));
}
- };
- let mut has_path = false;
- while let Some(node) = use_site.cast::<ast::FieldAccess>() {
- has_path = true;
- use_site = use_site.find(node.target().span())?;
+ _ => {}
}
- let name = use_site.cast::<ast::Ident>()?.get().clone();
- let src = named_items(world, use_site, |item: NamedItem| {
- if *item.name() != name {
- return None;
- }
+ None
+}
- match item {
- NamedItem::Var(name) => {
- let name_span = name.span();
- let span = find_let_binding(source, name_span);
- Some(Definition::item(name.get().clone(), span, name_span, None))
- }
- NamedItem::Fn(name) => {
- let name_span = name.span();
- let span = find_let_binding(source, name_span);
- Some(
- Definition::item(name.get().clone(), span, name_span, None)
- .with_kind(DefinitionKind::Function),
- )
- }
- NamedItem::Module(item, site) => Some(Definition::module(
- item,
- site.span(),
- matches!(site.kind(), SyntaxKind::Ident)
- .then_some(site.span())
- .unwrap_or_else(Span::detached),
- )),
- NamedItem::Import(name, name_span, value) => Some(Definition::item(
- name.clone(),
- Span::detached(),
- name_span,
- value.cloned(),
- )),
- }
- });
+#[cfg(test)]
+mod tests {
+ use std::ops::Range;
- let src = src.or_else(|| {
- let in_math = matches!(
- leaf.parent_kind(),
- Some(SyntaxKind::Equation)
- | Some(SyntaxKind::Math)
- | Some(SyntaxKind::MathFrac)
- | Some(SyntaxKind::MathAttach)
- );
+ use typst::foundations::{IntoValue, NativeElement};
+ use typst::syntax::Side;
+ use typst::WorldExt;
- let library = world.library();
- let scope = if in_math { library.math.scope() } else { library.global.scope() };
- for (item_name, value, span) in scope.iter() {
- if *item_name == name {
- return Some(Definition::item(
- name,
- span,
- Span::detached(),
- Some(value.clone()),
- ));
- }
- }
+ use super::{definition, Definition};
+ use crate::tests::{SourceExt, TestWorld};
- None
- })?;
+ type Response = (TestWorld, Option<Definition>);
- (!has_path).then_some(src)
-}
+ trait ResponseExt {
+ fn must_be_at(&self, path: &str, range: Range<usize>) -> &Self;
+ fn must_be_value(&self, value: impl IntoValue) -> &Self;
+ }
-/// A definition of some item.
-#[derive(Debug, Clone)]
-pub struct Definition {
- /// The name of the definition.
- pub name: EcoString,
- /// The kind of the definition.
- pub kind: DefinitionKind,
- /// An instance of the definition, if available.
- pub value: Option<Value>,
- /// The source span of the entire definition. May be detached if unknown.
- pub span: Span,
- /// The span of the definition's name. May be detached if unknown.
- pub name_span: Span,
-}
+ impl ResponseExt for Response {
+ #[track_caller]
+ fn must_be_at(&self, path: &str, expected: Range<usize>) -> &Self {
+ match self.1 {
+ Some(Definition::Span(span)) => {
+ let range = self.0.range(span);
+ assert_eq!(
+ span.id().unwrap().vpath().as_rootless_path().to_string_lossy(),
+ path
+ );
+ assert_eq!(range, Some(expected));
+ }
+ _ => panic!("expected span definition"),
+ }
+ self
+ }
-impl Definition {
- fn item(name: EcoString, span: Span, name_span: Span, value: Option<Value>) -> Self {
- Self {
- name,
- kind: match value {
- Some(Value::Func(_)) => DefinitionKind::Function,
- _ => DefinitionKind::Variable,
- },
- value,
- span,
- name_span,
+ #[track_caller]
+ fn must_be_value(&self, expected: impl IntoValue) -> &Self {
+ match &self.1 {
+ Some(Definition::Std(value)) => {
+ assert_eq!(*value, expected.into_value())
+ }
+ _ => panic!("expected std definition"),
+ }
+ self
}
}
- fn module(module: &Module, span: Span, name_span: Span) -> Self {
- Definition {
- name: module.name().clone(),
- kind: DefinitionKind::Module,
- value: Some(Value::Module(module.clone())),
- span,
- name_span,
- }
+ #[track_caller]
+ fn test(text: &str, cursor: isize, side: Side) -> Response {
+ let world = TestWorld::new(text);
+ test_with_world(world, cursor, side)
}
- fn with_kind(self, kind: DefinitionKind) -> Self {
- Self { kind, ..self }
+ #[track_caller]
+ fn test_with_world(world: TestWorld, cursor: isize, side: Side) -> Response {
+ let doc = typst::compile(&world).output.ok();
+ let source = &world.main;
+ let def = definition(&world, doc.as_ref(), source, source.cursor(cursor), side);
+ (world, def)
}
-}
-/// A kind of item that is definition.
-#[derive(Debug, Clone, PartialEq, Hash)]
-pub enum DefinitionKind {
- /// ```plain
- /// let foo;
- /// ^^^^^^^^ span
- /// ^^^ name_span
- /// ```
- Variable,
- /// ```plain
- /// let foo(it) = it;
- /// ^^^^^^^^^^^^^^^^^ span
- /// ^^^ name_span
- /// ```
- Function,
- /// Case 1
- /// ```plain
- /// import "foo.typ": *
- /// ^^^^^^^^^ span
- /// name_span is detached
- /// ```
- ///
- /// Case 2
- /// ```plain
- /// import "foo.typ" as bar: *
- /// span ^^^
- /// name_span ^^^
- /// ```
- Module,
- /// ```plain
- /// <foo>
- /// ^^^^^ span
- /// name_span is detached
- /// ```
- Label,
-}
+ #[test]
+ fn test_definition_let() {
+ test("#let x; #x", 9, Side::After).must_be_at("main.typ", 5..6);
+ test("#let x() = {}; #x", 16, Side::After).must_be_at("main.typ", 5..6);
+ }
-fn find_let_binding(source: &Source, name_span: Span) -> Span {
- let node = LinkedNode::new(source.root());
- std::iter::successors(node.find(name_span).as_ref(), |n| n.parent())
- .find(|n| matches!(n.kind(), SyntaxKind::LetBinding))
- .map(|s| s.span())
- .unwrap_or_else(Span::detached)
-}
+ #[test]
+ fn test_definition_field_access_function() {
+ let world = TestWorld::new("#import \"other.typ\"; #other.foo")
+ .with_source("other.typ", "#let foo(x) = x + 1");
-#[cfg(test)]
-mod tests {
- use std::ops::Range;
+ // The span is at the args here because that's what the function value's
+ // span is. Not ideal, but also not too big of a big deal.
+ test_with_world(world, -1, Side::Before).must_be_at("other.typ", 8..11);
+ }
- use typst::foundations::{IntoValue, Label, NativeElement, Value};
- use typst::syntax::Side;
- use typst::WorldExt;
+ #[test]
+ fn test_definition_cross_file() {
+ let world = TestWorld::new("#import \"other.typ\": x; #x")
+ .with_source("other.typ", "#let x = 1");
+ test_with_world(world, -1, Side::After).must_be_at("other.typ", 5..6);
+ }
+
+ #[test]
+ fn test_definition_import() {
+ let world = TestWorld::new("#import \"other.typ\" as o: x")
+ .with_source("other.typ", "#let x = 1");
+ test_with_world(world, 14, Side::Before).must_be_at("other.typ", 0..10);
+ }
- use super::{definition, DefinitionKind as Kind};
- use crate::tests::TestWorld;
+ #[test]
+ fn test_definition_include() {
+ let world = TestWorld::new("#include \"other.typ\"")
+ .with_source("other.typ", "Hello there");
+ test_with_world(world, 14, Side::Before).must_be_at("other.typ", 0..11);
+ }
- #[track_caller]
- fn test<T>(
- text: &str,
- cursor: usize,
- name: &str,
- kind: Kind,
- value: Option<T>,
- range: Option<Range<usize>>,
- ) where
- T: IntoValue,
- {
- let world = TestWorld::new(text);
- let doc = typst::compile(&world).output.ok();
- let actual = definition(&world, doc.as_ref(), &world.main, cursor, Side::After)
- .map(|d| (d.kind, d.name, world.range(d.span), d.value));
- assert_eq!(
- actual,
- Some((kind, name.into(), range, value.map(IntoValue::into_value)))
- );
+ #[test]
+ fn test_definition_ref() {
+ test("#figure[] <hi> See @hi", 21, Side::After).must_be_at("main.typ", 1..9);
}
#[test]
- fn test_definition() {
- test("#let x; #x", 9, "x", Kind::Variable, None::<Value>, Some(1..6));
- test("#let x() = {}; #x", 16, "x", Kind::Function, None::<Value>, Some(1..13));
- test(
- "#table",
- 1,
- "table",
- Kind::Function,
- Some(typst::model::TableElem::elem()),
- None,
- );
- test(
- "#figure[] <hi> See @hi",
- 21,
- "hi",
- Kind::Label,
- Some(Label::new("hi")),
- Some(1..9),
- );
+ fn test_definition_std() {
+ test("#table", 1, Side::After).must_be_value(typst::model::TableElem::elem());
}
}
diff --git a/crates/typst-ide/src/jump.rs b/crates/typst-ide/src/jump.rs
index e48db986..2dd5cf61 100644
--- a/crates/typst-ide/src/jump.rs
+++ b/crates/typst-ide/src/jump.rs
@@ -4,13 +4,15 @@ use typst::layout::{Frame, FrameItem, Point, Position, Size};
use typst::model::{Destination, Document, Url};
use typst::syntax::{FileId, LinkedNode, Side, Source, Span, SyntaxKind};
use typst::visualize::Geometry;
-use typst::World;
+use typst::WorldExt;
+
+use crate::IdeWorld;
/// 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 a position in a file.
+ File(FileId, usize),
/// Jump to an external URL.
Url(Url),
/// Jump to a point on a page.
@@ -18,17 +20,16 @@ pub enum Jump {
}
impl Jump {
- fn from_span(world: &dyn World, span: Span) -> Option<Self> {
+ fn from_span(world: &dyn IdeWorld, span: Span) -> Option<Self> {
let id = span.id()?;
- let source = world.source(id).ok()?;
- let node = source.find(span)?;
- Some(Self::Source(id, node.offset()))
+ let offset = world.range(span)?.start;
+ Some(Self::File(id, offset))
}
}
/// Determine where to jump to based on a click in a frame.
pub fn jump_from_click(
- world: &dyn World,
+ world: &dyn IdeWorld,
document: &Document,
frame: &Frame,
click: Point,
@@ -82,7 +83,7 @@ pub fn jump_from_click(
} else {
node.offset()
};
- return Some(Jump::Source(source.id(), pos));
+ return Some(Jump::File(source.id(), pos));
}
pos.x += width;
@@ -193,7 +194,7 @@ mod tests {
}
fn cursor(cursor: usize) -> Option<Jump> {
- Some(Jump::Source(TestWorld::main_id(), cursor))
+ Some(Jump::File(TestWorld::main_id(), cursor))
}
fn pos(page: usize, x: f64, y: f64) -> Option<Position> {
diff --git a/crates/typst-ide/src/lib.rs b/crates/typst-ide/src/lib.rs
index 63ba6f75..c0edcce9 100644
--- a/crates/typst-ide/src/lib.rs
+++ b/crates/typst-ide/src/lib.rs
@@ -6,199 +6,48 @@ mod definition;
mod jump;
mod matchers;
mod tooltip;
+mod utils;
pub use self::analyze::{analyze_expr, analyze_import, analyze_labels};
pub use self::complete::{autocomplete, Completion, CompletionKind};
-pub use self::definition::{definition, Definition, DefinitionKind};
+pub use self::definition::{definition, Definition};
pub use self::jump::{jump_from_click, jump_from_cursor, Jump};
pub use self::matchers::{deref_target, named_items, DerefTarget, NamedItem};
pub use self::tooltip::{tooltip, Tooltip};
-use std::fmt::Write;
-
-use ecow::{eco_format, EcoString};
-use typst::text::{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<Item = &'a FontInfo>) -> EcoString {
- let mut infos: Vec<_> = variants.collect();
- infos.sort_by_key(|info| info.variant);
-
- let mut has_italic = false;
- let mut min_weight = u16::MAX;
- let mut max_weight = 0;
- for info in &infos {
- let weight = info.variant.weight.to_number();
- has_italic |= info.variant.style == FontStyle::Italic;
- min_weight = min_weight.min(weight);
- max_weight = min_weight.max(weight);
- }
-
- let count = infos.len();
- let mut detail = eco_format!("{count} variant{}.", if count == 1 { "" } else { "s" });
-
- if min_weight == max_weight {
- write!(detail, " Weight {min_weight}.").unwrap();
- } else {
- write!(detail, " Weights {min_weight}–{max_weight}.").unwrap();
+use ecow::EcoString;
+use typst::syntax::package::PackageSpec;
+use typst::syntax::FileId;
+use typst::World;
+
+/// Extends the `World` for IDE functionality.
+pub trait IdeWorld: World {
+ /// Turn this into a normal [`World`].
+ ///
+ /// This is necessary because trait upcasting is experimental in Rust.
+ /// See <https://github.com/rust-lang/rust/issues/65991>.
+ ///
+ /// Implementors can simply return `self`.
+ fn upcast(&self) -> &dyn World;
+
+ /// A list of all available packages and optionally descriptions for them.
+ ///
+ /// This function is **optional** to implement. It enhances the user
+ /// experience by enabling autocompletion for packages. Details about
+ /// packages from the `@preview` namespace are available from
+ /// `https://packages.typst.org/preview/index.json`.
+ fn packages(&self) -> &[(PackageSpec, Option<EcoString>)] {
+ &[]
+ }
+
+ /// Returns a list of all known files.
+ ///
+ /// This function is **optional** to implement. It enhances the user
+ /// experience by enabling autocompletion for file paths.
+ fn files(&self) -> Vec<FileId> {
+ vec![]
}
-
- if has_italic {
- detail.push_str(" Has italics.");
- }
-
- detail
}
#[cfg(test)]
-mod tests {
- use typst::diag::{FileError, FileResult};
- use typst::foundations::{Bytes, Datetime, Smart};
- use typst::layout::{Abs, Margin, PageElem};
- use typst::syntax::{FileId, Source};
- use typst::text::{Font, FontBook, TextElem, TextSize};
- use typst::utils::{singleton, LazyHash};
- use typst::{Library, World};
-
- /// A world for IDE testing.
- pub struct TestWorld {
- pub main: Source,
- base: &'static TestBase,
- }
-
- impl TestWorld {
- /// Create a new world for a single test.
- ///
- /// This is cheap because the shared base for all test runs is lazily
- /// initialized just once.
- pub fn new(text: &str) -> Self {
- let main = Source::detached(text);
- Self {
- main,
- base: singleton!(TestBase, TestBase::default()),
- }
- }
-
- /// The ID of the main file in a `TestWorld`.
- pub fn main_id() -> FileId {
- *singleton!(FileId, Source::detached("").id())
- }
- }
-
- impl World for TestWorld {
- fn library(&self) -> &LazyHash<Library> {
- &self.base.library
- }
-
- fn book(&self) -> &LazyHash<FontBook> {
- &self.base.book
- }
-
- fn main(&self) -> FileId {
- self.main.id()
- }
-
- fn source(&self, id: FileId) -> FileResult<Source> {
- if id == self.main.id() {
- Ok(self.main.clone())
- } else {
- Err(FileError::NotFound(id.vpath().as_rootless_path().into()))
- }
- }
-
- fn file(&self, id: FileId) -> FileResult<Bytes> {
- Err(FileError::NotFound(id.vpath().as_rootless_path().into()))
- }
-
- fn font(&self, index: usize) -> Option<Font> {
- Some(self.base.fonts[index].clone())
- }
-
- fn today(&self, _: Option<i64>) -> Option<Datetime> {
- None
- }
- }
-
- /// Shared foundation of all test worlds.
- struct TestBase {
- library: LazyHash<Library>,
- book: LazyHash<FontBook>,
- fonts: Vec<Font>,
- }
-
- impl Default for TestBase {
- fn default() -> Self {
- let fonts: Vec<_> = typst_assets::fonts()
- .chain(typst_dev_assets::fonts())
- .flat_map(|data| Font::iter(Bytes::from_static(data)))
- .collect();
-
- Self {
- library: LazyHash::new(library()),
- book: LazyHash::new(FontBook::from_fonts(&fonts)),
- fonts,
- }
- }
- }
-
- /// The extended standard library for testing.
- fn library() -> Library {
- // Set page width to 120pt with 10pt margins, so that the inner page is
- // exactly 100pt wide. Page height is unbounded and font size is 10pt so
- // that it multiplies to nice round numbers.
- let mut lib = Library::default();
- lib.styles
- .set(PageElem::set_width(Smart::Custom(Abs::pt(120.0).into())));
- lib.styles.set(PageElem::set_height(Smart::Auto));
- lib.styles.set(PageElem::set_margin(Margin::splat(Some(Smart::Custom(
- Abs::pt(10.0).into(),
- )))));
- lib.styles.set(TextElem::set_size(TextSize(Abs::pt(10.0).into())));
- lib
- }
-}
+mod tests;
diff --git a/crates/typst-ide/src/matchers.rs b/crates/typst-ide/src/matchers.rs
index 1daec819..4aeba29b 100644
--- a/crates/typst-ide/src/matchers.rs
+++ b/crates/typst-ide/src/matchers.rs
@@ -2,13 +2,12 @@ use ecow::EcoString;
use typst::foundations::{Module, Value};
use typst::syntax::ast::AstNode;
use typst::syntax::{ast, LinkedNode, Span, SyntaxKind, SyntaxNode};
-use typst::World;
-use crate::analyze_import;
+use crate::{analyze_import, IdeWorld};
/// Find the named items starting from the given position.
pub fn named_items<T>(
- world: &dyn World,
+ world: &dyn IdeWorld,
position: LinkedNode,
mut recv: impl FnMut(NamedItem) -> Option<T>,
) -> Option<T> {
@@ -163,6 +162,14 @@ impl<'a> NamedItem<'a> {
NamedItem::Import(_, _, value) => value.cloned(),
}
}
+
+ pub(crate) fn span(&self) -> Span {
+ match *self {
+ NamedItem::Var(name) | NamedItem::Fn(name) => name.span(),
+ NamedItem::Module(_, site) => site.span(),
+ NamedItem::Import(_, span, _) => span,
+ }
+ }
}
/// Categorize an expression into common classes IDE functionality can operate
@@ -178,29 +185,29 @@ pub fn deref_target(node: LinkedNode) -> Option<DerefTarget<'_>> {
let expr_node = ancestor;
let expr = expr_node.cast::<ast::Expr>()?;
Some(match expr {
- ast::Expr::Label(..) => DerefTarget::Label(expr_node),
- ast::Expr::Ref(..) => DerefTarget::Ref(expr_node),
+ ast::Expr::Label(_) => DerefTarget::Label(expr_node),
+ ast::Expr::Ref(_) => DerefTarget::Ref(expr_node),
ast::Expr::FuncCall(call) => {
DerefTarget::Callee(expr_node.find(call.callee().span())?)
}
ast::Expr::Set(set) => DerefTarget::Callee(expr_node.find(set.target().span())?),
- ast::Expr::Ident(..) | ast::Expr::MathIdent(..) | ast::Expr::FieldAccess(..) => {
+ ast::Expr::Ident(_) | ast::Expr::MathIdent(_) | ast::Expr::FieldAccess(_) => {
DerefTarget::VarAccess(expr_node)
}
- ast::Expr::Str(..) => {
+ ast::Expr::Str(_) => {
let parent = expr_node.parent()?;
if parent.kind() == SyntaxKind::ModuleImport {
DerefTarget::ImportPath(expr_node)
} else if parent.kind() == SyntaxKind::ModuleInclude {
DerefTarget::IncludePath(expr_node)
} else {
- DerefTarget::Code(expr_node.kind(), expr_node)
+ DerefTarget::Code(expr_node)
}
}
_ if expr.hash()
|| matches!(expr_node.kind(), SyntaxKind::MathIdent | SyntaxKind::Error) =>
{
- DerefTarget::Code(expr_node.kind(), expr_node)
+ DerefTarget::Code(expr_node)
}
_ => return None,
})
@@ -209,10 +216,6 @@ pub fn deref_target(node: LinkedNode) -> Option<DerefTarget<'_>> {
/// Classes of expressions that can be operated on by IDE functionality.
#[derive(Debug, Clone)]
pub enum DerefTarget<'a> {
- /// A label expression.
- Label(LinkedNode<'a>),
- /// A reference expression.
- Ref(LinkedNode<'a>),
/// A variable access expression.
///
/// It can be either an identifier or a field access.
@@ -224,7 +227,11 @@ pub enum DerefTarget<'a> {
/// An include path expression.
IncludePath(LinkedNode<'a>),
/// Any code expression.
- Code(SyntaxKind, LinkedNode<'a>),
+ Code(LinkedNode<'a>),
+ /// A label expression.
+ Label(LinkedNode<'a>),
+ /// A reference expression.
+ Ref(LinkedNode<'a>),
}
#[cfg(test)]
diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs
new file mode 100644
index 00000000..52b189fa
--- /dev/null
+++ b/crates/typst-ide/src/tests.rs
@@ -0,0 +1,184 @@
+use std::collections::HashMap;
+
+use ecow::EcoString;
+use typst::diag::{FileError, FileResult};
+use typst::foundations::{Bytes, Datetime, Smart};
+use typst::layout::{Abs, Margin, PageElem};
+use typst::syntax::package::{PackageSpec, PackageVersion};
+use typst::syntax::{FileId, Source, VirtualPath};
+use typst::text::{Font, FontBook, TextElem, TextSize};
+use typst::utils::{singleton, LazyHash};
+use typst::{Library, World};
+
+use crate::IdeWorld;
+
+/// A world for IDE testing.
+pub struct TestWorld {
+ pub main: Source,
+ assets: HashMap<FileId, Bytes>,
+ sources: HashMap<FileId, Source>,
+ base: &'static TestBase,
+}
+
+impl TestWorld {
+ /// Create a new world for a single test.
+ ///
+ /// This is cheap because the shared base for all test runs is lazily
+ /// initialized just once.
+ pub fn new(text: &str) -> Self {
+ let main = Source::new(Self::main_id(), text.into());
+ Self {
+ main,
+ assets: HashMap::new(),
+ sources: HashMap::new(),
+ base: singleton!(TestBase, TestBase::default()),
+ }
+ }
+
+ /// Add an additional source file to the test world.
+ pub fn with_source(mut self, path: &str, text: &str) -> Self {
+ let id = FileId::new(None, VirtualPath::new(path));
+ let source = Source::new(id, text.into());
+ self.sources.insert(id, source);
+ self
+ }
+
+ /// Add an additional asset file to the test world.
+ #[track_caller]
+ pub fn with_asset(self, filename: &str) -> Self {
+ self.with_asset_at(filename, filename)
+ }
+
+ /// Add an additional asset file to the test world.
+ #[track_caller]
+ pub fn with_asset_at(mut self, path: &str, filename: &str) -> Self {
+ let id = FileId::new(None, VirtualPath::new(path));
+ let data = typst_dev_assets::get_by_name(filename).unwrap();
+ let bytes = Bytes::from_static(data);
+ self.assets.insert(id, bytes);
+ self
+ }
+
+ /// The ID of the main file in a `TestWorld`.
+ pub fn main_id() -> FileId {
+ *singleton!(FileId, FileId::new(None, VirtualPath::new("main.typ")))
+ }
+}
+
+impl World for TestWorld {
+ fn library(&self) -> &LazyHash<Library> {
+ &self.base.library
+ }
+
+ fn book(&self) -> &LazyHash<FontBook> {
+ &self.base.book
+ }
+
+ fn main(&self) -> FileId {
+ self.main.id()
+ }
+
+ fn source(&self, id: FileId) -> FileResult<Source> {
+ if id == self.main.id() {
+ Ok(self.main.clone())
+ } else if let Some(source) = self.sources.get(&id) {
+ Ok(source.clone())
+ } else {
+ Err(FileError::NotFound(id.vpath().as_rootless_path().into()))
+ }
+ }
+
+ fn file(&self, id: FileId) -> FileResult<Bytes> {
+ match self.assets.get(&id) {
+ Some(bytes) => Ok(bytes.clone()),
+ None => Err(FileError::NotFound(id.vpath().as_rootless_path().into())),
+ }
+ }
+
+ fn font(&self, index: usize) -> Option<Font> {
+ Some(self.base.fonts[index].clone())
+ }
+
+ fn today(&self, _: Option<i64>) -> Option<Datetime> {
+ None
+ }
+}
+
+impl IdeWorld for TestWorld {
+ fn upcast(&self) -> &dyn World {
+ self
+ }
+
+ fn files(&self) -> Vec<FileId> {
+ std::iter::once(self.main.id())
+ .chain(self.sources.keys().copied())
+ .chain(self.assets.keys().copied())
+ .collect()
+ }
+
+ fn packages(&self) -> &[(PackageSpec, Option<EcoString>)] {
+ const LIST: &[(PackageSpec, Option<EcoString>)] = &[(
+ PackageSpec {
+ namespace: EcoString::inline("preview"),
+ name: EcoString::inline("example"),
+ version: PackageVersion { major: 0, minor: 1, patch: 0 },
+ },
+ None,
+ )];
+ LIST
+ }
+}
+
+/// Extra methods for [`Source`].
+pub trait SourceExt {
+ /// Negative cursors index from the back.
+ fn cursor(&self, cursor: isize) -> usize;
+}
+
+impl SourceExt for Source {
+ fn cursor(&self, cursor: isize) -> usize {
+ if cursor < 0 {
+ self.len_bytes().checked_add_signed(cursor).unwrap()
+ } else {
+ cursor as usize
+ }
+ }
+}
+
+/// Shared foundation of all test worlds.
+struct TestBase {
+ library: LazyHash<Library>,
+ book: LazyHash<FontBook>,
+ fonts: Vec<Font>,
+}
+
+impl Default for TestBase {
+ fn default() -> Self {
+ let fonts: Vec<_> = typst_assets::fonts()
+ .chain(typst_dev_assets::fonts())
+ .flat_map(|data| Font::iter(Bytes::from_static(data)))
+ .collect();
+
+ Self {
+ library: LazyHash::new(library()),
+ book: LazyHash::new(FontBook::from_fonts(&fonts)),
+ fonts,
+ }
+ }
+}
+
+/// The extended standard library for testing.
+fn library() -> Library {
+ // Set page width to 120pt with 10pt margins, so that the inner page is
+ // exactly 100pt wide. Page height is unbounded and font size is 10pt so
+ // that it multiplies to nice round numbers.
+ let mut lib = typst::Library::default();
+ lib.styles
+ .set(PageElem::set_width(Smart::Custom(Abs::pt(120.0).into())));
+ lib.styles.set(PageElem::set_height(Smart::Auto));
+ lib.styles.set(PageElem::set_margin(Margin::splat(Some(Smart::Custom(
+ Abs::pt(10.0).into(),
+ )))));
+ lib.styles.set(TextElem::set_size(TextSize(Abs::pt(10.0).into())));
+ lib
+}
diff --git a/crates/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs
index 532cda39..c400c9b0 100644
--- a/crates/typst-ide/src/tooltip.rs
+++ b/crates/typst-ide/src/tooltip.rs
@@ -7,11 +7,12 @@ use typst::eval::CapturesVisitor;
use typst::foundations::{repr, Capturer, CastInfo, Repr, Value};
use typst::layout::Length;
use typst::model::Document;
+use typst::syntax::ast::AstNode;
use typst::syntax::{ast, LinkedNode, Side, Source, SyntaxKind};
use typst::utils::{round_with_precision, Numeric};
-use typst::World;
-use crate::{analyze_expr, analyze_labels, plain_docs_sentence, summarize_font_family};
+use crate::utils::{plain_docs_sentence, summarize_font_family};
+use crate::{analyze_expr, analyze_import, analyze_labels, IdeWorld};
/// Describe the item under the cursor.
///
@@ -19,7 +20,7 @@ use crate::{analyze_expr, analyze_labels, plain_docs_sentence, summarize_font_fa
/// the tooltips. Label tooltips, for instance, are only generated when the
/// document is available.
pub fn tooltip(
- world: &dyn World,
+ world: &dyn IdeWorld,
document: Option<&Document>,
source: &Source,
cursor: usize,
@@ -33,6 +34,7 @@ pub fn tooltip(
named_param_tooltip(world, &leaf)
.or_else(|| font_tooltip(world, &leaf))
.or_else(|| document.and_then(|doc| label_tooltip(doc, &leaf)))
+ .or_else(|| import_tooltip(world, &leaf))
.or_else(|| expr_tooltip(world, &leaf))
.or_else(|| closure_tooltip(&leaf))
}
@@ -47,7 +49,7 @@ pub enum Tooltip {
}
/// Tooltip for a hovered expression.
-fn expr_tooltip(world: &dyn World, leaf: &LinkedNode) -> Option<Tooltip> {
+fn expr_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
let mut ancestor = leaf;
while !ancestor.is::<ast::Expr>() {
ancestor = ancestor.parent()?;
@@ -106,6 +108,26 @@ fn expr_tooltip(world: &dyn World, leaf: &LinkedNode) -> Option<Tooltip> {
(!tooltip.is_empty()).then(|| Tooltip::Code(tooltip.into()))
}
+/// Tooltips for imports.
+fn import_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
+ if_chain! {
+ if leaf.kind() == SyntaxKind::Star;
+ if let Some(parent) = leaf.parent();
+ if let Some(import) = parent.cast::<ast::ModuleImport>();
+ if let Some(node) = parent.find(import.source().span());
+ if let Some(value) = analyze_import(world, &node);
+ if let Some(scope) = value.scope();
+ then {
+ let names: Vec<_> =
+ scope.iter().map(|(name, ..)| eco_format!("`{name}`")).collect();
+ let list = repr::separated_list(&names, "and");
+ return Some(Tooltip::Text(eco_format!("This star imports {list}")));
+ }
+ }
+
+ None
+}
+
/// Tooltip for a hovered closure.
fn closure_tooltip(leaf: &LinkedNode) -> Option<Tooltip> {
// Only show this tooltip when hovering over the equals sign or arrow of
@@ -134,7 +156,7 @@ fn closure_tooltip(leaf: &LinkedNode) -> Option<Tooltip> {
names.sort();
let tooltip = repr::separated_list(&names, "and");
- Some(Tooltip::Text(eco_format!("This closure captures {tooltip}.")))
+ Some(Tooltip::Text(eco_format!("This closure captures {tooltip}")))
}
/// Tooltip text for a hovered length.
@@ -168,7 +190,7 @@ fn label_tooltip(document: &Document, leaf: &LinkedNode) -> Option<Tooltip> {
}
/// Tooltips for components of a named parameter.
-fn named_param_tooltip(world: &dyn World, leaf: &LinkedNode) -> Option<Tooltip> {
+fn named_param_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
let (func, named) = if_chain! {
// Ensure that we are in a named pair in the arguments to a function
// call or set rule.
@@ -225,7 +247,7 @@ fn find_string_doc(info: &CastInfo, string: &str) -> Option<&'static str> {
}
/// Tooltip for font.
-fn font_tooltip(world: &dyn World, leaf: &LinkedNode) -> Option<Tooltip> {
+fn font_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option<Tooltip> {
if_chain! {
// Ensure that we are on top of a string.
if let Some(string) = leaf.cast::<ast::Str>();
@@ -256,32 +278,74 @@ mod tests {
use typst::syntax::Side;
use super::{tooltip, Tooltip};
- use crate::tests::TestWorld;
+ use crate::tests::{SourceExt, TestWorld};
- fn text(text: &str) -> Option<Tooltip> {
- Some(Tooltip::Text(text.into()))
+ type Response = Option<Tooltip>;
+
+ trait ResponseExt {
+ fn must_be_none(&self) -> &Self;
+ fn must_be_text(&self, text: &str) -> &Self;
+ fn must_be_code(&self, code: &str) -> &Self;
}
- fn code(code: &str) -> Option<Tooltip> {
- Some(Tooltip::Code(code.into()))
+ impl ResponseExt for Response {
+ #[track_caller]
+ fn must_be_none(&self) -> &Self {
+ assert_eq!(*self, None);
+ self
+ }
+
+ #[track_caller]
+ fn must_be_text(&self, text: &str) -> &Self {
+ assert_eq!(*self, Some(Tooltip::Text(text.into())));
+ self
+ }
+
+ #[track_caller]
+ fn must_be_code(&self, code: &str) -> &Self {
+ assert_eq!(*self, Some(Tooltip::Code(code.into())));
+ self
+ }
}
#[track_caller]
- fn test(text: &str, cursor: usize, side: Side, expected: Option<Tooltip>) {
+ fn test(text: &str, cursor: isize, side: Side) -> Response {
let world = TestWorld::new(text);
+ test_with_world(&world, cursor, side)
+ }
+
+ #[track_caller]
+ fn test_with_world(world: &TestWorld, cursor: isize, side: Side) -> Response {
+ let source = &world.main;
let doc = typst::compile(&world).output.ok();
- assert_eq!(tooltip(&world, doc.as_ref(), &world.main, cursor, side), expected);
+ tooltip(world, doc.as_ref(), source, source.cursor(cursor), side)
}
#[test]
fn test_tooltip() {
- test("#let x = 1 + 2", 5, Side::After, code("3"));
- test("#let x = 1 + 2", 6, Side::Before, code("3"));
- test("#let f(x) = x + y", 11, Side::Before, text("This closure captures `y`."));
+ test("#let x = 1 + 2", 14, Side::After).must_be_none();
+ test("#let x = 1 + 2", 5, Side::After).must_be_code("3");
+ test("#let x = 1 + 2", 6, Side::Before).must_be_code("3");
+ test("#let x = 1 + 2", 6, Side::Before).must_be_code("3");
+ }
+
+ #[test]
+ fn test_tooltip_empty_contextual() {
+ test("#{context}", 10, Side::Before).must_be_code("context()");
+ }
+
+ #[test]
+ fn test_tooltip_closure() {
+ test("#let f(x) = x + y", 11, Side::Before)
+ .must_be_text("This closure captures `y`");
}
#[test]
- fn test_empty_contextual() {
- test("#{context}", 10, Side::Before, code("context()"));
+ fn test_tooltip_star_import() {
+ let world = TestWorld::new("#import \"other.typ\": *")
+ .with_source("other.typ", "#let (a, b, c) = (1, 2, 3)");
+ test_with_world(&world, 21, Side::Before).must_be_none();
+ test_with_world(&world, 21, Side::After)
+ .must_be_text("This star imports `a`, `b`, and `c`");
}
}
diff --git a/crates/typst-ide/src/utils.rs b/crates/typst-ide/src/utils.rs
new file mode 100644
index 00000000..9ea058b9
--- /dev/null
+++ b/crates/typst-ide/src/utils.rs
@@ -0,0 +1,168 @@
+use std::fmt::Write;
+use std::ops::ControlFlow;
+
+use ecow::{eco_format, EcoString};
+use typst::foundations::{Scope, Value};
+use typst::syntax::{LinkedNode, SyntaxKind};
+use typst::text::{FontInfo, FontStyle};
+
+use crate::IdeWorld;
+
+/// Extract the first sentence of plain text of a piece of documentation.
+///
+/// Removes Markdown formatting.
+pub 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.
+pub fn summarize_font_family<'a>(
+ variants: impl Iterator<Item = &'a FontInfo>,
+) -> EcoString {
+ let mut infos: Vec<_> = variants.collect();
+ infos.sort_by_key(|info| info.variant);
+
+ let mut has_italic = false;
+ let mut min_weight = u16::MAX;
+ let mut max_weight = 0;
+ for info in &infos {
+ let weight = info.variant.weight.to_number();
+ has_italic |= info.variant.style == FontStyle::Italic;
+ min_weight = min_weight.min(weight);
+ max_weight = min_weight.max(weight);
+ }
+
+ let count = infos.len();
+ let mut detail = eco_format!("{count} variant{}.", if count == 1 { "" } else { "s" });
+
+ if min_weight == max_weight {
+ write!(detail, " Weight {min_weight}.").unwrap();
+ } else {
+ write!(detail, " Weights {min_weight}–{max_weight}.").unwrap();
+ }
+
+ if has_italic {
+ detail.push_str(" Has italics.");
+ }
+
+ detail
+}
+
+/// The global definitions at the given node.
+pub fn globals<'a>(world: &'a dyn IdeWorld, leaf: &LinkedNode) -> &'a Scope {
+ let in_math = matches!(
+ leaf.parent_kind(),
+ Some(SyntaxKind::Equation)
+ | Some(SyntaxKind::Math)
+ | Some(SyntaxKind::MathFrac)
+ | Some(SyntaxKind::MathAttach)
+ );
+
+ let library = world.library();
+ if in_math {
+ library.math.scope()
+ } else {
+ library.global.scope()
+ }
+}
+
+/// Checks whether the given value or any of its constituent parts satisfy the
+/// predicate.
+pub fn check_value_recursively(
+ value: &Value,
+ predicate: impl Fn(&Value) -> bool,
+) -> bool {
+ let mut searcher = Searcher { steps: 0, predicate, max_steps: 1000 };
+ match searcher.find(value) {
+ ControlFlow::Break(matching) => matching,
+ ControlFlow::Continue(_) => false,
+ }
+}
+
+/// Recursively searches for a value that passes the filter, but without
+/// exceeding a maximum number of search steps.
+struct Searcher<F> {
+ max_steps: usize,
+ steps: usize,
+ predicate: F,
+}
+
+impl<F> Searcher<F>
+where
+ F: Fn(&Value) -> bool,
+{
+ fn find(&mut self, value: &Value) -> ControlFlow<bool> {
+ if (self.predicate)(value) {
+ return ControlFlow::Break(true);
+ }
+
+ if self.steps > self.max_steps {
+ return ControlFlow::Break(false);
+ }
+
+ self.steps += 1;
+
+ match value {
+ Value::Dict(dict) => {
+ self.find_iter(dict.iter().map(|(_, v)| v))?;
+ }
+ Value::Content(content) => {
+ self.find_iter(content.fields().iter().map(|(_, v)| v))?;
+ }
+ Value::Module(module) => {
+ self.find_iter(module.scope().iter().map(|(_, v, _)| v))?;
+ }
+ _ => {}
+ }
+
+ ControlFlow::Continue(())
+ }
+
+ fn find_iter<'a>(
+ &mut self,
+ iter: impl Iterator<Item = &'a Value>,
+ ) -> ControlFlow<bool> {
+ for item in iter {
+ self.find(item)?;
+ }
+ ControlFlow::Continue(())
+ }
+}
diff --git a/crates/typst/src/lib.rs b/crates/typst/src/lib.rs
index 7f0b8e69..8833f2a5 100644
--- a/crates/typst/src/lib.rs
+++ b/crates/typst/src/lib.rs
@@ -73,7 +73,6 @@ use crate::foundations::{
use crate::introspection::Introspector;
use crate::layout::{Alignment, Dir};
use crate::model::Document;
-use crate::syntax::package::PackageSpec;
use crate::syntax::{FileId, Source, Span};
use crate::text::{Font, FontBook};
use crate::utils::LazyHash;
@@ -233,16 +232,6 @@ pub trait World: Send + Sync {
/// If this function returns `None`, Typst's `datetime` function will
/// return an error.
fn today(&self, offset: Option<i64>) -> Option<Datetime>;
-
- /// A list of all available packages and optionally descriptions for them.
- ///
- /// This function is optional to implement. It enhances the user experience
- /// by enabling autocompletion for packages. Details about packages from the
- /// `@preview` namespace are available from
- /// `https://packages.typst.org/preview/index.json`.
- fn packages(&self) -> &[(PackageSpec, Option<EcoString>)] {
- &[]
- }
}
macro_rules! delegate_for_ptr {
@@ -275,10 +264,6 @@ macro_rules! delegate_for_ptr {
fn today(&self, offset: Option<i64>) -> Option<Datetime> {
self.deref().today(offset)
}
-
- fn packages(&self) -> &[(PackageSpec, Option<EcoString>)] {
- self.deref().packages()
- }
}
};
}
@@ -295,7 +280,7 @@ pub trait WorldExt {
fn range(&self, span: Span) -> Option<Range<usize>>;
}
-impl<T: World> WorldExt for T {
+impl<T: World + ?Sized> WorldExt for T {
fn range(&self, span: Span) -> Option<Range<usize>> {
self.source(span.id()?).ok()?.range(span)
}