diff --git a/Cargo.lock b/Cargo.lock index 5685a9cd2..7289e6d5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2831,6 +2831,7 @@ dependencies = [ "if_chain", "log", "once_cell", + "pathdiff", "serde", "typst", "typst-assets", diff --git a/crates/typst-ide/Cargo.toml b/crates/typst-ide/Cargo.toml index 4e87f99b1..41397df90 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 c37795562..6d4ba28ff 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)> { let Some(expr) = node.cast::() 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 { +/// Tries to load a module from the given `source` node. +pub fn analyze_import(world: &dyn IdeWorld, source: &LinkedNode) -> Option { // 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 { 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 d534f55cc..a2791e071 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)> { - 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, ) { 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(); - - for param in params { - if exclude.iter().any(|ident| ident.as_str() == param.name) { - continue; + // 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 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(¶m.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(¶m.input); - } } if ctx.before.ends_with(',') { @@ -773,25 +801,52 @@ fn named_param_value_completions<'a>( return; } - ctx.cast_completions(¶m.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(¶m.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::() + && ty != Type::of::() + && ty != Type::of::() + }); + } 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 { 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 = 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, 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, 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, + kind: Option, + 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, value: &Value, parens: bool, - docs: Option<&str>, + kind: Option, + 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::() { @@ -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::>::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 super::autocomplete; - use crate::tests::TestWorld; + use typst::model::Document; + use typst::syntax::{FileId, Source, VirtualPath}; + use typst::World; + + use super::{autocomplete, Completion}; + use crate::tests::{SourceExt, TestWorld}; + + /// Quote a string. + macro_rules! q { + ($s:literal) => { + concat!("\"", $s, "\"") + }; + } + + type Response = Option<(usize, Vec)>; + + trait ResponseExt { + fn completions(&self) -> &[Completion]; + fn labels(&self) -> BTreeSet<&str>; + fn must_include<'a>(&self, includes: impl IntoIterator) -> &Self; + fn must_exclude<'a>(&self, excludes: impl IntoIterator) -> &Self; + fn must_apply<'a>(&self, label: &str, apply: impl Into>) + -> &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) -> &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) -> &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>, + ) -> &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); - let doc = typst::compile(&world).output.ok(); - let (_, completions) = - autocomplete(&world, doc.as_ref(), &world.main, cursor, true) - .unwrap_or_default(); + test_with_world(&world, 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_world(world: &TestWorld, cursor: isize) -> Response { + let doc = typst::compile(&world).output.ok(); + test_full(world, &world.main, doc.as_ref(), cursor) + } + + #[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_array_method() { + test("#().", 4).must_include(["insert", "remove", "len", "all"]); + test("#{ let x = (1, 2, 3); x. }", -2).must_include(["at", "push", "pop"]); + } + + /// Test that extra space before '.' is handled correctly. #[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"]); - - test("#() .a", 6, &[], &["insert", "remove", "len", "all"]); - test("#{() .a}", 7, &["at", "any", "all"], &["foo"]); + 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\") ").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_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_mutable_method() { - let s = "#{ let x = (1, 2, 3); x. }"; - test(s, s.len() - 2, &["at", "push", "pop"], &[]); + 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 4323226d3..1ef8c45e3 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::().ok())?; - return Some(Definition::module(&import_item, path.span(), Span::detached())); - } - DerefTarget::Ref(r) => { - let label = Label::new(r.cast::()?.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(), - }); - } - DerefTarget::Label(..) | DerefTarget::Code(..) => { - return None; - } - }; + 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::()?.get().clone(); + if let Some(src) = named_items(world, node.clone(), |item: NamedItem| { + (*item.name() == name).then(|| Definition::Span(item.span())) + }) { + return Some(src); + }; - let mut has_path = false; - while let Some(node) = use_site.cast::() { - has_path = true; - use_site = use_site.find(node.target().span())?; - } - - let name = use_site.cast::()?.get().clone(); - let src = named_items(world, use_site, |item: NamedItem| { - if *item.name() != name { - return 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)) + 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)); + } } - 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(), - )), - } - }); - let src = src.or_else(|| { - let in_math = matches!( - leaf.parent_kind(), - Some(SyntaxKind::Equation) - | Some(SyntaxKind::Math) - | Some(SyntaxKind::MathFrac) - | Some(SyntaxKind::MathAttach) - ); - - 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()), - )); + if let Some(value) = globals(world, &leaf).get(&name) { + return Some(Definition::Std(value.clone())); } } - None - })?; - - (!has_path).then_some(src) -} - -/// 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, - /// 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 Definition { - fn item(name: EcoString, span: Span, name_span: Span, value: Option) -> Self { - Self { - name, - kind: match value { - Some(Value::Func(_)) => DefinitionKind::Function, - _ => DefinitionKind::Variable, - }, - value, - span, - name_span, + // 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)); } - } - 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, + // Try to jump to the referenced content. + DerefTarget::Ref(node) => { + let label = Label::new(node.cast::()?.target()); + let selector = Selector::Label(label); + let elem = document?.introspector.query_first(&selector)?; + return Some(Definition::Span(elem.span())); } + + _ => {} } - fn with_kind(self, kind: DefinitionKind) -> Self { - Self { kind, ..self } - } -} - -/// 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 - /// - /// ^^^^^ span - /// name_span is detached - /// ``` - Label, -} - -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) + None } #[cfg(test)] mod tests { use std::ops::Range; - use typst::foundations::{IntoValue, Label, NativeElement, Value}; + use typst::foundations::{IntoValue, NativeElement}; use typst::syntax::Side; use typst::WorldExt; - use super::{definition, DefinitionKind as Kind}; - use crate::tests::TestWorld; + use super::{definition, Definition}; + use crate::tests::{SourceExt, TestWorld}; + + type Response = (TestWorld, Option); + + trait ResponseExt { + fn must_be_at(&self, path: &str, range: Range) -> &Self; + fn must_be_value(&self, value: impl IntoValue) -> &Self; + } + + impl ResponseExt for Response { + #[track_caller] + fn must_be_at(&self, path: &str, expected: Range) -> &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 + } + + #[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 + } + } #[track_caller] - fn test( - text: &str, - cursor: usize, - name: &str, - kind: Kind, - value: Option, - range: Option>, - ) where - T: IntoValue, - { + 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 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))) - ); + let source = &world.main; + let def = definition(&world, doc.as_ref(), source, source.cursor(cursor), side); + (world, def) } #[test] - fn test_definition() { - test("#let x; #x", 9, "x", Kind::Variable, None::, Some(1..6)); - test("#let x() = {}; #x", 16, "x", Kind::Function, None::, Some(1..13)); - test( - "#table", - 1, - "table", - Kind::Function, - Some(typst::model::TableElem::elem()), - None, - ); - test( - "#figure[] See @hi", - 21, - "hi", - Kind::Label, - Some(Label::new("hi")), - Some(1..9), - ); + 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); + } + + #[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"); + + // 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); + } + + #[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); + } + + #[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); + } + + #[test] + fn test_definition_ref() { + test("#figure[] See @hi", 21, Side::After).must_be_at("main.typ", 1..9); + } + + #[test] + 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 e48db9865..2dd5cf610 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 { + fn from_span(world: &dyn IdeWorld, span: Span) -> Option { 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 { - Some(Jump::Source(TestWorld::main_id(), cursor)) + Some(Jump::File(TestWorld::main_id(), cursor)) } fn pos(page: usize, x: f64, y: f64) -> Option { diff --git a/crates/typst-ide/src/lib.rs b/crates/typst-ide/src/lib.rs index 63ba6f75f..c0edcce9f 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::EcoString; +use typst::syntax::package::PackageSpec; +use typst::syntax::FileId; +use typst::World; -use ecow::{eco_format, EcoString}; -use typst::text::{FontInfo, FontStyle}; +/// 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 . + /// + /// Implementors can simply return `self`. + fn upcast(&self) -> &dyn World; -/// 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), - } + /// 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)] { + &[] } - 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); + /// 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 { + vec![] } - - 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 } #[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 { - &self.base.library - } - - fn book(&self) -> &LazyHash { - &self.base.book - } - - fn main(&self) -> FileId { - self.main.id() - } - - fn source(&self, id: FileId) -> FileResult { - 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 { - Err(FileError::NotFound(id.vpath().as_rootless_path().into())) - } - - fn font(&self, index: usize) -> Option { - Some(self.base.fonts[index].clone()) - } - - fn today(&self, _: Option) -> Option { - None - } - } - - /// Shared foundation of all test worlds. - struct TestBase { - library: LazyHash, - book: LazyHash, - fonts: Vec, - } - - 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 1daec8193..4aeba29be 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( - world: &dyn World, + world: &dyn IdeWorld, position: LinkedNode, mut recv: impl FnMut(NamedItem) -> Option, ) -> Option { @@ -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> { let expr_node = ancestor; let expr = expr_node.cast::()?; 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> { /// 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 000000000..52b189fa0 --- /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, + sources: HashMap, + 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 { + &self.base.library + } + + fn book(&self) -> &LazyHash { + &self.base.book + } + + fn main(&self) -> FileId { + self.main.id() + } + + fn source(&self, id: FileId) -> FileResult { + 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 { + 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 { + Some(self.base.fonts[index].clone()) + } + + fn today(&self, _: Option) -> Option { + None + } +} + +impl IdeWorld for TestWorld { + fn upcast(&self) -> &dyn World { + self + } + + fn files(&self) -> Vec { + std::iter::once(self.main.id()) + .chain(self.sources.keys().copied()) + .chain(self.assets.keys().copied()) + .collect() + } + + fn packages(&self) -> &[(PackageSpec, Option)] { + const LIST: &[(PackageSpec, Option)] = &[( + 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, + book: LazyHash, + fonts: Vec, +} + +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 532cda396..c400c9b08 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 { +fn expr_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option { let mut ancestor = leaf; while !ancestor.is::() { ancestor = ancestor.parent()?; @@ -106,6 +108,26 @@ fn expr_tooltip(world: &dyn World, leaf: &LinkedNode) -> Option { (!tooltip.is_empty()).then(|| Tooltip::Code(tooltip.into())) } +/// Tooltips for imports. +fn import_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option { + if_chain! { + if leaf.kind() == SyntaxKind::Star; + if let Some(parent) = leaf.parent(); + if let Some(import) = parent.cast::(); + 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 { // Only show this tooltip when hovering over the equals sign or arrow of @@ -134,7 +156,7 @@ fn closure_tooltip(leaf: &LinkedNode) -> Option { 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 { } /// Tooltips for components of a named parameter. -fn named_param_tooltip(world: &dyn World, leaf: &LinkedNode) -> Option { +fn named_param_tooltip(world: &dyn IdeWorld, 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. @@ -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 { +fn font_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option { if_chain! { // Ensure that we are on top of a string. if let Some(string) = leaf.cast::(); @@ -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 { - Some(Tooltip::Text(text.into())) + type Response = Option; + + 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 { - 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) { + 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_empty_contextual() { - test("#{context}", 10, Side::Before, code("context()")); + 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_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 000000000..9ea058b90 --- /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, +) -> 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 { + max_steps: usize, + steps: usize, + predicate: F, +} + +impl Searcher +where + F: Fn(&Value) -> bool, +{ + fn find(&mut self, value: &Value) -> ControlFlow { + 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, + ) -> ControlFlow { + for item in iter { + self.find(item)?; + } + ControlFlow::Continue(()) + } +} diff --git a/crates/typst/src/lib.rs b/crates/typst/src/lib.rs index 7f0b8e692..8833f2a50 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) -> Option; - - /// 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)] { - &[] - } } macro_rules! delegate_for_ptr { @@ -275,10 +264,6 @@ macro_rules! delegate_for_ptr { fn today(&self, offset: Option) -> Option { self.deref().today(offset) } - - fn packages(&self) -> &[(PackageSpec, Option)] { - self.deref().packages() - } } }; } @@ -295,7 +280,7 @@ pub trait WorldExt { fn range(&self, span: Span) -> Option>; } -impl WorldExt for T { +impl WorldExt for T { fn range(&self, span: Span) -> Option> { self.source(span.id()?).ok()?.range(span) }