Skip to main content

devset_core/
format.rs

1//! Post-merge validation of structured files.
2
3use core::ops::Range;
4use core::str;
5use std::collections::HashSet;
6
7use jsonc_parser::ast::Value;
8use jsonc_parser::{CollectOptions, ParseOptions, parse_to_ast};
9use schemars::JsonSchema;
10use serde::de::IgnoredAny;
11use serde::{Deserialize, Serialize};
12
13use crate::digest::BOM;
14use crate::path::RelPath;
15
16/// How a file is checked after a merge: it must parse, with no duplicate keys.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "lowercase")]
19pub enum Format {
20    /// TOML.
21    Toml,
22    /// JSON with comments and trailing commas (JSONC), a superset of JSON.
23    Json,
24    /// YAML.
25    Yaml,
26    /// Not checked.
27    None,
28}
29
30impl Format {
31    /// The format `path`'s extension implies.
32    #[must_use]
33    pub fn of(path: &RelPath) -> Self {
34        let name = path.file_name();
35        // Lockfiles that are TOML whatever their extension says.
36        if matches!(name, "Cargo.lock" | "mise.lock") || name.ends_with(".mise.lock") {
37            return Self::Toml;
38        }
39        match path.extension().as_deref() {
40            Some("toml") => Self::Toml,
41            Some("json" | "jsonc") => Self::Json,
42            Some("yaml" | "yml") => Self::Yaml,
43            _ => Self::None,
44        }
45    }
46
47    /// Checks that `bytes` parse, with no duplicate key at any depth.
48    ///
49    /// A UTF-8 BOM is allowed, and YAML may hold several documents.
50    ///
51    /// # Errors
52    /// Why they do not: the parser's message, or the first duplicate key, located by line.
53    pub fn check(self, bytes: &[u8]) -> Result<(), String> {
54        let bytes = bytes.strip_prefix(BOM).unwrap_or(bytes);
55        let text = || str::from_utf8(bytes).map_err(|e| e.to_string());
56        match self {
57            Self::None => Ok(()),
58            Self::Toml => {
59                let text = text()?;
60                toml::from_str::<IgnoredAny>(text).map(drop).map_err(|e| {
61                    let at = |span: Range<usize>| {
62                        format!("{} at line {}", e.message(), line_at(text, span.start))
63                    };
64                    e.span().map_or_else(|| e.message().to_owned(), at)
65                })
66            },
67            Self::Yaml => {
68                serde_saphyr::from_multiple::<IgnoredAny>(text()?).map(drop).map_err(|e| yaml(&e))
69            },
70            Self::Json => {
71                let text = text()?;
72                let parsed = parse_to_ast(text, &CollectOptions::default(), &jsonc())
73                    .map_err(|e| e.to_string())?;
74                parsed.value.as_ref().map_or(Ok(()), |value| unique_keys(value, text))
75            },
76        }
77    }
78}
79
80/// JSON as devset reads it: comments and trailing commas allowed, as in JSONC, and nothing looser.
81pub(crate) const fn jsonc() -> ParseOptions {
82    ParseOptions {
83        allow_comments: true,
84        allow_trailing_commas: true,
85        allow_loose_object_property_names: false,
86        allow_missing_commas: false,
87        allow_single_quoted_strings: false,
88        allow_hexadecimal_numbers: false,
89        allow_unary_plus_numbers: false,
90    }
91}
92
93/// A YAML error in the words the other formats use.
94fn yaml(error: &serde_saphyr::Error) -> String {
95    let error = error.without_snippet();
96    if let serde_saphyr::Error::DuplicateMappingKey { key, location } = error {
97        let key = key.as_deref().map_or_else(String::new, |key| format!(" `{key}`"));
98        return format!("duplicate key{key} at line {}", location.line());
99    }
100    let message = error.to_string();
101    message.strip_prefix("error: ").unwrap_or(&message).to_owned()
102}
103
104/// The 1-based line of byte `offset` in `text`.
105fn line_at(text: &str, offset: usize) -> usize {
106    text.get(..offset).unwrap_or(text).matches('\n').count().saturating_add(1)
107}
108
109/// The first duplicate key in `value`, parsed from `text`, at any depth.
110fn unique_keys(value: &Value<'_>, text: &str) -> Result<(), String> {
111    match value {
112        Value::Object(object) => {
113            let mut seen = HashSet::with_capacity(object.properties.len());
114            for property in &object.properties {
115                let key = property.name.as_str();
116                if !seen.insert(key) {
117                    let line = line_at(text, property.range.start);
118                    return Err(format!("duplicate key `{key}` at line {line}"));
119                }
120                unique_keys(&property.value, text)?;
121            }
122            Ok(())
123        },
124        Value::Array(array) => {
125            array.elements.iter().try_for_each(|element| unique_keys(element, text))
126        },
127        Value::StringLit(_)
128        | Value::NumberLit(_)
129        | Value::BooleanLit(_)
130        | Value::NullKeyword(_) => Ok(()),
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::Format;
137    use crate::path::RelPath;
138
139    #[test]
140    fn inferred_from_extension() {
141        for (path, want) in [
142            ("deny.toml", Format::Toml),
143            (".vscode/settings.JSON", Format::Json),
144            ("ci.yml", Format::Yaml),
145            ("justfile", Format::None),
146        ] {
147            assert_eq!(Format::of(&RelPath::new(path).unwrap()), want, "{path}");
148        }
149    }
150
151    #[test]
152    fn real_files_are_valid() {
153        for (format, bytes) in [
154            (Format::Yaml, &b"a: 1\n---\nb: 2\n"[..]),
155            (Format::Yaml, b"---\na: 1\n...\n"),
156            (Format::Toml, b"\xEF\xBB\xBFa = 1\n"),
157            (Format::Json, b"\xEF\xBB\xBF{\"a\": 1}"),
158            (Format::Yaml, b"\xEF\xBB\xBFa: 1\n"),
159            (Format::Toml, b""),
160            (Format::Yaml, b""),
161            (Format::Json, b""),
162        ] {
163            assert_eq!(
164                format.check(bytes),
165                Ok(()),
166                "{format:?} {:?}",
167                String::from_utf8_lossy(bytes)
168            );
169        }
170        assert!(
171            Format::Yaml.check(b"a: 1\n---\na: 1\na: 2\n").is_err(),
172            "each document is checked"
173        );
174    }
175
176    #[test]
177    fn duplicate_keys_are_invalid() {
178        for (format, valid, duplicated) in [
179            (Format::Toml, "a = 1\n[t]\nb = 2\n", "a = 1\nb = 2\na = 3\n"),
180            (
181                Format::Json,
182                "{\n  // why\n  \"a\": 1,\n  \"o\": {\"b\": [1, {\"c\": 2}]},\n}",
183                r#"{"o": {"b": 1, "b": 2}}"#,
184            ),
185            (Format::Json, "[1, 2]", r#"[{"a": 1, "a": 2}]"#),
186            (Format::Yaml, "a: 1\nt:\n  b: 2\n", "name: app\ntimeout: 30\nport: 80\ntimeout: 60\n"),
187        ] {
188            assert_eq!(format.check(valid.as_bytes()), Ok(()), "{format:?} valid: {valid:?}");
189            assert!(
190                format.check(duplicated.as_bytes()).is_err(),
191                "{format:?} duplicate: {duplicated:?}"
192            );
193        }
194        assert!(
195            Format::Json.check(b"{'single': 1}").is_err(),
196            "only comments and trailing commas are loosened"
197        );
198        assert_eq!(Format::None.check(b"\0 anything"), Ok(()), "none checks nothing");
199        let located = [
200            (Format::Json, &b"{\"a\": 1,\n \"a\": 2}"[..], "duplicate key `a` at line 2"),
201            (Format::Toml, b"a = 1\n\na = 2\n", "duplicate key at line 3"),
202            (Format::Yaml, b"a: 1\na: 2\n", "duplicate key `a` at line 2"),
203        ];
204        for (format, bytes, want) in located {
205            assert_eq!(format.check(bytes), Err(want.into()), "{format:?} names the line");
206        }
207    }
208}