1mod block;
9mod json;
10mod keys;
11mod toml;
12mod yaml;
13
14use alloc::collections::{BTreeMap, BTreeSet};
15use core::ops::Range;
16use core::str;
17
18use derive_more::Display;
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23pub use self::block::Comment;
24use self::block::Markers;
25pub(crate) use self::keys::{Key, Leaves, Written, decode, display, encode, merge, overlap};
26use self::keys::{lookup, walk};
27use crate::digest::BOM;
28use crate::errors::{ParseError, ProfileError, Result};
29use crate::format::Format;
30use crate::path::RelPath;
31
32#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub(crate) struct Slot {
35 pub path: RelPath,
37 pub part: Option<String>,
39}
40
41impl Slot {
42 pub(crate) const fn whole(path: RelPath) -> Self {
44 Self { path, part: None }
45 }
46}
47
48#[derive(
50 Clone,
51 Copy,
52 Debug,
53 Default,
54 PartialEq,
55 Eq,
56 PartialOrd,
57 Ord,
58 Hash,
59 Serialize,
60 Deserialize,
61 JsonSchema,
62 Display,
63)]
64#[serde(rename_all = "lowercase")]
65#[display(rename_all = "lowercase")]
66pub enum Scope {
67 #[default]
69 File,
70 Keys,
72 Block,
74}
75
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub(crate) enum Syntax {
79 Toml,
81 Json,
83 Yaml,
85}
86
87type Failure = (Option<Range<usize>>, String);
89
90type Parsed<T> = Result<T, Failure>;
92
93type Edit<'a> = (&'a Key, Option<&'a Value>);
95
96impl Syntax {
97 const fn of(format: Format) -> Option<Self> {
99 match format {
100 Format::Toml => Some(Self::Toml),
101 Format::Json => Some(Self::Json),
102 Format::Yaml => Some(Self::Yaml),
103 Format::None => None,
104 }
105 }
106
107 fn semantic(self, text: &str) -> Parsed<Value> {
109 match self {
110 Self::Toml => toml::semantic(text),
111 Self::Json => json::semantic(text),
112 Self::Yaml => yaml::semantic(text),
113 }
114 }
115
116 fn leaves(self, text: &str) -> Parsed<Written> {
118 match self {
119 Self::Toml => toml::leaves(text),
121 Self::Json | Self::Yaml => {
122 let mut leaves = Vec::new();
123 walk(&self.semantic(text)?, &mut Vec::new(), &mut leaves);
124 Ok(Written { leaves, source: text.to_owned(), ..Written::default() })
125 },
126 }
127 }
128
129 fn values(self, text: &str, keys: &BTreeSet<Key>) -> Parsed<Leaves> {
131 let values = self.semantic(text)?;
132 Ok(keys
133 .iter()
134 .filter_map(|key| Some((key.clone(), lookup(&values, key)?.clone())))
135 .collect())
136 }
137
138 fn apply(self, text: &str, edits: &[Edit<'_>], payload: &Written) -> Parsed<String> {
141 match self {
142 Self::Toml => toml::apply(text, edits, payload),
143 Self::Json => json::apply(text, edits, payload),
144 Self::Yaml => yaml::apply(text, edits, payload),
145 }
146 }
147}
148
149#[derive(Clone, Debug, PartialEq, Eq)]
151pub(crate) enum Shape {
152 Keys(Syntax),
154 Block(Markers),
156}
157
158impl Shape {
159 pub(crate) fn of(
166 scope: Scope, path: &RelPath, format: Format, comment: Option<Comment>, owner: &str,
167 ) -> Result<Option<Self>> {
168 match scope {
169 Scope::File => Ok(None),
170 Scope::Keys => Syntax::of(format)
171 .map(|syntax| Some(Self::Keys(syntax)))
172 .ok_or_else(|| ProfileError::NoKeys { path: path.clone() }.into()),
173 Scope::Block => {
174 let comment = comment
175 .or_else(|| block::comment(path))
176 .ok_or_else(|| ProfileError::NoComment { path: path.clone() })?;
177 Ok(Some(Self::Block(Markers::new(comment, owner))))
178 },
179 }
180 }
181
182 pub(crate) fn read(&self, payload: &[u8], label: &str) -> Result<(Vec<u8>, Written)> {
185 match self {
186 Self::Keys(syntax) => {
187 let (text, _) = text(payload, label)?;
188 let written = syntax.leaves(text).map_err(|f| failed(label, text, f))?;
189 let leaves: Leaves = written.leaves.iter().cloned().collect();
190 Ok((encode(&leaves), written))
191 },
192 Self::Block(_) => Ok((payload.to_vec(), Written::default())),
193 }
194 }
195
196 pub(crate) fn keys(&self, content: &[u8]) -> BTreeSet<Key> {
198 match self {
199 Self::Keys(_) => decode(content).map_or_default(|leaves| leaves.into_keys().collect()),
200 Self::Block(_) => BTreeSet::new(),
201 }
202 }
203
204 pub(crate) fn view(
206 &self, file: &[u8], keys: &BTreeSet<Key>, label: &str,
207 ) -> Result<Option<Vec<u8>>> {
208 let (text, _) = text(file, label)?;
209 match self {
210 Self::Keys(syntax) => {
211 let found = syntax.values(text, keys).map_err(|f| failed(label, text, f))?;
212 Ok((!found.is_empty() || keys.is_empty()).then(|| encode(&found)))
214 },
215 Self::Block(markers) => {
216 let lines = markers
217 .find(text)
218 .map_err(|(span, why)| failed(label, text, (Some(span), why)))?;
219 Ok(lines.and_then(|lines| text.get(lines)).map(|lines| lines.as_bytes().to_vec()))
220 },
221 }
222 }
223
224 pub(crate) fn splice(
230 &self, file: &[u8], content: &[u8], keys: &BTreeSet<Key>, payload: &Written, label: &str,
231 ) -> Result<Vec<u8>> {
232 let (text, bom) = text(file, label)?;
233 let spliced = match self {
234 Self::Keys(syntax) => {
235 let target = decode(content).unwrap_or_default();
236 let current = syntax.values(text, keys).map_err(|f| failed(label, text, f))?;
237 let written: BTreeMap<&Key, &Value> =
238 payload.leaves.iter().map(|(k, v)| (k, v)).collect();
239 let order = payload
240 .leaves
241 .iter()
242 .map(|(key, _)| key)
243 .filter(|key| keys.contains(*key))
244 .chain(keys.iter().filter(|key| !written.contains_key(key)));
245 let edits: Vec<Edit<'_>> = order
246 .filter(|key| current.get(*key) != target.get(*key))
247 .map(|key| {
248 let value = target.get(key);
249 let styled = written.get(key).copied().filter(|w| Some(*w) == value);
250 (key, styled.or(value))
251 })
252 .collect();
253 if edits.is_empty() {
254 text.to_owned()
255 } else {
256 syntax.apply(text, &edits, payload).map_err(|f| failed(label, text, f))?
257 }
258 },
259 Self::Block(markers) => {
260 let (content, _) = self::text(content, label)?;
261 markers
262 .splice(text, content)
263 .map_err(|(span, why)| failed(label, text, (Some(span), why)))?
264 },
265 };
266 let mut out = if bom { BOM.to_vec() } else { Vec::new() };
267 out.extend_from_slice(spliced.as_bytes());
268 Ok(out)
269 }
270
271 pub(crate) fn remove(&self, file: &[u8], keys: &BTreeSet<Key>, label: &str) -> Result<Vec<u8>> {
273 match self {
274 Self::Keys(_) => {
275 self.splice(file, &encode(&Leaves::new()), keys, &Written::default(), label)
276 },
277 Self::Block(markers) => {
278 let (text, bom) = text(file, label)?;
279 let removed = markers
280 .remove(text)
281 .map_err(|(span, why)| failed(label, text, (Some(span), why)))?;
282 let mut out = if bom && !removed.is_empty() { BOM.to_vec() } else { Vec::new() };
283 out.extend_from_slice(removed.as_bytes());
284 Ok(out)
285 },
286 }
287 }
288}
289
290fn text<'a>(bytes: &'a [u8], label: &str) -> Result<(&'a str, bool)> {
292 let bom = bytes.starts_with(BOM);
293 let bytes = bytes.strip_prefix(BOM).unwrap_or(bytes);
294 let text = str::from_utf8(bytes).map_err(|e| ParseError {
295 file: label.to_owned(),
296 text: String::new(),
297 span: None,
298 message: e.to_string(),
299 })?;
300 Ok((text, bom))
301}
302
303fn failed(label: &str, text: &str, (span, message): Failure) -> crate::Error {
305 ParseError { file: label.to_owned(), text: text.to_owned(), span, message }.into()
306}
307
308#[cfg(test)]
309mod tests {
310 use alloc::collections::BTreeSet;
311
312 use super::{Scope, Shape, Syntax};
313 use crate::format::Format;
314 use crate::path::RelPath;
315
316 fn keys(path: &str) -> Shape {
318 let path = RelPath::new(path).expect("a valid path");
319 let format = Format::of(&path);
320 Shape::of(Scope::Keys, &path, format, None, "p")
321 .expect("a structured file")
322 .expect("a part")
323 }
324
325 fn splice(shape: &Shape, file: &str, payload: &str) -> String {
327 let (content, written) = shape.read(payload.as_bytes(), "payload").expect("a payload");
328 let owned: BTreeSet<_> = shape.keys(&content);
329 let out =
330 shape.splice(file.as_bytes(), &content, &owned, &written, "file").expect("splices");
331 String::from_utf8(out).expect("UTF-8 out")
332 }
333
334 #[test]
335 fn toml_keys_join_the_target_tables() {
336 let shape = keys("Cargo.toml");
337 let file = "[workspace]\nmembers = [\"lib/*\"]\n\n[workspace.lints.clippy]\n# why\nunwrap_used = \"warn\" # ours\nlocal = \"allow\"\n";
338 let payload = "[workspace.lints.clippy]\nunwrap_used = \"deny\"\npedantic = { level = \"deny\", priority = -1 }\n";
339 let out = splice(&shape, file, payload);
340 assert!(out.contains("members = [\"lib/*\"]"), "the target's own keys stay: {out}");
341 assert!(out.contains("local = \"allow\""), "and its keys beside the shared ones");
342 assert!(
343 out.contains("unwrap_used = \"deny\" # ours"),
344 "a replaced value keeps its comment"
345 );
346 assert!(out.contains("# why"), "and the comment above it: {out}");
347 assert!(
348 out.contains("pedantic = { level = \"deny\", priority = -1 }"),
349 "a new leaf is added"
350 );
351 }
352
353 #[test]
354 fn toml_new_keys_keep_the_payload_layout() {
355 let shape = keys("deny.toml");
356 let payload = "[bans]\nmultiple-versions = \"deny\"\nwildcards = \"deny\"\ndeny = [\n { name = \"a\" },\n { name = \"b\" },\n]\n";
357 let out = splice(
358 &shape,
359 "[bans]\nwildcards = \"allow\"\n\n[graph]\nall-features = true\n",
360 payload,
361 );
362 assert!(
363 out.contains("multiple-versions = \"deny\"\n"),
364 "a new key is aligned as the payload aligns it: {out}"
365 );
366 assert!(
367 out.contains("wildcards = \"deny\"\n"),
368 "a key the file holds keeps the file's layout: {out}"
369 );
370 assert!(
371 out.contains("deny = [\n { name = \"a\" },\n { name = \"b\" },\n]"),
372 "and a new value is laid out as the payload lays it out: {out}"
373 );
374 let out = splice(&shape, "[graph]\nall-features = true\n", payload);
375 assert!(
376 out.contains("[bans]\nmultiple-versions = \"deny\"\nwildcards = \"deny\"\n"),
377 "keys of a new table are aligned as the payload aligns them: {out}"
378 );
379 }
380
381 #[test]
382 fn toml_tables_are_created_and_pruned() {
383 let shape = keys("Cargo.toml");
384 let out = splice(
385 &shape,
386 "[package]\nname = \"x\"\n",
387 "[workspace.lints.rust]\nunsafe_code = \"deny\"\n",
388 );
389 assert!(out.contains("[workspace.lints.rust]\nunsafe_code = \"deny\""), "created: {out}");
390 let (content, written) = shape
391 .read(
392 b"[workspace.lints.rust]
393unsafe_code = \"deny\"
394",
395 "p",
396 )
397 .expect("valid");
398 let owned = shape.keys(&content);
399 let emptied = shape.splice(out.as_bytes(), b"", &owned, &written, "file").expect("splices");
400 let emptied = String::from_utf8(emptied).expect("UTF-8");
401 assert_eq!(emptied.trim(), "[package]\nname = \"x\"", "a table the part leaves empty goes");
402 }
403
404 #[test]
405 fn toml_arrays_of_objects_are_arrays_of_tables() {
406 let shape = keys(".config/mise/mise.lock");
407 let file = "[[tools.dprint]]\nversion = \"0.57.4\"\n\n[tools.dprint.\"platforms.linux-x64\"]\nchecksum = \"sha256:a\"\n";
408 let payload = "[[tools.taplo]]\nversion = \"0.10.0\"\nbackend = \"aqua:tamasfe/taplo\"\n\n[tools.taplo.\"platforms.linux-x64\"]\nurl = \"https://example.com/taplo.gz\"\n";
409 let out = splice(&shape, file, payload);
410 assert!(out.starts_with(file), "the entry already there is untouched: {out}");
411 assert!(out.contains("[[tools.taplo]]\nversion = \"0.10.0\""), "an array of tables: {out}");
412 assert!(
413 out.contains("[tools.taplo.\"platforms.linux-x64\"]\nurl = "),
414 "with its sub-tables as tables: {out}"
415 );
416 }
417
418 #[test]
419 fn toml_arrays_keep_the_form_the_payload_writes() {
420 let shape = keys("deny.toml");
421 let out =
422 splice(&shape, "[bans]\nwildcards = \"deny\"\n", "[bans]\ndeny = [{ name = \"a\" }]\n");
423 assert!(
424 out.contains("deny = [{ name = \"a\" }]"),
425 "written inline, as the payload is: {out}"
426 );
427 }
428
429 #[test]
430 fn json_keys_keep_comments_and_commas() {
431 let shape = keys(".vscode/settings.json");
432 assert_eq!(shape, Shape::Keys(Syntax::Json), "JSON by extension");
433 let file = "{\n // ours\n \"rust-analyzer.cargo.target\": \"aarch64\",\n \"editor.formatOnSave\": false,\n}\n";
434 let out = splice(
435 &shape,
436 file,
437 "{ \"editor.formatOnSave\": true, \"[toml]\": { \"editor.tabSize\": 2 } }",
438 );
439 assert!(out.contains("// ours"), "comments survive: {out}");
440 assert!(
441 out.contains("\"rust-analyzer.cargo.target\": \"aarch64\""),
442 "the target's keys stay"
443 );
444 assert!(out.contains("\"editor.formatOnSave\": true"), "an owned key is set");
445 assert!(out.contains("\"editor.tabSize\": 2"), "a nested key is added");
446 }
447
448 #[test]
449 fn yaml_keys_keep_comments() {
450 let shape = keys(".github/dependabot.yml");
451 let file =
452 "# ours\nversion: 2\nupdates:\n - package-ecosystem: cargo\n directory: \"/\"\n";
453 let out =
454 splice(&shape, file, "version: 2\nregistries:\n crates:\n type: cargo-registry\n");
455 assert!(out.contains("# ours"), "comments survive: {out}");
456 assert!(out.contains("package-ecosystem: cargo"), "the target's keys stay");
457 assert!(out.contains("type: cargo-registry"), "a nested key is added");
458 }
459
460 #[test]
461 fn yaml_new_keys_keep_the_payload_layout() {
462 let shape = keys(".yamllint.yaml");
463 let payload = "extends: default\nignore-from-file: [.gitignore]\nrules:\n truthy:\n allowed-values: [\"true\", \"false\"]\n";
464 let out = splice(&shape, "extends: default\n", payload);
465 assert!(
466 out.contains("ignore-from-file: [.gitignore]\n"),
467 "a new sequence is written as the payload writes it: {out}"
468 );
469 assert!(
470 out.contains("rules:\n truthy:\n allowed-values: [\"true\", \"false\"]\n"),
471 "and a new value under mappings made for it, quotes and all: {out}"
472 );
473 let shape = keys(".github/dependabot.yml");
474 let updates = "updates:\n - package-ecosystem: cargo\n directory: \"/\"\n";
475 let out = splice(&shape, "version: 2\n", updates);
476 assert!(out.ends_with(updates), "a sequence of mappings keeps its keys' order: {out}");
477 }
478
479 #[test]
480 fn views_compare_values_not_layout() {
481 let shape = keys("a.toml");
482 let owned = shape.keys(&shape.read(b"a = 1\nb = \"x\"\n", "p").expect("valid").0);
483 let one = shape.view(b"a = 1\nb = \"x\"\n", &owned, "f").expect("reads");
484 let two = shape.view(b"b = 'x' # hi\na=1\nother = 2\n", &owned, "f").expect("reads");
485 assert_eq!(one, two, "reformatting and other keys are not part of the view");
486 assert_eq!(shape.view(b"other = 1\n", &owned, "f").expect("reads"), None, "absent part");
487 }
488}