1use 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
22const 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#[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 pub aliases: Vec<String>,
71 pub alias_normalized: Option<String>,
73}
74
75impl RootPackage {
76 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 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 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
134fn 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
145pub 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 for key in ENTRY_KEY_ORDER {
162 if let Some(v) = src.remove(key) {
163 entry.insert(key.to_owned(), v);
164 }
165 }
166 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#[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>>, dev_requirement: Option<bool>,
208 aliases: Vec<String>,
209 replaced: Vec<String>,
210 provided: Vec<String>,
211}
212
213fn 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
230pub 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 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 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 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 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 {
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 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 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 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 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}