use anyhow::{Context as _, Result, anyhow};
use glob::{MatchOptions, Pattern};
use nanoserde::{Toml, TomlParser};
use onlyargs_derive::OnlyArgs;
use std::fmt::Debug;
use std::fs;
use std::io::IsTerminal as _;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::OnceLock;
#[derive(Debug)]
pub enum LogWithColor {
Auto,
Always,
Never,
}
static IS_TERMINAL: OnceLock<bool> = OnceLock::new();
impl LogWithColor {
pub fn use_color(&self) -> bool {
match self {
LogWithColor::Auto => *IS_TERMINAL.get_or_init(|| std::io::stdout().is_terminal()),
LogWithColor::Always => true,
LogWithColor::Never => false,
}
}
pub fn str(&self) -> &'static str {
match self {
LogWithColor::Auto => "auto",
LogWithColor::Always => "always",
LogWithColor::Never => "never",
}
}
pub fn str_collapsing_auto(&self) -> &'static str {
if self.use_color() { "always" } else { "never" }
}
}
impl FromStr for LogWithColor {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"auto" => Ok(Self::Auto),
"always" => Ok(Self::Always),
"never" => Ok(Self::Never),
_ => Err(anyhow!(
"color argument must be one of \"auto\", \"always\", or \"never\""
)),
}
}
}
impl Default for LogWithColor {
fn default() -> Self {
Self::Auto
}
}
#[derive(Clone, Debug, Eq, PartialEq, OnlyArgs)]
struct Args {
path: Option<PathBuf>,
watch: bool,
serve: bool,
ignore_initial: bool,
verbose: bool,
trace: bool,
color: Option<String>,
}
#[derive(Default)]
struct ConfigFile {
passthrough_copy: Vec<String>,
init: Vec<String>,
post_processing_typ: Vec<String>,
literal_paths: bool,
file_listing: FileListing,
file_listing_extra_args: Vec<String>,
compilation_extra_args: Vec<String>,
disable_incremental: bool,
}
#[derive(Debug)]
pub enum FileListing {
Disabled,
Enabled,
IncludeData,
}
impl FileListing {
pub const DISABLED_STR: &str = "disabled";
pub const ENABLED_STR: &str = "enabled";
pub const INCLUDE_DATA_STR: &str = "include-data";
pub const DEFAULT_STR: &str = Self::DISABLED_STR;
}
impl Default for FileListing {
fn default() -> Self {
Self::Disabled
}
}
impl FromStr for FileListing {
type Err = anyhow::Error;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
Self::DISABLED_STR => Ok(FileListing::Disabled),
Self::ENABLED_STR => Ok(FileListing::Enabled),
Self::INCLUDE_DATA_STR => Ok(FileListing::IncludeData),
_ => Err(anyhow!(
"TOML parsing error: file_listing must be one of \"{}\", \"{}\", \"{}\", not {}",
Self::DISABLED_STR,
Self::ENABLED_STR,
Self::INCLUDE_DATA_STR,
s
)),
}
}
}
pub struct PassthroughCopyGlobs(Vec<Pattern>);
impl PassthroughCopyGlobs {
const MATCH_CFG: MatchOptions = MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: false,
};
pub fn matches_path_with(&self, path: &Path) -> bool {
self.0
.iter()
.any(|glob| glob.matches_path_with(&path, Self::MATCH_CFG))
}
}
impl Debug for PassthroughCopyGlobs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("omitted for brevity. see below")
}
}
#[derive(Debug)]
pub struct Config {
pub watch: bool,
pub serve: bool,
pub disable_incremental: bool,
pub ignore_initial: bool,
pub verbose: bool,
pub trace: bool,
pub color: LogWithColor,
pub passthrough_copy: Vec<String>,
pub passthrough_copy_globs: PassthroughCopyGlobs,
pub passthrough_copy_globs_string_form: Vec<String>,
pub init: Vec<String>,
pub post_processing_typ: Vec<String>,
pub literal_paths: bool,
pub file_listing: FileListing,
pub file_listing_extra_args: Vec<String>,
pub compilation_extra_args: Vec<String>,
pub project_root: PathBuf,
pub content_relpath: PathBuf,
pub output_relpath: PathBuf,
pub template_relpath: PathBuf,
}
pub const CONFIG_FNAME: &str = "compile-typst-site.toml";
impl Config {
pub fn content_root(&self) -> PathBuf {
self.project_root.join(&self.content_relpath)
}
pub fn output_root(&self) -> PathBuf {
self.project_root.join(&self.output_relpath)
}
pub fn template_root(&self) -> PathBuf {
self.project_root.join(&self.template_relpath)
}
pub fn new() -> Result<Self> {
let content_relpath = PathBuf::from("src");
let output_relpath = PathBuf::from("_site");
let template_relpath = PathBuf::from("templates");
let Args {
path,
watch,
serve,
ignore_initial,
verbose,
trace,
color,
} = onlyargs::parse()?;
let color = match color {
Some(c) => c.parse()?,
None => LogWithColor::default(),
};
let project_root = path.map_or_else(Self::get_project_root, Ok)?;
let ConfigFile {
passthrough_copy,
init,
post_processing_typ,
literal_paths,
file_listing,
file_listing_extra_args,
compilation_extra_args,
disable_incremental,
} = Self::get_configfile(&project_root)?;
let (passthrough_copy_globs, passthrough_copy_globs_string_form) =
Self::compile_globs(&passthrough_copy, &project_root, &content_relpath)?;
Ok(Self {
watch,
serve,
ignore_initial,
verbose,
trace,
color,
passthrough_copy,
passthrough_copy_globs,
passthrough_copy_globs_string_form,
init,
post_processing_typ,
literal_paths,
file_listing,
file_listing_extra_args,
compilation_extra_args,
disable_incremental,
project_root,
content_relpath,
output_relpath,
template_relpath,
})
}
fn get_project_root() -> Result<PathBuf> {
let mut root = std::env::current_dir()?;
loop {
let candidate = root.join(CONFIG_FNAME);
if candidate.exists() {
return Ok(root);
}
if !root.pop() {
return Err(anyhow!(
"Couldn't find a configuration file (looking for {CONFIG_FNAME}) in the current directory or any parent directories."
));
}
}
}
fn compile_globs(
string_globs: &[String],
project_root: &Path,
content_root: &Path,
) -> Result<(PassthroughCopyGlobs, Vec<String>)> {
let mut compiled_globs = Vec::new();
let mut compiled_globs_string_form = Vec::new();
for glob in string_globs {
let string_glob = project_root
.join(content_root)
.join(glob)
.to_str()
.context(anyhow!("{glob} not utf8"))?
.to_owned();
let compiled_glob = string_glob.parse::<Pattern>()?;
compiled_globs.push(compiled_glob);
compiled_globs_string_form.push(string_glob);
}
let compiled_globs = PassthroughCopyGlobs(compiled_globs);
Ok((compiled_globs, compiled_globs_string_form))
}
fn toml_to_strs(arr: &mut Toml) -> Result<Vec<String>> {
match arr {
Toml::SimpleArray(tomls) => {
let mut result = Vec::with_capacity(tomls.len());
for toml in tomls {
match toml {
Toml::Str(s) => result.push(std::mem::take(s)),
_ => return Err(anyhow!("toml array contained non-string: {:?}", toml)),
}
}
Ok(result)
}
_ => Err(anyhow!("toml value was not an array: {:?}", arr)),
}
}
fn get_configfile(project_root: &Path) -> Result<ConfigFile> {
const PROJ_ROOT_REPLACEE: &str = "$PROJECT_ROOT";
let file = project_root.join(CONFIG_FNAME);
let contents = fs::read_to_string(&file)
.context(anyhow!("Couldn't find file {file:?}"))?
.replace("\r\n", "\n"); let mut given =
TomlParser::parse(&contents).context(anyhow!("Trying to parse {file:?} failed."))?;
let mut config = ConfigFile::default();
macro_rules! load_strs_field {
($name:ident) => {
if let Some($name) = given.get_mut(stringify!($name)) {
config.$name = Self::toml_to_strs($name)?;
}
};
}
load_strs_field!(passthrough_copy);
load_strs_field!(init);
load_strs_field!(post_processing_typ);
if let Some(literal_paths) = given.get_mut("literal_paths") {
match literal_paths {
Toml::Bool(literal_paths) => config.literal_paths = *literal_paths,
_ => return Err(anyhow!("toml value was not a bool: {:?}", literal_paths)),
}
}
if let Some(file_listing) = given.get_mut("file_listing") {
match file_listing {
Toml::Str(file_listing) => {
config.file_listing = std::mem::take(file_listing).parse()?
}
_ => return Err(anyhow!("toml value was not a string: {:?}", file_listing)),
}
}
load_strs_field!(file_listing_extra_args);
load_strs_field!(compilation_extra_args);
if let Some(disable_incremental) = given.get_mut("disable_incremental") {
match disable_incremental {
Toml::Bool(disable_incremental) => {
config.disable_incremental = *disable_incremental
}
_ => {
return Err(anyhow!(
"toml value was not a bool: {:?}",
disable_incremental
));
}
}
}
for arg in [] .iter_mut()
.chain(config.init.iter_mut())
.chain(config.post_processing_typ.iter_mut())
{
*arg = arg.replace(PROJ_ROOT_REPLACEE, &project_root.to_string_lossy());
}
Ok(config)
}
}