From c0dfe4aab7d20110e08f92fcecb84c7b8512b619 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Sun, 10 Nov 2024 12:47:49 +0100 Subject: [PATCH] Autocomplete file paths --- Cargo.lock | 1 + crates/typst-ide/Cargo.toml | 1 + crates/typst-ide/src/complete.rs | 169 ++++++++++++++++++++++++++----- crates/typst-ide/src/lib.rs | 56 ++++++++-- 4 files changed, 189 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e06c581a..0d9b07312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2780,6 +2780,7 @@ dependencies = [ "ecow", "if_chain", "once_cell", + "pathdiff", "serde", "typst", "typst-assets", diff --git a/crates/typst-ide/Cargo.toml b/crates/typst-ide/Cargo.toml index 7320cd062..20162e836 100644 --- a/crates/typst-ide/Cargo.toml +++ b/crates/typst-ide/Cargo.toml @@ -18,6 +18,7 @@ typst-eval = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } if_chain = { workspace = true } +pathdiff = { workspace = true } serde = { workspace = true } unscanny = { workspace = true } diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 6ea0a212c..7b8ba69f7 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -1,17 +1,19 @@ use std::cmp::Reverse; use std::collections::{BTreeSet, 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, format_str, repr, AutoValue, CastInfo, Func, Label, NoneValue, ParamInfo, + Repr, Scope, StyleChain, Styles, Type, Value, }; 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; @@ -480,8 +482,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(), @@ -489,11 +491,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; } } @@ -757,7 +762,7 @@ fn param_completions<'a>( continue; } - ctx.cast_completions(¶m.input); + param_value_completions(ctx, func, param); } if param.named { @@ -791,16 +796,39 @@ 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, +) { + ctx.cast_completions(¶m.input); + + 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"], + _ => &[], + }); + } +} + /// Resolve a callee expression to a global function. fn resolve_global_callee<'a>( ctx: &CompletionContext<'a>, @@ -977,25 +1005,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.", ); @@ -1040,7 +1062,6 @@ 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, @@ -1130,6 +1151,50 @@ impl<'a> CompletionContext<'a> { } } + /// 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.value_completion(None, &Value::Str(path.into()), false, 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() { @@ -1409,11 +1474,19 @@ mod tests { use std::collections::BTreeSet; use typst::model::Document; - use typst::syntax::Source; + 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 { @@ -1488,6 +1561,14 @@ mod tests { 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, @@ -1495,7 +1576,7 @@ mod tests { doc: Option<&Document>, cursor: isize, ) -> Response { - autocomplete(&world, doc, source, source.cursor(cursor), true) + autocomplete(world, doc, source, source.cursor(cursor), true) } #[test] @@ -1530,8 +1611,8 @@ mod tests { #[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_by_name("works.bib"); + 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 @@ -1573,4 +1654,36 @@ mod tests { .must_include(["integer"]) .must_exclude(["string"]); } + + #[test] + fn test_autocomplete_packages() { + test("#import \"@\"", -1).must_include([q!("@preview/example:0.1.0")]); + } + + #[test] + fn test_autocomplete_file_path() { + let world = TestWorld::new("#include \"\"") + .with_source("utils.typ", "") + .with_source("content/a.typ", "#image()") + .with_source("content/b.typ", "#csv(\"\")") + .with_source("content/c.typ", "#include \"\"") + .with_asset_at("assets/tiger.jpg", "tiger.jpg") + .with_asset_at("assets/rhino.png", "rhino.png") + .with_asset_at("data/example.csv", "example.csv"); + + test_with_path(&world, "main.typ", -1) + .must_include([q!("content/a.typ"), q!("content/b.typ"), q!("utils.typ")]) + .must_exclude([q!("assets/tiger.jpg")]); + + test_with_path(&world, "content/c.typ", -1) + .must_include([q!("../main.typ"), q!("a.typ"), q!("b.typ")]) + .must_exclude([q!("c.typ")]); + + test_with_path(&world, "content/a.typ", -1) + .must_include([q!("../assets/tiger.jpg"), q!("../assets/rhino.png")]) + .must_exclude([q!("../data/example.csv"), q!("b.typ")]); + + test_with_path(&world, "content/b.typ", -2) + .must_include([q!("../data/example.csv")]); + } } diff --git a/crates/typst-ide/src/lib.rs b/crates/typst-ide/src/lib.rs index f66999b43..19a4f6434 100644 --- a/crates/typst-ide/src/lib.rs +++ b/crates/typst-ide/src/lib.rs @@ -18,6 +18,7 @@ use std::fmt::Write; use ecow::{eco_format, EcoString}; use typst::syntax::package::PackageSpec; +use typst::syntax::FileId; use typst::text::{FontInfo, FontStyle}; use typst::World; @@ -40,6 +41,14 @@ pub trait IdeWorld: World { fn packages(&self) -> &[(PackageSpec, Option)] { &[] } + + /// 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![] + } } /// Extract the first sentence of plain text of a piece of documentation. @@ -122,9 +131,11 @@ fn summarize_font_family<'a>(variants: impl Iterator) -> Ec mod tests { 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}; @@ -155,16 +166,6 @@ mod tests { } } - /// Add an additional asset file to the test world. - #[track_caller] - pub fn with_asset_by_name(mut self, filename: &str) -> Self { - let id = FileId::new(None, VirtualPath::new(filename)); - let data = typst_dev_assets::get_by_name(filename).unwrap(); - let bytes = Bytes::from_static(data); - self.assets.insert(id, bytes); - self - } - /// 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)); @@ -173,6 +174,22 @@ mod tests { 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"))) @@ -222,6 +239,25 @@ mod tests { 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`].