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_keys_take_the_alignment_of_the_table_they_join() {
383 let shape = keys("Cargo.toml");
384 let out = splice(
385 &shape,
386 "[package]\nname = \"x\"\nversion = \"1\"\n\n[lints]\nworkspace = true\n",
387 "[package]\ndescription = \"d\"\n",
388 );
389 assert!(
390 out.starts_with(
391 "[package]\nname = \"x\"\nversion = \"1\"\ndescription = \"d\"\n"
392 ),
393 "a key joining an aligned group realigns it, as taplo would: {out}"
394 );
395 let out = splice(&shape, "[a]\nx = 1\nlong = 2\n", "[a]\nmedium = 3\nz = 4\n");
396 assert_eq!(
397 out, "[a]\nx = 1\nlong = 2\nmedium = 3\nz = 4\n",
398 "a file that spaces its keys by one keeps doing so, whatever the payload does"
399 );
400 let out = splice(&shape, "[a]\nx = 1\n", "[a]\nlonger = 2\nz = 3\n");
401 assert_eq!(
402 out, "[a]\nx = 1\nlonger = 2\nz = 3\n",
403 "a file no group of which says takes the payload's alignment"
404 );
405 }
406
407 #[test]
408 fn toml_keys_realign_the_comments_of_their_group() {
409 let shape = keys("Cargo.toml");
410 let out = splice(
411 &shape,
412 "[a]\nx = 1 # one\nlong_key = \"abc\" # two\n",
413 "[a]\nlongest_key = 3\n",
414 );
415 assert_eq!(
416 out, "[a]\nx = 1 # one\nlong_key = \"abc\" # two\nlongest_key = 3\n",
417 "comments line up after the longest line: {out}"
418 );
419 let file = "[a]\nk = [\n \"a\",\n \"b\",\n]\nlonger = 2\n\n[b]\nx = 1\nlong = 2\n";
420 let out = splice(&shape, file, "[a]\nz = 3\n");
421 assert!(
422 out.starts_with("[a]\nk = [\n \"a\",\n \"b\",\n]\nlonger = 2\nz = 3\n"),
423 "a group with a value over several lines is aligned by no one: {out}"
424 );
425 }
426
427 #[test]
428 fn toml_tables_are_created_and_pruned() {
429 let shape = keys("Cargo.toml");
430 let out = splice(
431 &shape,
432 "[package]\nname = \"x\"\n",
433 "[workspace.lints.rust]\nunsafe_code = \"deny\"\n",
434 );
435 assert!(out.contains("[workspace.lints.rust]\nunsafe_code = \"deny\""), "created: {out}");
436 let (content, written) = shape
437 .read(
438 b"[workspace.lints.rust]
439unsafe_code = \"deny\"
440",
441 "p",
442 )
443 .expect("valid");
444 let owned = shape.keys(&content);
445 let emptied = shape.splice(out.as_bytes(), b"", &owned, &written, "file").expect("splices");
446 let emptied = String::from_utf8(emptied).expect("UTF-8");
447 assert_eq!(emptied.trim(), "[package]\nname = \"x\"", "a table the part leaves empty goes");
448 }
449
450 #[test]
451 fn toml_arrays_of_objects_are_arrays_of_tables() {
452 let shape = keys(".config/mise/mise.lock");
453 let file = "[[tools.dprint]]\nversion = \"0.57.4\"\n\n[tools.dprint.\"platforms.linux-x64\"]\nchecksum = \"sha256:a\"\n";
454 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";
455 let out = splice(&shape, file, payload);
456 assert!(out.starts_with(file), "the entry already there is untouched: {out}");
457 assert!(out.contains("[[tools.taplo]]\nversion = \"0.10.0\""), "an array of tables: {out}");
458 assert!(
459 out.contains("[tools.taplo.\"platforms.linux-x64\"]\nurl = "),
460 "with its sub-tables as tables: {out}"
461 );
462 }
463
464 #[test]
465 fn toml_arrays_of_tables_keep_the_payload_layout() {
466 let shape = keys("taplo.toml");
467 let rules = "# Ours, sorted.\n[[rule]]\ninclude = [\"**/Cargo.toml\"]\nkeys = [\"dependencies\"]\n\n[rule.formatting]\nreorder_keys = true\n\n# Theirs.\n[[rule]]\ninclude = [\"x.toml\"]\n";
468 let payload = format!("[formatting]\nalign_entries = true\n\n{rules}");
469 let file = "exclude = [\"target/**\"]\n\n[formatting]\nalign_entries = false\n\n[[rule]]\ninclude = [\"**/Cargo.toml\"]\nkeys = [\"dependencies\"]\n\n[rule.formatting]\nreorder_keys = true\n";
470 let out = splice(&shape, file, &payload);
471 assert!(
472 out.ends_with(&format!("align_entries = true\n\n{rules}")),
473 "an array of tables the file holds is rewritten as the payload writes it: {out}"
474 );
475 assert!(out.starts_with("exclude = [\"target/**\"]\n"), "and the file's own keys stay");
476 let out = splice(&shape, "[formatting]\nalign_entries = true\n", &payload);
477 assert!(out.ends_with(rules), "as is one the file lacks: {out}");
478 }
479
480 #[test]
481 fn toml_arrays_keep_the_form_the_payload_writes() {
482 let shape = keys("deny.toml");
483 let out =
484 splice(&shape, "[bans]\nwildcards = \"deny\"\n", "[bans]\ndeny = [{ name = \"a\" }]\n");
485 assert!(
486 out.contains("deny = [{ name = \"a\" }]"),
487 "written inline, as the payload is: {out}"
488 );
489 }
490
491 #[test]
492 fn json_keys_keep_comments_and_commas() {
493 let shape = keys(".vscode/settings.json");
494 assert_eq!(shape, Shape::Keys(Syntax::Json), "JSON by extension");
495 let file = "{\n // ours\n \"rust-analyzer.cargo.target\": \"aarch64\",\n \"editor.formatOnSave\": false,\n}\n";
496 let out = splice(
497 &shape,
498 file,
499 "{ \"editor.formatOnSave\": true, \"[toml]\": { \"editor.tabSize\": 2 } }",
500 );
501 assert!(out.contains("// ours"), "comments survive: {out}");
502 assert!(
503 out.contains("\"rust-analyzer.cargo.target\": \"aarch64\""),
504 "the target's keys stay"
505 );
506 assert!(out.contains("\"editor.formatOnSave\": true"), "an owned key is set");
507 assert!(out.contains("\"editor.tabSize\": 2"), "a nested key is added");
508 }
509
510 #[test]
511 fn json_new_keys_keep_the_payload_layout() {
512 let shape = keys("dprint.json");
513 let payload = "{\n \"includes\": [\"**/*.md\"],\n \"markdown\": { \"lineWidth\": 80, \"textWrap\": \"always\" }\n}\n";
514 let out = splice(
515 &shape,
516 "{\n \"lineWidth\": 100,\n \"includes\": [\"**/*.json\"]\n}\n",
517 payload,
518 );
519 assert!(
520 out.contains("\n \"markdown\": { \"lineWidth\": 80, \"textWrap\": \"always\" }\n"),
521 "a key the file lacks is written whole, as the payload writes it: {out}"
522 );
523 assert!(
524 out.contains("\n \"includes\": [\"**/*.md\"],\n"),
525 "and a value the file holds is replaced as the payload writes it: {out}"
526 );
527 let out = splice(&shape, "{\n \"markdown\": { \"ours\": true }\n}\n", payload);
528 assert!(
529 out.contains("\"ours\": true") && out.contains("\"textWrap\": \"always\""),
530 "an object the file holds keeps its own keys beside the payload's: {out}"
531 );
532 let plugins = "{\n \"plugins\": [\n \"a\",\n \"b\"\n ]\n}\n";
533 let out = splice(&shape, "{\n \"lineWidth\": 100\n}\n", plugins);
534 assert!(
535 out.contains("\n \"plugins\": [\n \"a\",\n \"b\"\n ]\n"),
536 "a value on several lines moves from the payload's indent to the file's: {out}"
537 );
538 }
539
540 #[test]
541 fn yaml_keys_keep_comments() {
542 let shape = keys(".github/dependabot.yml");
543 let file =
544 "# ours\nversion: 2\nupdates:\n - package-ecosystem: cargo\n directory: \"/\"\n";
545 let out =
546 splice(&shape, file, "version: 2\nregistries:\n crates:\n type: cargo-registry\n");
547 assert!(out.contains("# ours"), "comments survive: {out}");
548 assert!(out.contains("package-ecosystem: cargo"), "the target's keys stay");
549 assert!(out.contains("type: cargo-registry"), "a nested key is added");
550 }
551
552 #[test]
553 fn yaml_new_keys_keep_the_payload_layout() {
554 let shape = keys(".yamllint.yaml");
555 let payload = "extends: default\nignore-from-file: [.gitignore]\nrules:\n truthy:\n allowed-values: [\"true\", \"false\"]\n";
556 let out = splice(&shape, "extends: default\n", payload);
557 assert!(
558 out.contains("ignore-from-file: [.gitignore]\n"),
559 "a new sequence is written as the payload writes it: {out}"
560 );
561 assert!(
562 out.contains("rules:\n truthy:\n allowed-values: [\"true\", \"false\"]\n"),
563 "and a new value under mappings made for it, quotes and all: {out}"
564 );
565 let shape = keys(".github/dependabot.yml");
566 let updates = "updates:\n - package-ecosystem: cargo\n directory: \"/\"\n";
567 let out = splice(&shape, "version: 2\n", updates);
568 assert!(out.ends_with(updates), "a sequence of mappings keeps its keys' order: {out}");
569 }
570
571 #[test]
572 fn views_compare_values_not_layout() {
573 let shape = keys("a.toml");
574 let owned = shape.keys(&shape.read(b"a = 1\nb = \"x\"\n", "p").expect("valid").0);
575 let one = shape.view(b"a = 1\nb = \"x\"\n", &owned, "f").expect("reads");
576 let two = shape.view(b"b = 'x' # hi\na=1\nother = 2\n", &owned, "f").expect("reads");
577 assert_eq!(one, two, "reformatting and other keys are not part of the view");
578 assert_eq!(shape.view(b"other = 1\n", &owned, "f").expect("reads"), None, "absent part");
579 }
580}