1use std::path::{Path, PathBuf};
9
10pub use oxc_resolver::ResolveError;
11use oxc_resolver::{ResolveOptions, Resolver};
12use serde::de::DeserializeOwned;
13
14pub const FILE_NAME: &str = "atypical.toml";
15
16#[derive(Debug)]
17pub enum Error {
18 Io(std::io::Error),
19 Toml(toml::de::Error),
20 Cycle(PathBuf),
22 Extends(PathBuf),
24 Resolve(String, ResolveError),
26 Scheme(String),
28 Before(PathBuf, String),
30 Duplicate(PathBuf, String),
32}
33
34enum Conflict {
36 Before(String),
37 Duplicate(String),
38}
39
40impl Conflict {
41 fn at(self, path: &Path) -> Error {
42 let path = path.to_path_buf();
43
44 match self {
45 Conflict::Before(name) => Error::Before(path, name),
46 Conflict::Duplicate(name) => Error::Duplicate(path, name),
47 }
48 }
49}
50
51impl std::fmt::Display for Error {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 Error::Io(error) => error.fmt(f),
55 Error::Toml(error) => error.fmt(f),
56 Error::Cycle(path) => {
57 write!(f, "cyclic `extends` involving {}", path.display())
58 }
59 Error::Extends(path) => write!(
60 f,
61 "`extends` in {} must be a string or an array of strings",
62 path.display()
63 ),
64 Error::Resolve(base, error) => {
65 write!(f, "cannot resolve `extends = \"{base}\"`: {error}")
66 }
67 Error::Scheme(scheme) => {
68 write!(f, "`extends` scheme `{scheme}:` is not supported yet")
69 }
70 Error::Before(path, name) => write!(
71 f,
72 "`before = \"{name}\"` in {} names no other entry",
73 path.display()
74 ),
75 Error::Duplicate(path, name) => write!(
76 f,
77 "two entries named \"{name}\" in one array in {}",
78 path.display()
79 ),
80 }
81 }
82}
83
84impl std::error::Error for Error {
85 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
86 match self {
87 Error::Io(error) => Some(error),
88 Error::Toml(error) => Some(error),
89 Error::Resolve(_, error) => Some(error),
90 Error::Cycle(_)
91 | Error::Extends(_)
92 | Error::Scheme(_)
93 | Error::Before(..)
94 | Error::Duplicate(..) => None,
95 }
96 }
97}
98
99pub fn find(start: impl AsRef<Path>) -> Option<PathBuf> {
101 start
102 .as_ref()
103 .ancestors()
104 .map(|dir| dir.join(FILE_NAME))
105 .find(|path| path.is_file())
106}
107
108pub fn section<T: DeserializeOwned>(
112 document: &str,
113 key: &str,
114) -> Result<Option<T>, toml::de::Error> {
115 let table: toml::Table = document.parse()?;
116
117 let Some(value) = table.get(key) else {
118 return Ok(None);
119 };
120
121 value.clone().try_into().map(Some)
122}
123
124pub fn resolve(path: impl AsRef<Path>) -> Result<toml::Table, Error> {
156 resolve_into(path.as_ref(), &mut Vec::new())
157}
158
159fn resolve_into(
160 path: &Path,
161 stack: &mut Vec<PathBuf>,
162) -> Result<toml::Table, Error> {
163 let path = path.canonicalize().map_err(Error::Io)?;
164
165 if stack.contains(&path) {
166 return Err(Error::Cycle(path));
167 }
168
169 let document = std::fs::read_to_string(&path).map_err(Error::Io)?;
170 let mut table: toml::Table = document.parse().map_err(Error::Toml)?;
171
172 let bases = match table.remove("extends") {
173 None => Vec::new(),
174 Some(toml::Value::String(base)) => vec![base],
175 Some(toml::Value::Array(bases)) => bases
176 .into_iter()
177 .map(|base| match base {
178 toml::Value::String(base) => Ok(base),
179 _ => Err(Error::Extends(path.clone())),
180 })
181 .collect::<Result<_, _>>()?,
182 Some(_) => return Err(Error::Extends(path)),
183 };
184
185 let dir = path.parent().unwrap_or(Path::new("")).to_path_buf();
186
187 stack.push(path.clone());
188
189 let mut merged = toml::Table::new();
190
191 for base in bases {
192 let base = resolve_into(&locate(&dir, &base)?, stack)?;
193
194 merge(&mut merged, base).map_err(|it| it.at(&path))?;
195 }
196
197 stack.pop();
198 merge(&mut merged, table).map_err(|it| it.at(&path))?;
199
200 Ok(merged)
201}
202
203fn locate(dir: &Path, base: &str) -> Result<PathBuf, Error> {
206 match base.split_once(':') {
207 Some((scheme, spec))
208 if scheme.len() > 1
209 && scheme.chars().all(|c| c.is_ascii_lowercase()) =>
210 {
211 match scheme {
212 "npm" => resolve_package(dir, base, spec),
213 _ => Err(Error::Scheme(scheme.to_owned())),
214 }
215 }
216 _ => Ok(dir.join(base)),
217 }
218}
219
220fn resolve_package(
223 dir: &Path,
224 label: &str,
225 spec: &str,
226) -> Result<PathBuf, Error> {
227 Resolver::new(ResolveOptions::default())
228 .resolve(dir, spec)
229 .map(|resolution| resolution.path().to_path_buf())
230 .map_err(|error| Error::Resolve(label.to_owned(), error))
231}
232
233fn merge(base: &mut toml::Table, layer: toml::Table) -> Result<(), Conflict> {
234 for (key, value) in layer {
235 match (base.get_mut(&key), value) {
236 (Some(base), value) => merge_value(base, value)?,
237 (None, value) => {
238 base.insert(key, normalised(value)?);
239 }
240 }
241 }
242
243 Ok(())
244}
245
246fn merge_value(
247 base: &mut toml::Value,
248 layer: toml::Value,
249) -> Result<(), Conflict> {
250 match (base, layer) {
251 (toml::Value::Table(base), toml::Value::Table(layer)) => {
252 merge(base, layer)
253 }
254 (toml::Value::Array(base), toml::Value::Array(layer))
255 if named(base) && named(&layer) =>
256 {
257 merge_named(base, layer)
258 }
259 (base, layer) => {
260 *base = normalised(layer)?;
261
262 Ok(())
263 }
264 }
265}
266
267fn normalised(layer: toml::Value) -> Result<toml::Value, Conflict> {
271 match layer {
272 toml::Value::Table(layer) => {
273 let mut onto = toml::Table::new();
274
275 merge(&mut onto, layer)?;
276 Ok(toml::Value::Table(onto))
277 }
278 toml::Value::Array(layer) if named(&layer) => {
279 let mut onto = Vec::new();
280
281 merge_named(&mut onto, layer)?;
282 Ok(toml::Value::Array(onto))
283 }
284 layer => Ok(layer),
285 }
286}
287
288fn name_of(entry: &toml::Value) -> Option<&str> {
289 match entry {
290 toml::Value::Table(fields) => {
291 fields.get("name").and_then(toml::Value::as_str)
292 }
293 _ => None,
294 }
295}
296
297fn named(array: &[toml::Value]) -> bool {
300 !array.is_empty() && array.iter().all(|entry| name_of(entry).is_some())
301}
302
303fn position(base: &[toml::Value], name: &str) -> Option<usize> {
304 base.iter().position(|entry| name_of(entry) == Some(name))
305}
306
307fn dropped(entry: &mut toml::Value) -> bool {
312 let flag = entry
313 .as_table()
314 .and_then(|fields| fields.get("drop"))
315 .and_then(toml::Value::as_bool);
316
317 if let (Some(flag), Some(fields)) = (flag, entry.as_table_mut()) {
318 fields.remove("drop");
319
320 return flag;
321 }
322
323 false
324}
325
326fn before(entry: &mut toml::Value) -> Option<String> {
330 let name = entry
331 .as_table()
332 .and_then(|fields| fields.get("before"))
333 .and_then(toml::Value::as_str)
334 .map(str::to_owned)?;
335
336 entry.as_table_mut()?.remove("before");
337
338 Some(name)
339}
340
341fn duplicate(array: &[toml::Value]) -> Option<&str> {
343 array.iter().enumerate().find_map(|(at, entry)| {
344 let name = name_of(entry)?;
345
346 array[..at]
347 .iter()
348 .any(|earlier| name_of(earlier) == Some(name))
349 .then_some(name)
350 })
351}
352
353fn merge_named(
354 base: &mut Vec<toml::Value>,
355 layer: Vec<toml::Value>,
356) -> Result<(), Conflict> {
357 if let Some(name) = duplicate(&layer) {
358 return Err(Conflict::Duplicate(name.to_owned()));
359 }
360
361 let anchored = layer.iter().any(|entry| {
364 let kept =
365 entry.get("drop").and_then(toml::Value::as_bool) != Some(true);
366
367 kept && name_of(entry)
368 .is_some_and(|name| position(base, name).is_some())
369 });
370 let mut last: Option<String> = None;
371
372 for mut entry in layer {
373 let dropped = dropped(&mut entry);
374 let before = before(&mut entry);
375 let name = name_of(&entry).unwrap_or_default().to_owned();
376
377 let to = match before {
378 None => None,
379 Some(before) if before == name => {
380 return Err(Conflict::Before(before));
381 }
382 Some(before) => {
383 Some(position(base, &before).ok_or(Conflict::Before(before))?)
384 }
385 };
386
387 match (position(base, &name), dropped) {
388 (Some(at), true) => {
389 base.remove(at);
390 }
391 (Some(at), false) => {
392 merge_value(&mut base[at], entry)?;
393
394 if let Some(to) = to {
395 let entry = base.remove(at);
396
397 base.insert(to - usize::from(to > at), entry);
398 }
399
400 last = Some(name);
401 }
402 (None, true) => {}
403 (None, false) => {
404 let entry = normalised(entry)?;
405 let after_last = last
406 .as_deref()
407 .and_then(|last| position(base, last))
408 .map(|at| at + 1);
409 let to = match (to, after_last) {
410 (Some(to), _) | (None, Some(to)) => to,
411 (None, None) if anchored => 0,
412 (None, None) => base.len(),
413 };
414
415 base.insert(to, entry);
416 last = Some(name);
417 }
418 }
419 }
420
421 Ok(())
422}
423
424pub fn load<T: DeserializeOwned>(
427 path: impl AsRef<Path>,
428 key: &str,
429) -> Result<Option<T>, Error> {
430 let mut table = resolve(path)?;
431
432 let Some(value) = table.remove(key) else {
433 return Ok(None);
434 };
435
436 value.try_into().map(Some).map_err(Error::Toml)
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[derive(Debug, PartialEq, serde::Deserialize)]
444 struct Section {
445 name: String,
446 }
447
448 #[test]
449 fn test_section() {
450 let document = indoc::indoc! {r#"
451 [commit]
452 name = "value"
453 "#};
454
455 assert_eq!(
456 section::<Section>(document, "commit").unwrap(),
457 Some(Section {
458 name: "value".into()
459 })
460 );
461 assert_eq!(section::<Section>(document, "branch").unwrap(), None);
462 assert_eq!(section::<Section>("", "commit").unwrap(), None);
463
464 assert!(section::<Section>("not toml", "commit").is_err());
465 assert!(
466 section::<Section>(
467 indoc::indoc! {r#"
468 [commit]
469 name = 1
470 "#},
471 "commit"
472 )
473 .is_err()
474 );
475 }
476}