feat: CLI compile documents with lock updates (#1218)

This commit is contained in:
Myriad-Dreamin 2025-01-28 13:57:27 +08:00 committed by GitHub
parent ebd811db13
commit b541daf50e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 238 additions and 144 deletions

View file

@ -22,11 +22,7 @@ pub enum DocCommands {
/// Project task commands.
#[derive(Debug, Clone, clap::Subcommand)]
#[clap(rename_all = "kebab-case")]
// clippy bug: TaskCompileArgs is evaluated as 0 bytes
#[allow(clippy::large_enum_variant)]
pub enum TaskCommands {
/// Declare a compile task (output).
Compile(TaskCompileArgs),
/// Declare a preview task.
Preview(TaskPreviewArgs),
}
@ -48,6 +44,50 @@ pub struct DocNewArgs {
pub package: CompilePackageArgs,
}
impl DocNewArgs {
/// Converts to project input.
pub fn to_input(&self) -> ProjectInput {
let id: Id = (&self.id).into();
let root = self
.root
.as_ref()
.map(|root| ResourcePath::from_user_sys(Path::new(root)));
let main = ResourcePath::from_user_sys(Path::new(&self.id.input));
let font_paths = self
.font
.font_paths
.iter()
.map(|p| ResourcePath::from_user_sys(p))
.collect::<Vec<_>>();
let package_path = self
.package
.package_path
.as_ref()
.map(|p| ResourcePath::from_user_sys(p));
let package_cache_path = self
.package
.package_cache_path
.as_ref()
.map(|p| ResourcePath::from_user_sys(p));
ProjectInput {
id: id.clone(),
root,
main,
// todo: inputs
inputs: vec![],
font_paths,
system_fonts: !self.font.ignore_system_fonts,
package_path,
package_cache_path,
}
}
}
/// The id of a document.
///
/// If an identifier is not provided, the document's path is used as the id.

View file

@ -37,7 +37,7 @@ pub struct ProjectInsId(EcoString);
///
/// Whether to export depends on the current state of the document and the user
/// settings.
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, Default)]
pub struct ExportSignal {
/// Whether the revision is annotated by memory events.
pub by_mem_events: bool,
@ -62,6 +62,17 @@ pub struct CompileSnapshot<F: CompilerFeat> {
}
impl<F: CompilerFeat + 'static> CompileSnapshot<F> {
/// Creates a snapshot from the world.
pub fn from_world(world: CompilerWorld<F>) -> Self {
Self {
id: ProjectInsId("primary".into()),
signal: ExportSignal::default(),
env: CompileEnv::default(),
world,
success_doc: None,
}
}
/// Forks a new snapshot that compiles a different document.
///
/// Note: the resulting document should not be shared in system, because we

View file

@ -23,6 +23,10 @@ impl LockFile {
self.document.iter().find(|i| &i.id == id)
}
pub fn get_task(&self, id: &Id) -> Option<&ApplyProjectTask> {
self.task.iter().find(|i| &i.id == id)
}
pub fn replace_document(&mut self, input: ProjectInput) {
let id = input.id.clone();
let index = self.document.iter().position(|i| i.id == id);
@ -263,10 +267,13 @@ impl LockFileUpdate {
let _ = package_cache_path;
let _ = package_path;
// todo: freeze the sys.inputs
let input = ProjectInput {
id: id.clone(),
root: Some(root),
main: Some(main),
main,
inputs: vec![],
font_paths,
system_fonts: true, // !args.font.ignore_system_fonts,
package_path: None,

View file

@ -407,13 +407,13 @@ impl ResourcePath {
}
}
/// Converts the resource path to an absolute file system path.
pub fn to_abs_path(&self, rel: &Path) -> Option<PathBuf> {
pub fn to_abs_path(&self, base: &Path) -> Option<PathBuf> {
if self.0 == "file" {
let path = Path::new(&self.1);
if path.is_absolute() {
Some(path.to_owned())
} else {
Some(rel.join(path))
Some(base.join(path))
}
} else {
None
@ -481,12 +481,13 @@ pub struct LockFile {
pub struct ProjectInput {
/// The project's ID.
pub id: Id,
/// The project's root directory.
/// The path to the root directory of the project.
#[serde(skip_serializing_if = "Option::is_none")]
pub root: Option<ResourcePath>,
/// The project's main file.
#[serde(skip_serializing_if = "Option::is_none")]
pub main: Option<ResourcePath>,
/// The path to the main file of the project.
pub main: ResourcePath,
/// The key-value pairs visible through `sys.inputs`
pub inputs: Vec<(String, String)>,
/// The project's font paths.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub font_paths: Vec<ResourcePath>,

View file

@ -13,7 +13,7 @@ use std::path::Path;
use std::{borrow::Cow, sync::Arc};
use tinymist_std::error::prelude::*;
use tinymist_std::ImmutPath;
use tinymist_std::{bail, ImmutPath};
use tinymist_world::font::system::SystemFontSearcher;
use tinymist_world::package::{http::HttpRegistry, RegistryPathMapper};
use tinymist_world::vfs::{system::SystemAccessModel, Vfs};
@ -22,6 +22,7 @@ use typst::foundations::{Dict, Str, Value};
use typst::utils::LazyHash;
use crate::font::TinymistFontResolver;
use crate::ProjectInput;
/// Compiler feature for LSP universe and worlds without typst.ts to implement
/// more for tinymist. type trait of [`CompilerUniverse`].
@ -114,6 +115,80 @@ impl WorldProvider for CompileOnceArgs {
}
}
// todo: merge me with the above impl
impl WorldProvider for (ProjectInput, ImmutPath) {
fn resolve(&self) -> Result<LspUniverse> {
let (proj, lock_dir) = self;
let entry = self.entry()?.try_into()?;
let inputs = proj
.inputs
.iter()
.map(|(k, v)| (Str::from(k.as_str()), Value::Str(Str::from(v.as_str()))))
.collect();
let fonts = LspUniverseBuilder::resolve_fonts(CompileFontArgs {
font_paths: {
proj.font_paths
.iter()
.flat_map(|p| p.to_abs_path(lock_dir))
.collect::<Vec<_>>()
},
ignore_system_fonts: !proj.system_fonts,
})?;
let package = LspUniverseBuilder::resolve_package(
// todo: recover certificate path
None,
Some(&CompilePackageArgs {
package_path: proj
.package_path
.as_ref()
.and_then(|p| p.to_abs_path(lock_dir)),
package_cache_path: proj
.package_cache_path
.as_ref()
.and_then(|p| p.to_abs_path(lock_dir)),
}),
);
LspUniverseBuilder::build(
entry,
Arc::new(LazyHash::new(inputs)),
Arc::new(fonts),
package,
)
.context("failed to create universe")
}
fn entry(&self) -> Result<EntryOpts> {
let (proj, lock_dir) = self;
let entry = proj
.main
.to_abs_path(lock_dir)
.context("failed to resolve entry file")?;
let root = if let Some(root) = &proj.root {
root.to_abs_path(lock_dir)
.context("failed to resolve root")?
} else {
lock_dir.as_ref().to_owned()
};
if !entry.starts_with(&root) {
bail!("entry file must be in the root directory, {entry:?}, {root:?}");
}
let relative_entry = match entry.strip_prefix(&root) {
Ok(relative_entry) => relative_entry,
Err(_) => bail!("entry path must be inside the root: {}", entry.display()),
};
Ok(EntryOpts::new_rooted(
root.clone(),
Some(relative_entry.to_owned()),
))
}
}
/// Builder for LSP universe.
pub struct LspUniverseBuilder;

View file

@ -4,6 +4,7 @@ use sync_lsp::transport::MirrorArgs;
use tinymist::{
project::{DocCommands, TaskCommands},
tool::project::CompileArgs,
CompileFontArgs, CompileOnceArgs,
};
use tinymist_core::LONG_VERSION;
@ -32,8 +33,7 @@ pub enum Commands {
#[cfg(feature = "preview")]
Preview(tinymist::tool::preview::PreviewCliArgs),
/// Runs compile commands
#[clap(hide(true))] // still in development
/// Runs compile command like `typst-cli compile`
Compile(CompileArgs),
/// Runs language query
#[clap(hide(true))] // still in development
@ -146,17 +146,6 @@ pub struct TraceLspArgs {
pub compile: CompileOnceArgs,
}
/// Common arguments of compile, watch, and query.
#[derive(Debug, Clone, Default, clap::Parser)]
pub struct CompileArgs {
#[clap(flatten)]
pub compile: CompileOnceArgs,
/// Path to output file
#[clap(value_name = "OUTPUT")]
pub output: Option<String>,
}
#[derive(Debug, Clone, Default, clap::Parser)]
pub struct LspArgs {
#[clap(flatten)]

View file

@ -16,18 +16,18 @@ use futures::future::MaybeDone;
use lsp_server::RequestId;
use once_cell::sync::Lazy;
use reflexo::ImmutPath;
use reflexo_typst::{package::PackageSpec, Compiler, TypstDict};
use reflexo_typst::{package::PackageSpec, TypstDict};
use serde_json::Value as JsonValue;
use sync_lsp::{
internal_error,
transport::{with_stdio_transport, MirrorArgs},
LspBuilder, LspClientRoot, LspResult,
};
use tinymist::world::TaskInputs;
use tinymist::{
tool::project::{project_main, task_main},
tool::project::{compile_main, project_main, task_main},
CompileConfig, Config, RegularInit, ServerState, SuperInit, UserActionTask,
};
use tinymist::{world::TaskInputs, world::WorldProvider};
use tinymist_core::LONG_VERSION;
use tinymist_project::EntryResolver;
use tinymist_query::package::PackageInfo;
@ -65,27 +65,29 @@ fn main() -> Result<()> {
#[cfg(feature = "dhat-heap")]
let _profiler = dhat::Profiler::new_heap();
// Parse command line arguments
let args = CliArguments::parse();
let is_transient_cmd = matches!(args.command, Some(Commands::Compile(..)));
// Start logging
let _ = {
use log::LevelFilter::*;
let base_level = if is_transient_cmd { Warn } else { Info };
env_logger::builder()
.filter_module("tinymist", Info)
.filter_module("tinymist", base_level)
.filter_module("typst_preview", Debug)
.filter_module("typlite", Info)
.filter_module("reflexo", Info)
.filter_module("sync_lsp", Info)
.filter_module("reflexo_typst::service::compile", Info)
.filter_module("reflexo_typst::service::watch", Info)
.filter_module("typlite", base_level)
.filter_module("reflexo", base_level)
.filter_module("sync_lsp", base_level)
.filter_module("reflexo_typst::diag::console", Info)
.try_init()
};
// Parse command line arguments
let args = CliArguments::parse();
match args.command.unwrap_or_default() {
Commands::Completion(args) => completion(args),
Commands::Compile(args) => compile(args),
Commands::Compile(args) => RUNTIMES.tokio_runtime.block_on(compile_main(args)),
Commands::Query(query_cmds) => query_main(query_cmds),
Commands::Lsp(args) => lsp_main(args),
Commands::TraceLsp(args) => trace_lsp_main(args),
@ -114,41 +116,6 @@ pub fn completion(args: ShellCompletionArgs) -> Result<()> {
Ok(())
}
/// Runs compilation
pub fn compile(args: CompileArgs) -> Result<()> {
use std::io::Write;
let input = args
.compile
.input
.as_ref()
.context("Missing required argument: INPUT")?;
let output = match args.output {
Some(stdout_path) if stdout_path == "-" => None,
Some(output_path) => Some(PathBuf::from(output_path)),
None => Some(Path::new(input).with_extension("pdf")),
};
let universe = args.compile.resolve()?;
let world = universe.snapshot();
let converter = std::marker::PhantomData.compile(&world, &mut Default::default());
let pdf = typst_pdf::pdf(&converter.unwrap().output, &Default::default());
match (pdf, output) {
(Ok(pdf), None) => {
std::io::stdout().write_all(&pdf).unwrap();
}
(Ok(pdf), Some(output)) => std::fs::write(output, pdf).unwrap(),
(Err(err), ..) => {
eprintln!("{err:?}");
std::process::exit(1);
}
}
Ok(())
}
/// The main entry point for the language server.
pub fn lsp_main(args: LspArgs) -> Result<()> {
let pairs = LONG_VERSION.trim().split('\n');

View file

@ -263,8 +263,8 @@ impl ServerState {
.unwrap_or_else(|| lock_dir.clone());
let main = input
.main
.as_ref()
.and_then(|main| Some(main.to_abs_path(lock_dir)?.as_path().into()))
.to_abs_path(lock_dir)
.map(|path| path.as_path().into())
.unwrap_or_else(|| path.clone());
let entry = self
.entry_resolver()

View file

@ -1,76 +1,34 @@
//! Project management tools.
use std::path::Path;
use std::path::{Path, PathBuf};
use reflexo::ImmutPath;
use tinymist_std::error::prelude::*;
use crate::project::*;
use crate::{project::*, task::ExportTask};
/// Common arguments of compile, watch, and query.
#[derive(Debug, Clone, clap::Parser)]
pub struct CompileArgs {
/// Inherits the compile task arguments.
#[clap(flatten)]
pub compile: TaskCompileArgs,
/// Saves the compilation arguments to the lock file.
#[clap(long)]
pub save_lock: bool,
/// Specifies the path to the lock file. If the path is
/// set, the lock file will be saved.
#[clap(long)]
pub lockfile: Option<PathBuf>,
}
trait LockFileExt {
fn declare(&mut self, args: &DocNewArgs) -> Id;
fn preview(&mut self, doc_id: Id, args: &TaskPreviewArgs) -> Result<Id>;
fn compile(&mut self, args: TaskCompileArgs) -> Result<Id>;
fn export(&mut self, doc_id: Id, args: TaskCompileArgs) -> Result<Id>;
}
impl LockFileExt for LockFile {
fn declare(&mut self, args: &DocNewArgs) -> Id {
let id: Id = (&args.id).into();
let root = args
.root
.as_ref()
.map(|root| ResourcePath::from_user_sys(Path::new(root)));
let main = ResourcePath::from_user_sys(Path::new(&args.id.input));
let font_paths = args
.font
.font_paths
.iter()
.map(|p| ResourcePath::from_user_sys(p))
.collect::<Vec<_>>();
let package_path = args
.package
.package_path
.as_ref()
.map(|p| ResourcePath::from_user_sys(p));
let package_cache_path = args
.package
.package_cache_path
.as_ref()
.map(|p| ResourcePath::from_user_sys(p));
let input = ProjectInput {
id: id.clone(),
root,
main: Some(main),
font_paths,
system_fonts: !args.font.ignore_system_fonts,
package_path,
package_cache_path,
};
self.replace_document(input);
id
}
fn compile(&mut self, args: TaskCompileArgs) -> Result<Id> {
let id = self.declare(&args.declare);
self.export(id, args)
}
fn export(&mut self, doc_id: Id, args: TaskCompileArgs) -> Result<Id> {
let task = args.to_task(doc_id)?;
let task_id = task.id().clone();
self.replace_task(task);
Ok(task_id)
}
fn preview(&mut self, doc_id: Id, args: &TaskPreviewArgs) -> Result<Id> {
let task_id = args
.name
@ -92,12 +50,51 @@ impl LockFileExt for LockFile {
}
}
/// Runs project compilation(s)
pub async fn compile_main(args: CompileArgs) -> Result<()> {
// Identifies the input and output
let input = args.compile.declare.to_input();
let output = args.compile.to_task(input.id.clone())?;
// Saves the lock file if the flags are set
let save_lock = args.save_lock || args.lockfile.is_some();
// todo: respect the name of the lock file
let lock_dir: ImmutPath = if let Some(lockfile) = args.lockfile {
lockfile.parent().context("no parent")?.into()
} else {
std::env::current_dir().context("lock directory")?.into()
};
if save_lock {
LockFile::update(&lock_dir, |state| {
state.replace_document(input.clone());
state.replace_task(output.clone());
Ok(())
})?;
}
// Prepares for the compilation
let universe = (input, lock_dir.clone()).resolve()?;
let world = universe.snapshot();
let snap = CompileSnapshot::from_world(world);
// Compiles the project
let compiled = snap.compile();
// Exports the compiled project
let lock_dir = save_lock.then_some(lock_dir);
ExportTask::do_export(output.task, compiled, lock_dir).await?;
Ok(())
}
/// Project document commands' main
pub fn project_main(args: DocCommands) -> Result<()> {
LockFile::update(Path::new("."), |state| {
match args {
DocCommands::New(args) => {
state.declare(&args);
state.replace_document(args.to_input());
}
DocCommands::Configure(args) => {
let id: Id = (&args.id).into();
@ -117,11 +114,10 @@ pub fn project_main(args: DocCommands) -> Result<()> {
pub fn task_main(args: TaskCommands) -> Result<()> {
LockFile::update(Path::new("."), |state| {
match args {
TaskCommands::Compile(args) => {
let _ = state.compile(args);
}
TaskCommands::Preview(args) => {
let id = state.declare(&args.declare);
let input = args.declare.to_input();
let id = input.id.clone();
state.replace_document(input);
let _ = state.preview(id, &args);
}
}

View file

@ -4,10 +4,16 @@ version = "0.1.0-beta0"
[[document]]
id = "file:docs/tinymist/book.typ"
inputs = []
main = "file:docs/tinymist/book.typ"
root = "file:."
system-fonts = true
[[document]]
id = "file:docs/tinymist/ebook.typ"
inputs = []
main = "file:docs/tinymist/ebook.typ"
root = "file:."
system-fonts = true
[[task]]
@ -23,7 +29,9 @@ type = "export-svg"
when = "never"
[[task.transform]]
pages = ["1-1"]
[task.transform.pages]
ranges = ["1-1"]
[[task]]
document = "file:docs/tinymist/ebook.typ"