Autocomplete file paths

This commit is contained in:
Laurenz 2024-11-10 12:47:49 +01:00
parent a5a4b0b72f
commit c0dfe4aab7
4 changed files with 189 additions and 38 deletions

1
Cargo.lock generated
View File

@ -2780,6 +2780,7 @@ dependencies = [
"ecow",
"if_chain",
"once_cell",
"pathdiff",
"serde",
"typst",
"typst-assets",

View File

@ -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 }

View File

@ -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();
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(&param.input);
param_value_completions(ctx, func, param);
}
if param.named {
@ -791,16 +796,39 @@ fn named_param_value_completions<'a>(
return;
}
ctx.cast_completions(&param.input);
if name == "font" {
ctx.font_completions();
}
param_value_completions(ctx, func, param);
if ctx.before.ends_with(':') {
ctx.enrich(" ", "");
}
}
/// Add completions for the values of a parameter.
fn param_value_completions<'a>(
ctx: &mut CompletionContext<'a>,
func: &Func,
param: &'a ParamInfo,
) {
ctx.cast_completions(&param.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<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.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<Completion>)>;
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\") <bib>")
.with_asset_by_name("works.bib");
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
@ -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")]);
}
}

View File

@ -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<EcoString>)] {
&[]
}
/// Returns a list of all known files.
///
/// This function is **optional** to implement. It enhances the user
/// experience by enabling autocompletion for file paths.
fn files(&self) -> Vec<FileId> {
vec![]
}
}
/// Extract the first sentence of plain text of a piece of documentation.
@ -122,9 +131,11 @@ fn summarize_font_family<'a>(variants: impl Iterator<Item = &'a FontInfo>) -> 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<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`].