summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorspore <spore2050@gmail.com>2024-02-05 21:05:26 +0800
committerGitHub <noreply@github.com>2024-02-05 13:05:26 +0000
commit70b354e887cf735db070f766e4812357dddb8a40 (patch)
treec7a62d63db4518a6e7bd51ddc58df0a24c3c589f
parent302b87032121125bcfdb0490ad865a585ee0e3aa (diff)
Support reading input from stdin (#3339)
-rw-r--r--crates/typst-cli/src/args.rs26
-rw-r--r--crates/typst-cli/src/compile.rs11
-rw-r--r--crates/typst-cli/src/watch.rs7
-rw-r--r--crates/typst-cli/src/world.rs78
-rw-r--r--crates/typst-syntax/src/file.rs18
5 files changed, 113 insertions, 27 deletions
diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs
index c14f0277..973eea8b 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<PathBuf>,
/// 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<Input, clap::error::Error> {
+ 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 f66553f6..337ec966 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 67276a3e..35861242 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, " <stdin>"),
+ 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 fee6b7db..72efa7fa 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<FileId> =
+ Lazy::new(|| FileId::new_fake(VirtualPath::new("<stdin>")));
+
/// A world that provides access to the operating system.
pub struct SystemWorld {
/// The working directory.
workdir: Option<PathBuf>,
/// The canonical path to the input file.
- input: PathBuf,
+ input: Option<PathBuf>,
/// 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<Source> {
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<Bytes> {
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<T: Clone> SlotCell<T> {
/// Gets the contents of the cell or initialize them.
fn get_or_init(
&mut self,
- path: impl FnOnce() -> FileResult<PathBuf>,
+ load: impl FnOnce() -> FileResult<Vec<u8>>,
f: impl FnOnce(Vec<u8>, Option<T>) -> FileResult<T>,
) -> FileResult<T> {
// If we accessed the file already in this compilation, retrieve it.
@@ -294,7 +310,7 @@ impl<T: Clone> SlotCell<T> {
}
// 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<PathBuf> {
id.vpath().resolve(root).ok_or(FileError::AccessDenied)
}
-/// Read a file.
-fn read(path: &Path) -> FileResult<Vec<u8>> {
+/// 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<Vec<u8>> {
+ 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<Vec<u8>> {
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<Vec<u8>> {
}
}
+/// Read from stdin.
+fn read_from_stdin() -> FileResult<Vec<u8>> {
+ 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("<stdin>"))),
+ }
+ 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 40659c6a..6699f05d 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()