Skip to main content

vivacity_resolver/
config_source.rs

1//! Port of `Composer\Config\JsonConfigSource` for composer.json (not
2//! auth.json): every edit goes through `JsonManipulator` on the file text;
3//! if the manipulator gives up (`false`), Composer re-reads the file,
4//! applies the same modification to the decoded array and rewrites
5//! everything with `JsonFile::write` (detected indentation, empty arrays
6//! rendered as `{}` for keys that are objects in the schema).
7//!
8//! Not ported: the `LAX_SCHEMA` validation after each write. Composer
9//! restores the file and fails if the result violates the schema, which
10//! amounts to refusing to edit an already invalid manifest (uppercase
11//! `"name"`, non-string constraint...); here the edit goes through. A valid
12//! manifest gives the same result on both sides.
13
14use serde_json::{Map, Value};
15use std::path::{Path, PathBuf};
16use vivacity_core::phpjson::{empty_stdclass, php_json_encode_with, FLAGS_JSONFILE};
17
18use crate::json_manipulator::{detect_indenting, JsonManipulator, ManipulatorError};
19
20#[derive(Debug, thiserror::Error)]
21pub enum ConfigSourceError {
22    #[error("{0}")]
23    Manipulator(#[from] ManipulatorError),
24    #[error("cannot read {path}: {source}")]
25    Read {
26        path: PathBuf,
27        source: std::io::Error,
28    },
29    #[error("cannot write {path}: {source}")]
30    Write {
31        path: PathBuf,
32        source: std::io::Error,
33    },
34    #[error("{0}")]
35    Encode(String),
36}
37
38type Result<T> = std::result::Result<T, ConfigSourceError>;
39
40/// A project's composer.json, edited in place.
41pub struct JsonConfigSource {
42    path: PathBuf,
43}
44
45/// Edit to attempt with the manipulator, and its equivalent on the decoded
46/// array for the fallback.
47enum Edit<'a> {
48    RemoveSubNode(&'a str, &'a str),
49    RemoveMainKeyIfEmpty(&'a str),
50    RemoveConfigSetting(&'a str),
51    AddLink(&'a str, &'a str, &'a str, bool),
52}
53
54impl JsonConfigSource {
55    pub fn new(path: impl Into<PathBuf>) -> Self {
56        Self { path: path.into() }
57    }
58
59    /// `removeLink`: removes `$name` from the section, then the section if
60    /// it is empty.
61    pub fn remove_link(&self, link_type: &str, name: &str) -> Result<()> {
62        self.manipulate(Edit::RemoveSubNode(link_type, name))?;
63        self.manipulate(Edit::RemoveMainKeyIfEmpty(link_type))
64    }
65
66    /// `addLink` (sorting is decided by the caller, as RequireCommand reads
67    /// `config.sort-packages`).
68    pub fn add_link(
69        &self,
70        link_type: &str,
71        name: &str,
72        constraint: &str,
73        sort: bool,
74    ) -> Result<()> {
75        self.manipulate(Edit::AddLink(link_type, name, constraint, sort))
76    }
77
78    /// `removeConfigSetting` for a `config` key (`allow-plugins`,
79    /// `allow-plugins.vendor/name`).
80    pub fn remove_config_setting(&self, name: &str) -> Result<()> {
81        self.manipulate(Edit::RemoveConfigSetting(name))
82    }
83
84    /// `manipulateJson`: manipulator first, fallback to the full rewrite.
85    fn manipulate(&self, edit: Edit<'_>) -> Result<()> {
86        // `$this->file->exists()`: otherwise Composer starts from a skeleton.
87        let contents = match std::fs::read_to_string(&self.path) {
88            Ok(s) => s,
89            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
90                "{\n    \"config\": {\n    }\n}\n".to_owned()
91            }
92            Err(source) => {
93                return Err(ConfigSourceError::Read {
94                    path: self.path.clone(),
95                    source,
96                })
97            }
98        };
99        let mut manipulator = JsonManipulator::new(&contents)?;
100        let done = match &edit {
101            Edit::RemoveSubNode(main, name) => manipulator.remove_sub_node(main, name)?,
102            Edit::RemoveMainKeyIfEmpty(key) => manipulator.remove_main_key_if_empty(key)?,
103            Edit::RemoveConfigSetting(name) => manipulator.remove_config_setting(name)?,
104            Edit::AddLink(t, n, c, sort) => manipulator.add_link(t, n, c, *sort)?,
105        };
106        if done {
107            return self.write_text(&manipulator.contents());
108        }
109        // Fallback: `$this->file->read()` then the modification on the array.
110        let mut config: Value = serde_json::from_str(&contents).map_err(|e| {
111            ConfigSourceError::Encode(format!("cannot decode {}: {e}", self.path.display()))
112        })?;
113        let indent = detect_indenting(&contents)?;
114        if let Value::Object(root) = &mut config {
115            match edit {
116                Edit::RemoveSubNode(main, name) => {
117                    if let Some(Value::Object(section)) = root.get_mut(main) {
118                        section.shift_remove(name);
119                    }
120                }
121                Edit::RemoveMainKeyIfEmpty(key) => {
122                    // `0 === count($config[$type])`: an empty array (or an
123                    // absent one; count(null) is a TypeError in PHP 8, which
124                    // the manipulator already ruled out by returning `true`).
125                    let empty = match root.get(key) {
126                        Some(Value::Object(m)) => m.is_empty(),
127                        Some(Value::Array(a)) => a.is_empty(),
128                        _ => false,
129                    };
130                    if empty {
131                        root.shift_remove(key);
132                    }
133                }
134                Edit::RemoveConfigSetting(name) => {
135                    // Neither auth nor `policy.`: `unset($config['config'][$key])`
136                    // with the key as is (so `allow-plugins.x` removes
137                    // nothing).
138                    if let Some(Value::Object(cfg)) = root.get_mut("config") {
139                        cfg.shift_remove(name);
140                    }
141                }
142                Edit::AddLink(t, n, c, _) => {
143                    // `$config[$type][$name] = $value`: the section becomes
144                    // an array if it is not one.
145                    let replacement = match root.get(t) {
146                        Some(Value::Object(_)) => None,
147                        // A list is an array with integer keys.
148                        Some(Value::Array(a)) => Some(Value::Object(
149                            a.iter()
150                                .enumerate()
151                                .map(|(i, v)| (i.to_string(), v.clone()))
152                                .collect(),
153                        )),
154                        Some(Value::Null) | None => Some(Value::Object(Map::new())),
155                        Some(other) => {
156                            return Err(ConfigSourceError::Encode(format!(
157                                "Cannot use a scalar value as an array ({t}: {other})"
158                            )))
159                        }
160                    };
161                    if let Some(r) = replacement {
162                        root.insert(t.to_owned(), r);
163                    }
164                    if let Some(Value::Object(section)) = root.get_mut(t) {
165                        section.insert(n.to_owned(), Value::String(c.to_owned()));
166                    }
167                }
168            }
169            fixup_empty_objects(root);
170        }
171        let mut text = php_json_encode_with(&config, FLAGS_JSONFILE)
172            .map_err(|e| ConfigSourceError::Encode(e.to_string()))?;
173        if indent != "    " {
174            text = reindent(&text, &indent);
175        }
176        text.push('\n');
177        // `filePutContentsIfModified`.
178        if std::fs::read_to_string(&self.path).is_ok_and(|old| old == text) {
179            return Ok(());
180        }
181        self.write_text(&text)
182    }
183
184    fn write_text(&self, text: &str) -> Result<()> {
185        std::fs::write(&self.path, text).map_err(|source| ConfigSourceError::Write {
186            path: self.path.clone(),
187            source,
188        })
189    }
190}
191
192/// The empty arrays `JsonFile::write` must render as `{}` (objects in the
193/// schema): relevant `config.*`, `autoload.psr-*`, root sections.
194fn fixup_empty_objects(root: &mut Map<String, Value>) {
195    let is_empty_array = |v: &Value| match v {
196        Value::Object(m) => m.is_empty(),
197        Value::Array(a) => a.is_empty(),
198        _ => false,
199    };
200    if let Some(Value::Object(cfg)) = root.get_mut("config") {
201        if let Some(Value::Object(policy)) = cfg.get_mut("policy") {
202            for (_, v) in policy.iter_mut() {
203                if is_empty_array(v) {
204                    *v = empty_stdclass();
205                }
206            }
207            if policy.is_empty() {
208                cfg.insert("policy".into(), empty_stdclass());
209            }
210        }
211        for prop in [
212            "platform",
213            "http-basic",
214            "bearer",
215            "gitlab-token",
216            "gitlab-oauth",
217            "github-oauth",
218            "custom-headers",
219            "forgejo-token",
220            "preferred-install",
221        ] {
222            if cfg.get(prop).is_some_and(is_empty_array) {
223                cfg.insert(prop.into(), empty_stdclass());
224            }
225        }
226    }
227    for section in ["autoload", "autoload-dev"] {
228        if let Some(Value::Object(a)) = root.get_mut(section) {
229            for prop in ["psr-0", "psr-4"] {
230                if a.get(prop).is_some_and(is_empty_array) {
231                    a.insert(prop.into(), empty_stdclass());
232                }
233            }
234        }
235    }
236    for prop in [
237        "require",
238        "require-dev",
239        "conflict",
240        "provide",
241        "replace",
242        "suggest",
243        "config",
244        "autoload",
245        "autoload-dev",
246        "scripts",
247        "scripts-descriptions",
248        "scripts-aliases",
249        "support",
250    ] {
251        if root.get(prop).is_some_and(is_empty_array) {
252            root.insert(prop.into(), empty_stdclass());
253        }
254    }
255}
256
257/// `JsonFile::encode` with an indentation other than 4 spaces: every line
258/// start of 4n spaces becomes n indents.
259pub fn reindent(text: &str, indent: &str) -> String {
260    let mut out = String::with_capacity(text.len());
261    for (i, line) in text.split('\n').enumerate() {
262        if i > 0 {
263            out.push('\n');
264        }
265        let spaces = line.len() - line.trim_start_matches(' ').len();
266        if spaces >= 4 {
267            // `#^ {4,}#m` -> `str_repeat($indent, (int) (strlen / 4))`: the
268            // remainder of the division is dropped.
269            out.push_str(&indent.repeat(spaces / 4));
270            out.push_str(&line[spaces..]);
271        } else {
272            out.push_str(line);
273        }
274    }
275    out
276}
277
278/// Path of a project's manifest (`Factory::getComposerFile`).
279pub fn composer_file(project: &Path) -> PathBuf {
280    match std::env::var("COMPOSER") {
281        Ok(f) if !f.trim().is_empty() => project.join(f.trim()),
282        _ => project.join("composer.json"),
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn reindent_replaces_leading_groups_of_four() {
292        assert_eq!(
293            reindent("{\n    \"a\": {\n        \"b\": 1\n    }\n}", "\t"),
294            "{\n\t\"a\": {\n\t\t\"b\": 1\n\t}\n}"
295        );
296        assert_eq!(reindent("{\n     \"a\": 1\n}", "  "), "{\n  \"a\": 1\n}");
297    }
298
299    #[test]
300    fn remove_link_edits_in_place_and_drops_empty_section() {
301        let dir = tempfile::tempdir().expect("tmp");
302        let path = dir.path().join("composer.json");
303        std::fs::write(
304            &path,
305            "{\n  \"name\": \"a/b\",\n  \"require\": {\n    \"c/d\": \"^1\"\n  }\n}\n",
306        )
307        .expect("write");
308        let src = JsonConfigSource::new(&path);
309        src.remove_link("require", "c/d").expect("remove");
310        assert_eq!(
311            std::fs::read_to_string(&path).expect("read"),
312            "{\n  \"name\": \"a/b\"\n}\n"
313        );
314    }
315}