Skip to main content

vivacity_resolver/
loader.rs

1//! Port of `Composer\Package\Loader\ArrayLoader` (docs/reference/resolver/
2//! ArrayLoader.php) restricted to the model's fields, and of
3//! `MetadataMinifier::expand`. A JSON entry (p2 version or lock entry)
4//! becomes a `Package`, plus its branch alias if any.
5
6use crate::constraint::parse_constraints;
7use crate::package::{Link, LinkType, Links, Origin, Package, SourceRef};
8use crate::version::{
9    normalize, normalize_branch, parse_numeric_alias_prefix, parse_stability, VersionError,
10    DEFAULT_BRANCH_ALIAS,
11};
12use serde_json::{Map, Value};
13
14#[derive(Debug, thiserror::Error)]
15#[error("{0}")]
16pub struct LoadError(pub String);
17
18/// `MetadataMinifier::expand`.
19pub fn expand_minified(versions: &[Value]) -> Vec<Value> {
20    expand_minified_owned(versions.to_vec())
21}
22
23/// `expand` consuming the input (one copy fewer per version).
24pub fn expand_minified_owned(versions: Vec<Value>) -> Vec<Value> {
25    let mut expanded: Vec<Value> = Vec::with_capacity(versions.len());
26    let mut current: Option<Map<String, Value>> = None;
27    for v in versions {
28        let data = match v {
29            Value::Object(o) => o,
30            _ => Map::new(),
31        };
32        // `if (!$expandedVersion)`: an empty first element counts for nothing.
33        if current.as_ref().is_some_and(Map::is_empty) {
34            current = None;
35        }
36        match &mut current {
37            None => {
38                current = Some(data.clone());
39                expanded.push(Value::Object(data));
40            }
41            Some(cur) => {
42                for (k, val) in data {
43                    if val == Value::String("__unset".into()) {
44                        cur.shift_remove(&k);
45                    } else {
46                        cur.insert(k, val);
47                    }
48                }
49                expanded.push(Value::Object(cur.clone()));
50            }
51        }
52    }
53    expanded
54}
55
56/// `ArrayLoader::getBranchAlias($config)` -> normalized alias.
57pub fn branch_alias(config: &Map<String, Value>) -> Option<String> {
58    let version = config.get("version")?.as_str()?;
59    if !version.starts_with("dev-") && !version.ends_with("-dev") {
60        return None;
61    }
62    if let Some(map) = config
63        .get("extra")
64        .and_then(|e| e.get("branch-alias"))
65        .and_then(Value::as_object)
66    {
67        for (source, target) in map {
68            let Some(target) = target.as_str() else {
69                continue;
70            };
71            if !target.ends_with("-dev") {
72                continue;
73            }
74            let validated = if target == DEFAULT_BRANCH_ALIAS {
75                target.to_owned()
76            } else {
77                normalize_branch(&target[..target.len() - 4])
78            };
79            if !validated.ends_with("-dev") {
80                continue;
81            }
82            if version.to_lowercase() != source.to_lowercase() {
83                continue;
84            }
85            if let (Some(sp), Some(tp)) = (
86                parse_numeric_alias_prefix(source),
87                parse_numeric_alias_prefix(target),
88            ) {
89                if !tp.to_lowercase().starts_with(&sp.to_lowercase()) {
90                    continue;
91                }
92            }
93            return Some(validated);
94        }
95    }
96    if config.get("default-branch") == Some(&Value::Bool(true)) {
97        let v = version.strip_prefix('v').unwrap_or(version);
98        if parse_numeric_alias_prefix(v).is_none() {
99            return Some(DEFAULT_BRANCH_ALIAS.to_owned());
100        }
101    }
102    None
103}
104
105/// `preg_replace('{(\.9{7})+}', '.x', $alias)`.
106pub fn pretty_alias(normalized: &str) -> String {
107    const X: &str = ".9999999";
108    let mut out = String::new();
109    let mut rest = normalized;
110    while let Some(i) = rest.find(X) {
111        out.push_str(&rest[..i]);
112        out.push_str(".x");
113        rest = &rest[i + X.len()..];
114        while let Some(r) = rest.strip_prefix(X) {
115            rest = r;
116        }
117    }
118    out.push_str(rest);
119    out
120}
121
122/// PHP `(string)` cast of a JSON scalar.
123fn php_string(v: &Value) -> String {
124    match v {
125        Value::String(s) => s.clone(),
126        Value::Bool(true) => "1".to_owned(),
127        Value::Bool(false) | Value::Null => String::new(),
128        Value::Number(n) => match n.as_f64() {
129            Some(f) if n.is_f64() && f.fract() == 0.0 && f.abs() < 1e15 => format!("{}", f as i64),
130            _ => n.to_string(),
131        },
132        other => other.to_string(),
133    }
134}
135
136/// `source`/`dist` of `configureObject`: fixed shape, reference cast to
137/// string (`null` stays absent).
138fn source_ref(name: &str, key: &str, v: Option<&Value>) -> Result<Option<SourceRef>, LoadError> {
139    // `isset($config['source'])`: absent or null -> nothing.
140    let Some(v) = v.filter(|v| !v.is_null()) else {
141        return Ok(None);
142    };
143    let o = v.as_object();
144    let has = |k: &str| o.is_some_and(|o| o.get(k).is_some_and(|x| !x.is_null()));
145    let ok = if key == "source" {
146        has("type") && has("url") && has("reference")
147    } else {
148        has("type") && has("url")
149    };
150    if !ok {
151        let shape = if key == "source" {
152            "{\"type\": ..., \"url\": ..., \"reference\": ...}"
153        } else {
154            "{\"type\": ..., \"url\": ..., \"reference\": ..., \"shasum\": ...}"
155        };
156        return Err(LoadError(format!(
157            "Package {name}'s {key} key should be specified as {shape},\n{v} given."
158        )));
159    }
160    let o = o.expect("object checked above");
161    Ok(Some(SourceRef {
162        kind: php_string(&o["type"]),
163        url: php_string(&o["url"]),
164        reference: o.get("reference").filter(|r| !r.is_null()).map(php_string),
165    }))
166}
167
168/// `ArrayLoader::parseLinks` (`load`, one package) or
169/// `configureCachedLinks` (`loadPackages`, a repository batch) for one link
170/// type. In batch mode, a link to the package itself is ignored and a
171/// non-string constraint is an error; in single mode, it is ignored.
172pub fn parse_links(
173    source: &str,
174    source_version: &str,
175    kind: LinkType,
176    map: Option<&Value>,
177    batch: bool,
178) -> Result<Links, LoadError> {
179    let mut out = Links::default();
180    let Some(obj) = map.and_then(Value::as_object) else {
181        return Ok(out);
182    };
183    for (target, constraint) in obj {
184        let target = target.to_lowercase();
185        if batch && target == source {
186            continue;
187        }
188        let Some(pretty) = constraint.as_str() else {
189            if batch {
190                return Err(LoadError(format!(
191                    "Link constraint in {source} {} > {target} should be a string, got {}",
192                    kind.description(),
193                    match constraint {
194                        Value::Null => "null".to_owned(),
195                        Value::Bool(_) => "bool".to_owned(),
196                        Value::Number(n) =>
197                            if n.is_f64() {
198                                "float".to_owned()
199                            } else {
200                                "int".to_owned()
201                            },
202                        Value::Array(_) | Value::Object(_) => "array".to_owned(),
203                        Value::String(_) => unreachable!(),
204                    }
205                )));
206            }
207            continue;
208        };
209        let text = if pretty == "self.version" {
210            source_version
211        } else {
212            pretty
213        };
214        let parsed = parse_constraints(text).map_err(|e| {
215            LoadError(format!(
216                "Link constraint in {source} {} > {target} should be a valid version constraint, got \"{text}\": {}",
217                kind.description(),
218                e.0
219            ))
220        })?;
221        out.insert(Link::new(source, &target, parsed.constraint, pretty, kind));
222    }
223    Ok(out)
224}
225
226/// `ArrayLoader::load($config)` (`batch` = false) or one iteration of
227/// `ArrayLoader::loadPackages` (`batch` = true): the package, and its alias
228/// if any (to be added right before it, like `ArrayRepository::addPackage`).
229pub fn load(
230    config: &Value,
231    origin: Origin,
232    batch: bool,
233) -> Result<(Package, Option<(String, String)>), LoadError> {
234    let obj = config
235        .as_object()
236        .ok_or_else(|| LoadError("package config is not an object".into()))?;
237    let name = obj
238        .get("name")
239        .and_then(Value::as_str)
240        .ok_or_else(|| LoadError(format!("Unknown package has no name defined ({config}).")))?;
241    let pretty_version = match obj.get("version") {
242        Some(v @ (Value::String(_) | Value::Number(_) | Value::Bool(_))) => php_string(v),
243        _ => return Err(LoadError(format!("Package {name} has no version defined."))),
244    };
245    let version = match obj.get("version_normalized").and_then(Value::as_str) {
246        Some(v) if v == DEFAULT_BRANCH_ALIAS => {
247            normalize(&pretty_version, None).map_err(|e: VersionError| {
248                LoadError(format!(
249                    "Failed to normalize version for package \"{name}\": {}",
250                    e.0
251                ))
252            })?
253        }
254        Some(v) => v.to_owned(),
255        None => normalize(&pretty_version, None).map_err(|e: VersionError| {
256            LoadError(format!(
257                "Failed to normalize version for package \"{name}\": {}",
258                e.0
259            ))
260        })?,
261    };
262    let mut p = Package::new(name, &version, &pretty_version, origin);
263    p.package_type = obj
264        .get("type")
265        .and_then(Value::as_str)
266        .map(str::to_lowercase)
267        .unwrap_or_else(|| "library".to_owned());
268    p.is_default_branch = obj.get("default-branch") == Some(&Value::Bool(true));
269    p.source = source_ref(name, "source", obj.get("source"))?;
270    p.dist = source_ref(name, "dist", obj.get("dist"))?;
271    p.stability = parse_stability(&version);
272    let src = p.name.clone();
273    p.requires = parse_links(
274        &src,
275        &pretty_version,
276        LinkType::Require,
277        obj.get("require"),
278        batch,
279    )?;
280    p.conflicts = parse_links(
281        &src,
282        &pretty_version,
283        LinkType::Conflict,
284        obj.get("conflict"),
285        batch,
286    )?;
287    p.provides = parse_links(
288        &src,
289        &pretty_version,
290        LinkType::Provide,
291        obj.get("provide"),
292        batch,
293    )?;
294    p.replaces = parse_links(
295        &src,
296        &pretty_version,
297        LinkType::Replace,
298        obj.get("replace"),
299        batch,
300    )?;
301    p.dev_requires = parse_links(
302        &src,
303        &pretty_version,
304        LinkType::DevRequire,
305        obj.get("require-dev"),
306        batch,
307    )?;
308    p.raw = config.clone();
309    let alias = branch_alias(obj).map(|normalized| {
310        let pretty = pretty_alias(&normalized);
311        (normalized, pretty)
312    });
313    Ok((p, alias))
314}
315
316/// `ArrayLoader::loadPackages` (`batch`) or repeated `load()`, followed by
317/// the addition to a repository: `load()` returns the AliasPackage when
318/// there is one, and the repository adds the alias THEN the aliased package
319/// (`ArrayRepository::addPackage`, `loadAsyncPackages`). The returned order
320/// is therefore [alias, base]; the arena keeps base before alias.
321pub fn load_packages(
322    configs: &[Value],
323    origin: Origin,
324    arena: &mut Vec<Package>,
325    batch: bool,
326) -> Result<Vec<usize>, LoadError> {
327    let refs: Vec<&Value> = configs.iter().collect();
328    load_package_refs(&refs, origin, arena, batch)
329}
330
331/// `load_packages` over borrowed configs: a caller reading them out of a
332/// larger document (a lock's `packages`) hands the entries over without
333/// copying the document first.
334pub fn load_package_refs(
335    configs: &[&Value],
336    origin: Origin,
337    arena: &mut Vec<Package>,
338    batch: bool,
339) -> Result<Vec<usize>, LoadError> {
340    let mut out = Vec::new();
341    for c in configs {
342        let (p, alias) = load(c, origin, batch)?;
343        let idx = arena.len();
344        arena.push(p);
345        if let Some((normalized, pretty)) = alias {
346            let a = arena[idx].alias(idx, &normalized, &pretty);
347            let aidx = arena.len();
348            arena.push(a);
349            out.push(aidx);
350        }
351        out.push(idx);
352    }
353    Ok(out)
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use serde_json::json;
360
361    #[test]
362    fn expands_minified_metadata() {
363        let v = vec![
364            json!({"name": "a/b", "version": "1.1.0", "require": {"php": ">=8"}, "time": "t1"}),
365            json!({"version": "1.0.0", "time": "__unset"}),
366        ];
367        let e = expand_minified(&v);
368        assert_eq!(e[1]["version"], "1.0.0");
369        assert_eq!(e[1]["require"]["php"], ">=8");
370        assert!(e[1].get("time").is_none());
371        assert_eq!(e[0]["time"], "t1");
372    }
373
374    #[test]
375    fn loads_package_with_alias_and_self_version() {
376        let cfg = json!({"name": "Acme/Lib", "version": "dev-main", "default-branch": true,
377            "replace": {"acme/old": "self.version"}, "require": {"php": "^8.1"}});
378        let mut arena = Vec::new();
379        let ids = load_packages(&[cfg], Origin::Repository(0), &mut arena, false).unwrap();
380        assert_eq!(
381            ids,
382            vec![1, 0],
383            "alias first, like ArrayRepository::addPackage"
384        );
385        let p = &arena[0];
386        assert_eq!(p.name, "acme/lib");
387        assert_eq!(p.version, "dev-main");
388        assert_eq!(
389            p.replaces.get("acme/old").unwrap().constraint.to_string(),
390            "== dev-main"
391        );
392        let a = &arena[1];
393        assert_eq!(a.version, "9999999-dev");
394        assert_eq!(a.pretty_version, "9999999-dev");
395        assert_eq!(a.alias_of, Some(0));
396        // replace self.version: original link + added alias link.
397        assert_eq!(a.replaces.0.len(), 2);
398        assert_eq!(a.replaces.0[1].constraint.to_string(), "== 9999999-dev");
399        assert_eq!(a.replaces.0[1].pretty_constraint, "dev-main");
400        // In batch mode (composer repository), a self link is ignored
401        // (`configureCachedLinks`); in single mode (lock, root) it stays.
402        let cfg = json!({"name": "acme/lib", "version": "1.0.0", "replace": {"acme/lib": "self.version", "acme/old": "1.0"}});
403        let mut arena = Vec::new();
404        load_packages(
405            std::slice::from_ref(&cfg),
406            Origin::Repository(0),
407            &mut arena,
408            true,
409        )
410        .unwrap();
411        assert_eq!(
412            arena[0]
413                .replaces
414                .0
415                .iter()
416                .map(|l| l.target.as_str())
417                .collect::<Vec<_>>(),
418            vec!["acme/old"]
419        );
420        load_packages(&[cfg], Origin::Locked, &mut arena, false).unwrap();
421        assert_eq!(arena[1].replaces.len(), 2);
422        assert_eq!(pretty_alias("1.9999999.9999999.9999999-dev"), "1.x-dev");
423        assert_eq!(pretty_alias("2.0.9999999.9999999-dev"), "2.0.x-dev");
424    }
425}