feat: add init template command (#50)

* feat: add init template command

* dev: remove bad patch
This commit is contained in:
Myriad-Dreamin 2024-03-16 13:21:29 +08:00 committed by GitHub
parent f4fd0fc276
commit c92149fa3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 316 additions and 7 deletions

2
Cargo.lock generated
View file

@ -3604,6 +3604,7 @@ dependencies = [
"tinymist-query",
"tokio",
"tokio-util",
"toml",
"typst",
"typst-assets 0.10.0",
"typst-pdf",
@ -3611,6 +3612,7 @@ dependencies = [
"typst-ts-compiler",
"typst-ts-core",
"vergen",
"walkdir",
]
[[package]]

View file

@ -32,6 +32,11 @@ strum = { version = "0.25.0", features = ["derive"] }
async-trait = "0.1.73"
parking_lot = "0.12.1"
thiserror = "1.0.44"
walkdir = "2"
toml = { version = "0.8", default-features = false, features = [
"parse",
"display",
] }
typst = "0.10.0"
typst-ide = "0.10.0"

View file

@ -25,12 +25,9 @@ parking_lot.workspace = true
ena.workspace = true
once_cell.workspace = true
fxhash.workspace = true
walkdir = "2"
toml.workspace = true
walkdir.workspace = true
indexmap = "2.1.0"
toml = { version = "0.8", default-features = false, features = [
"parse",
"display",
] }
typst.workspace = true
typst-ide.workspace = true

View file

@ -46,8 +46,9 @@ typst-ts-core = { version = "0.4.2-rc6", default-features = false, features = [
] }
codespan-reporting = "0.11"
typst-ts-compiler.workspace = true
toml.workspace = true
walkdir.workspace = true
typst-preview = { workspace = true, optional = true }
lsp-server.workspace = true
crossbeam-channel.workspace = true
lsp-types.workspace = true

View file

@ -31,6 +31,7 @@ mod actor;
pub mod init;
mod state;
mod task;
mod tools;
pub mod transport;
mod utils;
@ -62,13 +63,18 @@ use tinymist_query::{
get_semantic_tokens_unregistration, DiagnosticsMap, SemanticTokenCache,
};
use tokio::sync::mpsc;
use typst::diag::StrResult;
use typst::syntax::package::VersionlessPackageSpec;
use typst_ts_compiler::service::Compiler;
use typst_ts_core::config::CompileOpts;
use typst_ts_core::ImmutPath;
use typst_ts_core::package::PackageSpec;
use typst_ts_core::{error::prelude::*, ImmutPath};
pub type MaySyncResult<'a> = Result<JsonValue, BoxFuture<'a, JsonValue>>;
use crate::actor::render::PdfExportConfig;
use crate::init::*;
use crate::tools::package::InitTask;
// Enforces drop order
pub struct Handle<H, C> {
@ -642,6 +648,7 @@ impl TypstLanguageServer {
redirected_command!("tinymist.doClearCache", Self::clear_cache),
redirected_command!("tinymist.pinMain", Self::pin_document),
redirected_command!("tinymist.focusMain", Self::focus_document),
redirected_command!("tinymist.doInitTemplate", Self::init_template),
])
}
@ -714,6 +721,61 @@ impl TypstLanguageServer {
info!("file focused: {entry:?}", entry = new_entry);
Ok(JsonValue::Null)
}
/// Initialize a new template.
pub fn init_template(&self, arguments: Vec<JsonValue>) -> LspResult<JsonValue> {
use crate::tools::package::{self, determine_latest_version, TemplateSource};
let from_source = arguments
.first()
.and_then(|v| v.as_str())
.map(|s| s.to_owned())
.ok_or_else(|| invalid_params("The first parameter is not a valid source or null"))?;
let to_path = parse_path_or_null(arguments.get(1))?;
self.primary()
.steal(move |c| {
let mut from_source = from_source.as_str();
if from_source.starts_with("typst ") {
from_source = from_source[6..].trim();
}
if from_source.starts_with("init ") {
from_source = from_source[5..].trim();
}
// Parse the package specification. If the user didn't specify the version,
// we try to figure it out automatically by downloading the package index
// or searching the disk.
let spec: PackageSpec = from_source
.parse()
.or_else(|err| {
// Try to parse without version, but prefer the error message of the
// normal package spec parsing if it fails.
let spec: VersionlessPackageSpec = from_source.parse().map_err(|_| err)?;
let version = determine_latest_version(c.compiler.world(), &spec)?;
StrResult::Ok(spec.at(version))
})
.map_err(map_string_err("failed to parse package spec"))?;
let from_source = TemplateSource::Package(spec);
package::init(
c.compiler.world(),
InitTask {
tmpl: from_source.clone(),
dir: to_path.clone(),
},
)
.map_err(map_string_err("failed to initialize template"))?;
info!("template initialized: {from_source:?} to {to_path:?}");
ZResult::Ok(())
})
.and_then(|e| e)
.map_err(|e| invalid_params(format!("failed to determine template source: {e}")))?;
Ok(JsonValue::Null)
}
}
fn parse_path_or_null(v: Option<&JsonValue>) -> LspResult<Option<ImmutPath>> {

View file

@ -0,0 +1 @@
pub mod package;

View file

@ -0,0 +1,158 @@
use std::io::Write;
use std::path::Path;
use typst::diag::{bail, eco_format, FileError, FileResult, StrResult};
use typst::syntax::package::{PackageManifest, PackageSpec, TemplateInfo};
use typst::syntax::VirtualPath;
use typst::World;
use typst_ts_compiler::TypstSystemWorld;
use typst_ts_core::{ImmutPath, TypstFileId};
#[derive(Debug, Clone)]
pub enum TemplateSource {
Package(PackageSpec),
}
pub struct InitTask {
pub tmpl: TemplateSource,
pub dir: Option<ImmutPath>,
}
/// Execute an initialization command.
pub fn init(world: &TypstSystemWorld, task: InitTask) -> StrResult<()> {
let TemplateSource::Package(spec) = task.tmpl;
let project_dir = task
.dir
.unwrap_or_else(|| Path::new(spec.name.as_str()).into());
let toml_id = TypstFileId::new(Some(spec.clone()), VirtualPath::new("typst.toml"));
// Parse the manifest.
let manifest = parse_manifest(world, toml_id)?;
manifest.validate(&spec)?;
// Ensure that it is indeed a template.
let Some(template) = &manifest.template else {
bail!("package {spec} is not a template");
};
// Determine the directory at which we will create the project.
// let project_dir =
// Path::new(command.dir.as_deref().unwrap_or(&manifest.package.name));
// Set up the project.
scaffold_project(world, template, toml_id, &project_dir)?;
Ok(())
}
/// Parses the manifest of the package located at `package_path`.
fn parse_manifest(world: &TypstSystemWorld, toml_id: TypstFileId) -> StrResult<PackageManifest> {
let toml_data = world
.file(toml_id)
.map_err(|err| eco_format!("failed to read package manifest ({})", err))?;
let string = std::str::from_utf8(&toml_data)
.map_err(|err| eco_format!("package manifest is not valid UTF-8 ({})", err))?;
toml::from_str(string)
.map_err(|err| eco_format!("package manifest is malformed ({})", err.message()))
}
/// Creates the project directory with the template's contents and returns the
/// path at which it was created.
fn scaffold_project(
world: &TypstSystemWorld,
tmpl_info: &TemplateInfo,
toml_id: TypstFileId,
project_dir: &Path,
) -> StrResult<()> {
if project_dir.exists() {
if !project_dir.is_dir() {
bail!(
"project directory already exists as a file (at {})",
project_dir.display()
);
}
// empty_dir(project_dir)?;
let mut entries = std::fs::read_dir(project_dir)
.map_err(|e| FileError::from_io(e, project_dir))?
.peekable();
if entries.peek().is_some() {
bail!(
"project directory already exists and is not empty (at {})",
project_dir.display()
);
}
}
let package_root = world.path_for_id(toml_id)?;
let package_root = package_root
.parent()
.ok_or_else(|| eco_format!("package root is not a directory (at {:?})", toml_id))?;
let template_dir = toml_id.join(tmpl_info.path.as_str());
let real_template_dir = world.path_for_id(template_dir)?;
if !real_template_dir.exists() {
bail!(
"template directory does not exist (at {})",
real_template_dir.display()
);
}
let files = scan_package_files(toml_id.package().cloned(), package_root, &real_template_dir)?;
// res.insert(id, world.file(id)?);
for id in files {
let f = world.file(id)?;
let template_dir = template_dir.vpath().as_rooted_path();
let file_path = id.vpath().as_rooted_path();
let relative_path = file_path.strip_prefix(template_dir).map_err(|err| {
eco_format!(
"failed to strip prefix, path: {file_path:?}, root: {template_dir:?}: {err}"
)
})?;
let file_path = project_dir.join(relative_path);
let file_dir = file_path.parent().unwrap();
std::fs::create_dir_all(file_dir).map_err(|e| FileError::from_io(e, file_dir))?;
let mut file =
std::fs::File::create(&file_path).map_err(|e| FileError::from_io(e, &file_path))?;
file.write_all(f.as_slice())
.map_err(|e| FileError::from_io(e, &file_path))?
}
Ok(())
}
fn scan_package_files(
package: Option<PackageSpec>,
root: &Path,
tmpl_root: &Path,
) -> FileResult<Vec<TypstFileId>> {
let mut res = Vec::new();
for path in walkdir::WalkDir::new(tmpl_root)
.follow_links(false)
.into_iter()
{
let Ok(de) = path else {
continue;
};
if !de.file_type().is_file() {
continue;
}
let path = de.path();
let relative_path = match path.strip_prefix(root) {
Ok(p) => p,
Err(err) => {
log::warn!("failed to strip prefix, path: {path:?}, root: {root:?}: {err}");
continue;
}
};
let id = TypstFileId::new(package.clone(), VirtualPath::new(relative_path));
res.push(id);
}
Ok(res)
}

View file

@ -0,0 +1,39 @@
use typst::diag::{eco_format, StrResult};
use typst::syntax::package::{PackageVersion, VersionlessPackageSpec};
use typst_ts_compiler::package::Registry;
use typst_ts_compiler::TypstSystemWorld;
mod init;
pub use init::*;
/// Try to determine the latest version of a package.
pub fn determine_latest_version(
world: &TypstSystemWorld,
spec: &VersionlessPackageSpec,
) -> StrResult<PackageVersion> {
if spec.namespace == "preview" {
let packages = world.registry.packages();
packages
.iter()
.filter(|(package, _)| package.namespace == "preview" && package.name == spec.name)
.map(|(package, _)| package.version)
.max()
.ok_or_else(|| eco_format!("failed to find package {spec}"))
} else {
// For other namespaces, search locally. We only search in the data
// directory and not the cache directory, because the latter is not
// intended for storage of local packages.
let subdir = format!("typst/packages/{}/{}", spec.namespace, spec.name);
world
.registry
.local_path()
.into_iter()
.flat_map(|dir| std::fs::read_dir(dir.join(&subdir)).ok())
.flatten()
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter_map(|path| path.file_name()?.to_string_lossy().parse().ok())
.max()
.ok_or_else(|| eco_format!("please specify the desired version"))
}
}

View file

@ -325,6 +325,11 @@
"command": "tinymist.clearCache",
"title": "Clear all cached resources",
"category": "Typst"
},
{
"command": "tinymist.initTemplate",
"title": "Initialize a new Typst project based on a template",
"category": "Typst"
}
],
"menus": {

View file

@ -87,6 +87,9 @@ async function startClient(context: ExtensionContext): Promise<void> {
context.subscriptions.push(
commands.registerCommand("tinymist.runCodeLens", commandRunCodeLens)
);
context.subscriptions.push(
commands.registerCommand("tinymist.initTemplate", commandInitTemplate)
);
return client.start();
}
@ -230,6 +233,42 @@ async function commandPinMain(isPin: boolean): Promise<void> {
});
}
async function commandInitTemplate(...args: string[]): Promise<void> {
const initArgs: string[] = [];
if (args.length === 2) {
initArgs.push(...args);
} else if (args.length > 0) {
await vscode.window.showErrorMessage("Invalid arguments for initTemplate");
return;
} else {
const mode = await vscode.window.showInputBox({
title: "template from url or package spec id",
prompt: "git or package spec with an optional version, you can also enters entire command, such as `typst init @preview/touying:0.3.2`",
});
initArgs.push(mode ?? "");
const path = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: "Select folder to initialize",
});
if (path === undefined) {
return;
}
initArgs.push(path[0].fsPath);
}
const fsPath = initArgs[1];
const uri = Uri.file(fsPath);
await client?.sendRequest("workspace/executeCommand", {
command: "tinymist.doInitTemplate",
arguments: [...initArgs],
});
await commands.executeCommand("vscode.openFolder", uri);
}
async function commandActivateDoc(editor: TextEditor | undefined): Promise<void> {
await client?.sendRequest("workspace/executeCommand", {
command: "tinymist.focusMain",