From 46fb49aed3832c2838f6668669f131d05f081b88 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Sun, 2 Jul 2023 18:22:20 +0200 Subject: [PATCH] Modularize CLI This introduces one small breaking change: `--root` and `--font-paths` can't appear in front of the command anymore. Also fixes #1491. --- cli/src/args.rs | 96 +-- cli/src/compile.rs | 239 ++++++++ cli/src/fonts.rs | 183 ++++++ cli/src/main.rs | 975 +------------------------------ cli/src/package.rs | 77 +++ cli/src/{trace.rs => tracing.rs} | 124 ++-- cli/src/watch.rs | 140 +++++ cli/src/world.rs | 291 +++++++++ 8 files changed, 1060 insertions(+), 1065 deletions(-) create mode 100644 cli/src/compile.rs create mode 100644 cli/src/fonts.rs create mode 100644 cli/src/package.rs rename cli/src/{trace.rs => tracing.rs} (82%) create mode 100644 cli/src/watch.rs create mode 100644 cli/src/world.rs diff --git a/cli/src/args.rs b/cli/src/args.rs index 6b9f3df83..7fdb041b9 100644 --- a/cli/src/args.rs +++ b/cli/src/args.rs @@ -11,41 +11,12 @@ pub struct CliArguments { #[command(subcommand)] pub command: Command, - /// Configure the project root - #[clap(long = "root", env = "TYPST_ROOT", value_name = "DIR")] - pub root: Option, - - /// 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, - /// Sets the level of logging verbosity: /// -v = warning & error, -vv = info, -vvv = debug, -vvvv = trace #[clap(short, long, action = ArgAction::Count)] pub verbosity: u8, } -/// 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) - } -} - /// What to do. #[derive(Debug, Clone, Subcommand)] #[command()] @@ -62,22 +33,6 @@ pub enum Command { Fonts(FontsCommand), } -impl Command { - /// Returns the compile command if this is a compile or watch command. - pub fn as_compile(&self) -> Option<&CompileCommand> { - match self { - Command::Compile(cmd) => Some(cmd), - Command::Watch(cmd) => Some(cmd), - Command::Fonts(_) => None, - } - } - - /// Returns whether this is a watch command. - pub fn is_watch(&self) -> bool { - matches!(self, Command::Watch(_)) - } -} - /// Compiles the input file into a PDF file #[derive(Debug, Clone, Parser)] pub struct CompileCommand { @@ -87,13 +42,26 @@ pub struct CompileCommand { /// Path to output PDF file or PNG file(s) pub output: Option, + /// Configure the project root + #[clap(long = "root", env = "TYPST_ROOT", value_name = "DIR")] + pub root: Option, + + /// 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, + /// Opens the output file after compilation using the default PDF viewer #[arg(long = "open")] pub open: Option>, /// The PPI to use if exported as PNG - #[arg(long = "ppi")] - pub ppi: Option, + #[arg(long = "ppi", default_value_t = 144.0)] + pub ppi: f32, /// In which format to emit diagnostics #[clap( @@ -108,10 +76,44 @@ pub struct CompileCommand { pub flamegraph: Option>, } +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, + /// 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/cli/src/compile.rs b/cli/src/compile.rs new file mode 100644 index 000000000..3250202b2 --- /dev/null +++ b/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 = Result; +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, + 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 { + Ok(id) + } + + fn source(&'a self, id: FileId) -> CodespanResult { + Ok(self.lookup(id)) + } + + fn line_index(&'a self, id: FileId, given: usize) -> CodespanResult { + 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> { + 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 { + 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/cli/src/fonts.rs b/cli/src/fonts.rs new file mode 100644 index 000000000..758357410 --- /dev/null +++ b/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, +} + +/// 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>, +} + +impl FontSlot { + /// Get the font for this slot. + pub fn get(&self) -> Option { + 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) { + 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/cli/src/main.rs b/cli/src/main.rs index fe86caec8..425d05fd1 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,52 +1,31 @@ mod args; -mod trace; +mod compile; +mod fonts; +mod package; +mod tracing; +mod watch; +mod world; -use std::cell::{Cell, RefCell, RefMut}; -use std::collections::{HashMap, HashSet}; +use std::cell::Cell; use std::env; -use std::fs::{self, File}; -use std::hash::Hash; use std::io::{self, IsTerminal, Write}; -use std::path::{Path, PathBuf}; use std::process::ExitCode; -use chrono::Datelike; use clap::Parser; -use codespan_reporting::diagnostic::{Diagnostic, Label}; use codespan_reporting::term::{self, termcolor}; -use comemo::Prehashed; -use memmap2::Mmap; -use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use same_file::{is_same_file, Handle}; -use siphasher::sip128::{Hasher128, SipHasher13}; -use std::cell::OnceCell; -use termcolor::{ColorChoice, StandardStream, WriteColor}; -use typst::diag::{ - bail, FileError, FileResult, PackageError, PackageResult, SourceError, StrResult, -}; -use typst::doc::Document; -use typst::eval::{eco_format, Datetime, Library}; -use typst::file::{FileId, PackageSpec}; -use typst::font::{Font, FontBook, FontInfo, FontVariant}; -use typst::geom::Color; -use typst::syntax::Source; -use typst::util::{Bytes, PathExt}; -use typst::World; -use walkdir::WalkDir; +use termcolor::{ColorChoice, WriteColor}; -use crate::args::{CliArguments, Command, CompileCommand, DiagnosticFormat}; - -type CodespanResult = Result; -type CodespanError = codespan_reporting::files::Error; +use crate::args::{CliArguments, Command}; thread_local! { + /// The CLI's exit code. static EXIT: Cell = Cell::new(ExitCode::SUCCESS); } /// Entry point. fn main() -> ExitCode { let arguments = CliArguments::parse(); - let _guard = match crate::trace::init_tracing(&arguments) { + let _guard = match crate::tracing::setup_tracing(&arguments) { Ok(guard) => guard, Err(err) => { eprintln!("failed to initialize tracing {}", err); @@ -54,11 +33,10 @@ fn main() -> ExitCode { } }; - let res = match &arguments.command { - Command::Compile(_) | Command::Watch(_) => { - compile(CompileSettings::with_arguments(arguments)) - } - Command::Fonts(_) => fonts(FontsSettings::with_arguments(arguments)), + 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 { @@ -86,296 +64,6 @@ fn print_error(msg: &str) -> io::Result<()> { writeln!(w, ": {msg}.") } -/// Used by `args.rs`. -fn typst_version() -> &'static str { - env!("TYPST_VERSION") -} - -/// A summary of the input arguments relevant to compilation. -struct CompileSettings { - /// The project's root directory. - root: Option, - /// The path to the input file. - input: PathBuf, - /// The path to the output file. - output: PathBuf, - /// Whether to watch the input files for changes. - watch: bool, - /// The paths to search for fonts. - font_paths: Vec, - /// The open command to use. - open: Option>, - /// The PPI to use for PNG export. - ppi: Option, - /// In which format to emit diagnostics. - diagnostic_format: DiagnosticFormat, -} - -impl CompileSettings { - /// Create a new compile settings from the field values. - #[allow(clippy::too_many_arguments)] - fn new( - input: PathBuf, - output: Option, - root: Option, - font_paths: Vec, - watch: bool, - open: Option>, - ppi: Option, - diagnostic_format: DiagnosticFormat, - ) -> Self { - let output = match output { - Some(path) => path, - None => input.with_extension("pdf"), - }; - Self { - root, - input, - output, - watch, - font_paths, - open, - diagnostic_format, - ppi, - } - } - - /// Create a new compile settings from the CLI arguments and a compile command. - /// - /// # Panics - /// Panics if the command is not a compile or watch command. - fn with_arguments(args: CliArguments) -> Self { - let watch = matches!(args.command, Command::Watch(_)); - let CompileCommand { input, output, open, ppi, diagnostic_format, .. } = - match args.command { - Command::Compile(command) => command, - Command::Watch(command) => command, - _ => unreachable!(), - }; - - Self::new( - input, - output, - args.root, - args.font_paths, - watch, - open, - ppi, - diagnostic_format, - ) - } -} - -struct FontsSettings { - /// The font paths - font_paths: Vec, - /// Whether to include font variants - variants: bool, -} - -impl FontsSettings { - /// Create font settings from the field values. - fn new(font_paths: Vec, variants: bool) -> Self { - Self { font_paths, variants } - } - - /// Create a new font settings from the CLI arguments. - /// - /// # Panics - /// Panics if the command is not a fonts command. - fn with_arguments(args: CliArguments) -> Self { - match args.command { - Command::Fonts(command) => Self::new(args.font_paths, command.variants), - _ => unreachable!(), - } - } -} - -/// Execute a compilation command. -fn compile(mut settings: CompileSettings) -> StrResult<()> { - // Create the world that serves sources, files, and fonts. - let mut world = SystemWorld::new(&settings)?; - - // Perform initial compilation. - let ok = compile_once(&mut world, &settings)?; - - // Open the file if requested, this must be done on the first **successful** - // compilation. - if ok { - if let Some(open) = settings.open.take() { - open_file(open.as_deref(), &settings.output)?; - } - } - - if !settings.watch { - return Ok(()); - } - - // 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. - world.watch(&mut watcher, HashSet::new())?; - - // Handle events. - let timeout = std::time::Duration::from_millis(100); - 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")?; - if event - .paths - .iter() - .all(|path| is_same_file(path, &settings.output).unwrap_or(false)) - { - continue; - } - - recompile |= is_event_relevant(&event); - } - - if recompile { - // Retrieve the dependencies of the last compilation. - let dependencies = world.dependencies(); - - // Recompile. - let ok = compile_once(&mut world, &settings)?; - comemo::evict(10); - - // Adjust the watching. - world.watch(&mut watcher, dependencies)?; - - // Open the file if requested, this must be done on the first - // **successful** compilation - if ok { - if let Some(open) = settings.open.take() { - open_file(open.as_deref(), &settings.output)?; - } - } - } - } -} - -/// Compile a single time. -/// -/// Returns whether it compiled without errors. -#[tracing::instrument(skip_all)] -fn compile_once(world: &mut SystemWorld, settings: &CompileSettings) -> StrResult { - tracing::info!("Starting compilation"); - - let start = std::time::Instant::now(); - status(settings, Status::Compiling).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, settings)?; - status(settings, Status::Success(duration)).unwrap(); - tracing::info!("Compilation succeeded in {duration:?}"); - Ok(true) - } - - // Print diagnostics. - Err(errors) => { - set_failed(); - status(settings, Status::Error).unwrap(); - print_diagnostics(world, *errors, settings.diagnostic_format) - .map_err(|_| "failed to print diagnostics")?; - tracing::info!("Compilation failed after {duration:?}"); - Ok(false) - } - } -} - -/// Export into the target format. -fn export(document: &Document, settings: &CompileSettings) -> StrResult<()> { - match settings.output.extension() { - Some(ext) if ext.eq_ignore_ascii_case("png") => { - // Determine whether we have a `{n}` numbering. - let string = settings.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 ppi = settings.ppi.unwrap_or(2.0); - let mut storage; - - for (i, frame) in document.pages.iter().enumerate() { - let pixmap = typst::export::render(frame, ppi, Color::WHITE); - let path = if numbered { - storage = string.replace("{n}", &format!("{:0width$}", i + 1)); - Path::new(&storage) - } else { - settings.output.as_path() - }; - pixmap.save_png(path).map_err(|_| "failed to write PNG file")?; - } - } - _ => { - let buffer = typst::export::pdf(document); - fs::write(&settings.output, buffer) - .map_err(|_| "failed to write PDF file")?; - } - } - Ok(()) -} - -/// Clear the terminal and render the status message. -#[tracing::instrument(skip_all)] -fn status(settings: &CompileSettings, status: Status) -> io::Result<()> { - if !settings.watch { - return Ok(()); - } - - let esc = 27 as char; - let input = settings.input.display(); - let output = settings.output.display(); - let time = chrono::offset::Local::now(); - let timestamp = time.format("%H:%M:%S"); - let message = status.message(); - let color = status.color(); - - let mut w = color_stream(); - if std::io::stderr().is_terminal() { - // Clear the terminal. - write!(w, "{esc}c{esc}[1;1H")?; - } - - w.set_color(&color)?; - write!(w, "watching")?; - w.reset()?; - writeln!(w, " {input}")?; - - w.set_color(&color)?; - write!(w, "writing to")?; - w.reset()?; - writeln!(w, " {output}")?; - - writeln!(w)?; - writeln!(w, "[{timestamp}] {message}")?; - writeln!(w)?; - - w.flush() -} - /// Get stderr with color support if desirable. fn color_stream() -> termcolor::StandardStream { termcolor::StandardStream::stderr(if std::io::stderr().is_terminal() { @@ -385,634 +73,7 @@ fn color_stream() -> termcolor::StandardStream { }) } -/// The status in which the watcher can be. -enum Status { - Compiling, - Success(std::time::Duration), - Error, -} - -impl Status { - 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, - } - } -} - -/// Print diagnostic messages to the terminal. -fn print_diagnostics( - world: &SystemWorld, - errors: Vec, - 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(()) -} - -/// Execute a font listing command. -fn fonts(command: FontsSettings) -> 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(()) -} - -/// A world that provides access to the operating system. -struct SystemWorld { - /// The root relative to which absolute paths are resolved. - root: PathBuf, - /// The input path. - main: FileId, - /// Typst's standard library. - library: Prehashed, - /// Metadata about discovered fonts. - book: Prehashed, - /// Locations of and storage for lazily loaded fonts. - fonts: Vec, - /// 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>>, - /// Maps canonical path hashes to source files and buffers. - paths: RefCell>, - /// The current date if requested. This is stored here to ensure it is - /// always the same within one compilation. Reset between compilations. - today: OnceCell>, -} - -/// Holds details about the location of a font and lazily the font itself. -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>, -} - -/// 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 path on the system. - system_path: PathBuf, - /// The lazily loaded source file for a path hash. - source: OnceCell>, - /// The lazily loaded buffer for a path hash. - buffer: OnceCell>, -} - -impl SystemWorld { - fn new(settings: &CompileSettings) -> StrResult { - let mut searcher = FontSearcher::new(); - searcher.search(&settings.font_paths); - - // Resolve the system-global input path. - let system_input = settings.input.canonicalize().map_err(|_| { - eco_format!("input file not found (searched at {})", settings.input.display()) - })?; - - // Resolve the system-global root directory. - let root = { - let path = settings - .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(), - }) - } -} - -impl World for SystemWorld { - fn library(&self) -> &Prehashed { - &self.library - } - - fn book(&self) -> &Prehashed { - &self.book - } - - fn main(&self) -> Source { - self.source(self.main).unwrap() - } - - fn source(&self, id: FileId) -> FileResult { - let slot = self.slot(id)?; - slot.source - .get_or_init(|| { - let buf = read(&slot.system_path)?; - let text = decode_utf8(buf)?; - Ok(Source::new(id, text)) - }) - .clone() - } - - fn file(&self, id: FileId) -> FileResult { - let slot = self.slot(id)?; - slot.buffer - .get_or_init(|| read(&slot.system_path).map(Bytes::from)) - .clone() - } - - fn font(&self, id: usize) -> Option { - let slot = &self.fonts[id]; - slot.font - .get_or_init(|| { - let data = read(&slot.path).ok()?.into(); - Font::new(data, slot.index) - }) - .clone() - } - - fn today(&self, offset: Option) -> Option { - *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 path. - #[tracing::instrument(skip_all)] - fn slot(&self, id: FileId) -> FileResult> { - 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 { - // This will only trigger if the `or_insert_with` above also - // triggered. - system_path, - source: OnceCell::new(), - buffer: OnceCell::new(), - }) - })) - } - - /// Collect all paths the last compilation depended on. - #[tracing::instrument(skip_all)] - fn dependencies(&self) -> HashSet { - self.paths - .borrow() - .values() - .map(|slot| slot.system_path.clone()) - .collect() - } - - /// Adjust the file watching. Watches all new dependencies and unwatches - /// all `previous` dependencies that are not relevant anymore. - #[tracing::instrument(skip_all)] - fn watch( - &self, - watcher: &mut dyn Watcher, - mut previous: HashSet, - ) -> StrResult<()> { - // Watch new paths that weren't watched yet. - for slot in self.paths.borrow().values() { - let path = &slot.system_path; - 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(()) - } - - /// Reset th compilation state in preparation of a new compilation. - #[tracing::instrument(skip_all)] - 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] - fn lookup(&self, id: FileId) -> Source { - self.source(id).expect("file id does not point to any source file") - } -} - -/// 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 { - 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. -#[tracing::instrument(skip_all)] -fn read(path: &Path) -> FileResult> { - 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) -> FileResult { - 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)? - }) -} - -/// Make a package available in the on-disk cache. -fn prepare_package(spec: &PackageSpec) -> PackageResult { - 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}") -} - -/// 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(()) -} - -/// Whether a watch event is relevant for compilation. -fn is_event_relevant(event: ¬ify::Event) -> bool { - 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, - } -} - -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 { - Ok(id) - } - - fn source(&'a self, id: FileId) -> CodespanResult { - Ok(self.lookup(id)) - } - - fn line_index(&'a self, id: FileId, given: usize) -> CodespanResult { - 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> { - 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 { - 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 } - } - }) - } -} - -/// Searches for fonts. -struct FontSearcher { - book: FontBook, - fonts: Vec, -} - -impl FontSearcher { - /// Create a new, empty system searcher. - fn new() -> Self { - Self { book: FontBook::new(), fonts: vec![] } - } - - /// Search everything that is available. - fn search(&mut self, font_paths: &[PathBuf]) { - self.search_system(); - - #[cfg(feature = "embed-fonts")] - self.search_embedded(); - - for path in font_paths { - self.search_dir(path) - } - } - - /// Add fonts that are embedded in the binary. - #[cfg(feature = "embed-fonts")] - fn search_embedded(&mut self) { - let mut search = |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)), - }); - } - }; - - // Embed default fonts. - search(include_bytes!("../../assets/fonts/LinLibertine_R.ttf")); - search(include_bytes!("../../assets/fonts/LinLibertine_RB.ttf")); - search(include_bytes!("../../assets/fonts/LinLibertine_RBI.ttf")); - search(include_bytes!("../../assets/fonts/LinLibertine_RI.ttf")); - search(include_bytes!("../../assets/fonts/NewCMMath-Book.otf")); - search(include_bytes!("../../assets/fonts/NewCMMath-Regular.otf")); - search(include_bytes!("../../assets/fonts/NewCM10-Regular.otf")); - search(include_bytes!("../../assets/fonts/NewCM10-Bold.otf")); - search(include_bytes!("../../assets/fonts/NewCM10-Italic.otf")); - search(include_bytes!("../../assets/fonts/NewCM10-BoldItalic.otf")); - search(include_bytes!("../../assets/fonts/DejaVuSansMono.ttf")); - search(include_bytes!("../../assets/fonts/DejaVuSansMono-Bold.ttf")); - search(include_bytes!("../../assets/fonts/DejaVuSansMono-Oblique.ttf")); - search(include_bytes!("../../assets/fonts/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) { - 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: impl AsRef) { - let path = path.as_ref(); - 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(), - }); - } - } - } - } +/// Used by `args.rs`. +fn typst_version() -> &'static str { + env!("TYPST_VERSION") } diff --git a/cli/src/package.rs b/cli/src/package.rs new file mode 100644 index 000000000..6853796b3 --- /dev/null +++ b/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 { + 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/cli/src/trace.rs b/cli/src/tracing.rs similarity index 82% rename from cli/src/trace.rs rename to cli/src/tracing.rs index 06b5668e3..80c2ff651 100644 --- a/cli/src/trace.rs +++ b/cli/src/tracing.rs @@ -1,5 +1,5 @@ use std::fs::File; -use std::io::{BufReader, BufWriter, Error, ErrorKind, Seek, SeekFrom}; +use std::io::{self, BufReader, BufWriter, Seek, SeekFrom}; use std::path::PathBuf; use inferno::flamegraph::Options; @@ -9,69 +9,21 @@ use tracing_flame::{FlameLayer, FlushGuard}; use tracing_subscriber::fmt; use tracing_subscriber::prelude::*; -use crate::args::CliArguments; - -/// Will flush the flamegraph to disk when dropped. -pub struct TracingGuard { - flush_guard: Option>>, - temp_file: File, - output_svg: PathBuf, -} - -impl TracingGuard { - pub fn finish(&mut self) -> Result<(), Error> { - 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| Error::new(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}"); - } - } - } -} +use crate::args::{CliArguments, Command}; /// Initializes the tracing system and returns a guard that will flush the /// flamegraph to disk when dropped. -pub fn init_tracing(args: &CliArguments) -> Result, Error> { - let flamegraph = args.command.as_compile().and_then(|c| c.flamegraph.as_ref()); - - if flamegraph.is_some() && args.command.is_watch() { - return Err(Error::new( - ErrorKind::InvalidInput, - "cannot use --flamegraph with watch command", - )); - } +pub fn setup_tracing(args: &CliArguments) -> io::Result> { + 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 { @@ -134,3 +86,53 @@ fn level_filter(args: &CliArguments) -> LevelFilter { _ => LevelFilter::TRACE, } } + +/// Will flush the flamegraph to disk when dropped. +struct TracingGuard { + flush_guard: Option>>, + 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/cli/src/watch.rs b/cli/src/watch.rs new file mode 100644 index 000000000..e70b69100 --- /dev/null +++ b/cli/src/watch.rs @@ -0,0 +1,140 @@ +use std::collections::HashSet; +use std::io::{self, IsTerminal, Write}; +use std::path::Path; + +use codespan_reporting::term::{self, termcolor}; +use notify::{RecommendedWatcher, Watcher}; +use same_file::is_same_file; +use termcolor::WriteColor; +use typst::diag::StrResult; + +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. + world.watch(&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 dependencies = world.dependencies(); + + // Recompile. + compile_once(&mut world, &mut command, true)?; + comemo::evict(10); + + // Adjust the watching. + world.watch(&mut watcher, dependencies)?; + } + } +} + +/// 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/cli/src/world.rs b/cli/src/world.rs new file mode 100644 index 000000000..f3dfaa429 --- /dev/null +++ b/cli/src/world.rs @@ -0,0 +1,291 @@ +use std::cell::{OnceCell, RefCell, RefMut}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::hash::Hash; +use std::path::{Path, PathBuf}; + +use chrono::Datelike; +use comemo::Prehashed; +use notify::{RecursiveMode, Watcher}; +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, + /// Metadata about discovered fonts. + book: Prehashed, + /// Locations of and storage for lazily loaded fonts. + fonts: Vec, + /// 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>>, + /// Maps canonical path hashes to source files and buffers. + paths: RefCell>, + /// The current date if requested. This is stored here to ensure it is + /// always the same within one compilation. Reset between compilations. + today: OnceCell>, +} + +impl SystemWorld { + pub fn new(command: &CompileCommand) -> StrResult { + 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 + } + + /// Adjust the file watching. Watches all new dependencies and unwatches + /// all `previous` dependencies that are not relevant anymore. + #[tracing::instrument(skip_all)] + pub fn watch( + &self, + watcher: &mut dyn Watcher, + mut previous: HashSet, + ) -> StrResult<()> { + // Watch new paths that weren't watched yet. + for slot in self.paths.borrow().values() { + let path = &slot.system_path; + 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(()) + } + + /// Collect all paths the last compilation depended on. + #[tracing::instrument(skip_all)] + pub fn dependencies(&self) -> HashSet { + self.paths + .borrow() + .values() + .map(|slot| slot.system_path.clone()) + .collect() + } + + /// Reset th compilation state in preparation of a new compilation. + #[tracing::instrument(skip_all)] + 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 { + &self.library + } + + fn book(&self) -> &Prehashed { + &self.book + } + + fn main(&self) -> Source { + self.source(self.main).unwrap() + } + + fn source(&self, id: FileId) -> FileResult { + self.slot(id)?.source() + } + + fn file(&self, id: FileId) -> FileResult { + self.slot(id)?.file() + } + + fn font(&self, index: usize) -> Option { + self.fonts[index].get() + } + + fn today(&self, offset: Option) -> Option { + *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> { + 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>, + /// The lazily loaded buffer for a path hash. + buffer: OnceCell>, +} + +impl PathSlot { + fn source(&self) -> FileResult { + 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 { + 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 { + 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> { + 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) -> FileResult { + 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)? + }) +}