Skip to main content

atypical_config/
lib.rs

1//! Discovery and loading of `atypical.toml`. Each tool owns its section
2//! schema; a top-level `extends` layers other files beneath, see
3//! [`resolve`].
4//!
5//! Schema-free but not key-free: within a named array, `name`, `drop`
6//! and `before` are directives consumed before any schema sees them.
7
8use 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    /// A document names itself, directly or indirectly, in `extends`.
21    Cycle(PathBuf),
22    /// `extends` is not a string or an array of strings.
23    Extends(PathBuf),
24    /// An `npm:` specifier that does not resolve to a file.
25    Resolve(String, ResolveError),
26    /// An `extends` `scheme:` naming an ecosystem with no resolver yet.
27    Scheme(String),
28    /// A `before` names no entry to sit ahead of, or names its own.
29    Before(PathBuf, String),
30    /// One array gives the same `name` to two of its entries.
31    Duplicate(PathBuf, String),
32}
33
34/// An [`Error`] from within a document, before the document is known.
35enum 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
99/// The nearest [`FILE_NAME`] in `start` or any of its ancestors.
100pub 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
108/// Deserialize the `[key]` section of a TOML document as written: no
109/// `extends`, no directives. A document without the section is
110/// `Ok(None)`.
111pub 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
124/// Parse the file at `path` into a table, resolving its top-level
125/// `extends` key (a string or an array of strings). Extended documents
126/// are applied one by one in declaration order, the extending document
127/// last: tables merge key-by-key, arrays whose every entry carries a
128/// `name` merge by that name, any other value replaces the one beneath
129/// it.
130///
131/// Targets take two forms:
132///
133/// - A `scheme:spec` prefix names the ecosystem to resolve `spec` in.
134///   Only `npm` is wired up, through the shared `node_modules` layout
135///   npm, pnpm, yarn and bun populate; every other scheme is the seam a
136///   per-ecosystem resolver (`cargo`, `pip`, ...) drops into.
137/// - Anything else is a file relative to the extending file, written
138///   `./x`, `../x`, an absolute path, or a bare name.
139///
140/// A named entry matching one beneath it merges into it field by field.
141/// An unmatched one keeps its place within its own document: right after
142/// the entry it follows there, or at the front when it follows none of
143/// them. It appends only when its document keeps nothing beneath it, so
144/// peer documents compose. `drop = true` removes
145/// the entry it names; `before = "other"` places the entry ahead of the
146/// one named, moving it if it was already there. A directive of the
147/// wrong type is left in place for the section schema to reject.
148///
149/// A `before` that names no other entry is an error rather than a
150/// silent append or no-op: the position asked for is part of the
151/// grammar, so anywhere else is a different grammar. Two entries of
152/// one array sharing a `name` is an error for the same reason: the
153/// second would merge into the first instead of being an entry of its
154/// own.
155pub 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
203/// Turn an `extends` target into a file path, in the forms listed on
204/// [`resolve`].
205fn 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
220/// Resolve an npm-style specifier through `node_modules`, naming the
221/// whole `label` (scheme included) if it fails.
222fn 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
267/// What a value looks like with nothing beneath it to merge into: as it
268/// was, except that the directives its entries carry are consumed here
269/// rather than left to reach a section schema.
270fn 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
297/// Whether every entry carries a `name` to be matched on, which is what
298/// makes an array mergeable entry by entry rather than wholesale.
299fn 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
307/// Whether this entry says to remove the one it names, consuming the
308/// directive so it never reaches a section schema. A `drop` that is not
309/// a boolean is left where it is, so the schema reports it rather than
310/// it quietly meaning `false`.
311fn 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
326/// Which entry this one says to sit ahead of, consuming the directive
327/// so it never reaches a section schema. Like `drop`, a `before` that
328/// is not a string is left where it is for the schema to report.
329fn 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
341/// The first name this array gives to two of its entries.
342fn 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    // A layer that keeps any entry beneath it is read as a layout around
362    // those entries, so what it adds lands where it sits between them.
363    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
424/// Read the file at `path`, [`resolve`] its `extends` chain, and
425/// deserialize the `[key]` section of the merged document.
426pub 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}