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