1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "lowercase")]
19pub enum Format {
20 Toml,
22 Json,
24 Yaml,
26 None,
28}
29
30impl Format {
31 #[must_use]
33 pub fn of(path: &RelPath) -> Self {
34 let name = path.file_name();
35 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 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
80pub(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
93fn 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
104fn line_at(text: &str, offset: usize) -> usize {
106 text.get(..offset).unwrap_or(text).matches('\n').count().saturating_add(1)
107}
108
109fn 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}