Skip to main content

devset_core/part/
mod.rs

1//! Parts of a file: the keys, or the marked block, one profile owns in a file the target otherwise
2//! owns.
3//!
4//! A part is read out of its file as *content*, bytes the rest of devset treats as a file's: for
5//! keys, one line per owned leaf with its value as canonical JSON, so layout is never drift; for a
6//! block, its lines as written.
7
8mod 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/// Where a managed thing lives: a whole file, or one profile's part of it.
33#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub(crate) struct Slot {
35    /// The file.
36    pub path: RelPath,
37    /// The profile whose part it is; `None` for the whole file.
38    pub part: Option<String>,
39}
40
41impl Slot {
42    /// The whole of `path`.
43    pub(crate) const fn whole(path: RelPath) -> Self {
44        Self { path, part: None }
45    }
46}
47
48/// How much of a file a profile owns.
49#[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    /// The whole file.
68    #[default]
69    File,
70    /// Every leaf the payload, a partial document, defines; the file's other keys are the target's.
71    Keys,
72    /// One comment-marked block, named after the profile; the file's other lines are the target's.
73    Block,
74}
75
76/// A structured format whose keys a profile can own.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub(crate) enum Syntax {
79    /// TOML, through `toml_edit`.
80    Toml,
81    /// JSON and JSONC, through `jsonc-parser`.
82    Json,
83    /// YAML, through `yaml-edit`; experimental.
84    Yaml,
85}
86
87/// Why a document could not be read or written, and where.
88type Failure = (Option<Range<usize>>, String);
89
90/// A document's reading or writing: done, or the failure.
91type Parsed<T> = Result<T, Failure>;
92
93/// One key to set to a value, or to remove.
94type Edit<'a> = (&'a Key, Option<&'a Value>);
95
96impl Syntax {
97    /// The syntax of `format`, if it is a structured one.
98    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    /// The document's values, which must be a table; an empty document is an empty one.
108    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    /// The leaves a partial document defines, in its order.
117    fn leaves(self, text: &str) -> Parsed<Written> {
118        match self {
119            // TOML's syntax says more than its values: which arrays are arrays of tables.
120            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    /// A document's values at `keys`; a key it lacks is left out.
130    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    /// A document with each edit made, in order, as `payload` writes each: a value the payload
139    /// gives keeps the payload's layout, and TOML its arrays of tables.
140    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/// How one profile's part of a file is found, read and written.
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub(crate) enum Shape {
152    /// The leaves of a partial document.
153    Keys(Syntax),
154    /// The lines between two markers.
155    Block(Markers),
156}
157
158impl Shape {
159    /// The shape of `owner`'s part of `path` under `scope`, read as `format`; `None` for the
160    /// whole file.
161    ///
162    /// # Errors
163    /// - [`ProfileError::NoKeys`], `format` is not TOML, JSON or YAML.
164    /// - [`ProfileError::NoComment`], devset knows no comment syntax for `path` and none is given.
165    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    /// What `payload` gives the part: its content, a partial document's leaves or a block's
183    /// lines; and for keys, the leaves as the payload writes them.
184    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    /// The keys `content` owns; none for a block.
197    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    /// What `file` holds of the part over `keys`, as content; `None` when none of it is there.
205    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                // A part of no keys is all there whenever its file is.
213                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    /// `file` with the part holding `content` over `keys`; a key `content` lacks is removed.
225    ///
226    /// Only what changes is rewritten, so the rest keeps its layout; a new key goes where
227    /// `payload`, the profile's leaves as written, puts it, and a value the profile wrote is
228    /// written as it wrote it.
229    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    /// `file` without the part: its keys over `keys`, or its block, markers included.
272    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
290/// `bytes` as text, a leading BOM set aside, and whether there was one.
291fn 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
303/// `failure` in `text` of `label`, as a parse error that points at it.
304fn 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    /// The keys shape of `path`.
317    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    /// Splices `payload`'s keys into `file` as `shape` does, returning the file.
326    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 json_new_keys_keep_the_payload_layout() {
450        let shape = keys("dprint.json");
451        let payload = "{\n  \"includes\": [\"**/*.md\"],\n  \"markdown\": { \"lineWidth\": 80, \"textWrap\": \"always\" }\n}\n";
452        let out = splice(
453            &shape,
454            "{\n  \"lineWidth\": 100,\n  \"includes\": [\"**/*.json\"]\n}\n",
455            payload,
456        );
457        assert!(
458            out.contains("\n  \"markdown\": { \"lineWidth\": 80, \"textWrap\": \"always\" }\n"),
459            "a key the file lacks is written whole, as the payload writes it: {out}"
460        );
461        assert!(
462            out.contains("\n  \"includes\": [\"**/*.md\"],\n"),
463            "and a value the file holds is replaced as the payload writes it: {out}"
464        );
465        let out = splice(&shape, "{\n  \"markdown\": { \"ours\": true }\n}\n", payload);
466        assert!(
467            out.contains("\"ours\": true") && out.contains("\"textWrap\": \"always\""),
468            "an object the file holds keeps its own keys beside the payload's: {out}"
469        );
470        let plugins = "{\n    \"plugins\": [\n        \"a\",\n        \"b\"\n    ]\n}\n";
471        let out = splice(&shape, "{\n  \"lineWidth\": 100\n}\n", plugins);
472        assert!(
473            out.contains("\n  \"plugins\": [\n      \"a\",\n      \"b\"\n  ]\n"),
474            "a value on several lines moves from the payload's indent to the file's: {out}"
475        );
476    }
477
478    #[test]
479    fn yaml_keys_keep_comments() {
480        let shape = keys(".github/dependabot.yml");
481        let file =
482            "# ours\nversion: 2\nupdates:\n  - package-ecosystem: cargo\n    directory: \"/\"\n";
483        let out =
484            splice(&shape, file, "version: 2\nregistries:\n  crates:\n    type: cargo-registry\n");
485        assert!(out.contains("# ours"), "comments survive: {out}");
486        assert!(out.contains("package-ecosystem: cargo"), "the target's keys stay");
487        assert!(out.contains("type: cargo-registry"), "a nested key is added");
488    }
489
490    #[test]
491    fn yaml_new_keys_keep_the_payload_layout() {
492        let shape = keys(".yamllint.yaml");
493        let payload = "extends: default\nignore-from-file: [.gitignore]\nrules:\n  truthy:\n    allowed-values: [\"true\", \"false\"]\n";
494        let out = splice(&shape, "extends: default\n", payload);
495        assert!(
496            out.contains("ignore-from-file: [.gitignore]\n"),
497            "a new sequence is written as the payload writes it: {out}"
498        );
499        assert!(
500            out.contains("rules:\n  truthy:\n    allowed-values: [\"true\", \"false\"]\n"),
501            "and a new value under mappings made for it, quotes and all: {out}"
502        );
503        let shape = keys(".github/dependabot.yml");
504        let updates = "updates:\n  - package-ecosystem: cargo\n    directory: \"/\"\n";
505        let out = splice(&shape, "version: 2\n", updates);
506        assert!(out.ends_with(updates), "a sequence of mappings keeps its keys' order: {out}");
507    }
508
509    #[test]
510    fn views_compare_values_not_layout() {
511        let shape = keys("a.toml");
512        let owned = shape.keys(&shape.read(b"a = 1\nb = \"x\"\n", "p").expect("valid").0);
513        let one = shape.view(b"a = 1\nb = \"x\"\n", &owned, "f").expect("reads");
514        let two = shape.view(b"b    =   'x'   # hi\na=1\nother = 2\n", &owned, "f").expect("reads");
515        assert_eq!(one, two, "reformatting and other keys are not part of the view");
516        assert_eq!(shape.view(b"other = 1\n", &owned, "f").expect("reads"), None, "absent part");
517    }
518}