dev: split tinymist-project crate (#1144)

* dev: split tinymist-project crate

* build: update cargo.lock
This commit is contained in:
Myriad-Dreamin 2025-01-11 16:02:01 +08:00 committed by GitHub
parent 50ae35a623
commit c2e8f6e5f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 832 additions and 753 deletions

20
Cargo.lock generated
View file

@ -3872,7 +3872,6 @@ dependencies = [
"reflexo",
"reflexo-typst",
"reflexo-vec2svg",
"semver",
"serde",
"serde_json",
"serde_yaml",
@ -3881,6 +3880,7 @@ dependencies = [
"tinymist-assets 0.12.18 (registry+https://github.com/rust-lang/crates.io-index)",
"tinymist-core",
"tinymist-fs",
"tinymist-project",
"tinymist-query",
"tinymist-render",
"tinymist-world",
@ -3963,6 +3963,24 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "tinymist-project"
version = "0.12.18"
dependencies = [
"anyhow",
"clap",
"log",
"pathdiff",
"reflexo",
"same-file",
"semver",
"serde",
"serde_json",
"tempfile",
"tinymist-fs",
"toml",
]
[[package]]
name = "tinymist-query"
version = "0.12.18"

View file

@ -80,6 +80,7 @@ pathdiff = "0.2"
percent-encoding = "2"
rust_iso639 = "0.0.3"
rust_iso3166 = "0.1.4"
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
@ -154,6 +155,7 @@ typst-preview = { path = "./crates/typst-preview" }
tinymist-assets = { version = "0.12.18" }
tinymist = { path = "./crates/tinymist/" }
tinymist-core = { path = "./crates/tinymist-core/", default-features = false }
tinymist-project = { path = "./crates/tinymist-project/" }
tinymist-fs = { path = "./crates/tinymist-fs/" }
tinymist-derive = { path = "./crates/tinymist-derive/" }
tinymist-analysis = { path = "./crates/tinymist-analysis/" }

View file

@ -0,0 +1,32 @@
[package]
name = "tinymist-project"
description = "Project model of tinymist."
categories = ["compilers", "command-line-utilities"]
keywords = ["tool"]
authors.workspace = true
version.workspace = true
license.workspace = true
edition.workspace = true
homepage.workspace = true
repository.workspace = true
rust-version.workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
log.workspace = true
pathdiff.workspace = true
reflexo.workspace = true
semver.workspace = true
same-file.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
tinymist-fs.workspace = true
toml.workspace = true
[features]
[lints]
workspace = true

View file

@ -0,0 +1,160 @@
use crate::*;
macro_rules! display_possible_values {
($ty:ty) => {
impl fmt::Display for $ty {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.to_possible_value()
.expect("no values are skipped")
.get_name()
.fmt(f)
}
}
};
}
/// When to export an output file.
#[derive(
Debug,
Copy,
Clone,
Eq,
PartialEq,
Ord,
PartialOrd,
serde::Serialize,
serde::Deserialize,
ValueEnum,
)]
#[serde(rename_all = "camelCase")]
#[clap(rename_all = "camelCase")]
pub enum TaskWhen {
/// Never watch to run task.
Never,
/// Run task on save.
OnSave,
/// Run task on type.
OnType,
}
impl TaskWhen {
/// Returns `true` if the task should never be run automatically.
pub fn is_never(&self) -> bool {
matches!(self, TaskWhen::Never)
}
}
display_possible_values!(TaskWhen);
/// Which format to use for the generated output file.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)]
pub enum OutputFormat {
/// Export to PDF.
Pdf,
/// Export to PNG.
Png,
/// Export to SVG.
Svg,
/// Export to HTML.
Html,
}
display_possible_values!(OutputFormat);
/// A PDF standard that Typst can enforce conformance with.
#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum, serde::Serialize, serde::Deserialize)]
#[allow(non_camel_case_types)]
pub enum PdfStandard {
/// PDF 1.7.
#[value(name = "1.7")]
#[serde(rename = "1.7")]
V_1_7,
/// PDF/A-2b.
#[value(name = "a-2b")]
#[serde(rename = "a-2b")]
A_2b,
}
display_possible_values!(PdfStandard);
/// Implements parsing of page ranges (`1-3`, `4`, `5-`, `-2`), used by the
/// `CompileCommand.pages` argument, through the `FromStr` trait instead of a
/// value parser, in order to generate better errors.
///
/// See also: https://github.com/clap-rs/clap/issues/5065
#[derive(Debug, Clone)]
pub struct Pages(pub RangeInclusive<Option<NonZeroUsize>>);
impl FromStr for Pages {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value
.split('-')
.map(str::trim)
.collect::<Vec<_>>()
.as_slice()
{
[] | [""] => Err("page export range must not be empty"),
[single_page] => {
let page_number = parse_page_number(single_page)?;
Ok(Pages(Some(page_number)..=Some(page_number)))
}
["", ""] => Err("page export range must have start or end"),
[start, ""] => Ok(Pages(Some(parse_page_number(start)?)..=None)),
["", end] => Ok(Pages(None..=Some(parse_page_number(end)?))),
[start, end] => {
let start = parse_page_number(start)?;
let end = parse_page_number(end)?;
if start > end {
Err("page export range must end at a page after the start")
} else {
Ok(Pages(Some(start)..=Some(end)))
}
}
[_, _, _, ..] => Err("page export range must have a single hyphen"),
}
}
}
impl fmt::Display for Pages {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let start = match self.0.start() {
Some(start) => start.to_string(),
None => String::from(""),
};
let end = match self.0.end() {
Some(end) => end.to_string(),
None => String::from(""),
};
write!(f, "{start}-{end}")
}
}
impl serde::Serialize for Pages {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> serde::Deserialize<'de> for Pages {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
/// Parses a single page number.
fn parse_page_number(value: &str) -> Result<NonZeroUsize, &'static str> {
if value == "0" {
Err("page numbers start at one")
} else {
NonZeroUsize::from_str(value).map_err(|_| "not a valid page number")
}
}

View file

@ -0,0 +1,484 @@
//! Project model of tinymist.
#![allow(missing_docs)]
mod args;
pub use args::*;
use core::fmt;
use std::{
cmp::Ordering,
io::{Read, Seek, SeekFrom, Write},
num::NonZeroUsize,
ops::RangeInclusive,
path::Path,
str::FromStr,
};
use anyhow::{bail, Context};
use clap::{ValueEnum, ValueHint};
use reflexo::path::unix_slash;
pub use anyhow::Result;
const LOCK_VERSION: &str = "0.1.0-beta0";
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case", tag = "version")]
pub enum LockFileCompat {
#[serde(rename = "0.1.0-beta0")]
Version010Beta0(LockFile),
#[serde(untagged)]
Other(serde_json::Value),
}
impl LockFileCompat {
pub fn version(&self) -> anyhow::Result<&str> {
match self {
LockFileCompat::Version010Beta0(..) => Ok(LOCK_VERSION),
LockFileCompat::Other(v) => v
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing version field")),
}
}
pub fn migrate(self) -> anyhow::Result<LockFile> {
match self {
LockFileCompat::Version010Beta0(v) => Ok(v),
this @ LockFileCompat::Other(..) => {
bail!(
"cannot migrate from version: {}",
this.version().unwrap_or("unknown version")
)
}
}
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct LockFile {
// The lock file version.
// version: String,
/// The project's document (input).
pub document: Vec<ProjectInput>,
/// The project's task (output).
pub task: Vec<ProjectTask>,
/// The project's task route.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub route: Vec<ProjectRoute>,
}
impl LockFile {
pub fn replace_document(&mut self, input: ProjectInput) {
let id = input.id.clone();
let index = self.document.iter().position(|i| i.id == id);
if let Some(index) = index {
self.document[index] = input;
} else {
self.document.push(input);
}
}
pub fn replace_task(&mut self, task: ProjectTask) {
let id = task.id().clone();
let index = self.task.iter().position(|i| *i.id() == id);
if let Some(index) = index {
self.task[index] = task;
} else {
self.task.push(task);
}
}
pub fn sort(&mut self) {
self.document.sort_by(|a, b| a.id.cmp(&b.id));
self.task
.sort_by(|a, b| a.doc_id().cmp(b.doc_id()).then_with(|| a.id().cmp(b.id())));
// the route's order is important, so we don't sort them.
}
pub fn serialize_resolve(&self) -> String {
let content = toml::Table::try_from(self).unwrap();
let mut out = String::new();
// At the start of the file we notify the reader that the file is generated.
// Specifically Phabricator ignores files containing "@generated", so we use
// that.
let marker_line = "# This file is automatically @generated by tinymist.";
let extra_line = "# It is not intended for manual editing.";
out.push_str(marker_line);
out.push('\n');
out.push_str(extra_line);
out.push('\n');
out.push_str(&format!("version = {LOCK_VERSION:?}\n"));
let document = content.get("document");
if let Some(document) = document {
for document in document.as_array().unwrap() {
out.push('\n');
out.push_str("[[document]]\n");
emit_document(document, &mut out);
}
}
let task = content.get("task");
if let Some(task) = task {
for task in task.as_array().unwrap() {
out.push('\n');
out.push_str("[[task]]\n");
emit_output(task, &mut out);
}
}
let route = content.get("route");
if let Some(route) = route {
for route in route.as_array().unwrap() {
out.push('\n');
out.push_str("[[route]]\n");
emit_route(route, &mut out);
}
}
return out;
fn emit_document(input: &toml::Value, out: &mut String) {
let table = input.as_table().unwrap();
out.push_str(&table.to_string());
}
fn emit_output(output: &toml::Value, out: &mut String) {
let mut table = output.clone();
let table = table.as_table_mut().unwrap();
// replace transform with task.transforms
if let Some(transform) = table.remove("transform") {
let mut task_table = toml::Table::new();
task_table.insert("transform".to_string(), transform);
table.insert("task".to_string(), task_table.into());
}
out.push_str(&table.to_string());
}
fn emit_route(route: &toml::Value, out: &mut String) {
let table = route.as_table().unwrap();
out.push_str(&table.to_string());
}
}
pub fn update(path: &str, f: impl FnOnce(&mut Self) -> Result<()>) -> Result<()> {
let cwd = Path::new(".").to_owned();
let fs = tinymist_fs::flock::Filesystem::new(cwd);
let mut lock_file = fs.open_rw_exclusive_create(path, "project commands")?;
let mut data = vec![];
lock_file.read_to_end(&mut data)?;
let old_data =
std::str::from_utf8(&data).context("tinymist.lock file is not valid utf-8")?;
let mut state = if old_data.trim().is_empty() {
LockFile {
document: vec![],
task: vec![],
route: vec![],
}
} else {
let old_state = toml::from_str::<LockFileCompat>(old_data)
.context("tinymist.lock file is not a valid TOML file")?;
let version = old_state.version()?;
match Version(version).partial_cmp(&Version(LOCK_VERSION)) {
Some(Ordering::Equal | Ordering::Less) => {}
Some(Ordering::Greater) => {
bail!(
"trying to update lock file having a future version, current tinymist-cli supports {LOCK_VERSION}, the lock file is {version}",
);
}
None => {
bail!(
"cannot compare version, are version strings in right format? current tinymist-cli supports {LOCK_VERSION}, the lock file is {version}",
);
}
}
old_state.migrate()?
};
f(&mut state)?;
// todo: for read only operations, we don't have to compare it.
state.sort();
let new_data = state.serialize_resolve();
// If the lock file contents haven't changed so don't rewrite it. This is
// helpful on read-only filesystems.
if old_data == new_data {
return Ok(());
}
lock_file.file().set_len(0)?;
lock_file.seek(SeekFrom::Start(0))?;
lock_file.write_all(new_data.as_bytes())?;
Ok(())
}
}
/// A project ID.
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "kebab-case")]
pub struct Id(String);
impl Id {
pub fn new(s: String) -> Self {
Id(s)
}
}
/// The id of a document.
///
/// If an identifier is not provided, the document's path is used as the id.
#[derive(Debug, Clone, clap::Parser)]
pub struct DocIdArgs {
/// Give a name to the document.
#[clap(long = "name")]
pub name: Option<String>,
/// Path to input Typst file.
#[clap(value_hint = ValueHint::FilePath)]
pub input: String,
}
impl From<&DocIdArgs> for Id {
fn from(args: &DocIdArgs) -> Self {
if let Some(id) = &args.name {
Id(id.clone())
} else {
let inp = Path::new(&args.input);
Id(ResourcePath::from_user_sys(inp).to_string())
}
}
}
/// A resource path.
#[derive(Debug, Clone)]
pub struct ResourcePath(String, String);
impl fmt::Display for ResourcePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.0, self.1)
}
}
impl FromStr for ResourcePath {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let mut parts = value.split(':');
let scheme = parts.next().ok_or("missing scheme")?;
let path = parts.next().ok_or("missing path")?;
if parts.next().is_some() {
Err("too many colons")
} else {
Ok(ResourcePath(scheme.to_string(), path.to_string()))
}
}
}
impl serde::Serialize for ResourcePath {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> serde::Deserialize<'de> for ResourcePath {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
impl ResourcePath {
pub fn from_user_sys(inp: &Path) -> Self {
let rel = if inp.is_relative() {
inp.to_path_buf()
} else {
let cwd = std::env::current_dir().unwrap();
pathdiff::diff_paths(inp, &cwd).unwrap()
};
let rel = unix_slash(&rel);
ResourcePath("file".to_string(), rel.to_string())
}
}
/// A project input specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ProjectInput {
/// The project's ID.
pub id: Id,
/// The project's root directory.
#[serde(skip_serializing_if = "Option::is_none")]
pub root: Option<ResourcePath>,
/// The project's font paths.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub font_paths: Vec<ResourcePath>,
/// Whether to use system fonts.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub system_fonts: bool,
/// The project's package path.
#[serde(skip_serializing_if = "Option::is_none")]
pub package_path: Option<ResourcePath>,
/// The project's package cache path.
#[serde(skip_serializing_if = "Option::is_none")]
pub package_cache_path: Option<ResourcePath>,
}
/// A project task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case", tag = "type")]
pub enum ProjectTask {
/// A preview task.
Preview(PreviewTask),
/// An export PDF task.
ExportPdf(ExportPdfTask),
/// An export PNG task.
ExportPng(ExportPngTask),
/// An export SVG task.
ExportSvg(ExportSvgTask),
// todo: compatibility
// An export task of another type.
// Other(serde_json::Value),
}
impl ProjectTask {
/// Returns the task's ID.
pub fn doc_id(&self) -> &Id {
match self {
ProjectTask::Preview(task) => &task.doc_id,
ProjectTask::ExportPdf(task) => &task.export.document,
ProjectTask::ExportPng(task) => &task.export.document,
ProjectTask::ExportSvg(task) => &task.export.document,
// ProjectTask::Other(_) => return None,
}
}
/// Returns the task's ID.
pub fn id(&self) -> &Id {
match self {
ProjectTask::Preview(task) => &task.id,
ProjectTask::ExportPdf(task) => &task.export.id,
ProjectTask::ExportPng(task) => &task.export.id,
ProjectTask::ExportSvg(task) => &task.export.id,
// ProjectTask::Other(_) => return None,
}
}
}
/// An lsp task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct PreviewTask {
/// The task's ID.
pub id: Id,
/// The doc's ID.
pub doc_id: Id,
/// When to run the task
pub when: TaskWhen,
}
/// An export task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ExportTask {
/// The task's ID.
pub id: Id,
/// The doc's ID.
pub document: Id,
/// When to run the task
pub when: TaskWhen,
/// The task's transforms.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub transform: Vec<ExportTransform>,
}
/// A project export transform specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExportTransform {
/// Only pick a subset of pages.
Pages(Vec<Pages>),
}
/// An export pdf task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ExportPdfTask {
/// The shared export arguments
#[serde(flatten)]
pub export: ExportTask,
/// The pdf standards.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub pdf_standards: Vec<PdfStandard>,
}
/// An export png task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ExportPngTask {
/// The shared export arguments
#[serde(flatten)]
pub export: ExportTask,
/// The PPI (pixels per inch) to use for PNG export.
pub ppi: f32,
}
/// An export png task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ExportSvgTask {
/// The shared export arguments
#[serde(flatten)]
pub export: ExportTask,
}
/// A project route specifier.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ProjectRoute {
/// A project.
pub id: Id,
/// The priority of the project.
pub priority: u32,
}
struct Version<'a>(&'a str);
impl PartialEq for Version<'_> {
fn eq(&self, other: &Self) -> bool {
semver::Version::parse(self.0)
.ok()
.and_then(|a| semver::Version::parse(other.0).ok().map(|b| a == b))
.unwrap_or(false)
}
}
impl PartialOrd for Version<'_> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
let lhs = semver::Version::parse(self.0).ok()?;
let rhs = semver::Version::parse(other.0).ok()?;
Some(lhs.cmp(&rhs))
}
}

View file

@ -19,11 +19,11 @@ tinymist-fs.workspace = true
tinymist-core = { workspace = true, default-features = false, features = [] }
tinymist-world.workspace = true
tinymist-render.workspace = true
tinymist-project.workspace = true
typlite.workspace = true
sync-lsp.workspace = true
chrono.workspace = true
pathdiff.workspace = true
semver = "1"
once_cell.workspace = true
anyhow.workspace = true

View file

@ -1,22 +1,142 @@
//! Project management tools.
use core::fmt;
use std::{
cmp::Ordering,
io::{Read, Seek, SeekFrom, Write},
num::NonZeroUsize,
ops::RangeInclusive,
path::Path,
str::FromStr,
};
use std::path::Path;
use anyhow::{bail, Context};
use clap::{ValueEnum, ValueHint};
use reflexo::path::unix_slash;
use anyhow::bail;
use clap::ValueHint;
use tinymist_project::*;
use typst_preview::{PreviewArgs, PreviewMode};
use crate::{CompileFontArgs, CompilePackageArgs};
trait LockFileExt {
fn preview(&mut self, doc_id: Id, args: &TaskPreviewArgs) -> anyhow::Result<Id>;
fn declare(&mut self, args: &DocNewArgs) -> Id;
fn export(&mut self, doc_id: Id, args: &TaskCompileArgs) -> anyhow::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 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,
font_paths,
system_fonts: !args.font.ignore_system_fonts,
package_path,
package_cache_path,
};
self.replace_document(input);
id
}
fn export(&mut self, doc_id: Id, args: &TaskCompileArgs) -> anyhow::Result<Id> {
let task_id = args
.task_name
.as_ref()
.map(|t| Id::new(t.clone()))
.unwrap_or(doc_id.clone());
let output_format = if let Some(specified) = args.format {
specified
} else if let Some(output) = &args.output {
let output = Path::new(output);
match output.extension() {
Some(ext) if ext.eq_ignore_ascii_case("pdf") => OutputFormat::Pdf,
Some(ext) if ext.eq_ignore_ascii_case("png") => OutputFormat::Png,
Some(ext) if ext.eq_ignore_ascii_case("svg") => OutputFormat::Svg,
Some(ext) if ext.eq_ignore_ascii_case("html") => OutputFormat::Html,
_ => bail!(
"could not infer output format for path {}.\n\
consider providing the format manually with `--format/-f`",
output.display()
),
}
} else {
OutputFormat::Pdf
};
let when = args.when.unwrap_or(TaskWhen::Never);
let mut transforms = vec![];
if let Some(pages) = &args.pages {
transforms.push(ExportTransform::Pages(pages.clone()));
}
let export = ExportTask {
document: doc_id,
id: task_id.clone(),
when,
transform: transforms,
};
let task = match output_format {
OutputFormat::Pdf => ProjectTask::ExportPdf(ExportPdfTask {
export,
pdf_standards: args.pdf_standard.clone(),
}),
OutputFormat::Png => ProjectTask::ExportPng(ExportPngTask {
export,
ppi: args.ppi,
}),
OutputFormat::Svg => ProjectTask::ExportSvg(ExportSvgTask { export }),
OutputFormat::Html => ProjectTask::ExportSvg(ExportSvgTask { export }),
};
self.replace_task(task);
Ok(task_id)
}
fn preview(&mut self, doc_id: Id, args: &TaskPreviewArgs) -> anyhow::Result<Id> {
let task_id = args
.name
.as_ref()
.map(|t| Id::new(t.clone()))
.unwrap_or(doc_id.clone());
let when = args.when.unwrap_or(TaskWhen::OnType);
let task = ProjectTask::Preview(PreviewTask {
id: task_id.clone(),
doc_id,
when,
});
self.replace_task(task);
Ok(task_id)
}
}
/// Project document commands.
#[derive(Debug, Clone, clap::Subcommand)]
#[clap(rename_all = "kebab-case")]
@ -37,19 +157,6 @@ pub enum TaskCommands {
Preview(TaskPreviewArgs),
}
/// The id of a document.
///
/// If an identifier is not provided, the document's path is used as the id.
#[derive(Debug, Clone, clap::Parser)]
pub struct DocIdArgs {
/// Give a name to the document.
#[clap(long = "name")]
pub name: Option<String>,
/// Path to input Typst file.
#[clap(value_hint = ValueHint::FilePath)]
pub input: String,
}
/// Declare a document (project's input).
#[derive(Debug, Clone, clap::Parser)]
pub struct DocNewArgs {
@ -156,735 +263,11 @@ pub struct TaskPreviewArgs {
pub preview_mode: PreviewMode,
}
/// Implements parsing of page ranges (`1-3`, `4`, `5-`, `-2`), used by the
/// `CompileCommand.pages` argument, through the `FromStr` trait instead of a
/// value parser, in order to generate better errors.
///
/// See also: https://github.com/clap-rs/clap/issues/5065
#[derive(Debug, Clone)]
pub struct Pages(pub RangeInclusive<Option<NonZeroUsize>>);
impl FromStr for Pages {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value
.split('-')
.map(str::trim)
.collect::<Vec<_>>()
.as_slice()
{
[] | [""] => Err("page export range must not be empty"),
[single_page] => {
let page_number = parse_page_number(single_page)?;
Ok(Pages(Some(page_number)..=Some(page_number)))
}
["", ""] => Err("page export range must have start or end"),
[start, ""] => Ok(Pages(Some(parse_page_number(start)?)..=None)),
["", end] => Ok(Pages(None..=Some(parse_page_number(end)?))),
[start, end] => {
let start = parse_page_number(start)?;
let end = parse_page_number(end)?;
if start > end {
Err("page export range must end at a page after the start")
} else {
Ok(Pages(Some(start)..=Some(end)))
}
}
[_, _, _, ..] => Err("page export range must have a single hyphen"),
}
}
}
impl fmt::Display for Pages {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let start = match self.0.start() {
Some(start) => start.to_string(),
None => String::from(""),
};
let end = match self.0.end() {
Some(end) => end.to_string(),
None => String::from(""),
};
write!(f, "{start}-{end}")
}
}
impl serde::Serialize for Pages {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> serde::Deserialize<'de> for Pages {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
/// Parses a single page number.
fn parse_page_number(value: &str) -> Result<NonZeroUsize, &'static str> {
if value == "0" {
Err("page numbers start at one")
} else {
NonZeroUsize::from_str(value).map_err(|_| "not a valid page number")
}
}
macro_rules! display_possible_values {
($ty:ty) => {
impl fmt::Display for $ty {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.to_possible_value()
.expect("no values are skipped")
.get_name()
.fmt(f)
}
}
};
}
/// When to export an output file.
#[derive(
Debug,
Copy,
Clone,
Eq,
PartialEq,
Ord,
PartialOrd,
serde::Serialize,
serde::Deserialize,
ValueEnum,
)]
#[serde(rename_all = "camelCase")]
#[clap(rename_all = "camelCase")]
pub enum TaskWhen {
/// Never watch to run task.
Never,
/// Run task on save.
OnSave,
/// Run task on type.
OnType,
}
impl TaskWhen {
/// Returns `true` if the task should never be run automatically.
pub fn is_never(&self) -> bool {
matches!(self, TaskWhen::Never)
}
}
display_possible_values!(TaskWhen);
/// Which format to use for the generated output file.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)]
pub enum OutputFormat {
/// Export to PDF.
Pdf,
/// Export to PNG.
Png,
/// Export to SVG.
Svg,
/// Export to HTML.
Html,
}
display_possible_values!(OutputFormat);
/// A PDF standard that Typst can enforce conformance with.
#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum, serde::Serialize, serde::Deserialize)]
#[allow(non_camel_case_types)]
pub enum PdfStandard {
/// PDF 1.7.
#[value(name = "1.7")]
#[serde(rename = "1.7")]
V_1_7,
/// PDF/A-2b.
#[value(name = "a-2b")]
#[serde(rename = "a-2b")]
A_2b,
}
display_possible_values!(PdfStandard);
const LOCK_VERSION: &str = "0.1.0-beta0";
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case", tag = "version")]
enum LockFileCompat {
#[serde(rename = "0.1.0-beta0")]
Version010Beta0(LockFile),
#[serde(untagged)]
Other(serde_json::Value),
}
impl LockFileCompat {
fn version(&self) -> anyhow::Result<&str> {
match self {
LockFileCompat::Version010Beta0(..) => Ok(LOCK_VERSION),
LockFileCompat::Other(v) => v
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing version field")),
}
}
fn migrate(self) -> anyhow::Result<LockFile> {
match self {
LockFileCompat::Version010Beta0(v) => Ok(v),
this @ LockFileCompat::Other(..) => {
bail!(
"cannot migrate from version: {}",
this.version().unwrap_or("unknown version")
)
}
}
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct LockFile {
// The lock file version.
// version: String,
/// The project's document (input).
document: Vec<ProjectInput>,
/// The project's task (output).
task: Vec<ProjectTask>,
/// The project's task route.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
route: Vec<ProjectRoute>,
}
impl 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 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,
font_paths,
system_fonts: !args.font.ignore_system_fonts,
package_path,
package_cache_path,
};
self.replace_document(input);
id
}
fn export(&mut self, doc_id: Id, args: &TaskCompileArgs) -> anyhow::Result<Id> {
let task_id = args
.task_name
.as_ref()
.map(|t| Id(t.clone()))
.unwrap_or(doc_id.clone());
let output_format = if let Some(specified) = args.format {
specified
} else if let Some(output) = &args.output {
let output = Path::new(output);
match output.extension() {
Some(ext) if ext.eq_ignore_ascii_case("pdf") => OutputFormat::Pdf,
Some(ext) if ext.eq_ignore_ascii_case("png") => OutputFormat::Png,
Some(ext) if ext.eq_ignore_ascii_case("svg") => OutputFormat::Svg,
Some(ext) if ext.eq_ignore_ascii_case("html") => OutputFormat::Html,
_ => bail!(
"could not infer output format for path {}.\n\
consider providing the format manually with `--format/-f`",
output.display()
),
}
} else {
OutputFormat::Pdf
};
let when = args.when.unwrap_or(TaskWhen::Never);
let mut transforms = vec![];
if let Some(pages) = &args.pages {
transforms.push(ExportTransform::Pages(pages.clone()));
}
let export = ExportTask {
document: doc_id,
id: task_id.clone(),
when,
transform: transforms,
};
let task = match output_format {
OutputFormat::Pdf => ProjectTask::ExportPdf(ExportPdfTask {
export,
pdf_standards: args.pdf_standard.clone(),
}),
OutputFormat::Png => ProjectTask::ExportPng(ExportPngTask {
export,
ppi: args.ppi,
}),
OutputFormat::Svg => ProjectTask::ExportSvg(ExportSvgTask { export }),
OutputFormat::Html => ProjectTask::ExportSvg(ExportSvgTask { export }),
};
self.replace_task(task);
Ok(task_id)
}
fn preview(&mut self, doc_id: Id, args: &TaskPreviewArgs) -> anyhow::Result<Id> {
let task_id = args
.name
.as_ref()
.map(|t| Id(t.clone()))
.unwrap_or(doc_id.clone());
let when = args.when.unwrap_or(TaskWhen::OnType);
let task = ProjectTask::Preview(PreviewTask {
id: task_id.clone(),
doc_id,
when,
});
self.replace_task(task);
Ok(task_id)
}
fn replace_document(&mut self, input: ProjectInput) {
let id = input.id.clone();
let index = self.document.iter().position(|i| i.id == id);
if let Some(index) = index {
self.document[index] = input;
} else {
self.document.push(input);
}
}
fn replace_task(&mut self, task: ProjectTask) {
let id = task.id().clone();
let index = self.task.iter().position(|i| *i.id() == id);
if let Some(index) = index {
self.task[index] = task;
} else {
self.task.push(task);
}
}
fn sort(&mut self) {
self.document.sort_by(|a, b| a.id.cmp(&b.id));
self.task
.sort_by(|a, b| a.doc_id().cmp(b.doc_id()).then_with(|| a.id().cmp(b.id())));
// the route's order is important, so we don't sort them.
}
}
fn serialize_resolve(resolve: &LockFile) -> String {
let content = toml::Table::try_from(resolve).unwrap();
let mut out = String::new();
// At the start of the file we notify the reader that the file is generated.
// Specifically Phabricator ignores files containing "@generated", so we use
// that.
let marker_line = "# This file is automatically @generated by tinymist.";
let extra_line = "# It is not intended for manual editing.";
out.push_str(marker_line);
out.push('\n');
out.push_str(extra_line);
out.push('\n');
out.push_str(&format!("version = {LOCK_VERSION:?}\n"));
let document = content.get("document");
if let Some(document) = document {
for document in document.as_array().unwrap() {
out.push('\n');
out.push_str("[[document]]\n");
emit_document(document, &mut out);
}
}
let task = content.get("task");
if let Some(task) = task {
for task in task.as_array().unwrap() {
out.push('\n');
out.push_str("[[task]]\n");
emit_output(task, &mut out);
}
}
let route = content.get("route");
if let Some(route) = route {
for route in route.as_array().unwrap() {
out.push('\n');
out.push_str("[[route]]\n");
emit_route(route, &mut out);
}
}
return out;
fn emit_document(input: &toml::Value, out: &mut String) {
let table = input.as_table().unwrap();
out.push_str(&table.to_string());
}
fn emit_output(output: &toml::Value, out: &mut String) {
let mut table = output.clone();
let table = table.as_table_mut().unwrap();
// replace transform with task.transforms
if let Some(transform) = table.remove("transform") {
let mut task_table = toml::Table::new();
task_table.insert("transform".to_string(), transform);
table.insert("task".to_string(), task_table.into());
}
out.push_str(&table.to_string());
}
fn emit_route(route: &toml::Value, out: &mut String) {
let table = route.as_table().unwrap();
out.push_str(&table.to_string());
}
}
/// A project ID.
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "kebab-case")]
pub struct Id(String);
impl From<&DocIdArgs> for Id {
fn from(args: &DocIdArgs) -> Self {
if let Some(id) = &args.name {
Id(id.clone())
} else {
let inp = Path::new(&args.input);
Id(ResourcePath::from_user_sys(inp).to_string())
}
}
}
/// A resource path.
#[derive(Debug, Clone)]
pub struct ResourcePath(String, String);
impl fmt::Display for ResourcePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.0, self.1)
}
}
impl FromStr for ResourcePath {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let mut parts = value.split(':');
let scheme = parts.next().ok_or("missing scheme")?;
let path = parts.next().ok_or("missing path")?;
if parts.next().is_some() {
Err("too many colons")
} else {
Ok(ResourcePath(scheme.to_string(), path.to_string()))
}
}
}
impl serde::Serialize for ResourcePath {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> serde::Deserialize<'de> for ResourcePath {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
impl ResourcePath {
fn from_user_sys(inp: &Path) -> Self {
let rel = if inp.is_relative() {
inp.to_path_buf()
} else {
let cwd = std::env::current_dir().unwrap();
pathdiff::diff_paths(inp, &cwd).unwrap()
};
let rel = unix_slash(&rel);
ResourcePath("file".to_string(), rel.to_string())
}
}
/// A project input specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ProjectInput {
/// The project's ID.
pub id: Id,
/// The project's root directory.
#[serde(skip_serializing_if = "Option::is_none")]
pub root: Option<ResourcePath>,
/// The project's font paths.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub font_paths: Vec<ResourcePath>,
/// Whether to use system fonts.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub system_fonts: bool,
/// The project's package path.
#[serde(skip_serializing_if = "Option::is_none")]
pub package_path: Option<ResourcePath>,
/// The project's package cache path.
#[serde(skip_serializing_if = "Option::is_none")]
pub package_cache_path: Option<ResourcePath>,
}
/// A project task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case", tag = "type")]
pub enum ProjectTask {
/// A preview task.
Preview(PreviewTask),
/// An export PDF task.
ExportPdf(ExportPdfTask),
/// An export PNG task.
ExportPng(ExportPngTask),
/// An export SVG task.
ExportSvg(ExportSvgTask),
// todo: compatibility
// An export task of another type.
// Other(serde_json::Value),
}
impl ProjectTask {
/// Returns the task's ID.
pub fn doc_id(&self) -> &Id {
match self {
ProjectTask::Preview(task) => &task.doc_id,
ProjectTask::ExportPdf(task) => &task.export.document,
ProjectTask::ExportPng(task) => &task.export.document,
ProjectTask::ExportSvg(task) => &task.export.document,
// ProjectTask::Other(_) => return None,
}
}
/// Returns the task's ID.
pub fn id(&self) -> &Id {
match self {
ProjectTask::Preview(task) => &task.id,
ProjectTask::ExportPdf(task) => &task.export.id,
ProjectTask::ExportPng(task) => &task.export.id,
ProjectTask::ExportSvg(task) => &task.export.id,
// ProjectTask::Other(_) => return None,
}
}
}
/// An lsp task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct PreviewTask {
/// The task's ID.
pub id: Id,
/// The doc's ID.
pub doc_id: Id,
/// When to run the task
pub when: TaskWhen,
}
/// An export task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ExportTask {
/// The task's ID.
pub id: Id,
/// The doc's ID.
pub document: Id,
/// When to run the task
pub when: TaskWhen,
/// The task's transforms.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub transform: Vec<ExportTransform>,
}
/// A project export transform specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExportTransform {
/// Only pick a subset of pages.
Pages(Vec<Pages>),
}
/// An export pdf task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ExportPdfTask {
/// The shared export arguments
#[serde(flatten)]
pub export: ExportTask,
/// The pdf standards.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pdf_standards: Vec<PdfStandard>,
}
/// An export png task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ExportPngTask {
/// The shared export arguments
#[serde(flatten)]
pub export: ExportTask,
/// The PPI (pixels per inch) to use for PNG export.
pub ppi: f32,
}
/// An export png task specifier.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ExportSvgTask {
/// The shared export arguments
#[serde(flatten)]
pub export: ExportTask,
}
/// A project route specifier.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct ProjectRoute {
/// A project.
id: Id,
/// The priority of the project.
priority: u32,
}
struct Version<'a>(&'a str);
impl PartialEq for Version<'_> {
fn eq(&self, other: &Self) -> bool {
semver::Version::parse(self.0)
.ok()
.and_then(|a| semver::Version::parse(other.0).ok().map(|b| a == b))
.unwrap_or(false)
}
}
impl PartialOrd for Version<'_> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
let lhs = semver::Version::parse(self.0).ok()?;
let rhs = semver::Version::parse(other.0).ok()?;
Some(lhs.cmp(&rhs))
}
}
fn update_lock_file(
path: &str,
f: impl FnOnce(&mut LockFile) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
let cwd = Path::new(".").to_owned();
let fs = tinymist_fs::flock::Filesystem::new(cwd);
let mut lock_file = fs.open_rw_exclusive_create(path, "project commands")?;
let mut data = vec![];
lock_file.read_to_end(&mut data)?;
let old_data = std::str::from_utf8(&data).context("tinymist.lock file is not valid utf-8")?;
let mut state = if old_data.trim().is_empty() {
LockFile {
document: vec![],
task: vec![],
route: vec![],
}
} else {
let old_state = toml::from_str::<LockFileCompat>(old_data)
.context("tinymist.lock file is not a valid TOML file")?;
let version = old_state.version()?;
match Version(version).partial_cmp(&Version(LOCK_VERSION)) {
Some(Ordering::Equal | Ordering::Less) => {}
Some(Ordering::Greater) => {
bail!(
"trying to update lock file having a future version, current tinymist-cli supports {LOCK_VERSION}, the lock file is {version}",
);
}
None => {
bail!(
"cannot compare version, are version strings in right format? current tinymist-cli supports {LOCK_VERSION}, the lock file is {version}",
);
}
}
old_state.migrate()?
};
f(&mut state)?;
// todo: for read only operations, we don't have to compare it.
state.sort();
let new_data = serialize_resolve(&state);
// If the lock file contents haven't changed so don't rewrite it. This is
// helpful on read-only filesystems.
if old_data == new_data {
return Ok(());
}
lock_file.file().set_len(0)?;
lock_file.seek(SeekFrom::Start(0))?;
lock_file.write_all(new_data.as_bytes())?;
Ok(())
}
const LOCKFILE_PATH: &str = "tinymist.lock";
/// Project document commands' main
pub fn project_main(args: DocCommands) -> anyhow::Result<()> {
update_lock_file(LOCKFILE_PATH, |state| {
LockFile::update(LOCKFILE_PATH, |state| {
match args {
DocCommands::New(args) => {
state.declare(&args);
@ -905,7 +288,7 @@ pub fn project_main(args: DocCommands) -> anyhow::Result<()> {
/// Project task commands' main
pub fn task_main(args: TaskCommands) -> anyhow::Result<()> {
update_lock_file(LOCKFILE_PATH, |state| {
LockFile::update(LOCKFILE_PATH, |state| {
match args {
TaskCommands::Compile(args) => {
let id = state.declare(&args.declare);