use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use quilldown::{ConvertOptions, Converter, Margins, Orientation, PageSetup, PageSize, Theme};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum PageSizeArg {
Letter,
A4,
Legal,
}
impl From<PageSizeArg> for PageSize {
fn from(a: PageSizeArg) -> Self {
match a {
PageSizeArg::Letter => PageSize::Letter,
PageSizeArg::A4 => PageSize::A4,
PageSizeArg::Legal => PageSize::Legal,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OrientationArg {
Portrait,
Landscape,
}
impl From<OrientationArg> for Orientation {
fn from(a: OrientationArg) -> Self {
match a {
OrientationArg::Portrait => Orientation::Portrait,
OrientationArg::Landscape => Orientation::Landscape,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ThemeArg {
Default,
Github,
Solarized,
}
impl From<ThemeArg> for Theme {
fn from(a: ThemeArg) -> Self {
match a {
ThemeArg::Default => Theme::DEFAULT,
ThemeArg::Github => Theme::GITHUB,
ThemeArg::Solarized => Theme::SOLARIZED,
}
}
}
#[derive(Debug, Parser)]
#[command(name = "quilldown", version, about)]
struct Cli {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, default_value_t = 192.0)]
dpi: f32,
#[arg(long)]
base_dir: Option<PathBuf>,
#[arg(long)]
no_embed_svg: bool,
#[arg(long)]
no_svg_light_mode: bool,
#[arg(long)]
no_highlight: bool,
#[arg(long, value_enum, default_value_t = PageSizeArg::Letter)]
page_size: PageSizeArg,
#[arg(long, value_enum, default_value_t = OrientationArg::Portrait)]
orientation: OrientationArg,
#[arg(long, default_value_t = 1.0)]
margin: f32,
#[arg(long, value_enum, default_value_t = ThemeArg::Default)]
theme: ThemeArg,
#[arg(long)]
page_numbers: bool,
#[arg(long)]
toc: bool,
#[arg(long, default_value = "en-US")]
language: String,
#[arg(long)]
allow_remote_images: bool,
#[arg(long)]
captions: bool,
#[arg(short, long)]
verbose: bool,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let output = cli.output.clone().unwrap_or_else(|| {
let mut o = cli.input.clone();
o.set_extension("docx");
o
});
let margin_dxa = (cli.margin.max(0.0) * 1440.0).round() as u32;
let page = PageSetup {
size: cli.page_size.into(),
orientation: cli.orientation.into(),
margins: Margins::uniform(margin_dxa),
};
let opts = ConvertOptions {
image_dpi: cli.dpi,
embed_svg: !cli.no_embed_svg,
svg_light_mode: !cli.no_svg_light_mode,
highlight_code: !cli.no_highlight,
base_dir: cli.base_dir.clone(),
page,
theme: cli.theme.into(),
page_numbers: cli.page_numbers,
table_of_contents: cli.toc,
language: cli.language.clone(),
allow_remote_images: cli.allow_remote_images,
captions: cli.captions,
..ConvertOptions::default()
};
let converter = Converter::new(opts);
let stats = converter
.convert_file(&cli.input, &output)
.with_context(|| format!("converting {} -> {}", cli.input.display(), output.display()))?;
if cli.verbose {
println!("{}", stats.summary());
}
for warning in &stats.warnings {
eprintln!("warning: {warning}");
}
println!("Wrote {}", output.display());
Ok(())
}
#[cfg(test)]
mod skill_sync {
use super::Cli;
use clap::CommandFactory;
use std::collections::BTreeSet;
const SKILL: &str = include_str!("../../../../.github/skills/quilldown/SKILL.md");
const LIB_RS: &str = include_str!("../../quilldown/src/lib.rs");
fn actual_flags() -> BTreeSet<String> {
Cli::command()
.get_arguments()
.filter(|a| !matches!(a.get_id().as_str(), "help" | "version"))
.filter_map(|a| a.get_long().map(str::to_owned))
.collect()
}
fn cli_options_block() -> &'static str {
let start = SKILL
.find("### CLI options")
.expect("SKILL.md must contain a `### CLI options` section");
let rest = &SKILL[start..];
let end = rest
.match_indices("\n## ")
.next()
.map(|(i, _)| i)
.unwrap_or(rest.len());
&rest[..end]
}
fn long_flags_in(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let b = text.as_bytes();
let mut i = 0;
while i + 2 < b.len() {
if b[i] == b'-' && b[i + 1] == b'-' && b[i + 2].is_ascii_lowercase() {
let mut j = i + 2;
while j < b.len()
&& (b[j].is_ascii_lowercase() || b[j].is_ascii_digit() || b[j] == b'-')
{
j += 1;
}
if j > i + 2 {
out.insert(text[i + 2..j].to_owned());
}
i = j;
} else {
i += 1;
}
}
out
}
fn documented_flags() -> BTreeSet<String> {
let mut out = BTreeSet::new();
for line in cli_options_block().lines() {
let line = line.trim_start();
if !line.starts_with('|') {
continue;
}
let first_cell = line.split('|').nth(1).unwrap_or("");
out.extend(long_flags_in(first_cell));
}
out
}
fn converter_methods() -> BTreeSet<String> {
let mut out = BTreeSet::new();
let mut in_impl = false;
for line in LIB_RS.lines() {
if line.starts_with("impl Converter") {
in_impl = true;
continue;
}
if in_impl && line == "}" {
in_impl = false;
continue;
}
if in_impl {
if let Some(rest) = line.trim_start().strip_prefix("pub fn ") {
let name: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
if !name.is_empty() {
out.insert(name);
}
}
}
}
out
}
#[test]
fn cli_flags_match_skill() {
let actual = actual_flags();
let documented = documented_flags();
let missing: Vec<_> = actual.difference(&documented).collect();
let extra: Vec<_> = documented.difference(&actual).collect();
assert!(
missing.is_empty() && extra.is_empty(),
"SKILL.md CLI options table is out of sync with the clap CLI.\n \
Missing from SKILL.md (add a row): {missing:?}\n \
In SKILL.md but not a real flag (remove/rename): {extra:?}"
);
}
#[test]
fn cli_defaults_documented() {
let block = cli_options_block();
for arg in Cli::command().get_arguments() {
let Some(long) = arg.get_long() else { continue };
let defaults = arg.get_default_values();
if defaults.is_empty() {
continue;
}
let needle = format!("--{long}");
let row = block
.lines()
.map(str::trim_start)
.find(|l| l.starts_with('|') && l.split('|').nth(1).unwrap_or("").contains(&needle))
.unwrap_or_else(|| panic!("no SKILL.md row documents `--{long}`"));
let row_lower = row.to_ascii_lowercase();
for d in defaults {
let d = d.to_string_lossy().to_ascii_lowercase();
if d.is_empty() {
continue;
}
assert!(
row_lower.contains(&d),
"SKILL.md row for `--{long}` should state its default `{d}`:\n{row}"
);
}
}
}
#[test]
fn converter_api_documented() {
let methods = converter_methods();
assert!(
!methods.is_empty(),
"failed to parse any `impl Converter` methods from lib.rs — the parser or \
the impl block layout changed"
);
let undocumented: Vec<_> = methods
.into_iter()
.filter(|m| {
!(SKILL.contains(&format!("`{m}`"))
|| SKILL.contains(&format!("{m}("))
|| SKILL.contains(&format!("::{m}")))
})
.collect();
assert!(
undocumented.is_empty(),
"These public `Converter` methods aren't mentioned in SKILL.md: {undocumented:?}"
);
}
}