1use std::collections::HashMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use crate::resource::{Resource, ResourceError, ResourceKind, ResourceProvider, ResourceRequest};
7
8pub const TEXMF_ROOT_ENV: &str = "MATHTEX_TEXMF_ROOT";
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13enum SearchPath {
14 Tex,
15 Tfm,
16 OpenType,
17 Enc,
18 Map,
19}
20
21impl SearchPath {
22 fn prefixes(self) -> &'static [&'static str] {
24 match self {
25 Self::Tex => &[
27 "tex/xelatex",
28 "tex/latex",
29 "tex/xetex",
30 "tex/generic",
31 "tex",
32 ],
33 Self::Tfm => &["fonts/tfm"],
34 Self::OpenType => &["fonts/opentype", "fonts/truetype"],
35 Self::Enc => &["fonts/enc"],
36 Self::Map => &["fonts/map"],
37 }
38 }
39
40 fn for_request(kind: ResourceKind, filename: &str) -> Self {
42 let lower = filename.to_ascii_lowercase();
43 let is_font_program = [".otf", ".ttf", ".otc"]
44 .iter()
45 .any(|ext| lower.ends_with(ext));
46 match kind {
47 ResourceKind::Encoding => Self::Enc,
48 ResourceKind::Map => Self::Map,
49 ResourceKind::Font if is_font_program => Self::OpenType,
50 ResourceKind::Font => Self::Tfm,
51 _ if lower.ends_with(".enc") => Self::Enc,
52 _ if lower.ends_with(".map") => Self::Map,
53 _ if lower.ends_with(".tfm") => Self::Tfm,
54 _ if is_font_program => Self::OpenType,
55 _ => Self::Tex,
56 }
57 }
58}
59
60#[derive(Clone, Debug)]
62pub struct TexmfResources {
63 root: PathBuf,
64 index: Arc<HashMap<String, Vec<String>>>,
66}
67
68impl TexmfResources {
69 pub fn from_root(root: impl Into<PathBuf>) -> Result<Self, TexmfError> {
71 let root = root.into();
72 let index_path = root.join("ls-R");
73 let bytes = std::fs::read(&index_path).map_err(|error| TexmfError::Index {
74 path: index_path.clone(),
75 message: error.to_string(),
76 })?;
77 let text = String::from_utf8_lossy(&bytes);
78 let mut index: HashMap<String, Vec<String>> = HashMap::new();
79 let mut dir = String::new();
80 for line in text.lines() {
81 let line = line.trim_end();
82 if line.is_empty() || line.starts_with('%') {
83 continue;
84 }
85 if let Some(header) = line.strip_suffix(':') {
86 dir = header.strip_prefix("./").unwrap_or(header).to_string();
87 continue;
88 }
89 index.entry(line.to_string()).or_default().push(dir.clone());
90 }
91 if index.is_empty() {
92 return Err(TexmfError::EmptyIndex { path: index_path });
93 }
94 Ok(Self {
95 root,
96 index: Arc::new(index),
97 })
98 }
99
100 pub fn discover() -> Result<Self, TexmfError> {
102 if let Some(root) = std::env::var_os(TEXMF_ROOT_ENV) {
103 return Self::from_root(root);
104 }
105 let output = std::process::Command::new("kpsewhich")
106 .args(["-var-value", "TEXMFDIST"])
107 .output()
108 .map_err(|error| TexmfError::NoRoot {
109 message: format!("{TEXMF_ROOT_ENV} is unset and kpsewhich did not run: {error}"),
110 })?;
111 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
112 if !output.status.success() || root.is_empty() {
113 return Err(TexmfError::NoRoot {
114 message: format!("{TEXMF_ROOT_ENV} is unset and kpsewhich knows no TEXMFDIST"),
115 });
116 }
117 Self::from_root(root)
118 }
119
120 #[must_use]
122 pub fn root(&self) -> &Path {
123 &self.root
124 }
125
126 #[must_use]
128 pub fn len(&self) -> usize {
129 self.index.len()
130 }
131
132 #[must_use]
134 pub fn is_empty(&self) -> bool {
135 self.index.is_empty()
136 }
137
138 #[must_use]
140 pub fn resolve(&self, request: &ResourceRequest) -> Option<PathBuf> {
141 let name = basename(&request.canonical_name());
142 let dirs = self.index.get(&name)?;
143 for prefix in SearchPath::for_request(request.kind, &name).prefixes() {
144 for dir in dirs {
145 let under = dir
146 .strip_prefix(prefix)
147 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
148 if under {
149 return Some(self.root.join(dir).join(&name));
150 }
151 }
152 }
153 None
154 }
155}
156
157fn basename(name: &str) -> String {
159 let mut name = name.trim();
160 loop {
161 if let Some(rest) = name.strip_prefix("./").or_else(|| name.strip_prefix("[]")) {
162 name = rest;
163 } else if let Some(rest) = name.strip_prefix(':') {
164 name = rest;
165 } else {
166 break;
167 }
168 }
169 let name = name.trim_matches(|c| matches!(c, '[' | ']' | '"' | '\''));
170 name.rsplit(['/', '\\']).next().unwrap_or(name).to_string()
171}
172
173impl ResourceProvider for TexmfResources {
174 fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
175 let path = self
176 .resolve(request)
177 .ok_or_else(|| ResourceError::not_found(request))?;
178 std::fs::read(&path)
179 .map(|bytes| Resource::answering(request, bytes))
180 .map_err(|error| ResourceError::from_io(request, &error))
181 }
182}
183
184#[derive(Clone, Debug, PartialEq, Eq)]
186#[non_exhaustive]
187pub enum TexmfError {
188 NoRoot {
190 message: String,
192 },
193 Index {
195 path: PathBuf,
197 message: String,
199 },
200 EmptyIndex {
202 path: PathBuf,
204 },
205}
206
207impl fmt::Display for TexmfError {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 match self {
210 Self::NoRoot { message } => f.write_str(message),
211 Self::Index { path, message } => {
212 write!(f, "cannot read {}: {message}", path.display())
213 }
214 Self::EmptyIndex { path } => write!(f, "{} lists no files", path.display()),
215 }
216 }
217}
218
219impl std::error::Error for TexmfError {}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 fn tree() -> PathBuf {
226 let root = std::env::temp_dir().join(format!("mathtex-texmf-{}", std::process::id()));
227 for dir in ["tex/latex/base", "tex/plain/base", "fonts/tfm/public/cm"] {
228 std::fs::create_dir_all(root.join(dir)).expect("create tree");
229 }
230 std::fs::write(root.join("tex/latex/base/x.tex"), b"latex").expect("write");
231 std::fs::write(root.join("tex/plain/base/x.tex"), b"plain").expect("write");
232 std::fs::write(root.join("fonts/tfm/public/cm/cmr10.tfm"), b"tfm").expect("write");
233 let index = "% ls-R\n./tex/plain/base:\nx.tex\n\n./tex/latex/base:\nx.tex\n\n./fonts/tfm/public/cm:\ncmr10.tfm\n";
234 std::fs::write(root.join("ls-R"), index).expect("write index");
235 root
236 }
237
238 #[test]
239 fn requests_resolve_by_search_path_priority() {
240 let root = tree();
241 let texmf = TexmfResources::from_root(&root).expect("index");
242 assert_eq!(texmf.len(), 2);
243 let latex = texmf
245 .read("./x.tex", ResourceKind::TexInput)
246 .expect("x.tex");
247 assert_eq!(latex.bytes, b"latex");
248 let tfm = texmf.read("cmr10.tfm", ResourceKind::Font).expect("tfm");
249 assert_eq!(tfm.bytes, b"tfm");
250 assert!(texmf.read("cmr10", ResourceKind::Font).is_err());
252 std::fs::remove_dir_all(root).expect("remove tree");
253 }
254
255 #[test]
256 fn a_root_without_an_index_says_why() {
257 let missing = std::env::temp_dir().join("mathtex-texmf-missing-root");
258 assert!(matches!(
259 TexmfResources::from_root(&missing),
260 Err(TexmfError::Index { .. })
261 ));
262 }
263}