mod block;
mod json;
mod keys;
mod toml;
mod yaml;
use alloc::collections::{BTreeMap, BTreeSet};
use core::ops::Range;
use core::str;
use derive_more::Display;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use self::block::Comment;
use self::block::Markers;
pub(crate) use self::keys::{Key, Leaves, Written, decode, display, encode, merge, overlap};
use self::keys::{lookup, walk};
use crate::digest::BOM;
use crate::errors::{ParseError, ProfileError, Result};
use crate::format::Format;
use crate::path::RelPath;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct Slot {
pub path: RelPath,
pub part: Option<String>,
}
impl Slot {
pub(crate) const fn whole(path: RelPath) -> Self {
Self { path, part: None }
}
}
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
JsonSchema,
Display,
)]
#[serde(rename_all = "lowercase")]
#[display(rename_all = "lowercase")]
pub enum Scope {
#[default]
File,
Keys,
Block,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Syntax {
Toml,
Json,
Yaml,
}
type Failure = (Option<Range<usize>>, String);
type Parsed<T> = Result<T, Failure>;
type Edit<'a> = (&'a Key, Option<&'a Value>);
impl Syntax {
const fn of(format: Format) -> Option<Self> {
match format {
Format::Toml => Some(Self::Toml),
Format::Json => Some(Self::Json),
Format::Yaml => Some(Self::Yaml),
Format::None => None,
}
}
fn semantic(self, text: &str) -> Parsed<Value> {
match self {
Self::Toml => toml::semantic(text),
Self::Json => json::semantic(text),
Self::Yaml => yaml::semantic(text),
}
}
fn leaves(self, text: &str) -> Parsed<Written> {
match self {
Self::Toml => toml::leaves(text),
Self::Json | Self::Yaml => {
let mut leaves = Vec::new();
walk(&self.semantic(text)?, &mut Vec::new(), &mut leaves);
Ok(Written { leaves, source: text.to_owned(), ..Written::default() })
},
}
}
fn values(self, text: &str, keys: &BTreeSet<Key>) -> Parsed<Leaves> {
let values = self.semantic(text)?;
Ok(keys
.iter()
.filter_map(|key| Some((key.clone(), lookup(&values, key)?.clone())))
.collect())
}
fn apply(self, text: &str, edits: &[Edit<'_>], payload: &Written) -> Parsed<String> {
match self {
Self::Toml => toml::apply(text, edits, payload),
Self::Json => json::apply(text, edits, payload),
Self::Yaml => yaml::apply(text, edits, payload),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Shape {
Keys(Syntax),
Block(Markers),
}
impl Shape {
pub(crate) fn of(
scope: Scope, path: &RelPath, format: Format, comment: Option<Comment>, owner: &str,
) -> Result<Option<Self>> {
match scope {
Scope::File => Ok(None),
Scope::Keys => Syntax::of(format)
.map(|syntax| Some(Self::Keys(syntax)))
.ok_or_else(|| ProfileError::NoKeys { path: path.clone() }.into()),
Scope::Block => {
let comment = comment
.or_else(|| block::comment(path))
.ok_or_else(|| ProfileError::NoComment { path: path.clone() })?;
Ok(Some(Self::Block(Markers::new(comment, owner))))
},
}
}
pub(crate) fn read(&self, payload: &[u8], label: &str) -> Result<(Vec<u8>, Written)> {
match self {
Self::Keys(syntax) => {
let (text, _) = text(payload, label)?;
let written = syntax.leaves(text).map_err(|f| failed(label, text, f))?;
let leaves: Leaves = written.leaves.iter().cloned().collect();
Ok((encode(&leaves), written))
},
Self::Block(_) => Ok((payload.to_vec(), Written::default())),
}
}
pub(crate) fn keys(&self, content: &[u8]) -> BTreeSet<Key> {
match self {
Self::Keys(_) => decode(content).map_or_default(|leaves| leaves.into_keys().collect()),
Self::Block(_) => BTreeSet::new(),
}
}
pub(crate) fn view(
&self, file: &[u8], keys: &BTreeSet<Key>, label: &str,
) -> Result<Option<Vec<u8>>> {
let (text, _) = text(file, label)?;
match self {
Self::Keys(syntax) => {
let found = syntax.values(text, keys).map_err(|f| failed(label, text, f))?;
Ok((!found.is_empty() || keys.is_empty()).then(|| encode(&found)))
},
Self::Block(markers) => {
let lines = markers
.find(text)
.map_err(|(span, why)| failed(label, text, (Some(span), why)))?;
Ok(lines.and_then(|lines| text.get(lines)).map(|lines| lines.as_bytes().to_vec()))
},
}
}
pub(crate) fn splice(
&self, file: &[u8], content: &[u8], keys: &BTreeSet<Key>, payload: &Written, label: &str,
) -> Result<Vec<u8>> {
let (text, bom) = text(file, label)?;
let spliced = match self {
Self::Keys(syntax) => {
let target = decode(content).unwrap_or_default();
let current = syntax.values(text, keys).map_err(|f| failed(label, text, f))?;
let written: BTreeMap<&Key, &Value> =
payload.leaves.iter().map(|(k, v)| (k, v)).collect();
let order = payload
.leaves
.iter()
.map(|(key, _)| key)
.filter(|key| keys.contains(*key))
.chain(keys.iter().filter(|key| !written.contains_key(key)));
let edits: Vec<Edit<'_>> = order
.filter(|key| current.get(*key) != target.get(*key))
.map(|key| {
let value = target.get(key);
let styled = written.get(key).copied().filter(|w| Some(*w) == value);
(key, styled.or(value))
})
.collect();
if edits.is_empty() {
text.to_owned()
} else {
syntax.apply(text, &edits, payload).map_err(|f| failed(label, text, f))?
}
},
Self::Block(markers) => {
let (content, _) = self::text(content, label)?;
markers
.splice(text, content)
.map_err(|(span, why)| failed(label, text, (Some(span), why)))?
},
};
let mut out = if bom { BOM.to_vec() } else { Vec::new() };
out.extend_from_slice(spliced.as_bytes());
Ok(out)
}
pub(crate) fn remove(&self, file: &[u8], keys: &BTreeSet<Key>, label: &str) -> Result<Vec<u8>> {
match self {
Self::Keys(_) => {
self.splice(file, &encode(&Leaves::new()), keys, &Written::default(), label)
},
Self::Block(markers) => {
let (text, bom) = text(file, label)?;
let removed = markers
.remove(text)
.map_err(|(span, why)| failed(label, text, (Some(span), why)))?;
let mut out = if bom && !removed.is_empty() { BOM.to_vec() } else { Vec::new() };
out.extend_from_slice(removed.as_bytes());
Ok(out)
},
}
}
}
fn text<'a>(bytes: &'a [u8], label: &str) -> Result<(&'a str, bool)> {
let bom = bytes.starts_with(BOM);
let bytes = bytes.strip_prefix(BOM).unwrap_or(bytes);
let text = str::from_utf8(bytes).map_err(|e| ParseError {
file: label.to_owned(),
text: String::new(),
span: None,
message: e.to_string(),
})?;
Ok((text, bom))
}
fn failed(label: &str, text: &str, (span, message): Failure) -> crate::Error {
ParseError { file: label.to_owned(), text: text.to_owned(), span, message }.into()
}
#[cfg(test)]
mod tests {
use alloc::collections::BTreeSet;
use super::{Scope, Shape, Syntax};
use crate::format::Format;
use crate::path::RelPath;
fn keys(path: &str) -> Shape {
let path = RelPath::new(path).expect("a valid path");
let format = Format::of(&path);
Shape::of(Scope::Keys, &path, format, None, "p")
.expect("a structured file")
.expect("a part")
}
fn splice(shape: &Shape, file: &str, payload: &str) -> String {
let (content, written) = shape.read(payload.as_bytes(), "payload").expect("a payload");
let owned: BTreeSet<_> = shape.keys(&content);
let out =
shape.splice(file.as_bytes(), &content, &owned, &written, "file").expect("splices");
String::from_utf8(out).expect("UTF-8 out")
}
#[test]
fn toml_keys_join_the_target_tables() {
let shape = keys("Cargo.toml");
let file = "[workspace]\nmembers = [\"lib/*\"]\n\n[workspace.lints.clippy]\n# why\nunwrap_used = \"warn\" # ours\nlocal = \"allow\"\n";
let payload = "[workspace.lints.clippy]\nunwrap_used = \"deny\"\npedantic = { level = \"deny\", priority = -1 }\n";
let out = splice(&shape, file, payload);
assert!(out.contains("members = [\"lib/*\"]"), "the target's own keys stay: {out}");
assert!(out.contains("local = \"allow\""), "and its keys beside the shared ones");
assert!(
out.contains("unwrap_used = \"deny\" # ours"),
"a replaced value keeps its comment"
);
assert!(out.contains("# why"), "and the comment above it: {out}");
assert!(
out.contains("pedantic = { level = \"deny\", priority = -1 }"),
"a new leaf is added"
);
}
#[test]
fn toml_new_keys_keep_the_payload_layout() {
let shape = keys("deny.toml");
let payload = "[bans]\nmultiple-versions = \"deny\"\nwildcards = \"deny\"\ndeny = [\n { name = \"a\" },\n { name = \"b\" },\n]\n";
let out = splice(
&shape,
"[bans]\nwildcards = \"allow\"\n\n[graph]\nall-features = true\n",
payload,
);
assert!(
out.contains("multiple-versions = \"deny\"\n"),
"a new key is aligned as the payload aligns it: {out}"
);
assert!(
out.contains("wildcards = \"deny\"\n"),
"a key the file holds keeps the file's layout: {out}"
);
assert!(
out.contains("deny = [\n { name = \"a\" },\n { name = \"b\" },\n]"),
"and a new value is laid out as the payload lays it out: {out}"
);
let out = splice(&shape, "[graph]\nall-features = true\n", payload);
assert!(
out.contains("[bans]\nmultiple-versions = \"deny\"\nwildcards = \"deny\"\n"),
"keys of a new table are aligned as the payload aligns them: {out}"
);
}
#[test]
fn toml_keys_take_the_alignment_of_the_table_they_join() {
let shape = keys("Cargo.toml");
let out = splice(
&shape,
"[package]\nname = \"x\"\nversion = \"1\"\n\n[lints]\nworkspace = true\n",
"[package]\ndescription = \"d\"\n",
);
assert!(
out.starts_with(
"[package]\nname = \"x\"\nversion = \"1\"\ndescription = \"d\"\n"
),
"a key joining an aligned group realigns it, as taplo would: {out}"
);
let out = splice(&shape, "[a]\nx = 1\nlong = 2\n", "[a]\nmedium = 3\nz = 4\n");
assert_eq!(
out, "[a]\nx = 1\nlong = 2\nmedium = 3\nz = 4\n",
"a file that spaces its keys by one keeps doing so, whatever the payload does"
);
let out = splice(&shape, "[a]\nx = 1\n", "[a]\nlonger = 2\nz = 3\n");
assert_eq!(
out, "[a]\nx = 1\nlonger = 2\nz = 3\n",
"a file no group of which says takes the payload's alignment"
);
}
#[test]
fn toml_keys_realign_the_comments_of_their_group() {
let shape = keys("Cargo.toml");
let out = splice(
&shape,
"[a]\nx = 1 # one\nlong_key = \"abc\" # two\n",
"[a]\nlongest_key = 3\n",
);
assert_eq!(
out, "[a]\nx = 1 # one\nlong_key = \"abc\" # two\nlongest_key = 3\n",
"comments line up after the longest line: {out}"
);
let file = "[a]\nk = [\n \"a\",\n \"b\",\n]\nlonger = 2\n\n[b]\nx = 1\nlong = 2\n";
let out = splice(&shape, file, "[a]\nz = 3\n");
assert!(
out.starts_with("[a]\nk = [\n \"a\",\n \"b\",\n]\nlonger = 2\nz = 3\n"),
"a group with a value over several lines is aligned by no one: {out}"
);
}
#[test]
fn toml_tables_are_created_and_pruned() {
let shape = keys("Cargo.toml");
let out = splice(
&shape,
"[package]\nname = \"x\"\n",
"[workspace.lints.rust]\nunsafe_code = \"deny\"\n",
);
assert!(out.contains("[workspace.lints.rust]\nunsafe_code = \"deny\""), "created: {out}");
let (content, written) = shape
.read(
b"[workspace.lints.rust]
unsafe_code = \"deny\"
",
"p",
)
.expect("valid");
let owned = shape.keys(&content);
let emptied = shape.splice(out.as_bytes(), b"", &owned, &written, "file").expect("splices");
let emptied = String::from_utf8(emptied).expect("UTF-8");
assert_eq!(emptied.trim(), "[package]\nname = \"x\"", "a table the part leaves empty goes");
}
#[test]
fn toml_a_table_taken_from_the_top_takes_the_blank_line_below_it() {
let shape = keys("mise.lock");
let file = "[[tools.book]]\nversion = \"1\"\n\n[[tools.other]]\nversion = \"2\"\n";
let (content, _) = shape.read(b"[[tools.book]]\nversion = \"1\"\n", "p").expect("valid");
let owned = shape.keys(&content);
let out = shape.remove(file.as_bytes(), &owned, "file").expect("removes");
let out = String::from_utf8(out).expect("UTF-8");
assert_eq!(
out, "[[tools.other]]\nversion = \"2\"\n",
"the file starts where it did: {out:?}"
);
}
#[test]
fn toml_arrays_of_objects_are_arrays_of_tables() {
let shape = keys(".config/mise/mise.lock");
let file = "[[tools.dprint]]\nversion = \"0.57.4\"\n\n[tools.dprint.\"platforms.linux-x64\"]\nchecksum = \"sha256:a\"\n";
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";
let out = splice(&shape, file, payload);
assert!(out.starts_with(file), "the entry already there is untouched: {out}");
assert!(out.contains("[[tools.taplo]]\nversion = \"0.10.0\""), "an array of tables: {out}");
assert!(
out.contains("[tools.taplo.\"platforms.linux-x64\"]\nurl = "),
"with its sub-tables as tables: {out}"
);
}
#[test]
fn toml_arrays_of_tables_keep_the_payload_layout() {
let shape = keys("taplo.toml");
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";
let payload = format!("[formatting]\nalign_entries = true\n\n{rules}");
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";
let out = splice(&shape, file, &payload);
assert!(
out.ends_with(&format!("align_entries = true\n\n{rules}")),
"an array of tables the file holds is rewritten as the payload writes it: {out}"
);
assert!(out.starts_with("exclude = [\"target/**\"]\n"), "and the file's own keys stay");
let out = splice(&shape, "[formatting]\nalign_entries = true\n", &payload);
assert!(out.ends_with(rules), "as is one the file lacks: {out}");
}
#[test]
fn toml_arrays_keep_the_form_the_payload_writes() {
let shape = keys("deny.toml");
let out =
splice(&shape, "[bans]\nwildcards = \"deny\"\n", "[bans]\ndeny = [{ name = \"a\" }]\n");
assert!(
out.contains("deny = [{ name = \"a\" }]"),
"written inline, as the payload is: {out}"
);
}
#[test]
fn json_keys_keep_comments_and_commas() {
let shape = keys(".vscode/settings.json");
assert_eq!(shape, Shape::Keys(Syntax::Json), "JSON by extension");
let file = "{\n // ours\n \"rust-analyzer.cargo.target\": \"aarch64\",\n \"editor.formatOnSave\": false,\n}\n";
let out = splice(
&shape,
file,
"{ \"editor.formatOnSave\": true, \"[toml]\": { \"editor.tabSize\": 2 } }",
);
assert!(out.contains("// ours"), "comments survive: {out}");
assert!(
out.contains("\"rust-analyzer.cargo.target\": \"aarch64\""),
"the target's keys stay"
);
assert!(out.contains("\"editor.formatOnSave\": true"), "an owned key is set");
assert!(out.contains("\"editor.tabSize\": 2"), "a nested key is added");
}
#[test]
fn json_new_keys_keep_the_payload_layout() {
let shape = keys("dprint.json");
let payload = "{\n \"includes\": [\"**/*.md\"],\n \"markdown\": { \"lineWidth\": 80, \"textWrap\": \"always\" }\n}\n";
let out = splice(
&shape,
"{\n \"lineWidth\": 100,\n \"includes\": [\"**/*.json\"]\n}\n",
payload,
);
assert!(
out.contains("\n \"markdown\": { \"lineWidth\": 80, \"textWrap\": \"always\" }\n"),
"a key the file lacks is written whole, as the payload writes it: {out}"
);
assert!(
out.contains("\n \"includes\": [\"**/*.md\"],\n"),
"and a value the file holds is replaced as the payload writes it: {out}"
);
let out = splice(&shape, "{\n \"markdown\": { \"ours\": true }\n}\n", payload);
assert!(
out.contains("\"ours\": true") && out.contains("\"textWrap\": \"always\""),
"an object the file holds keeps its own keys beside the payload's: {out}"
);
let plugins = "{\n \"plugins\": [\n \"a\",\n \"b\"\n ]\n}\n";
let out = splice(&shape, "{\n \"lineWidth\": 100\n}\n", plugins);
assert!(
out.contains("\n \"plugins\": [\n \"a\",\n \"b\"\n ]\n"),
"a value on several lines moves from the payload's indent to the file's: {out}"
);
}
#[test]
fn json_a_key_that_joins_an_object_on_one_line_keeps_it_there() {
let shape = keys(".github/automation.json");
let file = "{\n \"bump\": { \"mode\": \"pr\" },\n \"nightly\": { \"skip\": true }\n}\n";
let out = splice(&shape, file, "{ \"bump\": { \"merge\": true } }");
assert!(
out.contains("\n \"bump\": { \"mode\": \"pr\", \"merge\": true },\n"),
"the object stays on its line, the key after its own: {out}"
);
assert!(out.contains("\"nightly\": { \"skip\": true }"), "the rest is as it was: {out}");
let nested = "{\n \"a\": { \"b\": { \"c\": 1 } }\n}\n";
let out = splice(&shape, nested, "{ \"a\": { \"b\": { \"d\": 2 } } }");
assert!(
out.contains("\"a\": { \"b\": { \"c\": 1, \"d\": 2 } }"),
"and so does each object around it: {out}"
);
let compact = "{\n \"a\": {\"c\": 1}\n}\n";
let out = splice(&shape, compact, "{ \"a\": { \"d\": 2 } }");
assert!(out.contains("\"a\": {\"c\": 1, \"d\": 2}"), "unpadded where it was: {out}");
let commented = "{\n \"a\": { \"c\": 1 /* ours */ }\n}\n";
let out = splice(&shape, commented, "{ \"a\": { \"d\": 2 } }");
assert!(
out.contains("/* ours */") && out.contains("\"d\": 2"),
"a comment is never folded into a line it may end: {out}"
);
}
#[test]
fn yaml_keys_keep_comments() {
let shape = keys(".github/dependabot.yml");
let file =
"# ours\nversion: 2\nupdates:\n - package-ecosystem: cargo\n directory: \"/\"\n";
let out =
splice(&shape, file, "version: 2\nregistries:\n crates:\n type: cargo-registry\n");
assert!(out.contains("# ours"), "comments survive: {out}");
assert!(out.contains("package-ecosystem: cargo"), "the target's keys stay");
assert!(out.contains("type: cargo-registry"), "a nested key is added");
}
#[test]
fn yaml_new_keys_keep_the_payload_layout() {
let shape = keys(".yamllint.yaml");
let payload = "extends: default\nignore-from-file: [.gitignore]\nrules:\n truthy:\n allowed-values: [\"true\", \"false\"]\n";
let out = splice(&shape, "extends: default\n", payload);
assert!(
out.contains("ignore-from-file: [.gitignore]\n"),
"a new sequence is written as the payload writes it: {out}"
);
assert!(
out.contains("rules:\n truthy:\n allowed-values: [\"true\", \"false\"]\n"),
"and a new value under mappings made for it, quotes and all: {out}"
);
let shape = keys(".github/dependabot.yml");
let updates = "updates:\n - package-ecosystem: cargo\n directory: \"/\"\n";
let out = splice(&shape, "version: 2\n", updates);
assert!(out.ends_with(updates), "a sequence of mappings keeps its keys' order: {out}");
}
#[test]
fn views_compare_values_not_layout() {
let shape = keys("a.toml");
let owned = shape.keys(&shape.read(b"a = 1\nb = \"x\"\n", "p").expect("valid").0);
let one = shape.view(b"a = 1\nb = \"x\"\n", &owned, "f").expect("reads");
let two = shape.view(b"b = 'x' # hi\na=1\nother = 2\n", &owned, "f").expect("reads");
assert_eq!(one, two, "reformatting and other keys are not part of the view");
assert_eq!(shape.view(b"other = 1\n", &owned, "f").expect("reads"), None, "absent part");
}
}