use crate::parse_font_config::{
FontConfig, FontDependency, GitHubDependency, TypstFont, deserialize_fonts_from_file,
deserialize_fonts_from_toml,
};
use reqwest::Url;
use reqwest::blocking::Client;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::path::{Component, Path, PathBuf};
use typst::syntax::package::PackageSpec;
const CONFIG_FILE_NAME: &str = "font_config.toml";
const GITHUB_API_BASE: &str = "https://api.github.com";
const GITHUB_RAW_BASE: &str = "https://raw.githubusercontent.com";
#[derive(Debug)]
pub(crate) struct ResolvedFontConfig {
pub(crate) root: FontConfig,
pub(crate) fonts: Vec<TypstFont>,
pub(crate) sources: Vec<String>,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
enum ConfigId {
Local(PathBuf),
GitHub {
owner: String,
repo: String,
reference: String,
config_path: String,
},
}
#[derive(Clone, Debug)]
struct ConfigTarget {
id: ConfigId,
label: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum VisitState {
Visiting,
Done,
}
pub(crate) fn resolve_font_config(
config_file: &Path,
package_path: Option<&Path>,
) -> Result<ResolvedFontConfig, String> {
ConfigResolver::new(package_path).resolve(config_file)
}
struct ConfigResolver {
package_root: Option<PathBuf>,
github_api_base: String,
github_raw_base: String,
client: Client,
states: BTreeMap<ConfigId, VisitState>,
stack: Vec<ConfigTarget>,
fonts: BTreeSet<TypstFont>,
sources: Vec<String>,
root: Option<FontConfig>,
}
impl ConfigResolver {
fn new(package_path: Option<&Path>) -> Self {
let package_root = package_path
.map(Path::to_path_buf)
.or_else(|| env::var_os("TYPST_PACKAGE_PATH").map(PathBuf::from))
.or_else(|| dirs::data_dir().map(|path| path.join("typst/packages")));
Self::with_options(package_root, GITHUB_API_BASE, GITHUB_RAW_BASE)
}
fn with_options(
package_root: Option<PathBuf>,
github_api_base: &str,
github_raw_base: &str,
) -> Self {
let client = Client::builder().user_agent(concat!("typfont/", env!("CARGO_PKG_VERSION")));
#[cfg(test)]
let client = if github_api_base.starts_with("http://127.0.0.1:") {
client.no_proxy()
} else {
client
};
Self {
package_root,
github_api_base: github_api_base.trim_end_matches('/').to_string(),
github_raw_base: github_raw_base.trim_end_matches('/').to_string(),
client: client
.build()
.expect("reqwest client configuration is valid"),
states: BTreeMap::new(),
stack: Vec::new(),
fonts: BTreeSet::new(),
sources: Vec::new(),
root: None,
}
}
fn resolve(mut self, config_file: &Path) -> Result<ResolvedFontConfig, String> {
let root = self.local_target(config_file, config_file)?;
self.visit(root, true)?;
Ok(ResolvedFontConfig {
root: self
.root
.expect("the root configuration is recorded during traversal"),
fonts: self.fonts.into_iter().collect(),
sources: self.sources,
})
}
fn visit(&mut self, target: ConfigTarget, is_root: bool) -> Result<(), String> {
match self.states.get(&target.id) {
Some(VisitState::Done) => return Ok(()),
Some(VisitState::Visiting) => return Err(self.cycle_error(&target)),
None => {}
}
self.states.insert(target.id.clone(), VisitState::Visiting);
self.stack.push(target.clone());
let config = self.load_config(&target)?;
if is_root {
self.root = Some(config.clone());
}
self.sources.push(target.label.clone());
self.fonts.extend(config.fonts.iter().cloned());
for dependency in &config.dependencies {
let dependency_target = self.resolve_dependency(&target.id, dependency)?;
self.visit(dependency_target, false)?;
}
self.stack.pop();
self.states.insert(target.id, VisitState::Done);
Ok(())
}
fn load_config(&self, target: &ConfigTarget) -> Result<FontConfig, String> {
match &target.id {
ConfigId::Local(path) => deserialize_fonts_from_file(path)
.map_err(|error| format!("Failed to read font config {}: {error}", target.label)),
ConfigId::GitHub {
owner,
repo,
reference,
config_path,
} => {
let url = self.github_raw_url(owner, repo, reference, config_path)?;
let response = self
.client
.get(url.clone())
.send()
.map_err(|error| format!("Failed to fetch {url}: {error}"))?
.error_for_status()
.map_err(|error| format!("Failed to fetch {url}: {error}"))?;
let content = response
.text()
.map_err(|error| format!("Failed to read {url}: {error}"))?;
deserialize_fonts_from_toml(&content).map_err(|error| {
format!("Failed to parse font config {}: {error}", target.label)
})
}
}
}
fn resolve_dependency(
&self,
parent: &ConfigId,
dependency: &FontDependency,
) -> Result<ConfigTarget, String> {
match dependency {
FontDependency::Path(dependency) => match parent {
ConfigId::Local(config_path) => {
let base = config_path.parent().unwrap_or_else(|| Path::new("."));
let path = if dependency.path.is_absolute() {
dependency.path.clone()
} else {
base.join(&dependency.path)
};
self.local_target(&path, &dependency.path)
}
ConfigId::GitHub {
owner,
repo,
reference,
config_path,
} => {
if dependency.path.is_absolute() {
return Err(format!(
"Remote dependency path must be relative: {}",
dependency.path.display()
));
}
let base = Path::new(config_path)
.parent()
.unwrap_or_else(|| Path::new(""));
let mut path = base.join(&dependency.path);
if path.extension().is_none_or(|extension| extension != "toml") {
path.push(CONFIG_FILE_NAME);
}
let config_path = normalize_repository_path(&path)?;
Ok(self.github_target(
owner.clone(),
repo.clone(),
reference.clone(),
config_path,
))
}
},
FontDependency::Package(dependency) => self.package_target(&dependency.package),
FontDependency::GitHub(dependency) => self.remote_target(dependency),
}
}
fn local_target(&self, path: &Path, label: &Path) -> Result<ConfigTarget, String> {
let config_path = if path.is_dir() {
path.join(CONFIG_FILE_NAME)
} else {
path.to_path_buf()
};
let canonical = config_path.canonicalize().map_err(|error| {
format!(
"Font config not found for path {} (resolved to {}): {error}",
label.display(),
config_path.display()
)
})?;
Ok(ConfigTarget {
label: canonical.display().to_string(),
id: ConfigId::Local(canonical),
})
}
fn package_target(&self, package: &str) -> Result<ConfigTarget, String> {
let spec = package
.parse::<PackageSpec>()
.map_err(|error| format!("Invalid Typst package {package:?}: {error}"))?;
if spec.namespace.as_str() != "local" {
return Err(format!(
"Unsupported Typst package namespace in {package:?}; only @local is supported"
));
}
let package_root = self.package_root.as_ref().ok_or_else(|| {
"Cannot determine the Typst package path; pass --package-path or set TYPST_PACKAGE_PATH"
.to_string()
})?;
let config_path = package_root
.join(spec.namespace.as_str())
.join(spec.name.as_str())
.join(spec.version.to_string())
.join(CONFIG_FILE_NAME);
let mut target = self.local_target(&config_path, Path::new(package))?;
target.label = package.to_string();
Ok(target)
}
fn remote_target(&self, dependency: &GitHubDependency) -> Result<ConfigTarget, String> {
let (owner, repo) = parse_github_repository(&dependency.github)?;
let reference = match dependency.reference.as_deref() {
Some(reference) if !reference.trim().is_empty() => reference.to_string(),
Some(_) => return Err("GitHub dependency ref cannot be empty".to_string()),
None => self.github_default_branch(&owner, &repo)?,
};
let mut path = dependency.subdir.clone().unwrap_or_default();
path.push(CONFIG_FILE_NAME);
let config_path = normalize_repository_path(&path)?;
Ok(self.github_target(owner, repo, reference, config_path))
}
fn github_target(
&self,
owner: String,
repo: String,
reference: String,
config_path: String,
) -> ConfigTarget {
let label = format!("https://github.com/{owner}/{repo}#{reference}:{config_path}");
ConfigTarget {
id: ConfigId::GitHub {
owner,
repo,
reference,
config_path,
},
label,
}
}
fn github_default_branch(&self, owner: &str, repo: &str) -> Result<String, String> {
#[derive(Deserialize)]
struct Repository {
default_branch: String,
}
let url = format!("{}/repos/{owner}/{repo}", self.github_api_base);
let repository = self
.client
.get(&url)
.send()
.map_err(|error| format!("Failed to query {url}: {error}"))?
.error_for_status()
.map_err(|error| format!("Failed to query {url}: {error}"))?
.json::<Repository>()
.map_err(|error| format!("Failed to parse GitHub response from {url}: {error}"))?;
if repository.default_branch.is_empty() {
return Err(format!(
"GitHub returned an empty default branch for {owner}/{repo}"
));
}
Ok(repository.default_branch)
}
fn github_raw_url(
&self,
owner: &str,
repo: &str,
reference: &str,
config_path: &str,
) -> Result<Url, String> {
let mut url = Url::parse(&format!("{}/", self.github_raw_base)).map_err(|error| {
format!(
"Invalid GitHub raw base URL {:?}: {error}",
self.github_raw_base
)
})?;
let mut segments = url
.path_segments_mut()
.map_err(|_| "GitHub raw URL cannot be used as a base URL".to_string())?;
segments.pop_if_empty();
segments.push(owner).push(repo).push(reference);
for segment in config_path.split('/') {
segments.push(segment);
}
drop(segments);
Ok(url)
}
fn cycle_error(&self, repeated: &ConfigTarget) -> String {
let start = self
.stack
.iter()
.position(|target| target.id == repeated.id)
.unwrap_or(0);
let mut chain = self.stack[start..]
.iter()
.map(|target| target.label.as_str())
.collect::<Vec<_>>();
chain.push(repeated.label.as_str());
format!("Font config dependency cycle: {}", chain.join(" -> "))
}
}
fn parse_github_repository(repository: &str) -> Result<(String, String), String> {
let url = Url::parse(repository)
.map_err(|error| format!("Invalid GitHub repository URL {repository:?}: {error}"))?;
if url.scheme() != "https" || url.host_str() != Some("github.com") {
return Err(format!(
"Unsupported GitHub repository URL {repository:?}; expected https://github.com/OWNER/REPO"
));
}
if !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(format!(
"GitHub repository URL cannot contain credentials, a query, or a fragment: {repository:?}"
));
}
let segments = url
.path_segments()
.map(|segments| {
segments
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if segments.len() != 2 {
return Err(format!(
"GitHub repository URL must identify one repository: {repository:?}"
));
}
let owner = segments[0].to_string();
let repo = segments[1].strip_suffix(".git").unwrap_or(segments[1]);
if owner.is_empty() || repo.is_empty() {
return Err(format!("Invalid GitHub repository URL: {repository:?}"));
}
Ok((owner, repo.to_string()))
}
fn normalize_repository_path(path: &Path) -> Result<String, String> {
let mut segments = Vec::<String>::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(segment) => {
let segment = segment.to_str().ok_or_else(|| {
format!(
"GitHub dependency path is not valid UTF-8: {}",
path.display()
)
})?;
segments.push(segment.to_string());
}
Component::ParentDir => {
if segments.pop().is_none() {
return Err(format!(
"GitHub dependency path escapes the repository: {}",
path.display()
));
}
}
Component::RootDir | Component::Prefix(_) => {
return Err(format!(
"GitHub dependency path must be relative: {}",
path.display()
));
}
}
}
if segments.is_empty() {
return Err("GitHub font config path cannot be empty".to_string());
}
Ok(segments.join("/"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
fn test_dir(name: &str) -> PathBuf {
let target = env::var("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("target"));
let path = target.join("config_resolver_tests").join(name);
fs::remove_dir_all(&path).ok();
fs::create_dir_all(&path).unwrap();
path
}
fn font(name: &str) -> String {
format!("[[fonts]]\nfamily_name = {name:?}\n")
}
#[test]
fn resolves_recursive_local_dependencies_and_deduplicates_fonts() {
let dir = test_dir("recursive_local");
let child = dir.join("child");
fs::create_dir_all(&child).unwrap();
fs::write(
dir.join(CONFIG_FILE_NAME),
format!("{}\n[[dependencies]]\npath = \"child\"\n", font("Root")),
)
.unwrap();
fs::write(
child.join(CONFIG_FILE_NAME),
format!("{}\n{}", font("Root"), font("Child")),
)
.unwrap();
let resolved = ConfigResolver::new(None)
.resolve(&dir.join(CONFIG_FILE_NAME))
.unwrap();
assert_eq!(resolved.fonts.len(), 2);
assert_eq!(resolved.sources.len(), 2);
assert_eq!(resolved.root.font_dir, None);
}
#[test]
fn resolves_local_typst_package_from_explicit_package_root() {
let dir = test_dir("local_package");
let package_root = dir.join("packages");
let package_dir = package_root.join("local/math-notes/0.4.0");
fs::create_dir_all(&package_dir).unwrap();
fs::write(package_dir.join(CONFIG_FILE_NAME), font("Package Font")).unwrap();
fs::write(
dir.join(CONFIG_FILE_NAME),
"[[dependencies]]\npackage = \"@local/math-notes:0.4.0\"\n",
)
.unwrap();
let resolved =
ConfigResolver::with_options(Some(package_root), GITHUB_API_BASE, GITHUB_RAW_BASE)
.resolve(&dir.join(CONFIG_FILE_NAME))
.unwrap();
assert_eq!(resolved.fonts[0].family_name, "Package Font");
assert!(
resolved
.sources
.contains(&"@local/math-notes:0.4.0".to_string())
);
}
#[test]
fn reports_dependency_cycle_with_chain() {
let dir = test_dir("cycle");
let child = dir.join("child");
fs::create_dir_all(&child).unwrap();
fs::write(
dir.join(CONFIG_FILE_NAME),
"[[dependencies]]\npath = \"child\"\n",
)
.unwrap();
fs::write(
child.join(CONFIG_FILE_NAME),
"[[dependencies]]\npath = \"..\"\n",
)
.unwrap();
let error = ConfigResolver::new(None)
.resolve(&dir.join(CONFIG_FILE_NAME))
.unwrap_err();
assert!(error.contains("dependency cycle"));
assert!(error.contains("child"));
assert!(error.contains(" -> "));
}
#[test]
fn resolves_github_default_branch_and_remote_relative_dependency() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
for _ in 0..3 {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 4096];
let size = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..size]);
let path = request.split_whitespace().nth(1).unwrap();
let body = match path {
"/api/repos/owner/repo" => "{\"default_branch\":\"main\"}".to_string(),
"/raw/owner/repo/main/pkg/font_config.toml" => format!(
"{}\n[[dependencies]]\npath = \"../shared\"\n",
font("Remote")
),
"/raw/owner/repo/main/shared/font_config.toml" => font("Shared"),
unexpected => panic!("unexpected request path: {unexpected}"),
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
}
});
let dir = test_dir("github");
fs::write(
dir.join(CONFIG_FILE_NAME),
"[[dependencies]]\ngithub = \"https://github.com/owner/repo\"\nsubdir = \"pkg\"\n",
)
.unwrap();
let base = format!("http://{address}");
let resolved =
ConfigResolver::with_options(None, &format!("{base}/api"), &format!("{base}/raw"))
.resolve(&dir.join(CONFIG_FILE_NAME))
.unwrap();
server.join().unwrap();
assert_eq!(resolved.fonts.len(), 2);
assert!(
resolved
.sources
.iter()
.any(|source| source.contains("#main:pkg/"))
);
}
#[test]
fn validates_github_repository_and_paths() {
assert_eq!(
parse_github_repository("https://github.com/owner/repo.git").unwrap(),
("owner".to_string(), "repo".to_string())
);
assert!(parse_github_repository("https://gitlab.com/owner/repo").is_err());
assert!(parse_github_repository("https://token@github.com/owner/repo").is_err());
assert!(parse_github_repository("https://github.com/owner/repo/tree/main").is_err());
assert!(normalize_repository_path(Path::new("../../escape")).is_err());
let resolver = ConfigResolver::with_options(None, GITHUB_API_BASE, GITHUB_RAW_BASE);
let url = resolver
.github_raw_url("owner", "repo", "feature/fonts", "font_config.toml")
.unwrap();
assert!(url.as_str().contains("feature%2Ffonts"));
}
}