use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
#[cfg(feature = "std")]
use std::path::{Component, Path, PathBuf};
use mathtex_ir::ByteSpan;
pub trait ResourceProvider {
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError>;
fn read(&self, name: &str, kind: ResourceKind) -> Result<Resource, ResourceError> {
self.read_request(&ResourceRequest::new(name, kind))
}
fn read_tex_input(&self, name: &str) -> Result<Resource, ResourceError> {
self.read(name, ResourceKind::TexInput)
}
fn read_package(&self, name: &str) -> Result<Resource, ResourceError> {
self.read(name, ResourceKind::Package)
}
fn read_class(&self, name: &str) -> Result<Resource, ResourceError> {
self.read(name, ResourceKind::Class)
}
fn read_font_definition(&self, name: &str) -> Result<Resource, ResourceError> {
self.read(name, ResourceKind::FontDefinition)
}
fn read_package_support(&self, name: &str) -> Result<Resource, ResourceError> {
self.read(name, ResourceKind::PackageSupport)
}
fn read_font(&self, name: &str) -> Result<Resource, ResourceError> {
self.read(name, ResourceKind::Font)
}
fn read_encoding(&self, name: &str) -> Result<Resource, ResourceError> {
self.read(name, ResourceKind::Encoding)
}
fn read_map(&self, name: &str) -> Result<Resource, ResourceError> {
self.read(name, ResourceKind::Map)
}
fn read_config(&self, name: &str) -> Result<Resource, ResourceError> {
self.read(name, ResourceKind::Config)
}
fn read_format_image(&self, name: &str) -> Result<Resource, ResourceError> {
self.read(name, ResourceKind::FormatImage)
}
fn read_asset(&self, package: &str, name: &str) -> Result<Resource, ResourceError> {
self.read_request(&ResourceRequest::asset(package, name))
}
}
impl<T> ResourceProvider for &T
where
T: ResourceProvider,
{
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
(*self).read_request(request)
}
}
#[derive(Clone, Debug)]
pub struct ResolverResourceProvider<F> {
resolver: F,
}
impl<F> ResolverResourceProvider<F> {
#[must_use]
pub fn new(resolver: F) -> Self {
Self { resolver }
}
#[must_use]
pub fn resolver(&self) -> &F {
&self.resolver
}
}
impl<F> ResourceProvider for ResolverResourceProvider<F>
where
F: Fn(&ResourceRequest) -> Result<Resource, ResourceError>,
{
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
(self.resolver)(request)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct InMemoryResourceProvider {
resources: BTreeMap<ResourceKey, Resource>,
}
impl InMemoryResourceProvider {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_resource(
mut self,
name: impl Into<String>,
kind: ResourceKind,
bytes: impl Into<Vec<u8>>,
) -> Self {
self.insert(name, kind, bytes);
self
}
pub fn insert(
&mut self,
name: impl Into<String>,
kind: ResourceKind,
bytes: impl Into<Vec<u8>>,
) {
self.insert_request(ResourceRequest::new(name, kind), bytes);
}
pub fn insert_request(&mut self, request: ResourceRequest, bytes: impl Into<Vec<u8>>) {
let key = ResourceKey::from_request(&request);
let resource = Resource {
canonical_name: request.canonical_name(),
kind: request.kind,
bytes: bytes.into(),
};
self.resources.insert(key, resource);
}
#[must_use]
pub fn len(&self) -> usize {
self.resources.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.resources.is_empty()
}
}
impl ResourceProvider for InMemoryResourceProvider {
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
let key = ResourceKey::from_request(request);
self.resources
.get(&key)
.cloned()
.ok_or_else(|| ResourceError::NotFound {
name: request.canonical_name(),
kind: request.kind,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OverlayResourceProvider<Overrides, Base> {
overrides: Overrides,
base: Base,
}
impl<Overrides, Base> OverlayResourceProvider<Overrides, Base> {
#[must_use]
pub fn new(overrides: Overrides, base: Base) -> Self {
Self { overrides, base }
}
#[must_use]
pub fn overrides(&self) -> &Overrides {
&self.overrides
}
#[must_use]
pub fn base(&self) -> &Base {
&self.base
}
}
impl<Overrides, Base> ResourceProvider for OverlayResourceProvider<Overrides, Base>
where
Overrides: ResourceProvider,
Base: ResourceProvider,
{
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
match self.overrides.read_request(request) {
Ok(resource) => Ok(resource),
Err(ResourceError::NotFound { .. }) => self.base.read_request(request),
Err(error) => Err(error),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceBundle {
id: String,
resources: InMemoryResourceProvider,
}
impl ResourceBundle {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
resources: InMemoryResourceProvider::new(),
}
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn with_resource(
mut self,
name: impl Into<String>,
kind: ResourceKind,
bytes: impl Into<Vec<u8>>,
) -> Self {
self.resources.insert(name, kind, bytes);
self
}
#[must_use]
pub fn with_request(mut self, request: ResourceRequest, bytes: impl Into<Vec<u8>>) -> Self {
self.resources.insert_request(request, bytes);
self
}
}
impl ResourceProvider for ResourceBundle {
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
self.resources.read_request(request)
}
}
#[cfg(feature = "std")]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileSystemResourceProvider {
root: PathBuf,
}
#[cfg(feature = "std")]
impl FileSystemResourceProvider {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
fn validate_name(name: &str, kind: ResourceKind) -> Result<&Path, ResourceError> {
let path = Path::new(name);
let invalid_component = path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
});
if path.is_absolute() || invalid_component {
return Err(ResourceError::Denied {
name: name.to_string(),
message: "resource path must be relative to the provider root".to_string(),
});
}
if name.is_empty() {
return Err(ResourceError::Invalid {
name: name.to_string(),
message: "resource name cannot be empty".to_string(),
});
}
let _ = kind;
Ok(path)
}
}
#[cfg(feature = "std")]
impl ResourceProvider for FileSystemResourceProvider {
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
let name = request.canonical_name();
let relative_path = Self::validate_name(&name, request.kind)?;
let path = self.root.join(relative_path);
let bytes = std::fs::read(&path).map_err(|error| ResourceError::NotFound {
name: format!("{} ({})", name, error),
kind: request.kind,
})?;
Ok(Resource {
canonical_name: path.to_string_lossy().into_owned(),
kind: request.kind,
bytes,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct ResourceKey {
name: String,
kind: ResourceKind,
}
impl ResourceKey {
fn from_request(request: &ResourceRequest) -> Self {
Self {
name: request.canonical_name(),
kind: request.kind,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceRequest {
pub name: String,
pub kind: ResourceKind,
pub package: Option<String>,
pub source: Option<ResourceRequestSource>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceRequestSource {
pub name: String,
pub span: ByteSpan,
}
impl ResourceRequest {
#[must_use]
pub fn new(name: impl Into<String>, kind: ResourceKind) -> Self {
Self {
name: name.into(),
kind,
package: None,
source: None,
}
}
#[must_use]
pub fn asset(package: impl Into<String>, name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: ResourceKind::Asset,
package: Some(package.into()),
source: None,
}
}
#[must_use]
pub fn with_source(mut self, name: impl Into<String>, span: ByteSpan) -> Self {
self.source = Some(ResourceRequestSource {
name: name.into(),
span,
});
self
}
#[must_use]
pub fn canonical_name(&self) -> String {
match (&self.package, self.kind) {
(Some(package), ResourceKind::Asset) => {
let mut name = String::with_capacity(package.len() + 1 + self.name.len());
name.push_str(package);
name.push('/');
name.push_str(&self.name);
name
}
_ => self.name.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Resource {
pub canonical_name: String,
pub kind: ResourceKind,
pub bytes: Vec<u8>,
}
impl Resource {
#[must_use]
pub fn new(
canonical_name: impl Into<String>,
kind: ResourceKind,
bytes: impl Into<Vec<u8>>,
) -> Self {
Self {
canonical_name: canonical_name.into(),
kind,
bytes: bytes.into(),
}
}
#[must_use]
pub fn from_request(request: &ResourceRequest, bytes: impl Into<Vec<u8>>) -> Self {
Self::new(request.canonical_name(), request.kind, bytes)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum ResourceKind {
TexInput,
Package,
Class,
FontDefinition,
PackageSupport,
Font,
Encoding,
Map,
Config,
FormatImage,
Asset,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ResourceError {
NotFound {
name: String,
kind: ResourceKind,
},
Invalid {
name: String,
message: String,
},
Denied {
name: String,
message: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_memory_provider_loads_package_without_filesystem() {
let provider = InMemoryResourceProvider::new().with_resource(
"amsmath.sty",
ResourceKind::Package,
br"\ProvidesPackage{amsmath}".to_vec(),
);
let resource = provider
.read("amsmath.sty", ResourceKind::Package)
.expect("package should resolve");
assert_eq!(resource.canonical_name, "amsmath.sty");
assert_eq!(resource.kind, ResourceKind::Package);
assert_eq!(resource.bytes, br"\ProvidesPackage{amsmath}".to_vec());
}
#[test]
fn in_memory_provider_keeps_resource_kinds_separate() {
let provider =
InMemoryResourceProvider::new().with_resource("cmr10", ResourceKind::Font, b"font");
let error = provider
.read("cmr10", ResourceKind::Package)
.expect_err("font must not satisfy package lookup");
assert_eq!(
error,
ResourceError::NotFound {
name: "cmr10".to_string(),
kind: ResourceKind::Package,
}
);
}
#[test]
fn provider_convenience_methods_cover_tex_resource_kinds() {
let provider = InMemoryResourceProvider::new()
.with_resource("plain.tex", ResourceKind::TexInput, b"tex")
.with_resource("amsmath.sty", ResourceKind::Package, b"package")
.with_resource("article.cls", ResourceKind::Class, b"class")
.with_resource("ot1cmr.fd", ResourceKind::FontDefinition, b"fd")
.with_resource("size10.clo", ResourceKind::PackageSupport, b"support")
.with_resource("latinmodern-math.otf", ResourceKind::Font, b"font")
.with_resource("t1.enc", ResourceKind::Encoding, b"encoding")
.with_resource("pdftex.map", ResourceKind::Map, b"map")
.with_resource("texmf.cnf", ResourceKind::Config, b"config")
.with_resource("latex.fmt", ResourceKind::FormatImage, b"format");
assert_eq!(
provider.read_tex_input("plain.tex").expect("tex").bytes,
b"tex"
);
assert_eq!(
provider.read_package("amsmath.sty").expect("package").bytes,
b"package"
);
assert_eq!(
provider.read_class("article.cls").expect("class").bytes,
b"class"
);
assert_eq!(
provider
.read_font_definition("ot1cmr.fd")
.expect("font definition")
.bytes,
b"fd"
);
assert_eq!(
provider
.read_package_support("size10.clo")
.expect("package support")
.bytes,
b"support"
);
assert_eq!(
provider
.read_font("latinmodern-math.otf")
.expect("font")
.bytes,
b"font"
);
assert_eq!(
provider.read_encoding("t1.enc").expect("encoding").bytes,
b"encoding"
);
assert_eq!(provider.read_map("pdftex.map").expect("map").bytes, b"map");
assert_eq!(
provider.read_config("texmf.cnf").expect("config").bytes,
b"config"
);
assert_eq!(
provider
.read_format_image("latex.fmt")
.expect("format")
.bytes,
b"format"
);
}
#[test]
fn typed_asset_requests_include_package_owner() {
let mut provider = InMemoryResourceProvider::new();
provider.insert_request(ResourceRequest::asset("mhchem", "arrows.dat"), b"asset");
let resource = provider
.read_asset("mhchem", "arrows.dat")
.expect("asset should resolve");
assert_eq!(resource.kind, ResourceKind::Asset);
assert_eq!(resource.canonical_name, "mhchem/arrows.dat");
assert_eq!(resource.bytes, b"asset");
}
#[test]
fn overlay_provider_prefers_overrides_before_base_bundle() {
let base = ResourceBundle::new("latex-base")
.with_resource("article.cls", ResourceKind::TexInput, b"base")
.with_resource("amsmath.sty", ResourceKind::Package, b"base-ams");
let overrides = InMemoryResourceProvider::new().with_resource(
"amsmath.sty",
ResourceKind::Package,
b"override-ams",
);
let provider = OverlayResourceProvider::new(overrides, base);
let package = provider
.read_package("amsmath.sty")
.expect("override package should resolve");
let class = provider
.read_tex_input("article.cls")
.expect("base input should resolve");
assert_eq!(package.bytes, b"override-ams");
assert_eq!(class.bytes, b"base");
}
#[test]
fn overlay_provider_preserves_denied_errors_from_overrides() {
#[derive(Clone, Debug, PartialEq, Eq)]
struct DenyProvider;
impl ResourceProvider for DenyProvider {
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
Err(ResourceError::Denied {
name: request.canonical_name(),
message: "denied by policy".to_string(),
})
}
}
let base = InMemoryResourceProvider::new().with_resource(
"plain.tex",
ResourceKind::TexInput,
b"base",
);
let provider = OverlayResourceProvider::new(DenyProvider, base);
let error = provider
.read_tex_input("plain.tex")
.expect_err("override denial must not fall through");
assert_eq!(
error,
ResourceError::Denied {
name: "plain.tex".to_string(),
message: "denied by policy".to_string(),
}
);
}
#[test]
fn resource_bundle_resolves_package_owned_assets() {
let bundle = ResourceBundle::new("chemistry")
.with_request(ResourceRequest::asset("mhchem", "arrows.dat"), b"asset");
let asset = bundle
.read_asset("mhchem", "arrows.dat")
.expect("bundle asset should resolve");
assert_eq!(bundle.id(), "chemistry");
assert_eq!(asset.canonical_name, "mhchem/arrows.dat");
assert_eq!(asset.bytes, b"asset");
}
#[test]
fn resolver_provider_delegates_typed_requests_to_host_resolver() {
let provider = ResolverResourceProvider::new(|request: &ResourceRequest| {
if request.kind == ResourceKind::Package && request.name == "amsmath.sty" {
Ok(Resource::from_request(request, b"package"))
} else if request.kind == ResourceKind::Asset
&& request.package.as_deref() == Some("mhchem")
&& request.name == "arrows.dat"
{
Ok(Resource::from_request(request, b"asset"))
} else {
Err(ResourceError::NotFound {
name: request.canonical_name(),
kind: request.kind,
})
}
});
let package = provider
.read_package("amsmath.sty")
.expect("package should resolve through resolver");
let asset = provider
.read_asset("mhchem", "arrows.dat")
.expect("asset should resolve through resolver");
let error = provider
.read_tex_input("missing.tex")
.expect_err("missing input should propagate resolver error");
assert_eq!(package.canonical_name, "amsmath.sty");
assert_eq!(package.bytes, b"package");
assert_eq!(asset.canonical_name, "mhchem/arrows.dat");
assert_eq!(asset.bytes, b"asset");
assert_eq!(
error,
ResourceError::NotFound {
name: "missing.tex".to_string(),
kind: ResourceKind::TexInput,
}
);
}
#[cfg(feature = "std")]
#[test]
fn filesystem_provider_loads_relative_resource() {
let root =
std::env::temp_dir().join(format!("mathtex-resource-test-{}", std::process::id()));
std::fs::create_dir_all(&root).expect("create test root");
let path = root.join("plain.tex");
std::fs::write(&path, b"\\relax").expect("write resource");
let provider = FileSystemResourceProvider::new(&root);
let resource = provider
.read("plain.tex", ResourceKind::TexInput)
.expect("relative resource should load");
assert_eq!(resource.kind, ResourceKind::TexInput);
assert_eq!(resource.bytes, b"\\relax");
std::fs::remove_file(path).expect("remove resource");
std::fs::remove_dir(root).expect("remove test root");
}
#[cfg(feature = "std")]
#[test]
fn filesystem_provider_rejects_parent_directory_escape() {
let provider = FileSystemResourceProvider::new(std::env::temp_dir());
let error = provider
.read("../plain.tex", ResourceKind::TexInput)
.expect_err("parent path must be denied");
assert_eq!(
error,
ResourceError::Denied {
name: "../plain.tex".to_string(),
message: "resource path must be relative to the provider root".to_string(),
}
);
}
}