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", "ecow",
"if_chain", "if_chain",
"once_cell", "once_cell",
"pathdiff",
"serde", "serde",
"typst", "typst",
"typst-assets", "typst-assets",

View File

@ -18,6 +18,7 @@ typst-eval = { workspace = true }
comemo = { workspace = true } comemo = { workspace = true }
ecow = { workspace = true } ecow = { workspace = true }
if_chain = { workspace = true } if_chain = { workspace = true }
pathdiff = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
unscanny = { workspace = true } unscanny = { workspace = true }

View File

@ -1,17 +1,19 @@
use std::cmp::Reverse; use std::cmp::Reverse;
use std::collections::{BTreeSet, HashSet}; use std::collections::{BTreeSet, HashSet};
use std::ffi::OsStr;
use ecow::{eco_format, EcoString}; use ecow::{eco_format, EcoString};
use if_chain::if_chain; use if_chain::if_chain;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use typst::foundations::{ use typst::foundations::{
fields_on, format_str, repr, AutoValue, CastInfo, Func, Label, NoneValue, Repr, fields_on, format_str, repr, AutoValue, CastInfo, Func, Label, NoneValue, ParamInfo,
Scope, StyleChain, Styles, Type, Value, Repr, Scope, StyleChain, Styles, Type, Value,
}; };
use typst::model::Document; use typst::model::Document;
use typst::syntax::ast::AstNode; use typst::syntax::ast::AstNode;
use typst::syntax::{ 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::text::RawElem;
use typst::visualize::Color; use typst::visualize::Color;
@ -480,8 +482,8 @@ fn complete_open_labels(ctx: &mut CompletionContext) -> bool {
/// Complete imports. /// Complete imports.
fn complete_imports(ctx: &mut CompletionContext) -> bool { fn complete_imports(ctx: &mut CompletionContext) -> bool {
// In an import path for a package: // In an import path for a file or package:
// "#import "@|", // "#import "|",
if_chain! { if_chain! {
if matches!( if matches!(
ctx.leaf.parent_kind(), 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(); if let Some(ast::Expr::Str(str)) = ctx.leaf.cast();
let value = str.get(); let value = str.get();
if value.starts_with('@');
then { then {
let all_versions = value.contains(':');
ctx.from = ctx.leaf.offset(); 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; return true;
} }
} }
@ -757,7 +762,7 @@ fn param_completions<'a>(
continue; continue;
} }
ctx.cast_completions(&param.input); param_value_completions(ctx, func, param);
} }
if param.named { if param.named {
@ -791,16 +796,39 @@ fn named_param_value_completions<'a>(
return; return;
} }
ctx.cast_completions(&param.input); param_value_completions(ctx, func, param);
if name == "font" {
ctx.font_completions();
}
if ctx.before.ends_with(':') { if ctx.before.ends_with(':') {
ctx.enrich(" ", ""); 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. /// Resolve a callee expression to a global function.
fn resolve_global_callee<'a>( fn resolve_global_callee<'a>(
ctx: &CompletionContext<'a>, ctx: &CompletionContext<'a>,
@ -977,25 +1005,19 @@ fn code_completions(ctx: &mut CompletionContext, hash: bool) {
ctx.snippet_completion( ctx.snippet_completion(
"import (file)", "import (file)",
"import \"${file}.typ\": ${items}", "import \"${}\": ${}",
"Imports variables from another file.", "Imports variables from another file.",
); );
ctx.snippet_completion( ctx.snippet_completion(
"import (package)", "import (package)",
"import \"@${}\": ${items}", "import \"@${}\": ${}",
"Imports variables from another file.", "Imports variables from a package.",
); );
ctx.snippet_completion( ctx.snippet_completion(
"include (file)", "include (file)",
"include \"${file}.typ\"", "include \"${}\"",
"Includes content from another file.",
);
ctx.snippet_completion(
"include (package)",
"include \"@${}\"",
"Includes content from another file.", "Includes content from another file.",
); );
@ -1040,7 +1062,6 @@ struct CompletionContext<'a> {
impl<'a> CompletionContext<'a> { impl<'a> CompletionContext<'a> {
/// Create a new autocompletion context. /// Create a new autocompletion context.
fn new( fn new(
world: &'a (dyn World + 'a),
world: &'a (dyn IdeWorld + 'a), world: &'a (dyn IdeWorld + 'a),
document: Option<&'a Document>, document: Option<&'a Document>,
source: &'a Source, 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. /// Add completions for raw block tags.
fn raw_completions(&mut self) { fn raw_completions(&mut self) {
for (name, mut tags) in RawElem::languages() { for (name, mut tags) in RawElem::languages() {
@ -1409,11 +1474,19 @@ mod tests {
use std::collections::BTreeSet; use std::collections::BTreeSet;
use typst::model::Document; use typst::model::Document;
use typst::syntax::Source; use typst::syntax::{FileId, Source, VirtualPath};
use typst::World;
use super::{autocomplete, Completion}; use super::{autocomplete, Completion};
use crate::tests::{SourceExt, TestWorld}; use crate::tests::{SourceExt, TestWorld};
/// Quote a string.
macro_rules! q {
($s:literal) => {
concat!("\"", $s, "\"")
};
}
type Response = Option<(usize, Vec<Completion>)>; type Response = Option<(usize, Vec<Completion>)>;
trait ResponseExt { trait ResponseExt {
@ -1488,6 +1561,14 @@ mod tests {
test_full(world, &world.main, doc.as_ref(), cursor) 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] #[track_caller]
fn test_full( fn test_full(
world: &TestWorld, world: &TestWorld,
@ -1495,7 +1576,7 @@ mod tests {
doc: Option<&Document>, doc: Option<&Document>,
cursor: isize, cursor: isize,
) -> Response { ) -> Response {
autocomplete(&world, doc, source, source.cursor(cursor), true) autocomplete(world, doc, source, source.cursor(cursor), true)
} }
#[test] #[test]
@ -1530,8 +1611,8 @@ mod tests {
#[test] #[test]
fn test_autocomplete_cite_function() { fn test_autocomplete_cite_function() {
// First compile a working file to get a document. // First compile a working file to get a document.
let mut world = TestWorld::new("#bibliography(\"works.bib\") <bib>") let mut world =
.with_asset_by_name("works.bib"); TestWorld::new("#bibliography(\"works.bib\") <bib>").with_asset("works.bib");
let doc = typst::compile(&world).output.ok(); let doc = typst::compile(&world).output.ok();
// Then, add the invalid `#cite` call. Had the document been invalid // Then, add the invalid `#cite` call. Had the document been invalid
@ -1573,4 +1654,36 @@ mod tests {
.must_include(["integer"]) .must_include(["integer"])
.must_exclude(["string"]); .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 ecow::{eco_format, EcoString};
use typst::syntax::package::PackageSpec; use typst::syntax::package::PackageSpec;
use typst::syntax::FileId;
use typst::text::{FontInfo, FontStyle}; use typst::text::{FontInfo, FontStyle};
use typst::World; use typst::World;
@ -40,6 +41,14 @@ pub trait IdeWorld: World {
fn packages(&self) -> &[(PackageSpec, Option<EcoString>)] { 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. /// 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 { mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use ecow::EcoString;
use typst::diag::{FileError, FileResult}; use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime, Smart}; use typst::foundations::{Bytes, Datetime, Smart};
use typst::layout::{Abs, Margin, PageElem}; use typst::layout::{Abs, Margin, PageElem};
use typst::syntax::package::{PackageSpec, PackageVersion};
use typst::syntax::{FileId, Source, VirtualPath}; use typst::syntax::{FileId, Source, VirtualPath};
use typst::text::{Font, FontBook, TextElem, TextSize}; use typst::text::{Font, FontBook, TextElem, TextSize};
use typst::utils::{singleton, LazyHash}; 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. /// Add an additional source file to the test world.
pub fn with_source(mut self, path: &str, text: &str) -> Self { pub fn with_source(mut self, path: &str, text: &str) -> Self {
let id = FileId::new(None, VirtualPath::new(path)); let id = FileId::new(None, VirtualPath::new(path));
@ -173,6 +174,22 @@ mod tests {
self 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`. /// The ID of the main file in a `TestWorld`.
pub fn main_id() -> FileId { pub fn main_id() -> FileId {
*singleton!(FileId, FileId::new(None, VirtualPath::new("main.typ"))) *singleton!(FileId, FileId::new(None, VirtualPath::new("main.typ")))
@ -222,6 +239,25 @@ mod tests {
fn upcast(&self) -> &dyn World { fn upcast(&self) -> &dyn World {
self 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`]. /// Extra methods for [`Source`].