mirror of
https://github.com/Myriad-Dreamin/tinymist.git
synced 2025-07-24 05:05:00 +00:00
feat: add init template command (#50)
* feat: add init template command * dev: remove bad patch
This commit is contained in:
parent
f4fd0fc276
commit
c92149fa3e
10 changed files with 316 additions and 7 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -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]]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>> {
|
||||
|
|
1
crates/tinymist/src/tools/mod.rs
Normal file
1
crates/tinymist/src/tools/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod package;
|
158
crates/tinymist/src/tools/package/init.rs
Normal file
158
crates/tinymist/src/tools/package/init.rs
Normal 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)
|
||||
}
|
39
crates/tinymist/src/tools/package/mod.rs
Normal file
39
crates/tinymist/src/tools/package/mod.rs
Normal 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"))
|
||||
}
|
||||
}
|
|
@ -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": {
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue