diff options
Diffstat (limited to 'crates/typst-cli')
| -rw-r--r-- | crates/typst-cli/Cargo.toml | 59 | ||||
| -rw-r--r-- | crates/typst-cli/build.rs | 60 | ||||
| -rw-r--r-- | crates/typst-cli/src/args.rs | 119 | ||||
| -rw-r--r-- | crates/typst-cli/src/compile.rs | 239 | ||||
| -rw-r--r-- | crates/typst-cli/src/fonts.rs | 183 | ||||
| -rw-r--r-- | crates/typst-cli/src/main.rs | 79 | ||||
| -rw-r--r-- | crates/typst-cli/src/package.rs | 77 | ||||
| -rw-r--r-- | crates/typst-cli/src/tracing.rs | 138 | ||||
| -rw-r--r-- | crates/typst-cli/src/watch.rs | 170 | ||||
| -rw-r--r-- | crates/typst-cli/src/world.rs | 256 |
10 files changed, 1380 insertions, 0 deletions
diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml new file mode 100644 index 00000000..e77cbc2a --- /dev/null +++ b/crates/typst-cli/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "typst-cli" +description = "The command line interface for Typst." +categories = ["compilers", "command-line-utilities"] +keywords = ["typst", "cli"] +version.workspace = true +rust-version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true + +[[bin]] +name = "typst" +path = "src/main.rs" +test = false +doctest = false +bench = false +doc = false + +[dependencies] +typst = { path = "../typst" } +typst-library = { path = "../typst-library" } +chrono = { version = "0.4.24", default-features = false, features = ["clock", "std"] } +clap = { version = "4.2.4", features = ["derive", "env"] } +codespan-reporting = "0.11" +comemo = "0.3" +dirs = "5" +flate2 = "1" +inferno = "0.11.15" +memmap2 = "0.5" +notify = "5" +once_cell = "1" +open = "4.0.2" +same-file = "1" +siphasher = "0.3" +tar = "0.4" +tempfile = "3.5.0" +tracing = "0.1.37" +tracing-error = "0.2" +tracing-flame = "0.2.0" +tracing-subscriber = "0.3.17" +ureq = "2" +walkdir = "2" + +[build-dependencies] +clap = { version = "4.2.4", features = ["derive", "string"] } +clap_complete = "4.2.1" +clap_mangen = "0.2.10" + +[features] +default = ["embed-fonts"] + +# Embeds some fonts into the binary: +# - For text: Linux Libertine, New Computer Modern +# - For math: New Computer Modern Math +# - For code: Deja Vu Sans Mono +embed-fonts = [] diff --git a/crates/typst-cli/build.rs b/crates/typst-cli/build.rs new file mode 100644 index 00000000..86325e1d --- /dev/null +++ b/crates/typst-cli/build.rs @@ -0,0 +1,60 @@ +use std::env; +use std::fs::{create_dir_all, File}; +use std::path::Path; +use std::process::Command; + +use clap::{CommandFactory, ValueEnum}; +use clap_complete::{generate_to, Shell}; +use clap_mangen::Man; + +#[path = "src/args.rs"] +#[allow(dead_code)] +mod args; + +fn main() { + println!("cargo:rerun-if-env-changed=TYPST_VERSION"); + println!("cargo:rerun-if-env-changed=GEN_ARTIFACTS"); + + if option_env!("TYPST_VERSION").is_none() { + println!("cargo:rustc-env=TYPST_VERSION={}", typst_version()); + } + + if let Some(dir) = env::var_os("GEN_ARTIFACTS") { + let out = &Path::new(&dir); + create_dir_all(out).unwrap(); + let cmd = &mut args::CliArguments::command(); + + Man::new(cmd.clone()) + .render(&mut File::create(out.join("typst.1")).unwrap()) + .unwrap(); + + for subcmd in cmd.get_subcommands() { + let name = format!("typst-{}", subcmd.get_name()); + Man::new(subcmd.clone().name(&name)) + .render(&mut File::create(out.join(format!("{name}.1"))).unwrap()) + .unwrap(); + } + + for shell in Shell::value_variants() { + generate_to(*shell, cmd, "typst", out).unwrap(); + } + } +} + +/// Also used by `args.rs`. +fn typst_version() -> String { + if let Some(version) = option_env!("TYPST_VERSION") { + return version.to_owned(); + } + + let pkg = env!("CARGO_PKG_VERSION"); + let hash = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout.get(..8)?.into()).ok()) + .unwrap_or_else(|| "unknown hash".into()); + + format!("{pkg} ({hash})") +} diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs new file mode 100644 index 00000000..7fdb041b --- /dev/null +++ b/crates/typst-cli/src/args.rs @@ -0,0 +1,119 @@ +use std::fmt::{self, Display, Formatter}; +use std::path::PathBuf; + +use clap::{ArgAction, Parser, Subcommand, ValueEnum}; + +/// typst creates PDF files from .typ files +#[derive(Debug, Clone, Parser)] +#[clap(name = "typst", version = crate::typst_version(), author)] +pub struct CliArguments { + /// The typst command to run + #[command(subcommand)] + pub command: Command, + + /// Sets the level of logging verbosity: + /// -v = warning & error, -vv = info, -vvv = debug, -vvvv = trace + #[clap(short, long, action = ArgAction::Count)] + pub verbosity: u8, +} + +/// What to do. +#[derive(Debug, Clone, Subcommand)] +#[command()] +pub enum Command { + /// Compiles the input file into a PDF file + #[command(visible_alias = "c")] + Compile(CompileCommand), + + /// Watches the input file and recompiles on changes + #[command(visible_alias = "w")] + Watch(CompileCommand), + + /// List all discovered fonts in system and custom font paths + Fonts(FontsCommand), +} + +/// Compiles the input file into a PDF file +#[derive(Debug, Clone, Parser)] +pub struct CompileCommand { + /// Path to input Typst file + pub input: PathBuf, + + /// Path to output PDF file or PNG file(s) + pub output: Option<PathBuf>, + + /// Configure the project root + #[clap(long = "root", env = "TYPST_ROOT", value_name = "DIR")] + pub root: Option<PathBuf>, + + /// Add additional directories to search for fonts + #[clap( + long = "font-path", + env = "TYPST_FONT_PATHS", + value_name = "DIR", + action = ArgAction::Append, + )] + pub font_paths: Vec<PathBuf>, + + /// Opens the output file after compilation using the default PDF viewer + #[arg(long = "open")] + pub open: Option<Option<String>>, + + /// The PPI to use if exported as PNG + #[arg(long = "ppi", default_value_t = 144.0)] + pub ppi: f32, + + /// In which format to emit diagnostics + #[clap( + long, + default_value_t = DiagnosticFormat::Human, + value_parser = clap::value_parser!(DiagnosticFormat) + )] + pub diagnostic_format: DiagnosticFormat, + + /// Produces a flamegraph of the compilation process + #[arg(long = "flamegraph", value_name = "OUTPUT_SVG")] + pub flamegraph: Option<Option<PathBuf>>, +} + +impl CompileCommand { + /// The output path. + pub fn output(&self) -> PathBuf { + self.output + .clone() + .unwrap_or_else(|| self.input.with_extension("pdf")) + } +} + +/// List all discovered fonts in system and custom font paths +#[derive(Debug, Clone, Parser)] +pub struct FontsCommand { + /// Add additional directories to search for fonts + #[clap( + long = "font-path", + env = "TYPST_FONT_PATHS", + value_name = "DIR", + action = ArgAction::Append, + )] + pub font_paths: Vec<PathBuf>, + + /// Also list style variants of each font family + #[arg(long)] + pub variants: bool, +} + +/// Which format to use for diagnostics. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)] +pub enum DiagnosticFormat { + Human, + Short, +} + +impl Display for DiagnosticFormat { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + self.to_possible_value() + .expect("no values are skipped") + .get_name() + .fmt(f) + } +} diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs new file mode 100644 index 00000000..3250202b --- /dev/null +++ b/crates/typst-cli/src/compile.rs @@ -0,0 +1,239 @@ +use std::fs; +use std::path::Path; + +use codespan_reporting::diagnostic::{Diagnostic, Label}; +use codespan_reporting::term::{self, termcolor}; +use termcolor::{ColorChoice, StandardStream}; +use typst::diag::{bail, SourceError, StrResult}; +use typst::doc::Document; +use typst::eval::eco_format; +use typst::file::FileId; +use typst::geom::Color; +use typst::syntax::Source; +use typst::World; + +use crate::args::{CompileCommand, DiagnosticFormat}; +use crate::watch::Status; +use crate::world::SystemWorld; +use crate::{color_stream, set_failed}; + +type CodespanResult<T> = Result<T, CodespanError>; +type CodespanError = codespan_reporting::files::Error; + +/// Execute a compilation command. +pub fn compile(mut command: CompileCommand) -> StrResult<()> { + let mut world = SystemWorld::new(&command)?; + compile_once(&mut world, &mut command, false)?; + Ok(()) +} + +/// Compile a single time. +/// +/// Returns whether it compiled without errors. +#[tracing::instrument(skip_all)] +pub fn compile_once( + world: &mut SystemWorld, + command: &mut CompileCommand, + watching: bool, +) -> StrResult<()> { + tracing::info!("Starting compilation"); + + let start = std::time::Instant::now(); + if watching { + Status::Compiling.print(command).unwrap(); + } + + // Reset everything and ensure that the main file is still present. + world.reset(); + world.source(world.main()).map_err(|err| err.to_string())?; + + let result = typst::compile(world); + let duration = start.elapsed(); + + match result { + // Export the PDF / PNG. + Ok(document) => { + export(&document, command)?; + + tracing::info!("Compilation succeeded in {duration:?}"); + if watching { + Status::Success(duration).print(command).unwrap(); + } + + if let Some(open) = command.open.take() { + open_file(open.as_deref(), &command.output())?; + } + } + + // Print diagnostics. + Err(errors) => { + set_failed(); + tracing::info!("Compilation failed"); + + if watching { + Status::Error.print(command).unwrap(); + } + + print_diagnostics(world, *errors, command.diagnostic_format) + .map_err(|_| "failed to print diagnostics")?; + } + } + + Ok(()) +} + +/// Export into the target format. +fn export(document: &Document, command: &CompileCommand) -> StrResult<()> { + match command.output().extension() { + Some(ext) if ext.eq_ignore_ascii_case("png") => export_png(document, command), + _ => export_pdf(document, command), + } +} + +/// Export to a PDF. +fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> { + let output = command.output(); + let buffer = typst::export::pdf(document); + fs::write(output, buffer).map_err(|_| "failed to write PDF file")?; + Ok(()) +} + +/// Export to one or multiple PNGs. +fn export_png(document: &Document, command: &CompileCommand) -> StrResult<()> { + // Determine whether we have a `{n}` numbering. + let output = command.output(); + let string = output.to_str().unwrap_or_default(); + let numbered = string.contains("{n}"); + if !numbered && document.pages.len() > 1 { + bail!("cannot export multiple PNGs without `{{n}}` in output path"); + } + + // Find a number width that accommodates all pages. For instance, the + // first page should be numbered "001" if there are between 100 and + // 999 pages. + let width = 1 + document.pages.len().checked_ilog10().unwrap_or(0) as usize; + let mut storage; + + for (i, frame) in document.pages.iter().enumerate() { + let pixmap = typst::export::render(frame, command.ppi / 72.0, Color::WHITE); + let path = if numbered { + storage = string.replace("{n}", &format!("{:0width$}", i + 1)); + Path::new(&storage) + } else { + output.as_path() + }; + pixmap.save_png(path).map_err(|_| "failed to write PNG file")?; + } + + Ok(()) +} + +/// Opens the given file using: +/// - The default file viewer if `open` is `None`. +/// - The given viewer provided by `open` if it is `Some`. +fn open_file(open: Option<&str>, path: &Path) -> StrResult<()> { + if let Some(app) = open { + open::with_in_background(path, app); + } else { + open::that_in_background(path); + } + + Ok(()) +} + +/// Print diagnostic messages to the terminal. +fn print_diagnostics( + world: &SystemWorld, + errors: Vec<SourceError>, + diagnostic_format: DiagnosticFormat, +) -> Result<(), codespan_reporting::files::Error> { + let mut w = match diagnostic_format { + DiagnosticFormat::Human => color_stream(), + DiagnosticFormat::Short => StandardStream::stderr(ColorChoice::Never), + }; + + let mut config = term::Config { tab_width: 2, ..Default::default() }; + if diagnostic_format == DiagnosticFormat::Short { + config.display_style = term::DisplayStyle::Short; + } + + for error in errors { + // The main diagnostic. + let diag = Diagnostic::error() + .with_message(error.message) + .with_notes( + error + .hints + .iter() + .map(|e| (eco_format!("hint: {e}")).into()) + .collect(), + ) + .with_labels(vec![Label::primary(error.span.id(), error.span.range(world))]); + + term::emit(&mut w, &config, world, &diag)?; + + // Stacktrace-like helper diagnostics. + for point in error.trace { + let message = point.v.to_string(); + let help = Diagnostic::help().with_message(message).with_labels(vec![ + Label::primary(point.span.id(), point.span.range(world)), + ]); + + term::emit(&mut w, &config, world, &help)?; + } + } + + Ok(()) +} + +impl<'a> codespan_reporting::files::Files<'a> for SystemWorld { + type FileId = FileId; + type Name = FileId; + type Source = Source; + + fn name(&'a self, id: FileId) -> CodespanResult<Self::Name> { + Ok(id) + } + + fn source(&'a self, id: FileId) -> CodespanResult<Self::Source> { + Ok(self.lookup(id)) + } + + fn line_index(&'a self, id: FileId, given: usize) -> CodespanResult<usize> { + let source = self.lookup(id); + source + .byte_to_line(given) + .ok_or_else(|| CodespanError::IndexTooLarge { + given, + max: source.len_bytes(), + }) + } + + fn line_range( + &'a self, + id: FileId, + given: usize, + ) -> CodespanResult<std::ops::Range<usize>> { + let source = self.lookup(id); + source + .line_to_range(given) + .ok_or_else(|| CodespanError::LineTooLarge { given, max: source.len_lines() }) + } + + fn column_number( + &'a self, + id: FileId, + _: usize, + given: usize, + ) -> CodespanResult<usize> { + let source = self.lookup(id); + source.byte_to_column(given).ok_or_else(|| { + let max = source.len_bytes(); + if given <= max { + CodespanError::InvalidCharBoundary { given } + } else { + CodespanError::IndexTooLarge { given, max } + } + }) + } +} diff --git a/crates/typst-cli/src/fonts.rs b/crates/typst-cli/src/fonts.rs new file mode 100644 index 00000000..d8209fee --- /dev/null +++ b/crates/typst-cli/src/fonts.rs @@ -0,0 +1,183 @@ +use std::cell::OnceCell; +use std::env; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; + +use memmap2::Mmap; +use typst::diag::StrResult; +use typst::font::{Font, FontBook, FontInfo, FontVariant}; +use typst::util::Bytes; +use walkdir::WalkDir; + +use crate::args::FontsCommand; + +/// Execute a font listing command. +pub fn fonts(command: FontsCommand) -> StrResult<()> { + let mut searcher = FontSearcher::new(); + searcher.search(&command.font_paths); + + for (name, infos) in searcher.book.families() { + println!("{name}"); + if command.variants { + for info in infos { + let FontVariant { style, weight, stretch } = info.variant; + println!("- Style: {style:?}, Weight: {weight:?}, Stretch: {stretch:?}"); + } + } + } + + Ok(()) +} + +/// Searches for fonts. +pub struct FontSearcher { + /// Metadata about all discovered fonts. + pub book: FontBook, + /// Slots that the fonts are loaded into. + pub fonts: Vec<FontSlot>, +} + +/// Holds details about the location of a font and lazily the font itself. +pub struct FontSlot { + /// The path at which the font can be found on the system. + path: PathBuf, + /// The index of the font in its collection. Zero if the path does not point + /// to a collection. + index: u32, + /// The lazily loaded font. + font: OnceCell<Option<Font>>, +} + +impl FontSlot { + /// Get the font for this slot. + pub fn get(&self) -> Option<Font> { + self.font + .get_or_init(|| { + let data = fs::read(&self.path).ok()?.into(); + Font::new(data, self.index) + }) + .clone() + } +} + +impl FontSearcher { + /// Create a new, empty system searcher. + pub fn new() -> Self { + Self { book: FontBook::new(), fonts: vec![] } + } + + /// Search everything that is available. + pub fn search(&mut self, font_paths: &[PathBuf]) { + self.search_system(); + + #[cfg(feature = "embed-fonts")] + self.add_embedded(); + + for path in font_paths { + self.search_dir(path) + } + } + + /// Add fonts that are embedded in the binary. + #[cfg(feature = "embed-fonts")] + fn add_embedded(&mut self) { + let mut process = |bytes: &'static [u8]| { + let buffer = Bytes::from_static(bytes); + for (i, font) in Font::iter(buffer).enumerate() { + self.book.push(font.info().clone()); + self.fonts.push(FontSlot { + path: PathBuf::new(), + index: i as u32, + font: OnceCell::from(Some(font)), + }); + } + }; + + macro_rules! add { + ($filename:literal) => { + process(include_bytes!(concat!("../../../assets/fonts/", $filename))); + }; + } + + // Embed default fonts. + add!("LinLibertine_R.ttf"); + add!("LinLibertine_RB.ttf"); + add!("LinLibertine_RBI.ttf"); + add!("LinLibertine_RI.ttf"); + add!("NewCMMath-Book.otf"); + add!("NewCMMath-Regular.otf"); + add!("NewCM10-Regular.otf"); + add!("NewCM10-Bold.otf"); + add!("NewCM10-Italic.otf"); + add!("NewCM10-BoldItalic.otf"); + add!("DejaVuSansMono.ttf"); + add!("DejaVuSansMono-Bold.ttf"); + add!("DejaVuSansMono-Oblique.ttf"); + add!("DejaVuSansMono-BoldOblique.ttf"); + } + + /// Search for fonts in the linux system font directories. + fn search_system(&mut self) { + if cfg!(target_os = "macos") { + self.search_dir("/Library/Fonts"); + self.search_dir("/Network/Library/Fonts"); + self.search_dir("/System/Library/Fonts"); + } else if cfg!(unix) { + self.search_dir("/usr/share/fonts"); + self.search_dir("/usr/local/share/fonts"); + } else if cfg!(windows) { + self.search_dir( + env::var_os("WINDIR") + .map(PathBuf::from) + .unwrap_or_else(|| "C:\\Windows".into()) + .join("Fonts"), + ); + + if let Some(roaming) = dirs::config_dir() { + self.search_dir(roaming.join("Microsoft\\Windows\\Fonts")); + } + + if let Some(local) = dirs::cache_dir() { + self.search_dir(local.join("Microsoft\\Windows\\Fonts")); + } + } + + if let Some(dir) = dirs::font_dir() { + self.search_dir(dir); + } + } + + /// Search for all fonts in a directory recursively. + fn search_dir(&mut self, path: impl AsRef<Path>) { + for entry in WalkDir::new(path) + .follow_links(true) + .sort_by(|a, b| a.file_name().cmp(b.file_name())) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if matches!( + path.extension().and_then(|s| s.to_str()), + Some("ttf" | "otf" | "TTF" | "OTF" | "ttc" | "otc" | "TTC" | "OTC"), + ) { + self.search_file(path); + } + } + } + + /// Index the fonts in the file at the given path. + fn search_file(&mut self, path: &Path) { + if let Ok(file) = File::open(path) { + if let Ok(mmap) = unsafe { Mmap::map(&file) } { + for (i, info) in FontInfo::iter(&mmap).enumerate() { + self.book.push(info); + self.fonts.push(FontSlot { + path: path.into(), + index: i as u32, + font: OnceCell::new(), + }); + } + } + } + } +} diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs new file mode 100644 index 00000000..425d05fd --- /dev/null +++ b/crates/typst-cli/src/main.rs @@ -0,0 +1,79 @@ +mod args; +mod compile; +mod fonts; +mod package; +mod tracing; +mod watch; +mod world; + +use std::cell::Cell; +use std::env; +use std::io::{self, IsTerminal, Write}; +use std::process::ExitCode; + +use clap::Parser; +use codespan_reporting::term::{self, termcolor}; +use termcolor::{ColorChoice, WriteColor}; + +use crate::args::{CliArguments, Command}; + +thread_local! { + /// The CLI's exit code. + static EXIT: Cell<ExitCode> = Cell::new(ExitCode::SUCCESS); +} + +/// Entry point. +fn main() -> ExitCode { + let arguments = CliArguments::parse(); + let _guard = match crate::tracing::setup_tracing(&arguments) { + Ok(guard) => guard, + Err(err) => { + eprintln!("failed to initialize tracing {}", err); + None + } + }; + + let res = match arguments.command { + Command::Compile(command) => crate::compile::compile(command), + Command::Watch(command) => crate::watch::watch(command), + Command::Fonts(command) => crate::fonts::fonts(command), + }; + + if let Err(msg) = res { + set_failed(); + print_error(&msg).expect("failed to print error"); + } + + EXIT.with(|cell| cell.get()) +} + +/// Ensure a failure exit code. +fn set_failed() { + EXIT.with(|cell| cell.set(ExitCode::FAILURE)); +} + +/// Print an application-level error (independent from a source file). +fn print_error(msg: &str) -> io::Result<()> { + let mut w = color_stream(); + let styles = term::Styles::default(); + + w.set_color(&styles.header_error)?; + write!(w, "error")?; + + w.reset()?; + writeln!(w, ": {msg}.") +} + +/// Get stderr with color support if desirable. +fn color_stream() -> termcolor::StandardStream { + termcolor::StandardStream::stderr(if std::io::stderr().is_terminal() { + ColorChoice::Auto + } else { + ColorChoice::Never + }) +} + +/// Used by `args.rs`. +fn typst_version() -> &'static str { + env!("TYPST_VERSION") +} diff --git a/crates/typst-cli/src/package.rs b/crates/typst-cli/src/package.rs new file mode 100644 index 00000000..6853796b --- /dev/null +++ b/crates/typst-cli/src/package.rs @@ -0,0 +1,77 @@ +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use codespan_reporting::term::{self, termcolor}; +use termcolor::WriteColor; +use typst::diag::{PackageError, PackageResult}; +use typst::file::PackageSpec; + +use super::color_stream; + +/// Make a package available in the on-disk cache. +pub fn prepare_package(spec: &PackageSpec) -> PackageResult<PathBuf> { + let subdir = + format!("typst/packages/{}/{}-{}", spec.namespace, spec.name, spec.version); + + if let Some(data_dir) = dirs::data_dir() { + let dir = data_dir.join(&subdir); + if dir.exists() { + return Ok(dir); + } + } + + if let Some(cache_dir) = dirs::cache_dir() { + let dir = cache_dir.join(&subdir); + + // Download from network if it doesn't exist yet. + if spec.namespace == "preview" && !dir.exists() { + download_package(spec, &dir)?; + } + + if dir.exists() { + return Ok(dir); + } + } + + Err(PackageError::NotFound(spec.clone())) +} + +/// Download a package over the network. +fn download_package(spec: &PackageSpec, package_dir: &Path) -> PackageResult<()> { + // The `@preview` namespace is the only namespace that supports on-demand + // fetching. + assert_eq!(spec.namespace, "preview"); + + let url = format!( + "https://packages.typst.org/preview/{}-{}.tar.gz", + spec.name, spec.version + ); + + print_downloading(spec).unwrap(); + let reader = match ureq::get(&url).call() { + Ok(response) => response.into_reader(), + Err(ureq::Error::Status(404, _)) => { + return Err(PackageError::NotFound(spec.clone())) + } + Err(_) => return Err(PackageError::NetworkFailed), + }; + + let decompressed = flate2::read::GzDecoder::new(reader); + tar::Archive::new(decompressed).unpack(package_dir).map_err(|_| { + fs::remove_dir_all(package_dir).ok(); + PackageError::MalformedArchive + }) +} + +/// Print that a package downloading is happening. +fn print_downloading(spec: &PackageSpec) -> io::Result<()> { + let mut w = color_stream(); + let styles = term::Styles::default(); + + w.set_color(&styles.header_help)?; + write!(w, "downloading")?; + + w.reset()?; + writeln!(w, " {spec}") +} diff --git a/crates/typst-cli/src/tracing.rs b/crates/typst-cli/src/tracing.rs new file mode 100644 index 00000000..80c2ff65 --- /dev/null +++ b/crates/typst-cli/src/tracing.rs @@ -0,0 +1,138 @@ +use std::fs::File; +use std::io::{self, BufReader, BufWriter, Seek, SeekFrom}; +use std::path::PathBuf; + +use inferno::flamegraph::Options; +use tracing::metadata::LevelFilter; +use tracing_error::ErrorLayer; +use tracing_flame::{FlameLayer, FlushGuard}; +use tracing_subscriber::fmt; +use tracing_subscriber::prelude::*; + +use crate::args::{CliArguments, Command}; + +/// Initializes the tracing system and returns a guard that will flush the +/// flamegraph to disk when dropped. +pub fn setup_tracing(args: &CliArguments) -> io::Result<Option<impl Drop>> { + let flamegraph = match &args.command { + Command::Compile(command) => command.flamegraph.as_ref(), + Command::Watch(command) if command.flamegraph.is_some() => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cannot use --flamegraph with watch command", + )); + } + _ => None, + }; + + // Short circuit if we don't need to initialize flamegraph or debugging. + if flamegraph.is_none() && args.verbosity == 0 { + tracing_subscriber::fmt() + .without_time() + .with_max_level(level_filter(args)) + .init(); + + return Ok(None); + } + + // Build the FMT layer printing to the console. + let fmt_layer = fmt::Layer::default().without_time().with_filter(level_filter(args)); + + // Error layer for building backtraces + let error_layer = ErrorLayer::default(); + + // Build the registry. + let registry = tracing_subscriber::registry().with(fmt_layer).with(error_layer); + + let Some(path) = flamegraph else { + registry.init(); + return Ok(None); + }; + + // Create a temporary file to store the flamegraph data. + let temp_file = tempfile::tempfile()?; + let writer = BufWriter::new(temp_file.try_clone()?); + + // Build the flamegraph layer. + let flame_layer = FlameLayer::new(writer) + .with_empty_samples(false) + .with_threads_collapsed(true) + .with_module_path(false) + .with_file_and_line(true); + let flush_guard = flame_layer.flush_on_drop(); + + // Build the subscriber. + registry.with(flame_layer).init(); + + tracing::warn!( + "Flamegraph is enabled, this can create a large temporary \ + file and slow down the compilation process." + ); + + Ok(Some(TracingGuard { + flush_guard: Some(flush_guard), + temp_file, + output_svg: path.clone().unwrap_or_else(|| "flamegraph.svg".into()), + })) +} + +/// Returns the log level filter for the given verbosity level. +fn level_filter(args: &CliArguments) -> LevelFilter { + match args.verbosity { + 0 => LevelFilter::OFF, + 1 => LevelFilter::WARN, + 2 => LevelFilter::INFO, + 3 => LevelFilter::DEBUG, + _ => LevelFilter::TRACE, + } +} + +/// Will flush the flamegraph to disk when dropped. +struct TracingGuard { + flush_guard: Option<FlushGuard<BufWriter<File>>>, + temp_file: File, + output_svg: PathBuf, +} + +impl TracingGuard { + fn finish(&mut self) -> io::Result<()> { + if self.flush_guard.is_none() { + return Ok(()); + } + + tracing::info!("Flushing tracing flamegraph..."); + + // At this point, we're done tracing, so we can drop the guard. + // This will flush the tracing output to disk. + // We can then read the file and generate the flamegraph. + drop(self.flush_guard.take()); + + // Reset the file pointer to the beginning. + self.temp_file.seek(SeekFrom::Start(0))?; + + // Create the readers and writers. + let reader = BufReader::new(&mut self.temp_file); + let output = BufWriter::new(File::create(&self.output_svg)?); + + // Create the options: default in flame chart mode + let mut options = Options::default(); + options.flame_chart = true; + + inferno::flamegraph::from_reader(&mut options, reader, output) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + + Ok(()) + } +} + +impl Drop for TracingGuard { + fn drop(&mut self) { + if !std::thread::panicking() { + if let Err(e) = self.finish() { + // Since we are finished, we cannot rely on tracing to log the + // error. + eprintln!("failed to flush tracing flamegraph: {e}"); + } + } + } +} diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs new file mode 100644 index 00000000..2ad73f85 --- /dev/null +++ b/crates/typst-cli/src/watch.rs @@ -0,0 +1,170 @@ +use std::collections::HashSet; +use std::io::{self, IsTerminal, Write}; +use std::path::{Path, PathBuf}; + +use codespan_reporting::term::{self, termcolor}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use same_file::is_same_file; +use termcolor::WriteColor; +use typst::diag::StrResult; +use typst::eval::eco_format; + +use crate::args::CompileCommand; +use crate::color_stream; +use crate::compile::compile_once; +use crate::world::SystemWorld; + +/// Execute a watching compilation command. +pub fn watch(mut command: CompileCommand) -> StrResult<()> { + // Create the world that serves sources, files, and fonts. + let mut world = SystemWorld::new(&command)?; + + // Perform initial compilation. + compile_once(&mut world, &mut command, true)?; + + // Setup file watching. + let (tx, rx) = std::sync::mpsc::channel(); + let mut watcher = RecommendedWatcher::new(tx, notify::Config::default()) + .map_err(|_| "failed to setup file watching")?; + + // Watch all the files that are used by the input file and its dependencies. + watch_dependencies(&mut world, &mut watcher, HashSet::new())?; + + // Handle events. + let timeout = std::time::Duration::from_millis(100); + let output = command.output(); + loop { + let mut recompile = false; + for event in rx + .recv() + .into_iter() + .chain(std::iter::from_fn(|| rx.recv_timeout(timeout).ok())) + { + let event = event.map_err(|_| "failed to watch directory")?; + recompile |= is_event_relevant(&event, &output); + } + + if recompile { + // Retrieve the dependencies of the last compilation. + let previous: HashSet<PathBuf> = + world.dependencies().map(ToOwned::to_owned).collect(); + + // Recompile. + compile_once(&mut world, &mut command, true)?; + comemo::evict(10); + + // Adjust the watching. + watch_dependencies(&mut world, &mut watcher, previous)?; + } + } +} + +/// Adjust the file watching. Watches all new dependencies and unwatches +/// all `previous` dependencies that are not relevant anymore. +#[tracing::instrument(skip_all)] +fn watch_dependencies( + world: &mut SystemWorld, + watcher: &mut dyn Watcher, + mut previous: HashSet<PathBuf>, +) -> StrResult<()> { + // Watch new paths that weren't watched yet. + for path in world.dependencies() { + let watched = previous.remove(path); + if path.exists() && !watched { + tracing::info!("Watching {}", path.display()); + watcher + .watch(path, RecursiveMode::NonRecursive) + .map_err(|_| eco_format!("failed to watch {path:?}"))?; + } + } + + // Unwatch old paths that don't need to be watched anymore. + for path in previous { + tracing::info!("Unwatching {}", path.display()); + watcher.unwatch(&path).ok(); + } + + Ok(()) +} + +/// Whether a watch event is relevant for compilation. +fn is_event_relevant(event: ¬ify::Event, output: &Path) -> bool { + // Never recompile because the output file changed. + if event + .paths + .iter() + .all(|path| is_same_file(path, output).unwrap_or(false)) + { + return false; + } + + match &event.kind { + notify::EventKind::Any => true, + notify::EventKind::Access(_) => false, + notify::EventKind::Create(_) => true, + notify::EventKind::Modify(kind) => match kind { + notify::event::ModifyKind::Any => true, + notify::event::ModifyKind::Data(_) => true, + notify::event::ModifyKind::Metadata(_) => false, + notify::event::ModifyKind::Name(_) => true, + notify::event::ModifyKind::Other => false, + }, + notify::EventKind::Remove(_) => true, + notify::EventKind::Other => false, + } +} + +/// The status in which the watcher can be. +pub enum Status { + Compiling, + Success(std::time::Duration), + Error, +} + +impl Status { + /// Clear the terminal and render the status message. + pub fn print(&self, command: &CompileCommand) -> io::Result<()> { + let output = command.output(); + let timestamp = chrono::offset::Local::now().format("%H:%M:%S"); + let color = self.color(); + + let mut w = color_stream(); + if std::io::stderr().is_terminal() { + // Clear the terminal. + let esc = 27 as char; + write!(w, "{esc}c{esc}[1;1H")?; + } + + w.set_color(&color)?; + write!(w, "watching")?; + w.reset()?; + writeln!(w, " {}", command.input.display())?; + + w.set_color(&color)?; + write!(w, "writing to")?; + w.reset()?; + writeln!(w, " {}", output.display())?; + + writeln!(w)?; + writeln!(w, "[{timestamp}] {}", self.message())?; + writeln!(w)?; + + w.flush() + } + + fn message(&self) -> String { + match self { + Self::Compiling => "compiling ...".into(), + Self::Success(duration) => format!("compiled successfully in {duration:.2?}"), + Self::Error => "compiled with errors".into(), + } + } + + fn color(&self) -> termcolor::ColorSpec { + let styles = term::Styles::default(); + match self { + Self::Error => styles.header_error, + _ => styles.header_note, + } + } +} diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs new file mode 100644 index 00000000..f09a3f6c --- /dev/null +++ b/crates/typst-cli/src/world.rs @@ -0,0 +1,256 @@ +use std::cell::{OnceCell, RefCell, RefMut}; +use std::collections::HashMap; +use std::fs; +use std::hash::Hash; +use std::path::{Path, PathBuf}; + +use chrono::Datelike; +use comemo::Prehashed; +use same_file::Handle; +use siphasher::sip128::{Hasher128, SipHasher13}; +use typst::diag::{FileError, FileResult, StrResult}; +use typst::eval::{eco_format, Datetime, Library}; +use typst::file::FileId; +use typst::font::{Font, FontBook}; +use typst::syntax::Source; +use typst::util::{Bytes, PathExt}; +use typst::World; + +use crate::args::CompileCommand; +use crate::fonts::{FontSearcher, FontSlot}; +use crate::package::prepare_package; + +/// A world that provides access to the operating system. +pub struct SystemWorld { + /// The root relative to which absolute paths are resolved. + root: PathBuf, + /// The input path. + main: FileId, + /// Typst's standard library. + library: Prehashed<Library>, + /// Metadata about discovered fonts. + book: Prehashed<FontBook>, + /// Locations of and storage for lazily loaded fonts. + fonts: Vec<FontSlot>, + /// Maps package-path combinations to canonical hashes. All package-path + /// combinations that point to the same file are mapped to the same hash. To + /// be used in conjunction with `paths`. + hashes: RefCell<HashMap<FileId, FileResult<PathHash>>>, + /// Maps canonical path hashes to source files and buffers. + paths: RefCell<HashMap<PathHash, PathSlot>>, + /// The current date if requested. This is stored here to ensure it is + /// always the same within one compilation. Reset between compilations. + today: OnceCell<Option<Datetime>>, +} + +impl SystemWorld { + /// Create a new system world. + pub fn new(command: &CompileCommand) -> StrResult<Self> { + let mut searcher = FontSearcher::new(); + searcher.search(&command.font_paths); + + // Resolve the system-global input path. + let system_input = command.input.canonicalize().map_err(|_| { + eco_format!("input file not found (searched at {})", command.input.display()) + })?; + + // Resolve the system-global root directory. + let root = { + let path = command + .root + .as_deref() + .or_else(|| system_input.parent()) + .unwrap_or(Path::new(".")); + path.canonicalize().map_err(|_| { + eco_format!("root directory not found (searched at {})", path.display()) + })? + }; + + // Resolve the input path within the project. + let project_input = system_input + .strip_prefix(&root) + .map(|path| Path::new("/").join(path)) + .map_err(|_| "input file must be contained in project root")?; + + Ok(Self { + root, + main: FileId::new(None, &project_input), + library: Prehashed::new(typst_library::build()), + book: Prehashed::new(searcher.book), + fonts: searcher.fonts, + hashes: RefCell::default(), + paths: RefCell::default(), + today: OnceCell::new(), + }) + } + + /// The id of the main source file. + pub fn main(&self) -> FileId { + self.main + } + + /// Return all paths the last compilation depended on. + pub fn dependencies(&mut self) -> impl Iterator<Item = &Path> { + self.paths.get_mut().values().map(|slot| slot.system_path.as_path()) + } + + /// Reset the compilation state in preparation of a new compilation. + pub fn reset(&mut self) { + self.hashes.borrow_mut().clear(); + self.paths.borrow_mut().clear(); + self.today.take(); + } + + /// Lookup a source file by id. + #[track_caller] + pub fn lookup(&self, id: FileId) -> Source { + self.source(id).expect("file id does not point to any source file") + } +} + +impl World for SystemWorld { + fn library(&self) -> &Prehashed<Library> { + &self.library + } + + fn book(&self) -> &Prehashed<FontBook> { + &self.book + } + + fn main(&self) -> Source { + self.source(self.main).unwrap() + } + + fn source(&self, id: FileId) -> FileResult<Source> { + self.slot(id)?.source() + } + + fn file(&self, id: FileId) -> FileResult<Bytes> { + self.slot(id)?.file() + } + + fn font(&self, index: usize) -> Option<Font> { + self.fonts[index].get() + } + + fn today(&self, offset: Option<i64>) -> Option<Datetime> { + *self.today.get_or_init(|| { + let naive = match offset { + None => chrono::Local::now().naive_local(), + Some(o) => (chrono::Utc::now() + chrono::Duration::hours(o)).naive_utc(), + }; + + Datetime::from_ymd( + naive.year(), + naive.month().try_into().ok()?, + naive.day().try_into().ok()?, + ) + }) + } +} + +impl SystemWorld { + /// Access the canonical slot for the given file id. + #[tracing::instrument(skip_all)] + fn slot(&self, id: FileId) -> FileResult<RefMut<PathSlot>> { + let mut system_path = PathBuf::new(); + let hash = self + .hashes + .borrow_mut() + .entry(id) + .or_insert_with(|| { + // Determine the root path relative to which the file path + // will be resolved. + let root = match id.package() { + Some(spec) => prepare_package(spec)?, + None => self.root.clone(), + }; + + // Join the path to the root. If it tries to escape, deny + // access. Note: It can still escape via symlinks. + system_path = + root.join_rooted(id.path()).ok_or(FileError::AccessDenied)?; + + PathHash::new(&system_path) + }) + .clone()?; + + Ok(RefMut::map(self.paths.borrow_mut(), |paths| { + paths.entry(hash).or_insert_with(|| PathSlot { + id, + // This will only trigger if the `or_insert_with` above also + // triggered. + system_path, + source: OnceCell::new(), + buffer: OnceCell::new(), + }) + })) + } +} + +/// Holds canonical data for all paths pointing to the same entity. +/// +/// Both fields can be populated if the file is both imported and read(). +struct PathSlot { + /// The slot's canonical file id. + id: FileId, + /// The slot's path on the system. + system_path: PathBuf, + /// The lazily loaded source file for a path hash. + source: OnceCell<FileResult<Source>>, + /// The lazily loaded buffer for a path hash. + buffer: OnceCell<FileResult<Bytes>>, +} + +impl PathSlot { + fn source(&self) -> FileResult<Source> { + self.source + .get_or_init(|| { + let buf = read(&self.system_path)?; + let text = decode_utf8(buf)?; + Ok(Source::new(self.id, text)) + }) + .clone() + } + + fn file(&self) -> FileResult<Bytes> { + self.buffer + .get_or_init(|| read(&self.system_path).map(Bytes::from)) + .clone() + } +} + +/// A hash that is the same for all paths pointing to the same entity. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +struct PathHash(u128); + +impl PathHash { + fn new(path: &Path) -> FileResult<Self> { + let f = |e| FileError::from_io(e, path); + let handle = Handle::from_path(path).map_err(f)?; + let mut state = SipHasher13::new(); + handle.hash(&mut state); + Ok(Self(state.finish128().as_u128())) + } +} + +/// Read a file. +fn read(path: &Path) -> FileResult<Vec<u8>> { + let f = |e| FileError::from_io(e, path); + if fs::metadata(path).map_err(f)?.is_dir() { + Err(FileError::IsDirectory) + } else { + fs::read(path).map_err(f) + } +} + +/// Decode UTF-8 with an optional BOM. +fn decode_utf8(buf: Vec<u8>) -> FileResult<String> { + Ok(if buf.starts_with(b"\xef\xbb\xbf") { + // Remove UTF-8 BOM. + std::str::from_utf8(&buf[3..])?.into() + } else { + // Assume UTF-8. + String::from_utf8(buf)? + }) +} |
