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. /// Project task commands.
#[derive(Debug, Clone, clap::Subcommand)] #[derive(Debug, Clone, clap::Subcommand)]
#[clap(rename_all = "kebab-case")] #[clap(rename_all = "kebab-case")]
// clippy bug: TaskCompileArgs is evaluated as 0 bytes
#[allow(clippy::large_enum_variant)]
pub enum TaskCommands { pub enum TaskCommands {
/// Declare a compile task (output).
Compile(TaskCompileArgs),
/// Declare a preview task. /// Declare a preview task.
Preview(TaskPreviewArgs), Preview(TaskPreviewArgs),
} }
@ -48,6 +44,50 @@ pub struct DocNewArgs {
pub package: CompilePackageArgs, 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. /// The id of a document.
/// ///
/// If an identifier is not provided, the document's path is used as the id. /// 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 /// Whether to export depends on the current state of the document and the user
/// settings. /// settings.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy, Default)]
pub struct ExportSignal { pub struct ExportSignal {
/// Whether the revision is annotated by memory events. /// Whether the revision is annotated by memory events.
pub by_mem_events: bool, pub by_mem_events: bool,
@ -62,6 +62,17 @@ pub struct CompileSnapshot<F: CompilerFeat> {
} }
impl<F: CompilerFeat + 'static> CompileSnapshot<F> { 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. /// Forks a new snapshot that compiles a different document.
/// ///
/// Note: the resulting document should not be shared in system, because we /// 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) 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) { pub fn replace_document(&mut self, input: ProjectInput) {
let id = input.id.clone(); let id = input.id.clone();
let index = self.document.iter().position(|i| i.id == id); let index = self.document.iter().position(|i| i.id == id);
@ -263,10 +267,13 @@ impl LockFileUpdate {
let _ = package_cache_path; let _ = package_cache_path;
let _ = package_path; let _ = package_path;
// todo: freeze the sys.inputs
let input = ProjectInput { let input = ProjectInput {
id: id.clone(), id: id.clone(),
root: Some(root), root: Some(root),
main: Some(main), main,
inputs: vec![],
font_paths, font_paths,
system_fonts: true, // !args.font.ignore_system_fonts, system_fonts: true, // !args.font.ignore_system_fonts,
package_path: None, package_path: None,

View file

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

View file

@ -13,7 +13,7 @@ use std::path::Path;
use std::{borrow::Cow, sync::Arc}; use std::{borrow::Cow, sync::Arc};
use tinymist_std::error::prelude::*; use tinymist_std::error::prelude::*;
use tinymist_std::ImmutPath; use tinymist_std::{bail, ImmutPath};
use tinymist_world::font::system::SystemFontSearcher; use tinymist_world::font::system::SystemFontSearcher;
use tinymist_world::package::{http::HttpRegistry, RegistryPathMapper}; use tinymist_world::package::{http::HttpRegistry, RegistryPathMapper};
use tinymist_world::vfs::{system::SystemAccessModel, Vfs}; use tinymist_world::vfs::{system::SystemAccessModel, Vfs};
@ -22,6 +22,7 @@ use typst::foundations::{Dict, Str, Value};
use typst::utils::LazyHash; use typst::utils::LazyHash;
use crate::font::TinymistFontResolver; use crate::font::TinymistFontResolver;
use crate::ProjectInput;
/// Compiler feature for LSP universe and worlds without typst.ts to implement /// Compiler feature for LSP universe and worlds without typst.ts to implement
/// more for tinymist. type trait of [`CompilerUniverse`]. /// 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. /// Builder for LSP universe.
pub struct LspUniverseBuilder; pub struct LspUniverseBuilder;

View file

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

View file

@ -16,18 +16,18 @@ use futures::future::MaybeDone;
use lsp_server::RequestId; use lsp_server::RequestId;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use reflexo::ImmutPath; use reflexo::ImmutPath;
use reflexo_typst::{package::PackageSpec, Compiler, TypstDict}; use reflexo_typst::{package::PackageSpec, TypstDict};
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use sync_lsp::{ use sync_lsp::{
internal_error, internal_error,
transport::{with_stdio_transport, MirrorArgs}, transport::{with_stdio_transport, MirrorArgs},
LspBuilder, LspClientRoot, LspResult, LspBuilder, LspClientRoot, LspResult,
}; };
use tinymist::world::TaskInputs;
use tinymist::{ use tinymist::{
tool::project::{project_main, task_main}, tool::project::{compile_main, project_main, task_main},
CompileConfig, Config, RegularInit, ServerState, SuperInit, UserActionTask, CompileConfig, Config, RegularInit, ServerState, SuperInit, UserActionTask,
}; };
use tinymist::{world::TaskInputs, world::WorldProvider};
use tinymist_core::LONG_VERSION; use tinymist_core::LONG_VERSION;
use tinymist_project::EntryResolver; use tinymist_project::EntryResolver;
use tinymist_query::package::PackageInfo; use tinymist_query::package::PackageInfo;
@ -65,27 +65,29 @@ fn main() -> Result<()> {
#[cfg(feature = "dhat-heap")] #[cfg(feature = "dhat-heap")]
let _profiler = dhat::Profiler::new_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 // Start logging
let _ = { let _ = {
use log::LevelFilter::*; use log::LevelFilter::*;
let base_level = if is_transient_cmd { Warn } else { Info };
env_logger::builder() env_logger::builder()
.filter_module("tinymist", Info) .filter_module("tinymist", base_level)
.filter_module("typst_preview", Debug) .filter_module("typst_preview", Debug)
.filter_module("typlite", Info) .filter_module("typlite", base_level)
.filter_module("reflexo", Info) .filter_module("reflexo", base_level)
.filter_module("sync_lsp", Info) .filter_module("sync_lsp", base_level)
.filter_module("reflexo_typst::service::compile", Info)
.filter_module("reflexo_typst::service::watch", Info)
.filter_module("reflexo_typst::diag::console", Info) .filter_module("reflexo_typst::diag::console", Info)
.try_init() .try_init()
}; };
// Parse command line arguments
let args = CliArguments::parse();
match args.command.unwrap_or_default() { match args.command.unwrap_or_default() {
Commands::Completion(args) => completion(args), 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::Query(query_cmds) => query_main(query_cmds),
Commands::Lsp(args) => lsp_main(args), Commands::Lsp(args) => lsp_main(args),
Commands::TraceLsp(args) => trace_lsp_main(args), Commands::TraceLsp(args) => trace_lsp_main(args),
@ -114,41 +116,6 @@ pub fn completion(args: ShellCompletionArgs) -> Result<()> {
Ok(()) 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. /// The main entry point for the language server.
pub fn lsp_main(args: LspArgs) -> Result<()> { pub fn lsp_main(args: LspArgs) -> Result<()> {
let pairs = LONG_VERSION.trim().split('\n'); let pairs = LONG_VERSION.trim().split('\n');

View file

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

View file

@ -1,76 +1,34 @@
//! Project management tools. //! Project management tools.
use std::path::Path; use std::path::{Path, PathBuf};
use reflexo::ImmutPath;
use tinymist_std::error::prelude::*; 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 { trait LockFileExt {
fn declare(&mut self, args: &DocNewArgs) -> Id;
fn preview(&mut self, doc_id: Id, args: &TaskPreviewArgs) -> Result<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 { 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> { fn preview(&mut self, doc_id: Id, args: &TaskPreviewArgs) -> Result<Id> {
let task_id = args let task_id = args
.name .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 /// Project document commands' main
pub fn project_main(args: DocCommands) -> Result<()> { pub fn project_main(args: DocCommands) -> Result<()> {
LockFile::update(Path::new("."), |state| { LockFile::update(Path::new("."), |state| {
match args { match args {
DocCommands::New(args) => { DocCommands::New(args) => {
state.declare(&args); state.replace_document(args.to_input());
} }
DocCommands::Configure(args) => { DocCommands::Configure(args) => {
let id: Id = (&args.id).into(); let id: Id = (&args.id).into();
@ -117,11 +114,10 @@ pub fn project_main(args: DocCommands) -> Result<()> {
pub fn task_main(args: TaskCommands) -> Result<()> { pub fn task_main(args: TaskCommands) -> Result<()> {
LockFile::update(Path::new("."), |state| { LockFile::update(Path::new("."), |state| {
match args { match args {
TaskCommands::Compile(args) => {
let _ = state.compile(args);
}
TaskCommands::Preview(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); let _ = state.preview(id, &args);
} }
} }

View file

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