use std::fmt::Debug;
use std::path::{Path, PathBuf};
use ecow::eco_format;
use typst_syntax::package::{PackageSpec, PackageVersion, VersionlessPackageSpec};
use crate::files::FsRoot;
#[cfg(feature = "universe-packages")]
use {
crate::downloader::Downloader,
once_cell::sync::OnceCell,
serde::Deserialize,
std::io::{Cursor, Read},
typst_library::diag::{PackageError, PackageResult, StrResult, bail},
};
#[cfg(feature = "system-packages")]
#[derive(Debug)]
pub struct SystemPackages {
data: Option<FsPackages>,
cache: Option<FsPackages>,
universe: UniversePackages,
}
#[cfg(feature = "system-packages")]
impl SystemPackages {
pub fn new(downloader: impl Downloader) -> Self {
Self::from_parts(
FsPackages::system_data(),
FsPackages::system_cache(),
UniversePackages::new(downloader),
)
}
pub fn from_parts(
data: Option<FsPackages>,
cache: Option<FsPackages>,
universe: UniversePackages,
) -> Self {
Self { data, cache, universe }
}
pub fn data(&self) -> Option<&FsPackages> {
self.data.as_ref()
}
pub fn cache(&self) -> Option<&FsPackages> {
self.cache.as_ref()
}
pub fn universe(&self) -> &UniversePackages {
&self.universe
}
pub fn obtain(&self, spec: &PackageSpec) -> PackageResult<FsRoot> {
if let Some(packages) = &self.data
&& let Some(root) = packages.obtain(spec)
{
return Ok(root);
}
if let Some(cache) = &self.cache {
if let Some(root) = cache.obtain(spec) {
return Ok(root);
}
if spec.namespace == UniversePackages::NAMESPACE {
let mut archive = self.universe.package(spec)?;
cache.store(spec, |tempdir| {
archive.unpack(tempdir).map_err(|err| {
PackageError::MalformedArchive(Some(eco_format!("{err}")))
})
})?;
if let Some(root) = cache.obtain(spec) {
return Ok(root);
}
}
}
Err(PackageError::NotFound(spec.clone()))
}
pub fn latest_version(
&self,
spec: &VersionlessPackageSpec,
) -> StrResult<PackageVersion> {
if spec.namespace == UniversePackages::NAMESPACE {
self.universe.latest_version(spec)
} else {
self.data
.as_ref()
.and_then(|pkgs| pkgs.latest_version(spec))
.ok_or_else(|| eco_format!("please specify the desired version"))
}
}
}
#[derive(Debug, Clone)]
pub struct FsPackages(PathBuf);
impl FsPackages {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self(path.into())
}
#[cfg(feature = "system-packages")]
pub fn system_data() -> Option<FsPackages> {
dirs::data_dir().map(|dir| FsPackages::new(dir.join("typst/packages")))
}
#[cfg(feature = "system-packages")]
pub fn system_cache() -> Option<FsPackages> {
dirs::cache_dir().map(|dir| FsPackages::new(dir.join("typst/packages")))
}
pub fn path(&self) -> &Path {
&self.0
}
pub fn obtain(&self, spec: &PackageSpec) -> Option<FsRoot> {
let subdir = eco_format!("{}/{}/{}", spec.namespace, spec.name, spec.version);
let dir = self.path().join(subdir.as_str());
dir.exists().then_some(FsRoot::new(dir))
}
pub fn latest_version(
&self,
spec: &VersionlessPackageSpec,
) -> Option<PackageVersion> {
let subdir = format!("{}/{}", spec.namespace, spec.name);
std::fs::read_dir(self.path().join(&subdir))
.into_iter()
.flatten()
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter_map(|path| path.file_name()?.to_string_lossy().parse().ok())
.max()
}
#[cfg(feature = "universe-packages")]
pub fn store(
&self,
spec: &PackageSpec,
write: impl FnOnce(&Path) -> PackageResult<()>,
) -> PackageResult<()> {
let error = |message: &str, err: std::io::Error| -> PackageError {
PackageError::Other(Some(eco_format!("{message}: {err}")))
};
let base_dir = self.path().join(format!("{}/{}", spec.namespace, spec.name));
let package_dir = base_dir.join(format!("{}", spec.version));
let tempdir = Tempdir::create(base_dir.join(format!(
".tmp-{}-{}",
spec.version,
fastrand::u32(..),
)))
.map_err(|err| error("failed to create temporary package directory", err))?;
write(tempdir.as_ref())?;
match std::fs::rename(&tempdir, &package_dir) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => Ok(()),
Err(err) => Err(error("failed to move downloaded package directory", err)),
}
}
}
#[cfg(feature = "universe-packages")]
#[derive(Debug)]
struct Tempdir(PathBuf);
#[cfg(feature = "universe-packages")]
impl Tempdir {
fn create(path: PathBuf) -> std::io::Result<Self> {
std::fs::create_dir_all(&path)?;
Ok(Self(path))
}
}
#[cfg(feature = "universe-packages")]
impl Drop for Tempdir {
fn drop(&mut self) {
_ = std::fs::remove_dir_all(&self.0);
}
}
#[cfg(feature = "universe-packages")]
impl AsRef<Path> for Tempdir {
fn as_ref(&self) -> &Path {
&self.0
}
}
#[cfg(feature = "universe-packages")]
pub struct UniversePackages {
url: String,
downloader: Box<dyn Downloader>,
index: OnceCell<Box<[serde_json::Value]>>,
}
#[cfg(feature = "universe-packages")]
impl UniversePackages {
pub const NAMESPACE: &str = "preview";
pub fn new(downloader: impl Downloader) -> Self {
Self::with_url(downloader, "https://packages.typst.org")
}
pub fn with_url(downloader: impl Downloader, url: impl Into<String>) -> Self {
Self {
url: url.into(),
downloader: Box::new(downloader),
index: OnceCell::new(),
}
}
pub fn url(&self) -> &str {
&self.url
}
pub fn package(
&self,
spec: &PackageSpec,
) -> PackageResult<tar::Archive<impl Read + use<>>> {
if spec.namespace != Self::NAMESPACE {
return Err(PackageError::NotFound(spec.clone()));
}
let url = format!(
"{}/{}/{}-{}.tar.gz",
self.url,
Self::NAMESPACE,
spec.name,
spec.version,
);
match self.downloader.download(spec, &url) {
Ok(data) => {
let decompressed = flate2::read::GzDecoder::new(Cursor::new(data));
Ok(tar::Archive::new(decompressed))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
Err(match self.latest_version(&spec.versionless()) {
Ok(version) => PackageError::VersionNotFound(spec.clone(), version),
Err(_) => PackageError::NotFound(spec.clone()),
})
}
Err(err) => Err(PackageError::NetworkFailed(Some(eco_format!("{err}")))),
}
}
pub fn latest_version(
&self,
spec: &VersionlessPackageSpec,
) -> StrResult<PackageVersion> {
#[derive(Deserialize)]
struct MinimalPackageInfo {
name: String,
version: PackageVersion,
}
if spec.namespace != Self::NAMESPACE {
bail!(
"failed to determine latest version \
(an index is only available for the `{}` namespace)",
Self::NAMESPACE
)
}
self.index()?
.iter()
.filter_map(|value| MinimalPackageInfo::deserialize(value).ok())
.filter(|package| package.name == spec.name)
.map(|package| package.version)
.max()
.ok_or_else(|| eco_format!("failed to find package {spec}"))
}
fn index(&self) -> StrResult<&[serde_json::Value]> {
self.index
.get_or_try_init(|| {
let url = format!("{}/{}/index.json", self.url, Self::NAMESPACE);
match self.downloader.download(&"package index", &url) {
Ok(data) => serde_json::from_slice(&data).map_err(|err| {
eco_format!("failed to parse package index: {err}")
}),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
bail!("failed to fetch package index (not found)")
}
Err(err) => bail!("failed to fetch package index ({err})"),
}
})
.map(AsRef::as_ref)
}
}
#[cfg(feature = "universe-packages")]
impl Debug for UniversePackages {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Downloader")
.field("url", &self.url)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
#[test]
#[cfg(feature = "universe-packages")]
fn lazy_deserialize_index() {
use super::*;
use std::any::Any;
struct DummyDownloader;
impl Downloader for DummyDownloader {
fn stream(
&self,
_: &dyn Any,
_: &str,
) -> std::io::Result<(Option<usize>, Box<dyn Read>)> {
Err(std::io::ErrorKind::NotFound.into())
}
}
let mut packages = UniversePackages::new(DummyDownloader);
packages.index = OnceCell::from(Box::new([
serde_json::json!({
"name": "charged-ieee",
"version": "0.1.0",
"entrypoint": "lib.typ",
}),
serde_json::json!({
"name": "unequivocal-ams",
"version": "0.2.0-dev",
"entrypoint": "lib.typ",
}),
]) as Box<[_]>);
let ieee_version = packages.latest_version(&VersionlessPackageSpec {
namespace: "preview".into(),
name: "charged-ieee".into(),
});
assert_eq!(ieee_version, Ok(PackageVersion { major: 0, minor: 1, patch: 0 }));
let ams_version = packages.latest_version(&VersionlessPackageSpec {
namespace: "preview".into(),
name: "unequivocal-ams".into(),
});
assert_eq!(
ams_version,
Err("failed to find package @preview/unequivocal-ams".into())
)
}
}