Skip to main content

vivacity_core/
state.rs

1//! Generation of the vendor/composer/ state files:
2//! - `installed.json`: lock entries re-dumped in ArrayDumper's canonical key
3//!   order (docs/reference/ArrayDumper.php), enriched with
4//!   `version_normalized`, `installation-source` and `install-path`, sorted by
5//!   (name, version), in JsonFile format (pretty 4 spaces, slashes/unicode
6//!   unescaped);
7//! - `installed.php`: port of FilesystemRepository::generateInstalledVersions
8//!   + dumpToPhpCode (real packages, replaced/provided virtual ones, root);
9//! - `InstalledVersions.php`: vendored copy of the Composer 2.10.3 file (it
10//!   is a file COPIED by Composer, not generated; dedicated drift test).
11
12use crate::error::{Error, Result};
13use crate::layout::Layout;
14use crate::lock::{Lock, LockPackage};
15use crate::pathutil::php_str;
16use crate::phpjson::{php_json_encode_with, FLAGS_JSONFILE};
17use crate::version::normalize_pretty;
18use serde_json::{Map, Value};
19
20pub const INSTALLED_VERSIONS_PHP: &str = include_str!("../assets/InstalledVersions.php");
21
22/// Canonical key order of a package entry (ArrayDumper::dump, then
23/// install-path appended by FilesystemRepository).
24const ENTRY_KEY_ORDER: [&str; 33] = [
25    "name",
26    "version",
27    "version_normalized",
28    "target-dir",
29    "source",
30    "dist",
31    "require",
32    "conflict",
33    "provide",
34    "replace",
35    "require-dev",
36    "suggest",
37    "time",
38    "default-branch",
39    "bin",
40    "type",
41    "extra",
42    "installation-source",
43    "autoload",
44    "autoload-dev",
45    "notification-url",
46    "include-path",
47    "php-ext",
48    "archive",
49    "scripts",
50    "license",
51    "authors",
52    "description",
53    "homepage",
54    "keywords",
55    "repositories",
56    "support",
57    "funding",
58];
59
60/// The project's root package (composer.json), for installed.php.
61#[derive(Debug, Clone)]
62pub struct RootPackage {
63    pub name: String,
64    pub pretty_version: String,
65    pub version: String,
66    pub reference: Option<String>,
67    pub package_type: String,
68    pub dev: bool,
69    /// Branch alias (`extra.branch-alias`): pretty version of the alias.
70    pub aliases: Vec<String>,
71    /// The same alias, normalised (`RootAliasPackage::getVersion()`).
72    pub alias_normalized: Option<String>,
73}
74
75impl RootPackage {
76    /// Like RootPackageLoader: `version` from composer.json, else
77    /// COMPOSER_ROOT_VERSION, else guessed from git, else
78    /// `1.0.0+no-version-set` (see root_version.rs).
79    pub fn detect(manifest: &Value, project_dir: &std::path::Path, dev: bool) -> RootPackage {
80        let name = manifest
81            .get("name")
82            .and_then(Value::as_str)
83            .unwrap_or("__root__")
84            .to_owned();
85        let package_type = manifest
86            .get("type")
87            .and_then(Value::as_str)
88            .unwrap_or("library")
89            .to_owned();
90        let rv = crate::root_version::detect(manifest, project_dir);
91        let alias = crate::root_version::branch_alias(manifest, &rv);
92        RootPackage {
93            name,
94            pretty_version: rv.pretty_version,
95            version: rv.version,
96            reference: rv.reference,
97            package_type,
98            dev,
99            aliases: alias.iter().map(|(_, pretty)| pretty.clone()).collect(),
100            alias_normalized: alias.map(|(n, _)| n),
101        }
102    }
103
104    /// Without VCS or environment detection (tests, cases with no project on disk).
105    pub fn from_manifest(manifest: &Value, dev: bool) -> RootPackage {
106        let mut r = RootPackage::detect(
107            manifest,
108            std::path::Path::new("/nonexistent-vivacity-root"),
109            dev,
110        );
111        if manifest.get("version").is_none() && std::env::var("COMPOSER_ROOT_VERSION").is_err() {
112            r.pretty_version = crate::root_version::DEFAULT_PRETTY_VERSION.to_owned();
113            r.version = "1.0.0.0".to_owned();
114            r.reference = None;
115            r.aliases = Vec::new();
116            r.alias_normalized = None;
117        }
118        r
119    }
120}
121
122/// `install_path` of installed.php (dumpToPhpCode): `__DIR__ . '/<rel>'`,
123/// or the string exported as is if Composer found no relative path
124/// (absolute).
125fn install_path_code(install_path: &str) -> String {
126    if crate::pathutil::is_absolute_path(install_path) {
127        php_str(install_path)
128    } else {
129        format!("__DIR__ . {}", php_str(&format!("/{install_path}")))
130    }
131}
132
133/// Full installed.json (text, with JsonFile::write's trailing newline).
134pub fn installed_json(lock: &Lock, with_dev: bool, layout: &Layout) -> Result<String> {
135    let mut entries: Vec<&LockPackage> = lock.wanted_packages(with_dev).collect();
136    entries.sort_by(|a, b| a.name().cmp(b.name()).then(a.version().cmp(b.version())));
137
138    let mut packages = Vec::new();
139    for p in &entries {
140        let mut entry = Map::new();
141        let mut src = p.raw.clone();
142        src.insert(
143            "version_normalized".to_owned(),
144            Value::String(normalize_pretty(p.version()).unwrap_or_else(|_| p.version().to_owned())),
145        );
146        // `installation-source` is carried by the entry itself: the
147        // installer decides it (the download phase's doing), this only
148        // places it in ArrayDumper's key order.
149        for key in ENTRY_KEY_ORDER {
150            if let Some(v) = src.remove(key) {
151                entry.insert(key.to_owned(), v);
152            }
153        }
154        // Keys outside the list (rare): afterwards, in their original order.
155        for (k, v) in src {
156            entry.insert(k, v);
157        }
158        entry.insert(
159            "install-path".to_owned(),
160            layout
161                .install_path(p.name())
162                .map(Value::String)
163                .unwrap_or(Value::Null),
164        );
165        packages.push(Value::Object(entry));
166    }
167
168    let mut dev_names: Vec<Value> = lock
169        .packages_dev
170        .iter()
171        .map(|p| Value::String(p.name().to_ascii_lowercase()))
172        .collect();
173    dev_names.sort_by(|a, b| a.as_str().cmp(&b.as_str()));
174
175    let mut doc = Map::new();
176    doc.insert("packages".to_owned(), Value::Array(packages));
177    doc.insert("dev".to_owned(), Value::Bool(with_dev));
178    doc.insert(
179        "dev-package-names".to_owned(),
180        Value::Array(if with_dev { dev_names } else { Vec::new() }),
181    );
182    let mut text = php_json_encode_with(&Value::Object(doc), FLAGS_JSONFILE)?;
183    text.push('\n');
184    Ok(text)
185}
186
187/// An entry of the `versions` array of installed.php.
188#[derive(Debug, Default)]
189struct VersionEntry {
190    pretty_version: Option<String>,
191    version: Option<String>,
192    reference: Option<Option<String>>,
193    package_type: Option<String>,
194    install_path: Option<Option<String>>, // None = not set yet; Some(None) = null
195    dev_requirement: Option<bool>,
196    aliases: Vec<String>,
197    replaced: Vec<String>,
198    provided: Vec<String>,
199}
200
201/// `PlatformRepository::isPlatformPackage` (php, hhvm, ext-*, lib-*,
202/// composer, composer-plugin-api, composer-runtime-api).
203fn is_platform_package(name: &str) -> bool {
204    let n = name.to_ascii_lowercase();
205    n == "php"
206        || n == "hhvm"
207        || n == "composer"
208        || n == "composer-plugin-api"
209        || n == "composer-runtime-api"
210        || matches!(
211            n.as_str(),
212            "php-64bit" | "php-ipv6" | "php-zts" | "php-debug"
213        )
214        || (n.starts_with("ext-") && !n.contains('/'))
215        || (n.starts_with("lib-") && !n.contains('/'))
216}
217
218/// Full installed.php (port of generateInstalledVersions + dumpToPhpCode).
219pub fn installed_php(
220    lock: &Lock,
221    root: &RootPackage,
222    root_manifest: &Value,
223    with_dev: bool,
224    layout: &Layout,
225) -> Result<String> {
226    use std::collections::BTreeMap;
227
228    let mut versions: BTreeMap<String, VersionEntry> = BTreeMap::new();
229    let dev_names: std::collections::BTreeSet<&str> = lock
230        .packages_dev
231        .iter()
232        .map(|p| p.raw.get("name").and_then(Value::as_str).unwrap_or(""))
233        .collect();
234
235    let packages: Vec<&LockPackage> = lock.wanted_packages(with_dev).collect();
236    for p in &packages {
237        let name = p.name().to_owned();
238        let is_dev = dev_names.contains(name.as_str());
239        let reference = p
240            .dist_reference()
241            .or_else(|| {
242                p.raw
243                    .get("source")
244                    .and_then(|s| s.get("reference"))
245                    .and_then(Value::as_str)
246            })
247            .map(str::to_owned);
248        let entry = versions.entry(name.clone()).or_default();
249        entry.pretty_version = Some(p.version().to_owned());
250        entry.version =
251            Some(normalize_pretty(p.version()).unwrap_or_else(|_| p.version().to_owned()));
252        entry.reference = Some(reference);
253        entry.package_type = Some(p.package_type().to_owned());
254        entry.install_path = Some(
255            layout
256                .install_path(p.name())
257                .map(|ip| install_path_code(&ip)),
258        );
259        entry.dev_requirement = Some(is_dev);
260        // Branch package: Composer loads an AliasPackage (branch-alias or
261        // default-branch) and installed.php lists its pretty version.
262        let default_branch = p
263            .raw
264            .get("default-branch")
265            .and_then(Value::as_bool)
266            .unwrap_or(false);
267        if let Some((_, pretty)) =
268            crate::root_version::branch_alias_of(p.version(), p.raw.get("extra"), default_branch)
269        {
270            entry.aliases.push(pretty);
271        }
272        // Inline alias of the lock (`"x/y": "dev-branch as 1.2.3"`, the
273        // lock's `aliases` list): Composer marks the AliasPackage installed
274        // with the transaction, and installed.php lists its pretty version.
275        for a in &lock.aliases {
276            if a.get("package").and_then(Value::as_str) == Some(p.name())
277                && a.get("version").and_then(Value::as_str) == Some(p.version())
278            {
279                if let Some(alias) = a.get("alias").and_then(Value::as_str) {
280                    entry.aliases.push(alias.to_owned());
281                }
282            }
283        }
284    }
285
286    // Virtual packages: replace then provide (same rules as Composer).
287    for p in &packages {
288        let is_dev = dev_names.contains(p.name());
289        for (kind, is_replace) in [("replace", true), ("provide", false)] {
290            if let Some(map) = p.raw.get(kind).and_then(Value::as_object) {
291                for (target, constraint) in map {
292                    if is_platform_package(target) {
293                        continue;
294                    }
295                    let entry = versions.entry(target.clone()).or_default();
296                    match entry.dev_requirement {
297                        None => entry.dev_requirement = Some(is_dev),
298                        Some(true) if !is_dev => entry.dev_requirement = Some(false),
299                        _ => {}
300                    }
301                    let mut c = constraint.as_str().unwrap_or("*").to_owned();
302                    if c == "self.version" {
303                        c = p.version().to_owned();
304                    }
305                    let list = if is_replace {
306                        &mut entry.replaced
307                    } else {
308                        &mut entry.provided
309                    };
310                    if !list.contains(&c) {
311                        list.push(c);
312                    }
313                }
314            }
315        }
316    }
317
318    // replace/provide of the root composer.json (e.g. replaced polyfills).
319    for (kind, is_replace) in [("replace", true), ("provide", false)] {
320        if let Some(map) = root_manifest.get(kind).and_then(Value::as_object) {
321            for (target, constraint) in map {
322                if is_platform_package(target) {
323                    continue;
324                }
325                let entry = versions.entry(target.clone()).or_default();
326                entry.dev_requirement.get_or_insert(false);
327                if entry.dev_requirement == Some(true) {
328                    entry.dev_requirement = Some(false);
329                }
330                let mut c = constraint.as_str().unwrap_or("*").to_owned();
331                if c == "self.version" {
332                    c = root.pretty_version.clone();
333                }
334                let list = if is_replace {
335                    &mut entry.replaced
336                } else {
337                    &mut entry.provided
338                };
339                if !list.contains(&c) {
340                    list.push(c);
341                }
342            }
343        }
344    }
345
346    // The root is part of versions.
347    {
348        let entry = versions.entry(root.name.clone()).or_default();
349        entry.pretty_version = Some(root.pretty_version.clone());
350        entry.version = Some(root.version.clone());
351        entry.reference = Some(root.reference.clone());
352        entry.package_type = Some(root.package_type.clone());
353        entry.install_path = Some(Some(install_path_code(&layout.root_install_path())));
354        entry.dev_requirement = Some(false);
355        entry.aliases = root.aliases.clone();
356    }
357
358    for e in versions.values_mut() {
359        e.replaced.sort();
360        e.provided.sort();
361    }
362
363    // Rendered in dumpToPhpCode format (4 spaces per level, var_export of
364    // scalars, install_path as a __DIR__ expression).
365    let mut out = String::from("<?php return array(\n");
366    out.push_str("    'root' => array(\n");
367    push_kv(&mut out, 2, "name", &php_str(&root.name));
368    push_kv(
369        &mut out,
370        2,
371        "pretty_version",
372        &php_str(&root.pretty_version),
373    );
374    push_kv(&mut out, 2, "version", &php_str(&root.version));
375    push_kv(
376        &mut out,
377        2,
378        "reference",
379        &root
380            .reference
381            .as_deref()
382            .map(php_str)
383            .unwrap_or_else(|| "null".to_owned()),
384    );
385    push_kv(&mut out, 2, "type", &php_str(&root.package_type));
386    push_kv(
387        &mut out,
388        2,
389        "install_path",
390        &install_path_code(&layout.root_install_path()),
391    );
392    if root.aliases.is_empty() {
393        push_kv(&mut out, 2, "aliases", "array()");
394    } else {
395        push_string_list(&mut out, 2, "aliases", &root.aliases);
396    }
397    push_kv(&mut out, 2, "dev", if root.dev { "true" } else { "false" });
398    out.push_str("    ),\n");
399    out.push_str("    'versions' => array(\n");
400    for (name, e) in &versions {
401        out.push_str(&format!("        {} => array(\n", php_str(name)));
402        if let Some(v) = &e.pretty_version {
403            push_kv(&mut out, 3, "pretty_version", &php_str(v));
404        }
405        if let Some(v) = &e.version {
406            push_kv(&mut out, 3, "version", &php_str(v));
407        }
408        if let Some(r) = &e.reference {
409            push_kv(
410                &mut out,
411                3,
412                "reference",
413                &r.as_deref()
414                    .map(php_str)
415                    .unwrap_or_else(|| "null".to_owned()),
416            );
417        }
418        if let Some(t) = &e.package_type {
419            push_kv(&mut out, 3, "type", &php_str(t));
420        }
421        if let Some(ip) = &e.install_path {
422            push_kv(&mut out, 3, "install_path", ip.as_deref().unwrap_or("null"));
423        }
424        if e.pretty_version.is_some() {
425            if e.aliases.is_empty() {
426                push_kv(&mut out, 3, "aliases", "array()");
427            } else {
428                push_string_list(&mut out, 3, "aliases", &e.aliases);
429            }
430        }
431        if let Some(d) = e.dev_requirement {
432            push_kv(
433                &mut out,
434                3,
435                "dev_requirement",
436                if d { "true" } else { "false" },
437            );
438        }
439        push_string_list(&mut out, 3, "replaced", &e.replaced);
440        push_string_list(&mut out, 3, "provided", &e.provided);
441        out.push_str("        ),\n");
442    }
443    out.push_str("    ),\n");
444    out.push_str(");\n");
445    Ok(out)
446}
447
448fn push_kv(out: &mut String, level: usize, key: &str, value: &str) {
449    for _ in 0..level {
450        out.push_str("    ");
451    }
452    out.push_str(&format!("{} => {},\n", php_str(key), value));
453}
454
455fn push_string_list(out: &mut String, level: usize, key: &str, values: &[String]) {
456    if values.is_empty() {
457        return;
458    }
459    for _ in 0..level {
460        out.push_str("    ");
461    }
462    out.push_str(&format!("{} => array(\n", php_str(key)));
463    for (i, v) in values.iter().enumerate() {
464        for _ in 0..=level {
465            out.push_str("    ");
466        }
467        out.push_str(&format!("{i} => {},\n", php_str(v)));
468    }
469    for _ in 0..level {
470        out.push_str("    ");
471    }
472    out.push_str("),\n");
473}
474
475pub fn write_state_files(
476    vendor_composer: &std::path::Path,
477    lock: &Lock,
478    root: &RootPackage,
479    root_manifest: &Value,
480    with_dev: bool,
481    layout: &Layout,
482) -> Result<()> {
483    std::fs::create_dir_all(vendor_composer).map_err(Error::io(vendor_composer))?;
484    let writes = [
485        ("installed.json", installed_json(lock, with_dev, layout)?),
486        (
487            "installed.php",
488            installed_php(lock, root, root_manifest, with_dev, layout)?,
489        ),
490        ("InstalledVersions.php", INSTALLED_VERSIONS_PHP.to_owned()),
491    ];
492    for (file, content) in writes {
493        let path = vendor_composer.join(file);
494        // Deterministic content: skip the write (and the mtime bump) when
495        // the file is already byte-identical — safe for parity.
496        if std::fs::read(&path).is_ok_and(|existing| existing == content.as_bytes()) {
497            continue;
498        }
499        let tmp = vendor_composer.join(format!(".{file}.vivacity-tmp"));
500        std::fs::write(&tmp, content).map_err(Error::io(&tmp))?;
501        std::fs::rename(&tmp, &path).map_err(Error::io(&path))?;
502    }
503    Ok(())
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use serde_json::json;
510
511    fn sample_lock() -> Lock {
512        Lock::parse(
513            &json!({
514                "packages": [
515                    {"name": "a/lib", "version": "v1.2.0", "type": "library",
516                     "dist": {"type": "zip", "url": "https://x/a.zip", "reference": "abcdef1234567890"},
517                     "replace": {"a/lib-compat": "self.version", "php": "*"},
518                     "provide": {"psr/log-implementation": "1.0"}},
519                    {"name": "a/meta", "version": "2.0.0", "type": "metapackage"}
520                ],
521                "packages-dev": [
522                    {"name": "d/tool", "version": "3.1.4", "type": "library",
523                     "dist": {"type": "zip", "url": "https://x/d.zip", "reference": "feedfacefeedface"}}
524                ]
525            })
526            .to_string(),
527        )
528        .expect("lock")
529    }
530
531    #[test]
532    fn inline_alias_of_the_lock_is_listed() {
533        let lock = Lock::parse(
534            &json!({
535                "packages": [
536                    {"name": "voku/portable-utf8", "version": "dev-joomla-5.3", "type": "library",
537                     "source": {"type": "git", "url": "https://x/u.git", "reference": "eeb3d9e390411cd31af808caa7f7de337ea3a24c"}}
538                ],
539                "packages-dev": [],
540                "aliases": [{"package": "voku/portable-utf8", "version": "dev-joomla-5.3",
541                             "alias": "6.0.13", "alias_normalized": "6.0.13.0"}]
542            })
543            .to_string(),
544        )
545        .expect("lock");
546        let layout = Layout::vendor_only(std::path::Path::new("/proj"), &lock, true);
547        let root = RootPackage::from_manifest(&json!({"name": "joomla/joomla-cms"}), true);
548        let text = installed_php(&lock, &root, &json!({}), true, &layout).expect("php");
549        assert!(
550            text.contains("'aliases' => array(\n                0 => '6.0.13',\n            ),"),
551            "{text}"
552        );
553    }
554
555    #[test]
556    fn installed_json_shape() {
557        // `installation-source` is decided by the installer (the download
558        // phase's doing) and carried by the entry; this only places it.
559        let mut lock = sample_lock();
560        for p in lock.packages.iter_mut() {
561            if p.name() == "a/lib" {
562                p.raw.insert(
563                    "installation-source".to_owned(),
564                    Value::String("dist".to_owned()),
565                );
566            }
567        }
568        let layout = Layout::vendor_only(std::path::Path::new("/proj"), &lock, true);
569        let text = installed_json(&lock, true, &layout).expect("json");
570        let v: Value = serde_json::from_str(&text).expect("parse");
571        let names: Vec<&str> = v["packages"]
572            .as_array()
573            .expect("arr")
574            .iter()
575            .map(|p| p["name"].as_str().expect("name"))
576            .collect();
577        assert_eq!(
578            names,
579            vec!["a/lib", "a/meta", "d/tool"],
580            "global sort by name"
581        );
582        assert_eq!(v["packages"][0]["version_normalized"], "1.2.0.0");
583        assert_eq!(v["packages"][0]["installation-source"], "dist");
584        assert_eq!(
585            v["packages"][1]["installation-source"],
586            Value::Null,
587            "an entry that does not carry it does not grow one"
588        );
589        assert_eq!(v["packages"][0]["install-path"], "../a/lib");
590        assert_eq!(v["packages"][1]["install-path"], Value::Null, "metapackage");
591        assert_eq!(v["dev"], true);
592        assert_eq!(v["dev-package-names"][0], "d/tool");
593        // Key order: version_normalized right after version.
594        let entry_text = text.split("\"a/lib\"").nth(1).expect("entry");
595        let vn = entry_text.find("version_normalized").expect("vn");
596        let dist = entry_text.find("\"dist\"").expect("dist");
597        assert!(vn < dist);
598
599        let lock = sample_lock();
600        let layout = Layout::vendor_only(std::path::Path::new("/proj"), &lock, false);
601        let no_dev = installed_json(&lock, false, &layout).expect("json");
602        let v: Value = serde_json::from_str(&no_dev).expect("parse");
603        assert_eq!(v["packages"].as_array().expect("arr").len(), 2);
604        assert_eq!(v["dev"], false);
605    }
606
607    #[test]
608    fn installed_php_contains_virtual_and_root_entries() {
609        let root = RootPackage {
610            name: "acme/app".to_owned(),
611            pretty_version: "1.0.0+no-version-set".to_owned(),
612            version: "1.0.0.0".to_owned(),
613            reference: None,
614            package_type: "project".to_owned(),
615            dev: true,
616            aliases: Vec::new(),
617            alias_normalized: None,
618        };
619        let lock = sample_lock();
620        let layout = Layout::vendor_only(std::path::Path::new("/proj"), &lock, true);
621        let text = installed_php(&lock, &root, &json!({}), true, &layout).expect("php");
622        assert!(text.starts_with("<?php return array(\n"));
623        assert!(text.contains("'acme/app' => array("));
624        assert!(text.contains("'a/lib-compat' => array("));
625        assert!(
626            text.contains("0 => 'v1.2.0',"),
627            "self.version resolved: {text}"
628        );
629        assert!(text.contains("'psr/log-implementation' => array("));
630        assert!(
631            !text.contains("'php' => array("),
632            "platform targets are excluded"
633        );
634        assert!(text.contains("'install_path' => __DIR__ . '/../a/lib',"));
635        assert!(
636            text.contains("'install_path' => null,"),
637            "metapackage without a path"
638        );
639        assert!(text.contains("'dev_requirement' => true,"));
640    }
641
642    #[test]
643    fn php_str_escapes() {
644        assert_eq!(php_str("a'b\\c"), r"'a\'b\\c'");
645    }
646}