mirror of
https://github.com/typst/typst
synced 2025-05-13 20:46:23 +08:00
Backport IDE improvements
This commit is contained in:
parent
737895d769
commit
8dbbe68527
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -2831,6 +2831,7 @@ dependencies = [
|
||||
"if_chain",
|
||||
"log",
|
||||
"once_cell",
|
||||
"pathdiff",
|
||||
"serde",
|
||||
"typst",
|
||||
"typst-assets",
|
||||
|
@ -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 }
|
||||
|
||||
|
@ -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(),
|
||||
|
@ -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();
|
||||
|
||||
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::<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 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<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);
|
||||
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\") <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: [${}]");
|
||||
}
|
||||
}
|
||||
|
@ -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()));
|
||||
}
|
||||
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(),
|
||||
});
|
||||
}
|
||||
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::<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);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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<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 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,
|
||||
// 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::<ast::Ref>()?.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
|
||||
/// <foo>
|
||||
/// ^^^^^ 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<Definition>);
|
||||
|
||||
trait ResponseExt {
|
||||
fn must_be_at(&self, path: &str, range: Range<usize>) -> &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<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
|
||||
}
|
||||
|
||||
#[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<T>(
|
||||
text: &str,
|
||||
cursor: usize,
|
||||
name: &str,
|
||||
kind: Kind,
|
||||
value: Option<T>,
|
||||
range: Option<Range<usize>>,
|
||||
) 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::<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_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[] <hi> 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());
|
||||
}
|
||||
}
|
||||
|
@ -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> {
|
||||
|
@ -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 <https://github.com/rust-lang/rust/issues/65991>.
|
||||
///
|
||||
/// 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<EcoString>)] {
|
||||
&[]
|
||||
}
|
||||
|
||||
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);
|
||||
/// 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![]
|
||||
}
|
||||
|
||||
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<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;
|
||||
|
@ -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)]
|
||||
|
184
crates/typst-ide/src/tests.rs
Normal file
184
crates/typst-ide/src/tests.rs
Normal file
@ -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
|
||||
}
|
@ -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_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`");
|
||||
}
|
||||
}
|
||||
|
168
crates/typst-ide/src/utils.rs
Normal file
168
crates/typst-ide/src/utils.rs
Normal file
@ -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(())
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user