Skip to content

Commit 46fb49a

Browse files
committed
Modularize CLI
This introduces one small breaking change: `--root` and `--font-paths` can't appear in front of the command anymore. Also fixes typst#1491.
1 parent 2dfd44f commit 46fb49a

File tree

8 files changed

+1061
-1066
lines changed

8 files changed

+1061
-1066
lines changed

cli/src/args.rs

Lines changed: 49 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,41 +11,12 @@ pub struct CliArguments {
1111
#[command(subcommand)]
1212
pub command: Command,
1313

14-
/// Configure the project root
15-
#[clap(long = "root", env = "TYPST_ROOT", value_name = "DIR")]
16-
pub root: Option<PathBuf>,
17-
18-
/// Add additional directories to search for fonts
19-
#[clap(
20-
long = "font-path",
21-
env = "TYPST_FONT_PATHS",
22-
value_name = "DIR",
23-
action = ArgAction::Append,
24-
)]
25-
pub font_paths: Vec<PathBuf>,
26-
2714
/// Sets the level of logging verbosity:
2815
/// -v = warning & error, -vv = info, -vvv = debug, -vvvv = trace
2916
#[clap(short, long, action = ArgAction::Count)]
3017
pub verbosity: u8,
3118
}
3219

33-
/// Which format to use for diagnostics.
34-
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)]
35-
pub enum DiagnosticFormat {
36-
Human,
37-
Short,
38-
}
39-
40-
impl Display for DiagnosticFormat {
41-
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
42-
self.to_possible_value()
43-
.expect("no values are skipped")
44-
.get_name()
45-
.fmt(f)
46-
}
47-
}
48-
4920
/// What to do.
5021
#[derive(Debug, Clone, Subcommand)]
5122
#[command()]
@@ -62,22 +33,6 @@ pub enum Command {
6233
Fonts(FontsCommand),
6334
}
6435

65-
impl Command {
66-
/// Returns the compile command if this is a compile or watch command.
67-
pub fn as_compile(&self) -> Option<&CompileCommand> {
68-
match self {
69-
Command::Compile(cmd) => Some(cmd),
70-
Command::Watch(cmd) => Some(cmd),
71-
Command::Fonts(_) => None,
72-
}
73-
}
74-
75-
/// Returns whether this is a watch command.
76-
pub fn is_watch(&self) -> bool {
77-
matches!(self, Command::Watch(_))
78-
}
79-
}
80-
8136
/// Compiles the input file into a PDF file
8237
#[derive(Debug, Clone, Parser)]
8338
pub struct CompileCommand {
@@ -87,13 +42,26 @@ pub struct CompileCommand {
8742
/// Path to output PDF file or PNG file(s)
8843
pub output: Option<PathBuf>,
8944

45+
/// Configure the project root
46+
#[clap(long = "root", env = "TYPST_ROOT", value_name = "DIR")]
47+
pub root: Option<PathBuf>,
48+
49+
/// Add additional directories to search for fonts
50+
#[clap(
51+
long = "font-path",
52+
env = "TYPST_FONT_PATHS",
53+
value_name = "DIR",
54+
action = ArgAction::Append,
55+
)]
56+
pub font_paths: Vec<PathBuf>,
57+
9058
/// Opens the output file after compilation using the default PDF viewer
9159
#[arg(long = "open")]
9260
pub open: Option<Option<String>>,
9361

9462
/// The PPI to use if exported as PNG
95-
#[arg(long = "ppi")]
96-
pub ppi: Option<f32>,
63+
#[arg(long = "ppi", default_value_t = 144.0)]
64+
pub ppi: f32,
9765

9866
/// In which format to emit diagnostics
9967
#[clap(
@@ -108,10 +76,44 @@ pub struct CompileCommand {
10876
pub flamegraph: Option<Option<PathBuf>>,
10977
}
11078

79+
impl CompileCommand {
80+
/// The output path.
81+
pub fn output(&self) -> PathBuf {
82+
self.output
83+
.clone()
84+
.unwrap_or_else(|| self.input.with_extension("pdf"))
85+
}
86+
}
87+
11188
/// List all discovered fonts in system and custom font paths
11289
#[derive(Debug, Clone, Parser)]
11390
pub struct FontsCommand {
91+
/// Add additional directories to search for fonts
92+
#[clap(
93+
long = "font-path",
94+
env = "TYPST_FONT_PATHS",
95+
value_name = "DIR",
96+
action = ArgAction::Append,
97+
)]
98+
pub font_paths: Vec<PathBuf>,
99+
114100
/// Also list style variants of each font family
115101
#[arg(long)]
116102
pub variants: bool,
117103
}
104+
105+
/// Which format to use for diagnostics.
106+
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)]
107+
pub enum DiagnosticFormat {
108+
Human,
109+
Short,
110+
}
111+
112+
impl Display for DiagnosticFormat {
113+
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
114+
self.to_possible_value()
115+
.expect("no values are skipped")
116+
.get_name()
117+
.fmt(f)
118+
}
119+
}

cli/src/compile.rs

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
use std::fs;
2+
use std::path::Path;
3+
4+
use codespan_reporting::diagnostic::{Diagnostic, Label};
5+
use codespan_reporting::term::{self, termcolor};
6+
use termcolor::{ColorChoice, StandardStream};
7+
use typst::diag::{bail, SourceError, StrResult};
8+
use typst::doc::Document;
9+
use typst::eval::eco_format;
10+
use typst::file::FileId;
11+
use typst::geom::Color;
12+
use typst::syntax::Source;
13+
use typst::World;
14+
15+
use crate::args::{CompileCommand, DiagnosticFormat};
16+
use crate::watch::Status;
17+
use crate::world::SystemWorld;
18+
use crate::{color_stream, set_failed};
19+
20+
type CodespanResult<T> = Result<T, CodespanError>;
21+
type CodespanError = codespan_reporting::files::Error;
22+
23+
/// Execute a compilation command.
24+
pub fn compile(mut command: CompileCommand) -> StrResult<()> {
25+
let mut world = SystemWorld::new(&command)?;
26+
compile_once(&mut world, &mut command, false)?;
27+
Ok(())
28+
}
29+
30+
/// Compile a single time.
31+
///
32+
/// Returns whether it compiled without errors.
33+
#[tracing::instrument(skip_all)]
34+
pub fn compile_once(
35+
world: &mut SystemWorld,
36+
command: &mut CompileCommand,
37+
watching: bool,
38+
) -> StrResult<()> {
39+
tracing::info!("Starting compilation");
40+
41+
let start = std::time::Instant::now();
42+
if watching {
43+
Status::Compiling.print(command).unwrap();
44+
}
45+
46+
// Reset everything and ensure that the main file is still present.
47+
world.reset();
48+
world.source(world.main()).map_err(|err| err.to_string())?;
49+
50+
let result = typst::compile(world);
51+
let duration = start.elapsed();
52+
53+
match result {
54+
// Export the PDF / PNG.
55+
Ok(document) => {
56+
export(&document, command)?;
57+
58+
tracing::info!("Compilation succeeded in {duration:?}");
59+
if watching {
60+
Status::Success(duration).print(command).unwrap();
61+
}
62+
63+
if let Some(open) = command.open.take() {
64+
open_file(open.as_deref(), &command.output())?;
65+
}
66+
}
67+
68+
// Print diagnostics.
69+
Err(errors) => {
70+
set_failed();
71+
tracing::info!("Compilation failed");
72+
73+
if watching {
74+
Status::Error.print(command).unwrap();
75+
}
76+
77+
print_diagnostics(world, *errors, command.diagnostic_format)
78+
.map_err(|_| "failed to print diagnostics")?;
79+
}
80+
}
81+
82+
Ok(())
83+
}
84+
85+
/// Export into the target format.
86+
fn export(document: &Document, command: &CompileCommand) -> StrResult<()> {
87+
match command.output().extension() {
88+
Some(ext) if ext.eq_ignore_ascii_case("png") => export_png(document, command),
89+
_ => export_pdf(document, command),
90+
}
91+
}
92+
93+
/// Export to a PDF.
94+
fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> {
95+
let output = command.output();
96+
let buffer = typst::export::pdf(document);
97+
fs::write(output, buffer).map_err(|_| "failed to write PDF file")?;
98+
Ok(())
99+
}
100+
101+
/// Export to one or multiple PNGs.
102+
fn export_png(document: &Document, command: &CompileCommand) -> StrResult<()> {
103+
// Determine whether we have a `{n}` numbering.
104+
let output = command.output();
105+
let string = output.to_str().unwrap_or_default();
106+
let numbered = string.contains("{n}");
107+
if !numbered && document.pages.len() > 1 {
108+
bail!("cannot export multiple PNGs without `{{n}}` in output path");
109+
}
110+
111+
// Find a number width that accommodates all pages. For instance, the
112+
// first page should be numbered "001" if there are between 100 and
113+
// 999 pages.
114+
let width = 1 + document.pages.len().checked_ilog10().unwrap_or(0) as usize;
115+
let mut storage;
116+
117+
for (i, frame) in document.pages.iter().enumerate() {
118+
let pixmap = typst::export::render(frame, command.ppi / 72.0, Color::WHITE);
119+
let path = if numbered {
120+
storage = string.replace("{n}", &format!("{:0width$}", i + 1));
121+
Path::new(&storage)
122+
} else {
123+
output.as_path()
124+
};
125+
pixmap.save_png(path).map_err(|_| "failed to write PNG file")?;
126+
}
127+
128+
Ok(())
129+
}
130+
131+
/// Opens the given file using:
132+
/// - The default file viewer if `open` is `None`.
133+
/// - The given viewer provided by `open` if it is `Some`.
134+
fn open_file(open: Option<&str>, path: &Path) -> StrResult<()> {
135+
if let Some(app) = open {
136+
open::with_in_background(path, app);
137+
} else {
138+
open::that_in_background(path);
139+
}
140+
141+
Ok(())
142+
}
143+
144+
/// Print diagnostic messages to the terminal.
145+
fn print_diagnostics(
146+
world: &SystemWorld,
147+
errors: Vec<SourceError>,
148+
diagnostic_format: DiagnosticFormat,
149+
) -> Result<(), codespan_reporting::files::Error> {
150+
let mut w = match diagnostic_format {
151+
DiagnosticFormat::Human => color_stream(),
152+
DiagnosticFormat::Short => StandardStream::stderr(ColorChoice::Never),
153+
};
154+
155+
let mut config = term::Config { tab_width: 2, ..Default::default() };
156+
if diagnostic_format == DiagnosticFormat::Short {
157+
config.display_style = term::DisplayStyle::Short;
158+
}
159+
160+
for error in errors {
161+
// The main diagnostic.
162+
let diag = Diagnostic::error()
163+
.with_message(error.message)
164+
.with_notes(
165+
error
166+
.hints
167+
.iter()
168+
.map(|e| (eco_format!("hint: {e}")).into())
169+
.collect(),
170+
)
171+
.with_labels(vec![Label::primary(error.span.id(), error.span.range(world))]);
172+
173+
term::emit(&mut w, &config, world, &diag)?;
174+
175+
// Stacktrace-like helper diagnostics.
176+
for point in error.trace {
177+
let message = point.v.to_string();
178+
let help = Diagnostic::help().with_message(message).with_labels(vec![
179+
Label::primary(point.span.id(), point.span.range(world)),
180+
]);
181+
182+
term::emit(&mut w, &config, world, &help)?;
183+
}
184+
}
185+
186+
Ok(())
187+
}
188+
189+
impl<'a> codespan_reporting::files::Files<'a> for SystemWorld {
190+
type FileId = FileId;
191+
type Name = FileId;
192+
type Source = Source;
193+
194+
fn name(&'a self, id: FileId) -> CodespanResult<Self::Name> {
195+
Ok(id)
196+
}
197+
198+
fn source(&'a self, id: FileId) -> CodespanResult<Self::Source> {
199+
Ok(self.lookup(id))
200+
}
201+
202+
fn line_index(&'a self, id: FileId, given: usize) -> CodespanResult<usize> {
203+
let source = self.lookup(id);
204+
source
205+
.byte_to_line(given)
206+
.ok_or_else(|| CodespanError::IndexTooLarge {
207+
given,
208+
max: source.len_bytes(),
209+
})
210+
}
211+
212+
fn line_range(
213+
&'a self,
214+
id: FileId,
215+
given: usize,
216+
) -> CodespanResult<std::ops::Range<usize>> {
217+
let source = self.lookup(id);
218+
source
219+
.line_to_range(given)
220+
.ok_or_else(|| CodespanError::LineTooLarge { given, max: source.len_lines() })
221+
}
222+
223+
fn column_number(
224+
&'a self,
225+
id: FileId,
226+
_: usize,
227+
given: usize,
228+
) -> CodespanResult<usize> {
229+
let source = self.lookup(id);
230+
source.byte_to_column(given).ok_or_else(|| {
231+
let max = source.len_bytes();
232+
if given <= max {
233+
CodespanError::InvalidCharBoundary { given }
234+
} else {
235+
CodespanError::IndexTooLarge { given, max }
236+
}
237+
})
238+
}
239+
}

0 commit comments

Comments
 (0)