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        // ArrayDumper: the key only exists if an installation source was
147        // chosen, never for a metapackage (nothing is installed).
148        if !p.is_metapackage() {
149            src.insert(
150                "installation-source".to_owned(),
151                Value::String("dist".to_owned()),
152            );
153        }
154        for key in ENTRY_KEY_ORDER {
155            if let Some(v) = src.remove(key) {
156                entry.insert(key.to_owned(), v);
157            }
158        }
159        // Keys outside the list (rare): afterwards, in their original order.
160        for (k, v) in src {
161            entry.insert(k, v);
162        }
163        entry.insert(
164            "install-path".to_owned(),
165            layout
166                .install_path(p.name())
167                .map(Value::String)
168                .unwrap_or(Value::Null),
169        );
170        packages.push(Value::Object(entry));
171    }
172
173    let mut dev_names: Vec<Value> = lock
174        .packages_dev
175        .iter()
176        .map(|p| Value::String(p.name().to_ascii_lowercase()))
177        .collect();
178    dev_names.sort_by(|a, b| a.as_str().cmp(&b.as_str()));
179
180    let mut doc = Map::new();
181    doc.insert("packages".to_owned(), Value::Array(packages));
182    doc.insert("dev".to_owned(), Value::Bool(with_dev));
183    doc.insert(
184        "dev-package-names".to_owned(),
185        Value::Array(if with_dev { dev_names } else { Vec::new() }),
186    );
187    let mut text = php_json_encode_with(&Value::Object(doc), FLAGS_JSONFILE)?;
188    text.push('\n');
189    Ok(text)
190}
191
192/// An entry of the `versions` array of installed.php.
193#[derive(Debug, Default)]
194struct VersionEntry {
195    pretty_version: Option<String>,
196    version: Option<String>,
197    reference: Option<Option<String>>,
198    package_type: Option<String>,
199    install_path: Option<Option<String>>, // None = not set yet; Some(None) = null
200    dev_requirement: Option<bool>,
201    aliases: Vec<String>,
202    replaced: Vec<String>,
203    provided: Vec<String>,
204}
205
206/// `PlatformRepository::isPlatformPackage` (php, hhvm, ext-*, lib-*,
207/// composer, composer-plugin-api, composer-runtime-api).
208fn is_platform_package(name: &str) -> bool {
209    let n = name.to_ascii_lowercase();
210    n == "php"
211        || n == "hhvm"
212        || n == "composer"
213        || n == "composer-plugin-api"
214        || n == "composer-runtime-api"
215        || matches!(
216            n.as_str(),
217            "php-64bit" | "php-ipv6" | "php-zts" | "php-debug"
218        )
219        || (n.starts_with("ext-") && !n.contains('/'))
220        || (n.starts_with("lib-") && !n.contains('/'))
221}
222
223/// Full installed.php (port of generateInstalledVersions + dumpToPhpCode).
224pub fn installed_php(
225    lock: &Lock,
226    root: &RootPackage,
227    root_manifest: &Value,
228    with_dev: bool,
229    layout: &Layout,
230) -> Result<String> {
231    use std::collections::BTreeMap;
232
233    let mut versions: BTreeMap<String, VersionEntry> = BTreeMap::new();
234    let dev_names: std::collections::BTreeSet<&str> = lock
235        .packages_dev
236        .iter()
237        .map(|p| p.raw.get("name").and_then(Value::as_str).unwrap_or(""))
238        .collect();
239
240    let packages: Vec<&LockPackage> = lock.wanted_packages(with_dev).collect();
241    for p in &packages {
242        let name = p.name().to_owned();
243        let is_dev = dev_names.contains(name.as_str());
244        let reference = p
245            .dist_reference()
246            .or_else(|| {
247                p.raw
248                    .get("source")
249                    .and_then(|s| s.get("reference"))
250                    .and_then(Value::as_str)
251            })
252            .map(str::to_owned);
253        let entry = versions.entry(name.clone()).or_default();
254        entry.pretty_version = Some(p.version().to_owned());
255        entry.version =
256            Some(normalize_pretty(p.version()).unwrap_or_else(|_| p.version().to_owned()));
257        entry.reference = Some(reference);
258        entry.package_type = Some(p.package_type().to_owned());
259        entry.install_path = Some(
260            layout
261                .install_path(p.name())
262                .map(|ip| install_path_code(&ip)),
263        );
264        entry.dev_requirement = Some(is_dev);
265        // Branch package: Composer loads an AliasPackage (branch-alias or
266        // default-branch) and installed.php lists its pretty version.
267        let default_branch = p
268            .raw
269            .get("default-branch")
270            .and_then(Value::as_bool)
271            .unwrap_or(false);
272        if let Some((_, pretty)) =
273            crate::root_version::branch_alias_of(p.version(), p.raw.get("extra"), default_branch)
274        {
275            entry.aliases.push(pretty);
276        }
277    }
278
279    // Virtual packages: replace then provide (same rules as Composer).
280    for p in &packages {
281        let is_dev = dev_names.contains(p.name());
282        for (kind, is_replace) in [("replace", true), ("provide", false)] {
283            if let Some(map) = p.raw.get(kind).and_then(Value::as_object) {
284                for (target, constraint) in map {
285                    if is_platform_package(target) {
286                        continue;
287                    }
288                    let entry = versions.entry(target.clone()).or_default();
289                    match entry.dev_requirement {
290                        None => entry.dev_requirement = Some(is_dev),
291                        Some(true) if !is_dev => entry.dev_requirement = Some(false),
292                        _ => {}
293                    }
294                    let mut c = constraint.as_str().unwrap_or("*").to_owned();
295                    if c == "self.version" {
296                        c = p.version().to_owned();
297                    }
298                    let list = if is_replace {
299                        &mut entry.replaced
300                    } else {
301                        &mut entry.provided
302                    };
303                    if !list.contains(&c) {
304                        list.push(c);
305                    }
306                }
307            }
308        }
309    }
310
311    // replace/provide of the root composer.json (e.g. replaced polyfills).
312    for (kind, is_replace) in [("replace", true), ("provide", false)] {
313        if let Some(map) = root_manifest.get(kind).and_then(Value::as_object) {
314            for (target, constraint) in map {
315                if is_platform_package(target) {
316                    continue;
317                }
318                let entry = versions.entry(target.clone()).or_default();
319                entry.dev_requirement.get_or_insert(false);
320                if entry.dev_requirement == Some(true) {
321                    entry.dev_requirement = Some(false);
322                }
323                let mut c = constraint.as_str().unwrap_or("*").to_owned();
324                if c == "self.version" {
325                    c = root.pretty_version.clone();
326                }
327                let list = if is_replace {
328                    &mut entry.replaced
329                } else {
330                    &mut entry.provided
331                };
332                if !list.contains(&c) {
333                    list.push(c);
334                }
335            }
336        }
337    }
338
339    // The root is part of versions.
340    {
341        let entry = versions.entry(root.name.clone()).or_default();
342        entry.pretty_version = Some(root.pretty_version.clone());
343        entry.version = Some(root.version.clone());
344        entry.reference = Some(root.reference.clone());
345        entry.package_type = Some(root.package_type.clone());
346        entry.install_path = Some(Some("__DIR__ . '/../../'".to_owned()));
347        entry.dev_requirement = Some(false);
348        entry.aliases = root.aliases.clone();
349    }
350
351    for e in versions.values_mut() {
352        e.replaced.sort();
353        e.provided.sort();
354    }
355
356    // Rendered in dumpToPhpCode format (4 spaces per level, var_export of
357    // scalars, install_path as a __DIR__ expression).
358    let mut out = String::from("<?php return array(\n");
359    out.push_str("    'root' => array(\n");
360    push_kv(&mut out, 2, "name", &php_str(&root.name));
361    push_kv(
362        &mut out,
363        2,
364        "pretty_version",
365        &php_str(&root.pretty_version),
366    );
367    push_kv(&mut out, 2, "version", &php_str(&root.version));
368    push_kv(
369        &mut out,
370        2,
371        "reference",
372        &root
373            .reference
374            .as_deref()
375            .map(php_str)
376            .unwrap_or_else(|| "null".to_owned()),
377    );
378    push_kv(&mut out, 2, "type", &php_str(&root.package_type));
379    push_kv(&mut out, 2, "install_path", "__DIR__ . '/../../'");
380    if root.aliases.is_empty() {
381        push_kv(&mut out, 2, "aliases", "array()");
382    } else {
383        push_string_list(&mut out, 2, "aliases", &root.aliases);
384    }
385    push_kv(&mut out, 2, "dev", if root.dev { "true" } else { "false" });
386    out.push_str("    ),\n");
387    out.push_str("    'versions' => array(\n");
388    for (name, e) in &versions {
389        out.push_str(&format!("        {} => array(\n", php_str(name)));
390        if let Some(v) = &e.pretty_version {
391            push_kv(&mut out, 3, "pretty_version", &php_str(v));
392        }
393        if let Some(v) = &e.version {
394            push_kv(&mut out, 3, "version", &php_str(v));
395        }
396        if let Some(r) = &e.reference {
397            push_kv(
398                &mut out,
399                3,
400                "reference",
401                &r.as_deref()
402                    .map(php_str)
403                    .unwrap_or_else(|| "null".to_owned()),
404            );
405        }
406        if let Some(t) = &e.package_type {
407            push_kv(&mut out, 3, "type", &php_str(t));
408        }
409        if let Some(ip) = &e.install_path {
410            push_kv(&mut out, 3, "install_path", ip.as_deref().unwrap_or("null"));
411        }
412        if e.pretty_version.is_some() {
413            if e.aliases.is_empty() {
414                push_kv(&mut out, 3, "aliases", "array()");
415            } else {
416                push_string_list(&mut out, 3, "aliases", &e.aliases);
417            }
418        }
419        if let Some(d) = e.dev_requirement {
420            push_kv(
421                &mut out,
422                3,
423                "dev_requirement",
424                if d { "true" } else { "false" },
425            );
426        }
427        push_string_list(&mut out, 3, "replaced", &e.replaced);
428        push_string_list(&mut out, 3, "provided", &e.provided);
429        out.push_str("        ),\n");
430    }
431    out.push_str("    ),\n");
432    out.push_str(");\n");
433    Ok(out)
434}
435
436fn push_kv(out: &mut String, level: usize, key: &str, value: &str) {
437    for _ in 0..level {
438        out.push_str("    ");
439    }
440    out.push_str(&format!("{} => {},\n", php_str(key), value));
441}
442
443fn push_string_list(out: &mut String, level: usize, key: &str, values: &[String]) {
444    if values.is_empty() {
445        return;
446    }
447    for _ in 0..level {
448        out.push_str("    ");
449    }
450    out.push_str(&format!("{} => array(\n", php_str(key)));
451    for (i, v) in values.iter().enumerate() {
452        for _ in 0..=level {
453            out.push_str("    ");
454        }
455        out.push_str(&format!("{i} => {},\n", php_str(v)));
456    }
457    for _ in 0..level {
458        out.push_str("    ");
459    }
460    out.push_str("),\n");
461}
462
463pub fn write_state_files(
464    vendor_composer: &std::path::Path,
465    lock: &Lock,
466    root: &RootPackage,
467    root_manifest: &Value,
468    with_dev: bool,
469    layout: &Layout,
470) -> Result<()> {
471    std::fs::create_dir_all(vendor_composer).map_err(Error::io(vendor_composer))?;
472    let writes = [
473        ("installed.json", installed_json(lock, with_dev, layout)?),
474        (
475            "installed.php",
476            installed_php(lock, root, root_manifest, with_dev, layout)?,
477        ),
478        ("InstalledVersions.php", INSTALLED_VERSIONS_PHP.to_owned()),
479    ];
480    for (file, content) in writes {
481        let path = vendor_composer.join(file);
482        let tmp = vendor_composer.join(format!(".{file}.vivacity-tmp"));
483        std::fs::write(&tmp, content).map_err(Error::io(&tmp))?;
484        std::fs::rename(&tmp, &path).map_err(Error::io(&path))?;
485    }
486    Ok(())
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use serde_json::json;
493
494    fn sample_lock() -> Lock {
495        Lock::parse(
496            &json!({
497                "packages": [
498                    {"name": "a/lib", "version": "v1.2.0", "type": "library",
499                     "dist": {"type": "zip", "url": "https://x/a.zip", "reference": "abcdef1234567890"},
500                     "replace": {"a/lib-compat": "self.version", "php": "*"},
501                     "provide": {"psr/log-implementation": "1.0"}},
502                    {"name": "a/meta", "version": "2.0.0", "type": "metapackage"}
503                ],
504                "packages-dev": [
505                    {"name": "d/tool", "version": "3.1.4", "type": "library",
506                     "dist": {"type": "zip", "url": "https://x/d.zip", "reference": "feedfacefeedface"}}
507                ]
508            })
509            .to_string(),
510        )
511        .expect("lock")
512    }
513
514    #[test]
515    fn installed_json_shape() {
516        let lock = sample_lock();
517        let layout = Layout::vendor_only(std::path::Path::new("/proj"), &lock, true);
518        let text = installed_json(&lock, true, &layout).expect("json");
519        let v: Value = serde_json::from_str(&text).expect("parse");
520        let names: Vec<&str> = v["packages"]
521            .as_array()
522            .expect("arr")
523            .iter()
524            .map(|p| p["name"].as_str().expect("name"))
525            .collect();
526        assert_eq!(
527            names,
528            vec!["a/lib", "a/meta", "d/tool"],
529            "global sort by name"
530        );
531        assert_eq!(v["packages"][0]["version_normalized"], "1.2.0.0");
532        assert_eq!(v["packages"][0]["installation-source"], "dist");
533        assert_eq!(v["packages"][0]["install-path"], "../a/lib");
534        assert_eq!(v["packages"][1]["install-path"], Value::Null, "metapackage");
535        assert_eq!(v["dev"], true);
536        assert_eq!(v["dev-package-names"][0], "d/tool");
537        // Key order: version_normalized right after version.
538        let entry_text = text.split("\"a/lib\"").nth(1).expect("entry");
539        let vn = entry_text.find("version_normalized").expect("vn");
540        let dist = entry_text.find("\"dist\"").expect("dist");
541        assert!(vn < dist);
542
543        let lock = sample_lock();
544        let layout = Layout::vendor_only(std::path::Path::new("/proj"), &lock, false);
545        let no_dev = installed_json(&lock, false, &layout).expect("json");
546        let v: Value = serde_json::from_str(&no_dev).expect("parse");
547        assert_eq!(v["packages"].as_array().expect("arr").len(), 2);
548        assert_eq!(v["dev"], false);
549    }
550
551    #[test]
552    fn installed_php_contains_virtual_and_root_entries() {
553        let root = RootPackage {
554            name: "acme/app".to_owned(),
555            pretty_version: "1.0.0+no-version-set".to_owned(),
556            version: "1.0.0.0".to_owned(),
557            reference: None,
558            package_type: "project".to_owned(),
559            dev: true,
560            aliases: Vec::new(),
561            alias_normalized: None,
562        };
563        let lock = sample_lock();
564        let layout = Layout::vendor_only(std::path::Path::new("/proj"), &lock, true);
565        let text = installed_php(&lock, &root, &json!({}), true, &layout).expect("php");
566        assert!(text.starts_with("<?php return array(\n"));
567        assert!(text.contains("'acme/app' => array("));
568        assert!(text.contains("'a/lib-compat' => array("));
569        assert!(
570            text.contains("0 => 'v1.2.0',"),
571            "self.version resolved: {text}"
572        );
573        assert!(text.contains("'psr/log-implementation' => array("));
574        assert!(
575            !text.contains("'php' => array("),
576            "platform targets are excluded"
577        );
578        assert!(text.contains("'install_path' => __DIR__ . '/../a/lib',"));
579        assert!(
580            text.contains("'install_path' => null,"),
581            "metapackage without a path"
582        );
583        assert!(text.contains("'dev_requirement' => true,"));
584    }
585
586    #[test]
587    fn php_str_escapes() {
588        assert_eq!(php_str("a'b\\c"), r"'a\'b\\c'");
589    }
590}