Skip to main content

vivacity_resolver/
json_manipulator.rs

1//! Port of `Composer\Json\JsonManipulator` (docs/reference/JsonManipulator.php)
2//! for the scope reached by `require` and `remove`: textual editing of
3//! composer.json through regular expressions, so as to touch only the
4//! modified part and preserve the file's formatting.
5//!
6//! The patterns are Composer's, compiled by pcre2 with the same flags
7//! (`s`, `x`, `i`, never `u`), over bytes. Manipulated values are
8//! `serde_json::Value`s following the `vivacity_core::phpjson` convention: an
9//! object reduced to `STDCLASS_MARKER` is an `ArrayObject` (formatted
10//! `{...}` even when empty), an empty object is an empty PHP array
11//! (formatted `[]`).
12//!
13//! Not ported: `repositories`, lists (`addListItem`...), `addProperty`, the
14//! `policy.*` branch of `removeConfigSetting`.
15//!
16//! Known deviations from PHP, all on manifests `json_decode` rejects or
17//! only half accepts: the `pcre.backtrack_limit` threshold (pcre2 does not
18//! expose `match_limit`; the limit error does follow Composer's `catch`
19//! path, the threshold differs); an out-of-range float (`1e999`, `INF` on
20//! the PHP side, rejected here); a lone UTF-16 surrogate or more than 512
21//! nesting levels (PHP carries on with `null` and adds a duplicate root
22//! key, here an error).
23
24use pcre2::bytes::{Captures, Regex, RegexBuilder};
25use serde::Deserialize as _;
26use serde_json::{Map, Value};
27use vivacity_core::phpjson::{php_json_encode_with, STDCLASS_MARKER};
28
29use crate::platform::is_platform_package;
30use crate::version::preg_quote;
31
32/// `PCRE2_ERROR_MATCHLIMIT` (pcre2.h), the equivalent of
33/// `PREG_BACKTRACK_LIMIT_ERROR` on the PHP side.
34const PCRE2_ERROR_MATCHLIMIT: i32 = -47;
35
36/// `JsonFile::INDENT_DEFAULT`.
37const INDENT_DEFAULT: &str = "    ";
38
39/// `JsonManipulator::DEFINES`: the JSON grammar as named subpatterns.
40const DEFINES: &str = r#"(?(DEFINE)
41       (?<number>    -? (?= [1-9]|0(?!\d) ) \d++ (?:\.\d++)? (?:[eE] [+-]?+ \d++)? )
42       (?<boolean>   true | false | null )
43       (?<string>    " (?:[^"\\]*+ | \\ ["\\bfnrt\/] | \\ u [0-9A-Fa-f]{4} )* " )
44       (?<array>     \[  (?:  (?&json) \s*+ (?: , (?&json) \s*+ )*+  )?+  \s*+ \] )
45       (?<pair>      \s*+ (?&string) \s*+ : (?&json) \s*+ )
46       (?<object>    \{  (?:  (?&pair)  (?: , (?&pair)  )*+  )?+  \s*+ \} )
47       (?<json>      \s*+ (?: (?&number) | (?&boolean) | (?&string) | (?&array) | (?&object) ) )
48    )"#;
49
50#[derive(Debug, thiserror::Error)]
51pub enum ManipulatorError {
52    /// `The json file must be an object ({})`.
53    #[error("The json file must be an object ({{}})")]
54    NotAnObject,
55    /// `JsonFile::parseJson` failed (ParsingException on the Composer side).
56    #[error("The input does not contain valid JSON\n{0}")]
57    Parse(String),
58    /// A regular expression failed where Composer would let the exception
59    /// propagate.
60    #[error("regex: {0}")]
61    Regex(String),
62    /// Path Composer ends with an exception (`LogicException`,
63    /// `InvalidArgumentException`, `TypeError`).
64    #[error("{0}")]
65    Logic(String),
66    /// Method or branch outside the ported scope.
67    #[error("unsupported: {0}")]
68    Unsupported(String),
69}
70
71type Result<T> = std::result::Result<T, ManipulatorError>;
72
73/// PCRE flags of a pattern (the letters after the delimiter in PHP).
74#[derive(Clone, Copy, Default)]
75struct Flags {
76    s: bool,
77    x: bool,
78    i: bool,
79    m: bool,
80}
81
82const SX: Flags = Flags {
83    s: true,
84    x: true,
85    i: false,
86    m: false,
87};
88const X: Flags = Flags {
89    s: false,
90    x: true,
91    i: false,
92    m: false,
93};
94const IX: Flags = Flags {
95    s: false,
96    x: true,
97    i: true,
98    m: false,
99};
100const I: Flags = Flags {
101    s: false,
102    x: false,
103    i: true,
104    m: false,
105};
106const S: Flags = Flags {
107    s: true,
108    x: false,
109    i: false,
110    m: false,
111};
112const NONE: Flags = Flags {
113    s: false,
114    x: false,
115    i: false,
116    m: false,
117};
118
119fn compile(pattern: &str, flags: Flags) -> Result<Regex> {
120    RegexBuilder::new()
121        .dotall(flags.s)
122        .extended(flags.x)
123        .caseless(flags.i)
124        .multi_line(flags.m)
125        .build(pattern)
126        .map_err(|e| ManipulatorError::Regex(format!("{e} in `{pattern}`")))
127}
128
129/// `Preg::isMatch`: the captures, or `None` if nothing matches.
130fn captures<'s>(re: &Regex, subject: &'s str) -> Result<Option<Captures<'s>>> {
131    re.captures(subject.as_bytes())
132        .map_err(|e| ManipulatorError::Regex(e.to_string()))
133}
134
135/// `Preg::isMatch` inside a `catch` of `PREG_BACKTRACK_LIMIT_ERROR`: the
136/// pcre2 match limit (`PCRE2_ERROR_MATCHLIMIT`) follows this path
137/// (`false`), any other error propagates.
138fn captures_or_limit<'s>(re: &Regex, subject: &'s str) -> Result<Option<Captures<'s>>> {
139    match re.captures(subject.as_bytes()) {
140        Ok(c) => Ok(c),
141        Err(e) if e.code() == PCRE2_ERROR_MATCHLIMIT => Ok(None),
142        Err(e) => Err(ManipulatorError::Regex(e.to_string())),
143    }
144}
145
146/// Named group: `None` if it did not participate (`PREG_UNMATCHED_AS_NULL`).
147fn named<'s>(caps: &Captures<'s>, name: &str) -> Option<&'s str> {
148    caps.name(name)
149        .and_then(|m| std::str::from_utf8(m.as_bytes()).ok())
150}
151
152fn group<'s>(caps: &Captures<'s>, i: usize) -> Option<&'s str> {
153    caps.get(i)
154        .and_then(|m| std::str::from_utf8(m.as_bytes()).ok())
155}
156
157/// `Preg::replaceCallback` on all occurrences.
158fn replace_all(
159    re: &Regex,
160    subject: &str,
161    mut f: impl FnMut(&Captures<'_>) -> Result<String>,
162) -> Result<(String, usize)> {
163    let mut out = String::with_capacity(subject.len());
164    let mut last = 0;
165    let mut count = 0;
166    for caps in re.captures_iter(subject.as_bytes()) {
167        let caps = caps.map_err(|e| ManipulatorError::Regex(e.to_string()))?;
168        let m = caps
169            .get(0)
170            .ok_or_else(|| ManipulatorError::Regex("no group 0".into()))?;
171        out.push_str(&subject[last..m.start()]);
172        out.push_str(&f(&caps)?);
173        last = m.end();
174        count += 1;
175    }
176    out.push_str(&subject[last..]);
177    Ok((out, count))
178}
179
180/// `Preg::replace` with a literal replacement string (Composer runs its
181/// replacements through `addcslashes(..., '\\$')`, which amounts to
182/// inserting them verbatim).
183fn replace_literal(re: &Regex, subject: &str, replacement: &str) -> Result<(String, usize)> {
184    replace_all(re, subject, |_| Ok(replacement.to_owned()))
185}
186
187/// `JsonFile::encode` of a scalar or a key (`JSON_UNESCAPED_SLASHES |
188/// JSON_UNESCAPED_UNICODE`; `JSON_PRETTY_PRINT` has no effect here).
189fn encode(value: &Value) -> Result<String> {
190    php_json_encode_with(value, vivacity_core::phpjson::FLAGS_JSONFILE)
191        .map_err(|e| ManipulatorError::Logic(format!("JSON encoding failed: {e}")))
192}
193
194fn encode_str(s: &str) -> Result<String> {
195    encode(&Value::String(s.to_owned()))
196}
197
198/// Maximum depth of `json_decode` (its default `$depth` parameter).
199const PHP_JSON_DEPTH: usize = 512;
200
201/// `json_decode`: serde with two deviations corrected: the recursion limit
202/// (128 in serde, 512 in PHP, hence a preliminary pass measuring nesting
203/// outside strings) and the `-0` literal, integer `0` for PHP but float
204/// `-0.0` for serde (rewritten before parsing).
205fn json_decode(s: &str) -> std::result::Result<Value, String> {
206    let (normalized, depth) = scan_json(s);
207    if depth > PHP_JSON_DEPTH {
208        return Err("Maximum stack depth exceeded".into());
209    }
210    let mut de = serde_json::Deserializer::from_str(&normalized);
211    de.disable_recursion_limit();
212    let v = Value::deserialize(&mut de).map_err(|e| e.to_string())?;
213    de.end().map_err(|e| e.to_string())?;
214    Ok(v)
215}
216
217/// One pass over the JSON text, outside strings: bare `-0` -> `0`, and the
218/// maximum nesting depth.
219fn scan_json(s: &str) -> (String, usize) {
220    let bytes = s.as_bytes();
221    let mut out = String::with_capacity(s.len());
222    let mut depth = 0usize;
223    let mut max_depth = 0usize;
224    let mut i = 0;
225    let mut in_string = false;
226    let mut last = 0;
227    while i < bytes.len() {
228        let c = bytes[i];
229        if in_string {
230            match c {
231                b'\\' => i += 1,
232                b'"' => in_string = false,
233                _ => {}
234            }
235        } else {
236            match c {
237                b'"' => in_string = true,
238                b'[' | b'{' => {
239                    depth += 1;
240                    max_depth = max_depth.max(depth);
241                }
242                b']' | b'}' => depth = depth.saturating_sub(1),
243                b'-' if bytes.get(i + 1) == Some(&b'0')
244                    && !bytes
245                        .get(i + 2)
246                        .is_some_and(|n| matches!(n, b'.' | b'e' | b'E' | b'0'..=b'9')) =>
247                {
248                    out.push_str(&s[last..i]);
249                    last = i + 1;
250                }
251                _ => {}
252            }
253        }
254        i += 1;
255    }
256    out.push_str(&s[last..]);
257    (out, max_depth)
258}
259
260/// `JsonFile::parseJson` (associative decoding).
261fn parse_json(contents: &str) -> Result<Value> {
262    json_decode(contents).map_err(ManipulatorError::Parse)
263}
264
265/// `@json_decode($s)` then PHP truthiness test: `false` if the JSON is
266/// invalid or if the decoded value is falsy (`null`, `false`, `0`, `""`,
267/// `"0"`, empty array; an empty object is an empty array in associative
268/// mode, but a truthy `stdClass` otherwise). In object mode, a key starting
269/// with a null byte is a decoding failure
270/// (`JSON_ERROR_INVALID_PROPERTY_NAME`).
271fn decodes_truthy(s: &str, assoc: bool) -> bool {
272    match json_decode(s) {
273        Ok(v) => {
274            if !assoc && has_nul_key(&v) {
275                return false;
276            }
277            match &v {
278                Value::Object(m) => !assoc || !m.is_empty(),
279                other => php_truthy(other),
280            }
281        }
282        Err(_) => false,
283    }
284}
285
286fn has_nul_key(v: &Value) -> bool {
287    match v {
288        Value::Object(m) => m.iter().any(|(k, v)| k.starts_with('\0') || has_nul_key(v)),
289        Value::Array(a) => a.iter().any(has_nul_key),
290        _ => false,
291    }
292}
293
294fn php_truthy(v: &Value) -> bool {
295    match v {
296        Value::Null => false,
297        Value::Bool(b) => *b,
298        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
299        Value::String(s) => !(s.is_empty() || s == "0"),
300        Value::Array(a) => !a.is_empty(),
301        Value::Object(m) => !m.is_empty(),
302    }
303}
304
305/// `isset($decoded[$key])`: present and not `null`.
306fn isset<'v>(decoded: &'v Value, key: &str) -> Option<&'v Value> {
307    php_index(decoded, key).filter(|v| !v.is_null())
308}
309
310/// `$value[$key]` on a decoded PHP array: object by key, list by canonical
311/// index.
312fn php_index<'v>(value: &'v Value, key: &str) -> Option<&'v Value> {
313    match value {
314        Value::Object(m) => m.get(key),
315        Value::Array(a) => canonical_index(key).and_then(|i| a.get(i)),
316        // `isset("abc"[1])`: an integer offset into the string (negative
317        // from the end) is set; the value itself stands in for the
318        // character, which is enough for existence tests.
319        Value::String(s) => php_int_key(key)
320            .filter(|&i| {
321                (0..s.len() as i64).contains(&(if i < 0 { i + s.len() as i64 } else { i }))
322            })
323            .map(|_| value),
324        _ => None,
325    }
326}
327
328/// A key PHP converts to an integer, negatives included (`"-3"`, never
329/// `"-0"` nor `"03"`).
330fn php_int_key(key: &str) -> Option<i64> {
331    if let Some(i) = canonical_index(key) {
332        return i64::try_from(i).ok();
333    }
334    let rest = key.strip_prefix('-')?;
335    if rest.starts_with(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
336        && rest.bytes().all(|b| b.is_ascii_digit())
337    {
338        return key.parse().ok();
339    }
340    None
341}
342
343/// A key PHP converts to an integer (`"0"`, `"12"`, never `"012"`).
344fn canonical_index(key: &str) -> Option<usize> {
345    if key == "0" {
346        return Some(0);
347    }
348    if key.starts_with(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
349        && key.bytes().all(|b| b.is_ascii_digit())
350    {
351        return key.parse().ok();
352    }
353    None
354}
355
356/// `array_is_list` of an array decoded from a JSON object: keys `"0"`,
357/// `"1"`, ... in order.
358fn php_is_list(m: &Map<String, Value>) -> bool {
359    m.keys()
360        .enumerate()
361        .all(|(i, k)| canonical_index(k) == Some(i))
362}
363
364/// `$subName` is truthy in the PHP sense (`if ($subName && ...)`).
365fn truthy_str(s: &str) -> bool {
366    !(s.is_empty() || s == "0")
367}
368
369/// `strnatcmp` (ext/standard/strnatcmp.c, case-sensitive).
370pub fn strnatcmp(a: &str, b: &str) -> std::cmp::Ordering {
371    use std::cmp::Ordering::{Equal, Greater, Less};
372    let (a, b) = (a.as_bytes(), b.as_bytes());
373    if a.is_empty() || b.is_empty() {
374        return a.len().cmp(&b.len());
375    }
376    // A PHP string is null-terminated: reading past the end yields 0.
377    let at = |s: &[u8], i: usize| s.get(i).copied().unwrap_or(0);
378    let (mut ap, mut bp) = (0usize, 0usize);
379    let (mut ca, mut cb) = (a[0], b[0]);
380    while ca == b'0' && ap + 1 < a.len() && a[ap + 1].is_ascii_digit() {
381        ap += 1;
382        ca = a[ap];
383    }
384    while cb == b'0' && bp + 1 < b.len() && b[bp + 1].is_ascii_digit() {
385        bp += 1;
386        cb = b[bp];
387    }
388    let is_space = |c: u8| matches!(c, b' ' | b'\t' | b'\n' | 0x0B | 0x0C | b'\r');
389    loop {
390        while is_space(ca) {
391            ap += 1;
392            ca = at(a, ap);
393        }
394        while is_space(cb) {
395            bp += 1;
396            cb = at(b, bp);
397        }
398        if ca.is_ascii_digit() && cb.is_ascii_digit() {
399            let fractional = ca == b'0' || cb == b'0';
400            let result = if fractional {
401                compare_left(a, &mut ap, b, &mut bp)
402            } else {
403                compare_right(a, &mut ap, b, &mut bp)
404            };
405            if result != Equal {
406                return result;
407            }
408            if ap >= a.len() && bp >= b.len() {
409                return Equal;
410            }
411            if ap >= a.len() {
412                return Less;
413            }
414            if bp >= b.len() {
415                return Greater;
416            }
417            ca = a[ap];
418            cb = b[bp];
419        }
420        match ca.cmp(&cb) {
421            Less => return Less,
422            Greater => return Greater,
423            Equal => {}
424        }
425        ap += 1;
426        bp += 1;
427        if ap >= a.len() && bp >= b.len() {
428            return Equal;
429        }
430        if ap >= a.len() {
431            return Less;
432        }
433        if bp >= b.len() {
434            return Greater;
435        }
436        ca = a[ap];
437        cb = b[bp];
438    }
439}
440
441/// Two left-aligned numbers (fractions): the first difference wins.
442fn compare_left(a: &[u8], ap: &mut usize, b: &[u8], bp: &mut usize) -> std::cmp::Ordering {
443    use std::cmp::Ordering::{Equal, Greater, Less};
444    loop {
445        let da = a.get(*ap).filter(|c| c.is_ascii_digit());
446        let db = b.get(*bp).filter(|c| c.is_ascii_digit());
447        match (da, db) {
448            (None, None) => return Equal,
449            (None, Some(_)) => return Less,
450            (Some(_), None) => return Greater,
451            (Some(x), Some(y)) => match x.cmp(y) {
452                Less => return Less,
453                Greater => return Greater,
454                Equal => {}
455            },
456        }
457        *ap += 1;
458        *bp += 1;
459    }
460}
461
462/// Two right-aligned numbers: the longer one wins, otherwise the first
463/// difference (remembered in `bias`).
464fn compare_right(a: &[u8], ap: &mut usize, b: &[u8], bp: &mut usize) -> std::cmp::Ordering {
465    use std::cmp::Ordering::{Equal, Greater, Less};
466    let mut bias = Equal;
467    loop {
468        let da = a.get(*ap).filter(|c| c.is_ascii_digit());
469        let db = b.get(*bp).filter(|c| c.is_ascii_digit());
470        match (da, db) {
471            (None, None) => return bias,
472            (None, Some(_)) => return Less,
473            (Some(_), None) => return Greater,
474            (Some(x), Some(y)) => {
475                if bias == Equal {
476                    bias = x.cmp(y);
477                }
478            }
479        }
480        *ap += 1;
481        *bp += 1;
482    }
483}
484
485/// The sort prefix of `sortPackages`: platforms first (`php`, `hhvm`,
486/// `ext-*`, `lib-*`, the others), then packages.
487fn sort_prefix(requirement: &str) -> String {
488    if !is_platform_package(requirement) {
489        return format!("5-{requirement}");
490    }
491    // The five replacements chain on the same string; once prefixed with a
492    // digit, it is no longer touched by `^\D`.
493    let mut s = requirement.to_owned();
494    for (prefix, digit) in [("php", "0"), ("hhvm", "1"), ("ext", "2"), ("lib", "3")] {
495        if s.starts_with(prefix) {
496            s = format!("{digit}-{s}");
497        }
498    }
499    if s.starts_with(|c: char| !c.is_ascii_digit()) {
500        s = format!("4-{s}");
501    }
502    s
503}
504
505/// `sortPackages`: stable `uksort` by `strnatcmp` of the prefixes. As soon
506/// as the comparator is called (two or more entries), an integer key makes
507/// `isPlatformPackage(string $name)` fail (`strict_types`).
508fn sort_packages(packages: &mut Map<String, Value>) -> Result<()> {
509    if packages.len() >= 2 && packages.keys().any(|k| php_int_key(k).is_some()) {
510        return Err(ManipulatorError::Logic(
511            "PlatformRepository::isPlatformPackage(): Argument #1 ($name) must be of type string, int given".into(),
512        ));
513    }
514    let mut entries: Vec<(String, Value)> = std::mem::take(packages).into_iter().collect();
515    entries.sort_by(|(a, _), (b, _)| strnatcmp(&sort_prefix(a), &sort_prefix(b)));
516    packages.extend(entries);
517    Ok(())
518}
519
520/// An object reduced to the `stdClass` sentinel (an `ArrayObject`).
521fn is_stdclass_marker(m: &Map<String, Value>) -> bool {
522    m.len() == 1 && m.contains_key(STDCLASS_MARKER)
523}
524
525pub struct JsonManipulator {
526    contents: String,
527    newline: &'static str,
528    indent: String,
529}
530
531impl JsonManipulator {
532    /// Constructor: `trim`, `{}` -> `{\n}`, newline and indentation
533    /// detection.
534    pub fn new(contents: &str) -> Result<Self> {
535        let contents = contents.trim_matches([' ', '\t', '\n', '\r', '\0', '\x0B']);
536        let contents = if contents.is_empty() { "{}" } else { contents };
537        if captures(&compile(r"^\{(.*)\}$", S)?, contents)?.is_none() {
538            return Err(ManipulatorError::NotAnObject);
539        }
540        let newline = if contents.contains("\r\n") {
541            "\r\n"
542        } else {
543            "\n"
544        };
545        let contents = if contents == "{}" {
546            format!("{{{newline}}}")
547        } else {
548            contents.to_owned()
549        };
550        let indent = detect_indenting(&contents)?;
551        Ok(Self {
552            contents,
553            newline,
554            indent,
555        })
556    }
557
558    /// `getContents`: the text followed by a newline.
559    pub fn contents(&self) -> String {
560        format!("{}{}", self.contents, self.newline)
561    }
562
563    /// `addLink`: adds or replaces `$package: $constraint` in the `$type`
564    /// section, preserving the existing spelling of the name.
565    pub fn add_link(
566        &mut self,
567        link_type: &str,
568        package: &str,
569        constraint: &str,
570        sort: bool,
571    ) -> Result<bool> {
572        let decoded = parse_json(&self.contents)?;
573        if isset(&decoded, link_type).is_none() {
574            let mut m = Map::new();
575            m.insert(package.to_owned(), Value::String(constraint.to_owned()));
576            return self.add_main_key(link_type, &Value::Object(m));
577        }
578
579        let regex = compile(
580            &format!(
581                r#"{DEFINES}^(?P<start>\s*\{{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)(?P<property>{}\s*:\s*)(?P<value>(?&json))(?P<end>.*)"#,
582                preg_quote(&encode_str(link_type)?)
583            ),
584            SX,
585        )?;
586        let Some(caps) = captures(&regex, &self.contents)? else {
587            return Ok(false);
588        };
589        let start = named(&caps, "start").unwrap_or("").to_owned();
590        let property = named(&caps, "property").unwrap_or("").to_owned();
591        let end = named(&caps, "end").unwrap_or("").to_owned();
592        let mut links = named(&caps, "value").unwrap_or("").to_owned();
593
594        // The name may be written `vendor\/name` in the file.
595        let package_regex = preg_quote(package).replace('/', "\\\\?/");
596        let regex = compile(
597            &format!(r#"{DEFINES}"(?P<package>{package_regex})"(\s*:\s*)(?&string)"#),
598            IX,
599        )?;
600        if let Some(pm) = captures(&regex, &links)? {
601            let existing = named(&pm, "package").unwrap_or("").to_owned();
602            let package_regex = preg_quote(&existing).replace('/', "\\\\?/");
603            let regex = compile(
604                &format!(r#"{DEFINES}"{package_regex}"(?P<separator>\s*:\s*)(?&string)"#),
605                IX,
606            )?;
607            let name = encode_str(&existing.replace("\\/", "/"))?;
608            links = replace_all(&regex, &links, |m| {
609                Ok(format!(
610                    "{name}{}\"{constraint}\"",
611                    named(m, "separator").unwrap_or("")
612                ))
613            })?
614            .0;
615        } else {
616            let regex = compile(r"^\s*\{\s*\S+.*?(\s*\}\s*)$", S)?;
617            let tail = captures(&regex, &links)?.and_then(|m| group(&m, 1).map(str::to_owned));
618            if let Some(tail) = tail {
619                let regex = compile(&format!("{}$", preg_quote(&tail)), NONE)?;
620                let replacement = format!(
621                    ",{nl}{ind}{ind}{}: {}{tail}",
622                    encode_str(package)?,
623                    encode_str(constraint)?,
624                    nl = self.newline,
625                    ind = self.indent
626                );
627                links = replace_literal(&regex, &links, &replacement)?.0;
628            } else {
629                links = format!(
630                    "{{{nl}{ind}{ind}{}: {}{nl}{ind}}}",
631                    encode_str(package)?,
632                    encode_str(constraint)?,
633                    nl = self.newline,
634                    ind = self.indent
635                );
636            }
637        }
638
639        if sort {
640            let requirements = json_decode(&links).map_err(|e| {
641                ManipulatorError::Logic(format!("sortPackages(): links are not JSON: {e}"))
642            })?;
643            links = match requirements {
644                Value::Object(mut m) => {
645                    sort_packages(&mut m)?;
646                    self.format(&Value::Object(m), 0, false)?
647                }
648                // A list is a PHP array with integer keys: `uksort` compares
649                // nothing when there is at most one element.
650                Value::Array(a) => {
651                    let mut m = list_to_map(a);
652                    sort_packages(&mut m)?;
653                    self.format(&Value::Object(m), 0, false)?
654                }
655                _ => {
656                    return Err(ManipulatorError::Logic(
657                        "sortPackages(): Argument #1 ($packages) must be of type array".into(),
658                    ))
659                }
660            };
661        }
662
663        self.contents = format!("{start}{property}{links}{end}");
664        Ok(true)
665    }
666
667    /// `removeConfigSetting` (excluding `policy.*`).
668    pub fn remove_config_setting(&mut self, name: &str) -> Result<bool> {
669        if name.starts_with("policy.") && name.matches('.').count() >= 2 {
670            return Err(ManipulatorError::Unsupported(format!(
671                "removeConfigSetting({name}): policy lists"
672            )));
673        }
674        self.remove_sub_node("config", name)
675    }
676
677    /// `addSubNode`: adds or replaces `$name` (or `$name.$subName` for
678    /// `config`/`extra`/`scripts`) in the `$mainNode` object.
679    pub fn add_sub_node(
680        &mut self,
681        main_node: &str,
682        name: &str,
683        value: &Value,
684        append: bool,
685    ) -> Result<bool> {
686        let decoded = parse_json(&self.contents)?;
687        let (name, sub_name) = split_sub_name(main_node, name);
688
689        if isset(&decoded, main_node).is_none() {
690            let inner = match sub_name {
691                Some(sub) => {
692                    let mut m = Map::new();
693                    m.insert(sub.to_owned(), value.clone());
694                    Value::Object(m)
695                }
696                None => value.clone(),
697            };
698            let mut m = Map::new();
699            m.insert(name.to_owned(), inner);
700            self.add_main_key(main_node, &Value::Object(m))?;
701            return Ok(true);
702        }
703
704        let node_regex = self.node_regex(main_node)?;
705        let Some(caps) = captures_or_limit(&node_regex, &self.contents)? else {
706            return Ok(false);
707        };
708        let mut children = named(&caps, "content").unwrap_or("").to_owned();
709
710        if !decodes_truthy(&children, false) {
711            return Ok(false);
712        }
713
714        let child_regex = compile(
715            &format!(
716                r#"{DEFINES}(?P<start>"{}"\s*:\s*)(?P<content>(?&json))(?P<end>,?)"#,
717                preg_quote(name)
718            ),
719            X,
720        )?;
721        if captures(&child_regex, &children)?.is_some() {
722            children = replace_all(&child_regex, &children, |m| {
723                let content = named(m, "content").unwrap_or("");
724                let value = match sub_name {
725                    Some(sub) => {
726                        let mut cur = match json_decode(content) {
727                            Ok(Value::Object(m)) => m,
728                            Ok(Value::Array(a)) => list_to_map(a),
729                            _ => Map::new(),
730                        };
731                        cur.insert(sub.to_owned(), value.clone());
732                        Value::Object(cur)
733                    }
734                    None => value.clone(),
735                };
736                Ok(format!(
737                    "{}{}{}",
738                    named(m, "start").unwrap_or(""),
739                    self.format(&value, 1, false)?,
740                    named(m, "end").unwrap_or("")
741                ))
742            })?
743            .0;
744        } else {
745            let regex = compile(
746                r"^\{(?P<leadingspace>\s*?)(?P<content>\S+.*?)?(?P<trailingspace>\s*)\}$",
747                S,
748            )?;
749            let Some(m) = captures(&regex, &children)? else {
750                return Err(ManipulatorError::Logic(format!(
751                    "Nothing matched above for: {children}"
752                )));
753            };
754            let leading = named(&m, "leadingspace").unwrap_or("").to_owned();
755            let trailing = named(&m, "trailingspace").unwrap_or("").to_owned();
756            let has_content = named(&m, "content").is_some();
757            let value = match sub_name {
758                Some(sub) => {
759                    let mut m = Map::new();
760                    m.insert(sub.to_owned(), value.clone());
761                    Value::Object(m)
762                }
763                None => value.clone(),
764            };
765            let entry = format!("{}: {}", encode_str(name)?, self.format(&value, 1, false)?);
766            if has_content {
767                if append {
768                    let regex = compile(&format!("{trailing}}}$"), NONE)?;
769                    let replacement = format!(
770                        ",{nl}{ind}{ind}{entry}{trailing}}}",
771                        nl = self.newline,
772                        ind = self.indent
773                    );
774                    children = replace_literal(&regex, &children, &replacement)?.0;
775                } else {
776                    let regex = compile(&format!("^{{{leading}"), NONE)?;
777                    let replacement = format!(
778                        "{{{leading}{entry},{nl}{ind}{ind}",
779                        nl = self.newline,
780                        ind = self.indent
781                    );
782                    children = replace_literal(&regex, &children, &replacement)?.0;
783                }
784            } else {
785                children = format!(
786                    "{{{nl}{ind}{ind}{entry}{trailing}}}",
787                    nl = self.newline,
788                    ind = self.indent
789                );
790            }
791        }
792
793        self.contents = replace_all(&node_regex, &self.contents, |m| {
794            Ok(format!(
795                "{}{children}{}",
796                named(m, "start").unwrap_or(""),
797                named(m, "end").unwrap_or("")
798            ))
799        })?
800        .0;
801        Ok(true)
802    }
803
804    /// `removeSubNode`: removes `$name` (or `$name.$subName`) from the
805    /// `$mainNode` object.
806    pub fn remove_sub_node(&mut self, main_node: &str, name: &str) -> Result<bool> {
807        let decoded = parse_json(&self.contents)?;
808        if !php_index(&decoded, main_node).is_some_and(php_truthy) {
809            return Ok(true);
810        }
811
812        let node_regex = self.node_regex(main_node)?;
813        let Some(caps) = captures_or_limit(&node_regex, &self.contents)? else {
814            return Ok(false);
815        };
816        let children = named(&caps, "content").unwrap_or("").to_owned();
817
818        if !decodes_truthy(&children, true) {
819            return Ok(false);
820        }
821
822        let (name, sub_name) = split_sub_name(main_node, name);
823
824        let node = php_index(&decoded, main_node).unwrap_or(&Value::Null);
825        let Some(entry) = isset(node, name) else {
826            return Ok(true);
827        };
828        if let Some(sub) = sub_name {
829            if truthy_str(sub) && isset(entry, sub).is_none() {
830                return Ok(true);
831            }
832        }
833
834        let key_regex = preg_quote(name).replace('/', "\\\\?/");
835        let children_clean = if captures(&compile(&format!(r#""{key_regex}"\s*:"#), I)?, &children)?
836            .is_some()
837        {
838            let all = compile(&format!(r#"{DEFINES}"{key_regex}"\s*:\s*(?:(?&json))"#), X)?;
839            let mut best = String::new();
840            let mut any = false;
841            for m in all.captures_iter(children.as_bytes()) {
842                let m = m.map_err(|e| ManipulatorError::Regex(e.to_string()))?;
843                any = true;
844                let whole = group(&m, 0).unwrap_or("");
845                if best.len() < whole.len() {
846                    best = whole.to_owned();
847                }
848            }
849            if !any {
850                return Err(ManipulatorError::Logic(
851                    "JsonManipulator: $childrenClean is not defined. Please report at https://github.com/composer/composer/issues/new.".into(),
852                ));
853            }
854            let (mut clean, count) = replace_literal(
855                &compile(&format!(r",\s*{}", preg_quote(&best)), I)?,
856                &children,
857                "",
858            )?;
859            if count != 1 {
860                let (clean2, count2) = replace_literal(
861                    &compile(&format!(r"{}\s*,?\s*", preg_quote(&best)), I)?,
862                    &clean,
863                    "",
864                )?;
865                if count2 != 1 {
866                    return Ok(false);
867                }
868                clean = clean2;
869            }
870            clean
871        } else {
872            children.clone()
873        };
874
875        let regex = compile(r"^\{\s*?(?P<content>\S+.*?)?(?P<trailingspace>\s*)\}$", S)?;
876        if let Some(m) = captures(&regex, &children_clean)? {
877            if named(&m, "content").is_none() {
878                let empty = format!("{{{}{}}}", self.newline, self.indent);
879                self.contents = replace_all(&node_regex, &self.contents, |m| {
880                    Ok(format!(
881                        "{}{empty}{}",
882                        named(m, "start").unwrap_or(""),
883                        named(m, "end").unwrap_or("")
884                    ))
885                })?
886                .0;
887                if let Some(sub) = sub_name {
888                    let cur = json_decode(&children).map_err(ManipulatorError::Parse)?;
889                    let inner = unset_sub(&cur, name, sub)?;
890                    self.add_sub_node(main_node, name, &inner, true)?;
891                }
892                return Ok(true);
893            }
894        }
895
896        let replacement = match sub_name {
897            Some(sub) => {
898                let content = named(&caps, "content").unwrap_or("");
899                let mut cur = match json_decode(content) {
900                    Ok(Value::Object(m)) => m,
901                    Ok(Value::Array(a)) => list_to_map(a),
902                    _ => Map::new(),
903                };
904                let inner = unset_sub(&Value::Object(cur.clone()), name, sub)?;
905                cur.insert(name.to_owned(), inner);
906                self.format(&Value::Object(cur), 0, true)?
907            }
908            None => children_clean,
909        };
910        self.contents = replace_all(&node_regex, &self.contents, |m| {
911            Ok(format!(
912                "{}{replacement}{}",
913                named(m, "start").unwrap_or(""),
914                named(m, "end").unwrap_or("")
915            ))
916        })?
917        .0;
918        Ok(true)
919    }
920
921    /// `addMainKey`: replaces the root key if it exists, otherwise appends
922    /// it at the end of the object.
923    pub fn add_main_key(&mut self, key: &str, content: &Value) -> Result<bool> {
924        let decoded = parse_json(&self.contents)?;
925        let content = self.format(content, 0, false)?;
926        let encoded_key = encode_str(key)?;
927
928        let regex = compile(
929            &format!(
930                r#"{DEFINES}^(?P<start>\s*\{{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)(?P<key>{}\s*:\s*(?&json))(?P<end>.*)"#,
931                preg_quote(&encoded_key)
932            ),
933            SX,
934        )?;
935        if isset(&decoded, key).is_some() {
936            if let Some(m) = captures(&regex, &self.contents)? {
937                let key_text = named(&m, "key").unwrap_or("");
938                if !decodes_truthy(&format!("{{{key_text}}}"), false) {
939                    return Ok(false);
940                }
941                self.contents = format!(
942                    "{}{encoded_key}: {content}{}",
943                    named(&m, "start").unwrap_or(""),
944                    named(&m, "end").unwrap_or("")
945                );
946                return Ok(true);
947            }
948        }
949
950        let regex = compile(r"[^{\s](\s*)\}$", NONE)?;
951        if let Some(m) = captures(&regex, &self.contents)? {
952            let ws = group(&m, 1).unwrap_or("").to_owned();
953            let regex = compile(&format!(r"{ws}\}}$"), NONE)?;
954            let replacement = format!(
955                ",{nl}{ind}{encoded_key}: {content}{nl}}}",
956                nl = self.newline,
957                ind = self.indent
958            );
959            self.contents = replace_literal(&regex, &self.contents, &replacement)?.0;
960            return Ok(true);
961        }
962
963        let regex = compile(r"\}$", NONE)?;
964        let replacement = format!(
965            "{ind}{encoded_key}: {content}{nl}}}",
966            nl = self.newline,
967            ind = self.indent
968        );
969        self.contents = replace_literal(&regex, &self.contents, &replacement)?.0;
970        Ok(true)
971    }
972
973    /// `removeMainKey`.
974    pub fn remove_main_key(&mut self, key: &str) -> Result<bool> {
975        let decoded = parse_json(&self.contents)?;
976        if php_index(&decoded, key).is_none() {
977            return Ok(true);
978        }
979
980        let regex = compile(
981            &format!(
982                r#"{DEFINES}^(?P<start>\s*\{{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)(?P<removal>{}\s*:\s*(?&json))\s*,?\s*(?P<end>.*)"#,
983                preg_quote(&encode_str(key)?)
984            ),
985            SX,
986        )?;
987        let Some(m) = captures(&regex, &self.contents)? else {
988            return Ok(false);
989        };
990        let mut start = named(&m, "start").unwrap_or("").to_owned();
991        let removal = named(&m, "removal").unwrap_or("");
992        let end = named(&m, "end").unwrap_or("").to_owned();
993
994        if !decodes_truthy(&format!("{{{removal}}}"), false) {
995            return Ok(false);
996        }
997
998        // Last key removed: the comma preceding it goes with it.
999        if captures(&compile(r",\s*$", NONE)?, &start)?.is_some()
1000            && captures(&compile(r"^\}$", NONE)?, &end)?.is_some()
1001        {
1002            let regex = compile(r",(\s*)$", NONE)?;
1003            let stripped =
1004                replace_all(&regex, &start, |m| Ok(group(m, 1).unwrap_or("").to_owned()))?.0;
1005            let chars: Vec<char> = self.indent.chars().collect();
1006            start = stripped.trim_end_matches(chars.as_slice()).to_owned();
1007        }
1008
1009        self.contents = format!("{start}{end}");
1010        if captures(&compile(r"^\{\s*\}\s*$", NONE)?, &self.contents)?.is_some() {
1011            self.contents = "{\n}".to_owned();
1012        }
1013        Ok(true)
1014    }
1015
1016    /// `removeMainKeyIfEmpty`: removes the key if it is an empty array.
1017    pub fn remove_main_key_if_empty(&mut self, key: &str) -> Result<bool> {
1018        let decoded = parse_json(&self.contents)?;
1019        let Some(v) = php_index(&decoded, key) else {
1020            return Ok(true);
1021        };
1022        let empty = match v {
1023            Value::Array(a) => a.is_empty(),
1024            Value::Object(m) => m.is_empty(),
1025            _ => false,
1026        };
1027        if empty {
1028            return self.remove_main_key(key);
1029        }
1030        Ok(true)
1031    }
1032
1033    /// `format`: formats a value at the given depth, with the file's
1034    /// indentation and newline.
1035    pub fn format(&self, data: &Value, depth: usize, was_object: bool) -> Result<String> {
1036        let indent = |n: usize| self.indent.repeat(n);
1037        let empty_object = |was_object: bool| {
1038            if was_object {
1039                format!("{{{}{}}}", self.newline, indent(depth + 1))
1040            } else {
1041                "[]".to_owned()
1042            }
1043        };
1044        match data {
1045            Value::Object(m) if is_stdclass_marker(m) => Ok(empty_object(true)),
1046            Value::Object(m) if m.is_empty() => Ok(empty_object(was_object)),
1047            Value::Array(a) if a.is_empty() => Ok(empty_object(was_object)),
1048            Value::Array(a) => {
1049                let items = a
1050                    .iter()
1051                    .map(|v| self.format(v, depth + 1, false))
1052                    .collect::<Result<Vec<_>>>()?;
1053                Ok(format!("[{}]", items.join(", ")))
1054            }
1055            Value::Object(m) if php_is_list(m) => {
1056                let items = m
1057                    .values()
1058                    .map(|v| self.format(v, depth + 1, false))
1059                    .collect::<Result<Vec<_>>>()?;
1060                Ok(format!("[{}]", items.join(", ")))
1061            }
1062            Value::Object(m) => {
1063                let mut elems = Vec::with_capacity(m.len());
1064                for (k, v) in m {
1065                    elems.push(format!(
1066                        "{}{}: {}",
1067                        indent(depth + 2),
1068                        encode_str(k)?,
1069                        self.format(v, depth + 1, false)?
1070                    ));
1071                }
1072                Ok(format!(
1073                    "{{{nl}{}{nl}{}}}",
1074                    elems.join(&format!(",{}", self.newline)),
1075                    indent(depth + 1),
1076                    nl = self.newline
1077                ))
1078            }
1079            scalar => encode(scalar),
1080        }
1081    }
1082
1083    /// The pattern shared by `addSubNode`/`removeSubNode`: everything up to
1084    /// the root key, then its object, then the rest.
1085    fn node_regex(&self, main_node: &str) -> Result<Regex> {
1086        compile(
1087            &format!(
1088                r#"{DEFINES}^(?P<start> \s* \{{ \s* (?: (?&string) \s* : (?&json) \s* , \s* )*?{}\s*:\s*)(?P<content>(?&object))(?P<end>.*)"#,
1089                preg_quote(&encode_str(main_node)?)
1090            ),
1091            SX,
1092        )
1093    }
1094}
1095
1096/// `config`/`extra`/`scripts` accept `name.sub` to target a sub-key.
1097fn split_sub_name<'a>(main_node: &str, name: &'a str) -> (&'a str, Option<&'a str>) {
1098    if matches!(main_node, "config" | "extra" | "scripts") {
1099        if let Some((n, s)) = name.split_once('.') {
1100            return (n, Some(s));
1101        }
1102    }
1103    (name, None)
1104}
1105
1106/// A decoded list seen as a PHP array with integer keys.
1107fn list_to_map(a: Vec<Value>) -> Map<String, Value> {
1108    a.into_iter()
1109        .enumerate()
1110        .map(|(i, v)| (i.to_string(), v))
1111        .collect()
1112}
1113
1114/// `unset($cur[$name][$sub]); if ($cur[$name] === []) new ArrayObject`.
1115fn unset_sub(cur: &Value, name: &str, sub: &str) -> Result<Value> {
1116    let inner = php_index(cur, name).cloned().unwrap_or(Value::Null);
1117    let mut m = match inner {
1118        Value::Object(m) => m,
1119        Value::Array(a) => list_to_map(a),
1120        // `unset` on `null` or `false` does nothing (and `=== []` is false).
1121        Value::Null => return Ok(Value::Null),
1122        Value::Bool(false) => return Ok(Value::Bool(false)),
1123        other => {
1124            return Err(ManipulatorError::Logic(format!(
1125                "Cannot unset offset in a non-array variable ({other})"
1126            )))
1127        }
1128    };
1129    m.shift_remove(sub);
1130    if m.is_empty() {
1131        return Ok(vivacity_core::phpjson::empty_stdclass());
1132    }
1133    Ok(Value::Object(m))
1134}
1135
1136/// `JsonFile::detectIndenting`: the first line matching `^[ \t]+"`.
1137pub fn detect_indenting(json: &str) -> Result<String> {
1138    let re = compile(r#"^([ \t]+)""#, Flags { m: true, ..NONE })?;
1139    Ok(captures(&re, json)?
1140        .and_then(|c| group(&c, 1).map(str::to_owned))
1141        .unwrap_or_else(|| INDENT_DEFAULT.to_owned()))
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    use super::*;
1147    use std::cmp::Ordering::{Equal, Greater, Less};
1148
1149    #[test]
1150    fn strnatcmp_matches_php() {
1151        assert_eq!(strnatcmp("0-php", "1-hhvm"), Less);
1152        assert_eq!(strnatcmp("ext-json", "ext-Json"), Greater);
1153        assert_eq!(strnatcmp("a10", "a9"), Greater);
1154        assert_eq!(strnatcmp("a 1", "a1"), Equal);
1155        assert_eq!(strnatcmp("a01", "a1"), Less);
1156        assert_eq!(strnatcmp("1.10", "1.9"), Greater);
1157        assert_eq!(strnatcmp("01", "1"), Equal);
1158        assert_eq!(strnatcmp("", "a"), Less);
1159        assert_eq!(strnatcmp("abc", "ab"), Greater);
1160    }
1161
1162    #[test]
1163    fn sort_prefixes() {
1164        assert_eq!(sort_prefix("php"), "0-php");
1165        assert_eq!(sort_prefix("php-64bit"), "0-php-64bit");
1166        assert_eq!(sort_prefix("hhvm"), "1-hhvm");
1167        assert_eq!(sort_prefix("ext-json"), "2-ext-json");
1168        assert_eq!(sort_prefix("lib-icu"), "3-lib-icu");
1169        assert_eq!(sort_prefix("composer-plugin-api"), "4-composer-plugin-api");
1170        assert_eq!(sort_prefix("acme/lib"), "5-acme/lib");
1171    }
1172
1173    #[test]
1174    fn add_link_to_existing_section() {
1175        let mut m =
1176            JsonManipulator::new("{\n    \"require\": {\n        \"a/b\": \"^1\"\n    }\n}\n")
1177                .expect("new");
1178        assert!(m.add_link("require", "c/d", "^2", false).expect("add"));
1179        assert_eq!(
1180            m.contents(),
1181            "{\n    \"require\": {\n        \"a/b\": \"^1\",\n        \"c/d\": \"^2\"\n    }\n}\n"
1182        );
1183        assert!(m.add_link("require", "A/B", "^3", true).expect("replace"));
1184        assert_eq!(
1185            m.contents(),
1186            "{\n    \"require\": {\n        \"a/b\": \"^3\",\n        \"c/d\": \"^2\"\n    }\n}\n"
1187        );
1188    }
1189
1190    #[test]
1191    fn add_link_creates_section_and_remove_drops_it() {
1192        let mut m = JsonManipulator::new("{}").expect("new");
1193        assert!(m.add_link("require", "a/b", "^1", false).expect("add"));
1194        assert_eq!(
1195            m.contents(),
1196            "{\n    \"require\": {\n        \"a/b\": \"^1\"\n    }\n}\n"
1197        );
1198        assert!(m.remove_sub_node("require", "a/b").expect("remove"));
1199        assert_eq!(m.contents(), "{\n    \"require\": {\n    }\n}\n");
1200        assert!(m.remove_main_key_if_empty("require").expect("drop"));
1201        assert_eq!(m.contents(), "{\n}\n");
1202    }
1203
1204    #[test]
1205    fn crlf_and_tabs_are_kept() {
1206        let mut m =
1207            JsonManipulator::new("{\r\n\t\"require\": {\r\n\t\t\"a/b\": \"^1\"\r\n\t}\r\n}")
1208                .expect("new");
1209        assert!(m.add_link("require", "c/d", "^2", false).expect("add"));
1210        assert_eq!(
1211            m.contents(),
1212            "{\r\n\t\"require\": {\r\n\t\t\"a/b\": \"^1\",\r\n\t\t\"c/d\": \"^2\"\r\n\t}\r\n}\r\n"
1213        );
1214    }
1215
1216    #[test]
1217    fn config_sub_key_removal() {
1218        let mut m = JsonManipulator::new(
1219            "{\n    \"config\": {\n        \"allow-plugins\": {\n            \"a/b\": true,\n            \"c/d\": false\n        }\n    }\n}",
1220        )
1221        .expect("new");
1222        assert!(m
1223            .remove_config_setting("allow-plugins.a/b")
1224            .expect("remove"));
1225        assert_eq!(
1226            m.contents(),
1227            "{\n    \"config\": {\n        \"allow-plugins\": {\n            \"c/d\": false\n        }\n    }\n}\n"
1228        );
1229        assert!(m
1230            .remove_config_setting("allow-plugins.c/d")
1231            .expect("remove"));
1232        assert_eq!(
1233            m.contents(),
1234            "{\n    \"config\": {\n        \"allow-plugins\": {\n        }\n    }\n}\n"
1235        );
1236    }
1237}