diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index c14f0277f..973eea8be 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -65,6 +65,7 @@ pub struct CompileCommand { pub common: SharedArgs, /// Path to output file (PDF, PNG, or SVG) + #[clap(required_if_eq("input", "-"))] pub output: Option, /// The format of the output file, inferred from the extension by default @@ -121,8 +122,9 @@ pub enum SerializationFormat { /// Common arguments of compile, watch, and query. #[derive(Debug, Clone, Args)] pub struct SharedArgs { - /// Path to input Typst file - pub input: PathBuf, + /// Path to input Typst file, use `-` to read input from stdin + #[clap(value_parser = input_value_parser)] + pub input: Input, /// Configures the project root (for absolute paths) #[clap(long = "root", env = "TYPST_ROOT", value_name = "DIR")] @@ -155,6 +157,26 @@ pub struct SharedArgs { pub diagnostic_format: DiagnosticFormat, } +/// An input that is either stdin or a real path. +#[derive(Debug, Clone)] +pub enum Input { + /// Stdin, represented by `-`. + Stdin, + /// A non-empty path. + Path(PathBuf), +} + +/// The clap value parser used by `SharedArgs.input` +fn input_value_parser(value: &str) -> Result { + if value.is_empty() { + Err(clap::Error::new(clap::error::ErrorKind::InvalidValue)) + } else if value == "-" { + Ok(Input::Stdin) + } else { + Ok(Input::Path(value.into())) + } +} + /// Parses key/value pairs split by the first equal sign. /// /// This function will return an error if the argument contains no equals sign diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index f66553f6d..337ec966f 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -16,7 +16,7 @@ use typst::syntax::{FileId, Source, Span}; use typst::visualize::Color; use typst::{World, WorldExt}; -use crate::args::{CompileCommand, DiagnosticFormat, OutputFormat}; +use crate::args::{CompileCommand, DiagnosticFormat, Input, OutputFormat}; use crate::timings::Timer; use crate::watch::Status; use crate::world::SystemWorld; @@ -29,7 +29,10 @@ impl CompileCommand { /// The output path. pub fn output(&self) -> PathBuf { self.output.clone().unwrap_or_else(|| { - self.common.input.with_extension( + let Input::Path(path) = &self.common.input else { + panic!("output must be specified when input is from stdin, as guarded by the CLI"); + }; + path.with_extension( match self.output_format().unwrap_or(OutputFormat::Pdf) { OutputFormat::Pdf => "pdf", OutputFormat::Png => "png", @@ -163,8 +166,8 @@ fn export_pdf( command: &CompileCommand, world: &SystemWorld, ) -> StrResult<()> { - let ident = world.input().to_string_lossy(); - let buffer = typst_pdf::pdf(document, Some(&ident), now()); + let ident = world.input().map(|i| i.to_string_lossy()); + let buffer = typst_pdf::pdf(document, ident.as_deref(), now()); let output = command.output(); fs::write(output, buffer) .map_err(|err| eco_format!("failed to write PDF file ({err})"))?; diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index 67276a3e5..35861242d 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -9,7 +9,7 @@ use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use same_file::is_same_file; use typst::diag::StrResult; -use crate::args::CompileCommand; +use crate::args::{CompileCommand, Input}; use crate::compile::compile_once; use crate::terminal; use crate::timings::Timer; @@ -168,7 +168,10 @@ impl Status { term_out.set_color(&color)?; write!(term_out, "watching")?; term_out.reset()?; - writeln!(term_out, " {}", command.common.input.display())?; + match &command.common.input { + Input::Stdin => writeln!(term_out, " "), + Input::Path(path) => writeln!(term_out, " {}", path.display()), + }?; term_out.set_color(&color)?; write!(term_out, "writing to")?; diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index fee6b7db1..72efa7fa4 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -1,11 +1,13 @@ use std::collections::HashMap; +use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::OnceLock; -use std::{fs, mem}; +use std::{fs, io, mem}; use chrono::{DateTime, Datelike, Local}; use comemo::Prehashed; use ecow::eco_format; +use once_cell::sync::Lazy; use parking_lot::Mutex; use typst::diag::{FileError, FileResult, StrResult}; use typst::foundations::{Bytes, Datetime, Dict, IntoValue}; @@ -14,17 +16,22 @@ use typst::text::{Font, FontBook}; use typst::{Library, World}; use typst_timing::{timed, TimingScope}; -use crate::args::SharedArgs; +use crate::args::{Input, SharedArgs}; use crate::compile::ExportCache; use crate::fonts::{FontSearcher, FontSlot}; use crate::package::prepare_package; +/// Static `FileId` allocated for stdin. +/// This is to ensure that a file is read in the correct way. +static STDIN_ID: Lazy = + Lazy::new(|| FileId::new_fake(VirtualPath::new(""))); + /// A world that provides access to the operating system. pub struct SystemWorld { /// The working directory. workdir: Option, /// The canonical path to the input file. - input: PathBuf, + input: Option, /// The root relative to which absolute paths are resolved. root: PathBuf, /// The input path. @@ -52,25 +59,34 @@ impl SystemWorld { searcher.search(&command.font_paths); // Resolve the system-global input path. - let input = command.input.canonicalize().map_err(|_| { - eco_format!("input file not found (searched at {})", command.input.display()) - })?; + let input = match &command.input { + Input::Stdin => None, + Input::Path(path) => Some(path.canonicalize().map_err(|_| { + eco_format!("input file not found (searched at {})", path.display()) + })?), + }; // Resolve the system-global root directory. let root = { let path = command .root .as_deref() - .or_else(|| input.parent()) + .or_else(|| input.as_deref().and_then(|i| i.parent())) .unwrap_or(Path::new(".")); path.canonicalize().map_err(|_| { eco_format!("root directory not found (searched at {})", path.display()) })? }; - // Resolve the virtual path of the main file within the project root. - let main_path = VirtualPath::within_root(&input, &root) - .ok_or("source file must be contained in project root")?; + let main = if let Some(path) = &input { + // Resolve the virtual path of the main file within the project root. + let main_path = VirtualPath::within_root(path, &root) + .ok_or("source file must be contained in project root")?; + FileId::new(None, main_path) + } else { + // Return the special id of STDIN otherwise + *STDIN_ID + }; let library = { // Convert the input pairs to a dictionary. @@ -87,7 +103,7 @@ impl SystemWorld { workdir: std::env::current_dir().ok(), input, root, - main: FileId::new(None, main_path), + main, library: Prehashed::new(library), book: Prehashed::new(searcher.book), fonts: searcher.fonts, @@ -130,8 +146,8 @@ impl SystemWorld { } /// Return the canonical path to the input file. - pub fn input(&self) -> &PathBuf { - &self.input + pub fn input(&self) -> Option<&PathBuf> { + self.input.as_ref() } /// Lookup a source file by id. @@ -231,7 +247,7 @@ impl FileSlot { /// Retrieve the source for this file. fn source(&mut self, project_root: &Path) -> FileResult { self.source.get_or_init( - || system_path(project_root, self.id), + || read(self.id, project_root), |data, prev| { let name = if prev.is_some() { "reparsing file" } else { "parsing file" }; let _scope = TimingScope::new(name, None); @@ -249,7 +265,7 @@ impl FileSlot { /// Retrieve the file's bytes. fn file(&mut self, project_root: &Path) -> FileResult { self.file - .get_or_init(|| system_path(project_root, self.id), |data, _| Ok(data.into())) + .get_or_init(|| read(self.id, project_root), |data, _| Ok(data.into())) } } @@ -283,7 +299,7 @@ impl SlotCell { /// Gets the contents of the cell or initialize them. fn get_or_init( &mut self, - path: impl FnOnce() -> FileResult, + load: impl FnOnce() -> FileResult>, f: impl FnOnce(Vec, Option) -> FileResult, ) -> FileResult { // If we accessed the file already in this compilation, retrieve it. @@ -294,7 +310,7 @@ impl SlotCell { } // Read and hash the file. - let result = timed!("loading file", path().and_then(|p| read(&p))); + let result = timed!("loading file", load()); let fingerprint = timed!("hashing file", typst::util::hash128(&result)); // If the file contents didn't change, yield the old processed data. @@ -329,8 +345,20 @@ fn system_path(project_root: &Path, id: FileId) -> FileResult { id.vpath().resolve(root).ok_or(FileError::AccessDenied) } -/// Read a file. -fn read(path: &Path) -> FileResult> { +/// Reads a file from a `FileId`. +/// +/// If the ID represents stdin it will read from standard input, +/// otherwise it gets the file path of the ID and reads the file from disk. +fn read(id: FileId, project_root: &Path) -> FileResult> { + if id == *STDIN_ID { + read_from_stdin() + } else { + read_from_disk(&system_path(project_root, id)?) + } +} + +/// Read a file from disk. +fn read_from_disk(path: &Path) -> FileResult> { let f = |e| FileError::from_io(e, path); if fs::metadata(path).map_err(f)?.is_dir() { Err(FileError::IsDirectory) @@ -339,6 +367,18 @@ fn read(path: &Path) -> FileResult> { } } +/// Read from stdin. +fn read_from_stdin() -> FileResult> { + let mut buf = Vec::new(); + let result = io::stdin().read_to_end(&mut buf); + match result { + Ok(_) => (), + Err(err) if err.kind() == io::ErrorKind::BrokenPipe => (), + Err(err) => return Err(FileError::from_io(err, Path::new(""))), + } + Ok(buf) +} + /// Decode UTF-8 with an optional BOM. fn decode_utf8(buf: &[u8]) -> FileResult<&str> { // Remove UTF-8 BOM. diff --git a/crates/typst-syntax/src/file.rs b/crates/typst-syntax/src/file.rs index 40659c6ae..6699f05d7 100644 --- a/crates/typst-syntax/src/file.rs +++ b/crates/typst-syntax/src/file.rs @@ -57,6 +57,24 @@ impl FileId { id } + /// Create a new unique ("fake") file specification, which is not + /// accessible by path. + /// + /// Caution: the ID returned by this method is the *only* identifier of the + /// file, constructing a file ID with a path will *not* reuse the ID even + /// if the path is the same. This method should only be used for generating + /// "virtual" file ids such as content read from stdin. + #[track_caller] + pub fn new_fake(path: VirtualPath) -> Self { + let mut interner = INTERNER.write().unwrap(); + let num = interner.from_id.len().try_into().expect("out of file ids"); + + let id = FileId(num); + let leaked = Box::leak(Box::new((None, path))); + interner.from_id.push(leaked); + id + } + /// The package the file resides in, if any. pub fn package(&self) -> Option<&'static PackageSpec> { self.pair().0.as_ref()