Skip to main content

usage/spec/
config_type.rs

1//! The type a config property's values take.
2//!
3//! A small expression grammar rather than an enum of names, because the fleet's registries
4//! need composition — `list<string>`, `map<string,string>`, `option<path>`, and the
5//! occasional union like `bool|string`. Anything unrecognized parses as
6//! [`Base::Custom`] and is preserved verbatim: a spec written for a newer usage, or one
7//! naming a type only its own tool understands, keeps working rather than failing to load.
8
9use std::fmt;
10use std::str::FromStr;
11
12use serde::Serialize;
13
14use crate::error::UsageErr;
15use crate::miette;
16
17/// A named, non-composite type.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum Base {
21    Bool,
22    String,
23    Int,
24    Uint,
25    Float,
26    Path,
27    Url,
28    Duration,
29    /// A free-form table: keys and values the spec does not describe.
30    Object,
31    /// A name this version does not know.
32    ///
33    /// Not an error: `data_type` used to be five values and a spec that names a sixth
34    /// should load. Consumers that need a type they understand treat it as a string, which
35    /// is what a schema generator can always do.
36    Custom(String),
37}
38
39impl fmt::Display for Base {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        let name = match self {
42            Self::Bool => "bool",
43            Self::String => "string",
44            Self::Int => "int",
45            Self::Uint => "uint",
46            Self::Float => "float",
47            Self::Path => "path",
48            Self::Url => "url",
49            Self::Duration => "duration",
50            Self::Object => "object",
51            Self::Custom(name) => name,
52        };
53        f.write_str(name)
54    }
55}
56
57/// The characters the grammar itself uses, which therefore cannot appear in a name.
58///
59/// Without this check a typo keeps the delimiter and becomes a [`Base::Custom`] named after
60/// it — `int>` loads as a type called `int>`, and every consumer treats it as a string. The
61/// escape hatch is for names a newer usage understands, not for text that failed to parse.
62const DELIMITERS: [char; 4] = ['<', '>', ',', '|'];
63
64impl From<&str> for Base {
65    fn from(name: &str) -> Self {
66        match name {
67            "bool" | "boolean" => Self::Bool,
68            "string" | "str" => Self::String,
69            "int" | "integer" => Self::Int,
70            "uint" | "usize" => Self::Uint,
71            "float" | "number" => Self::Float,
72            "path" => Self::Path,
73            "url" => Self::Url,
74            "duration" => Self::Duration,
75            "object" | "table" => Self::Object,
76            other => Self::Custom(other.to_string()),
77        }
78    }
79}
80
81/// A config property's type, as written.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
83#[serde(rename_all = "snake_case", tag = "kind", content = "of")]
84pub enum SpecConfigType {
85    Base(Base),
86    /// `list<T>` — ordered, duplicates kept.
87    List(Box<SpecConfigType>),
88    /// `set<T>` — deduplicated.
89    Set(Box<SpecConfigType>),
90    /// `map<K, V>` — the key is always a base type.
91    Map(Base, Box<SpecConfigType>),
92    /// `option<T>` — may be absent, with no default standing in.
93    Option(Box<SpecConfigType>),
94    /// `a|b` — one of several. Only what a spec can express; nothing validates which.
95    Union(Vec<SpecConfigType>),
96}
97
98impl Default for SpecConfigType {
99    fn default() -> Self {
100        Self::Base(Base::String)
101    }
102}
103
104impl SpecConfigType {
105    /// The type this collapses to for a consumer that only handles one shape.
106    ///
107    /// A union's first member, an option's inner type: what a schema generator writes when
108    /// it has to pick. Kept here rather than in each generator so they agree.
109    pub fn simplified(&self) -> &SpecConfigType {
110        match self {
111            Self::Option(inner) => inner.simplified(),
112            Self::Union(members) => members.first().map_or(self, |m| m.simplified()),
113            other => other,
114        }
115    }
116
117    /// Whether a value may be absent with nothing standing in for it.
118    pub fn is_optional(&self) -> bool {
119        matches!(self, Self::Option(_))
120    }
121}
122
123impl fmt::Display for SpecConfigType {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self {
126            Self::Base(base) => write!(f, "{base}"),
127            Self::List(inner) => write!(f, "list<{inner}>"),
128            Self::Set(inner) => write!(f, "set<{inner}>"),
129            Self::Map(key, value) => write!(f, "map<{key}, {value}>"),
130            Self::Option(inner) => write!(f, "option<{inner}>"),
131            Self::Union(members) => {
132                let rendered: Vec<String> = members.iter().map(|m| m.to_string()).collect();
133                f.write_str(&rendered.join("|"))
134            }
135        }
136    }
137}
138
139impl FromStr for SpecConfigType {
140    type Err = UsageErr;
141
142    fn from_str(text: &str) -> Result<Self, Self::Err> {
143        parse_union(text.trim())
144    }
145}
146
147/// A base type, refusing a name that still holds a delimiter.
148///
149/// `Base::from` cannot do this itself: it is infallible on purpose, so a name usage does not
150/// know survives a load. What it must not do is swallow a malformed *expression* as a name.
151fn base(text: &str) -> Result<Base, UsageErr> {
152    if text.is_empty() {
153        // `map<,string>`: a name is missing, not merely unrecognized.
154        return Err(invalid(text, "it is empty"));
155    }
156    if let Some(delimiter) = text.chars().find(|c| DELIMITERS.contains(c)) {
157        return Err(invalid(
158            text,
159            &format!("a stray `{delimiter}` — check the brackets"),
160        ));
161    }
162    Ok(Base::from(text))
163}
164
165fn invalid(text: &str, why: &str) -> UsageErr {
166    UsageErr::InvalidInput(
167        format!("`{text}` is not a config type: {why}"),
168        (0, 0).into(),
169        miette::NamedSource::new("", String::new()),
170    )
171}
172
173/// `a|b|c`, splitting only on bars outside angle brackets.
174fn parse_union(text: &str) -> Result<SpecConfigType, UsageErr> {
175    let members = split_top_level(text, '|');
176    match members.as_slice() {
177        [] => Err(invalid(text, "it is empty")),
178        [one] => parse_single(one),
179        many => Ok(SpecConfigType::Union(
180            many.iter()
181                .map(|m| parse_single(m))
182                .collect::<Result<Vec<_>, _>>()?,
183        )),
184    }
185}
186
187fn parse_single(text: &str) -> Result<SpecConfigType, UsageErr> {
188    let text = text.trim();
189    if text.is_empty() {
190        return Err(invalid(text, "it is empty"));
191    }
192    let Some(open) = text.find('<') else {
193        return Ok(SpecConfigType::Base(base(text)?));
194    };
195    if !text.ends_with('>') {
196        return Err(invalid(text, "a `<` without a closing `>`"));
197    }
198    let name = text[..open].trim();
199    let inner = &text[open + 1..text.len() - 1];
200    match name {
201        "list" | "array" => Ok(SpecConfigType::List(Box::new(parse_union(inner)?))),
202        "set" => Ok(SpecConfigType::Set(Box::new(parse_union(inner)?))),
203        "option" | "optional" => Ok(SpecConfigType::Option(Box::new(parse_union(inner)?))),
204        "map" | "table" => {
205            let parts = split_top_level(inner, ',');
206            match parts.as_slice() {
207                // `map<string>` is a map from strings to that type, which is what every
208                // registry in the fleet means by a one-argument map.
209                [value] => Ok(SpecConfigType::Map(
210                    Base::String,
211                    Box::new(parse_union(value)?),
212                )),
213                [key, value] => Ok(SpecConfigType::Map(
214                    base(key.trim())?,
215                    Box::new(parse_union(value)?),
216                )),
217                _ => Err(invalid(text, "a map takes a key and a value")),
218            }
219        }
220        other => Err(invalid(
221            text,
222            &format!("`{other}` does not take a type argument"),
223        )),
224    }
225}
226
227/// Split on `sep`, ignoring separators nested inside `<…>`.
228fn split_top_level(text: &str, sep: char) -> Vec<&str> {
229    let mut parts = Vec::new();
230    let mut depth = 0usize;
231    let mut start = 0usize;
232    for (i, c) in text.char_indices() {
233        match c {
234            '<' => depth += 1,
235            '>' => depth = depth.saturating_sub(1),
236            c if c == sep && depth == 0 => {
237                parts.push(text[start..i].trim());
238                start = i + c.len_utf8();
239            }
240            _ => {}
241        }
242    }
243    let last = text[start..].trim();
244    // An empty tail only counts when a separator put it there: `"bool"` is one part, while
245    // `"bool|"` is two, the second of which is empty and fails to parse. Dropping empties
246    // here instead made `bool|`, `bool||string` and `map<string,>` load as something else.
247    if !last.is_empty() || !parts.is_empty() {
248        parts.push(last);
249    }
250    parts
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn parsed(text: &str) -> SpecConfigType {
258        text.parse().unwrap_or_else(|e| panic!("{text}: {e}"))
259    }
260
261    #[test]
262    fn every_shape_round_trips_through_its_own_spelling() {
263        // The written form is the interchange, so parsing and displaying have to agree —
264        // otherwise a spec changes meaning by being saved.
265        for text in [
266            "bool",
267            "string",
268            "uint",
269            "path",
270            "duration",
271            "object",
272            "list<string>",
273            "set<path>",
274            "map<string, string>",
275            "option<int>",
276            "list<map<string, list<string>>>",
277            "bool|string",
278            "option<list<string>>",
279        ] {
280            assert_eq!(parsed(text).to_string(), text, "{text}");
281        }
282    }
283
284    #[test]
285    fn familiar_spellings_are_accepted() {
286        // The registries being migrated say `boolean`, `ListString`-ish `array<…>`,
287        // `usize`, `number`. Accepting the synonyms costs nothing and normalizes them.
288        assert_eq!(parsed("boolean"), SpecConfigType::Base(Base::Bool));
289        assert_eq!(parsed("usize"), SpecConfigType::Base(Base::Uint));
290        assert_eq!(parsed("number"), SpecConfigType::Base(Base::Float));
291        assert_eq!(
292            parsed("array<string>"),
293            SpecConfigType::List(Box::new(SpecConfigType::Base(Base::String)))
294        );
295        assert_eq!(
296            parsed("optional<path>"),
297            SpecConfigType::Option(Box::new(SpecConfigType::Base(Base::Path)))
298        );
299        // A one-argument map is keyed by strings, which is what the fleet means by it.
300        assert_eq!(
301            parsed("map<string>"),
302            SpecConfigType::Map(Base::String, Box::new(SpecConfigType::Base(Base::String)))
303        );
304    }
305
306    #[test]
307    fn an_unknown_name_is_kept_rather_than_refused() {
308        // The escape hatch: a tool may name a type only it understands, and a spec written
309        // for a newer usage must still load.
310        let ty = parsed("crate::PythonUvVenvAuto");
311        assert_eq!(
312            ty,
313            SpecConfigType::Base(Base::Custom("crate::PythonUvVenvAuto".into()))
314        );
315        assert_eq!(ty.to_string(), "crate::PythonUvVenvAuto");
316        // Including inside a composite.
317        assert_eq!(parsed("list<Weird>").to_string(), "list<Weird>");
318    }
319
320    #[test]
321    fn a_malformed_type_is_an_error() {
322        for text in [
323            "list<string",
324            "list<>",
325            "map<string, int, extra>",
326            "int<x>",
327            // A missing member, rather than a missing bracket. Each of these used to load as
328            // something else: `bool|` as plain `bool`, `bool||string` as the two-member
329            // union, `map<string,>` as a map to strings — a typo quietly changing what a
330            // setting holds, which the type is the interchange for precisely to avoid.
331            "bool|",
332            "|bool",
333            "bool||string",
334            "map<string,>",
335            "map<,string>",
336            // A stray delimiter is a broken expression, not the name of a type only this
337            // tool knows: `int>` used to become `Custom("int>")` and every consumer treated
338            // it as a string.
339            "int>",
340            "list<string>>",
341            "map<string>, int>",
342            "bool|>",
343        ] {
344            assert!(
345                text.parse::<SpecConfigType>().is_err(),
346                "`{text}` should not parse"
347            );
348        }
349    }
350
351    #[test]
352    fn simplified_picks_what_a_generator_can_write() {
353        assert_eq!(
354            parsed("option<list<string>>").simplified(),
355            &parsed("list<string>")
356        );
357        assert_eq!(parsed("bool|string").simplified(), &parsed("bool"));
358        assert!(parsed("option<int>").is_optional());
359        assert!(!parsed("int").is_optional());
360    }
361}