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 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 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
122fn 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
133pub 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 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 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#[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>>, dev_requirement: Option<bool>,
201 aliases: Vec<String>,
202 replaced: Vec<String>,
203 provided: Vec<String>,
204}
205
206fn 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
223pub 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 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 for a in &lock.aliases {
281 if a.get("package").and_then(Value::as_str) == Some(p.name())
282 && a.get("version").and_then(Value::as_str) == Some(p.version())
283 {
284 if let Some(alias) = a.get("alias").and_then(Value::as_str) {
285 entry.aliases.push(alias.to_owned());
286 }
287 }
288 }
289 }
290
291 for p in &packages {
293 let is_dev = dev_names.contains(p.name());
294 for (kind, is_replace) in [("replace", true), ("provide", false)] {
295 if let Some(map) = p.raw.get(kind).and_then(Value::as_object) {
296 for (target, constraint) in map {
297 if is_platform_package(target) {
298 continue;
299 }
300 let entry = versions.entry(target.clone()).or_default();
301 match entry.dev_requirement {
302 None => entry.dev_requirement = Some(is_dev),
303 Some(true) if !is_dev => entry.dev_requirement = Some(false),
304 _ => {}
305 }
306 let mut c = constraint.as_str().unwrap_or("*").to_owned();
307 if c == "self.version" {
308 c = p.version().to_owned();
309 }
310 let list = if is_replace {
311 &mut entry.replaced
312 } else {
313 &mut entry.provided
314 };
315 if !list.contains(&c) {
316 list.push(c);
317 }
318 }
319 }
320 }
321 }
322
323 for (kind, is_replace) in [("replace", true), ("provide", false)] {
325 if let Some(map) = root_manifest.get(kind).and_then(Value::as_object) {
326 for (target, constraint) in map {
327 if is_platform_package(target) {
328 continue;
329 }
330 let entry = versions.entry(target.clone()).or_default();
331 entry.dev_requirement.get_or_insert(false);
332 if entry.dev_requirement == Some(true) {
333 entry.dev_requirement = Some(false);
334 }
335 let mut c = constraint.as_str().unwrap_or("*").to_owned();
336 if c == "self.version" {
337 c = root.pretty_version.clone();
338 }
339 let list = if is_replace {
340 &mut entry.replaced
341 } else {
342 &mut entry.provided
343 };
344 if !list.contains(&c) {
345 list.push(c);
346 }
347 }
348 }
349 }
350
351 {
353 let entry = versions.entry(root.name.clone()).or_default();
354 entry.pretty_version = Some(root.pretty_version.clone());
355 entry.version = Some(root.version.clone());
356 entry.reference = Some(root.reference.clone());
357 entry.package_type = Some(root.package_type.clone());
358 entry.install_path = Some(Some(install_path_code(&layout.root_install_path())));
359 entry.dev_requirement = Some(false);
360 entry.aliases = root.aliases.clone();
361 }
362
363 for e in versions.values_mut() {
364 e.replaced.sort();
365 e.provided.sort();
366 }
367
368 let mut out = String::from("<?php return array(\n");
371 out.push_str(" 'root' => array(\n");
372 push_kv(&mut out, 2, "name", &php_str(&root.name));
373 push_kv(
374 &mut out,
375 2,
376 "pretty_version",
377 &php_str(&root.pretty_version),
378 );
379 push_kv(&mut out, 2, "version", &php_str(&root.version));
380 push_kv(
381 &mut out,
382 2,
383 "reference",
384 &root
385 .reference
386 .as_deref()
387 .map(php_str)
388 .unwrap_or_else(|| "null".to_owned()),
389 );
390 push_kv(&mut out, 2, "type", &php_str(&root.package_type));
391 push_kv(
392 &mut out,
393 2,
394 "install_path",
395 &install_path_code(&layout.root_install_path()),
396 );
397 if root.aliases.is_empty() {
398 push_kv(&mut out, 2, "aliases", "array()");
399 } else {
400 push_string_list(&mut out, 2, "aliases", &root.aliases);
401 }
402 push_kv(&mut out, 2, "dev", if root.dev { "true" } else { "false" });
403 out.push_str(" ),\n");
404 out.push_str(" 'versions' => array(\n");
405 for (name, e) in &versions {
406 out.push_str(&format!(" {} => array(\n", php_str(name)));
407 if let Some(v) = &e.pretty_version {
408 push_kv(&mut out, 3, "pretty_version", &php_str(v));
409 }
410 if let Some(v) = &e.version {
411 push_kv(&mut out, 3, "version", &php_str(v));
412 }
413 if let Some(r) = &e.reference {
414 push_kv(
415 &mut out,
416 3,
417 "reference",
418 &r.as_deref()
419 .map(php_str)
420 .unwrap_or_else(|| "null".to_owned()),
421 );
422 }
423 if let Some(t) = &e.package_type {
424 push_kv(&mut out, 3, "type", &php_str(t));
425 }
426 if let Some(ip) = &e.install_path {
427 push_kv(&mut out, 3, "install_path", ip.as_deref().unwrap_or("null"));
428 }
429 if e.pretty_version.is_some() {
430 if e.aliases.is_empty() {
431 push_kv(&mut out, 3, "aliases", "array()");
432 } else {
433 push_string_list(&mut out, 3, "aliases", &e.aliases);
434 }
435 }
436 if let Some(d) = e.dev_requirement {
437 push_kv(
438 &mut out,
439 3,
440 "dev_requirement",
441 if d { "true" } else { "false" },
442 );
443 }
444 push_string_list(&mut out, 3, "replaced", &e.replaced);
445 push_string_list(&mut out, 3, "provided", &e.provided);
446 out.push_str(" ),\n");
447 }
448 out.push_str(" ),\n");
449 out.push_str(");\n");
450 Ok(out)
451}
452
453fn push_kv(out: &mut String, level: usize, key: &str, value: &str) {
454 for _ in 0..level {
455 out.push_str(" ");
456 }
457 out.push_str(&format!("{} => {},\n", php_str(key), value));
458}
459
460fn push_string_list(out: &mut String, level: usize, key: &str, values: &[String]) {
461 if values.is_empty() {
462 return;
463 }
464 for _ in 0..level {
465 out.push_str(" ");
466 }
467 out.push_str(&format!("{} => array(\n", php_str(key)));
468 for (i, v) in values.iter().enumerate() {
469 for _ in 0..=level {
470 out.push_str(" ");
471 }
472 out.push_str(&format!("{i} => {},\n", php_str(v)));
473 }
474 for _ in 0..level {
475 out.push_str(" ");
476 }
477 out.push_str("),\n");
478}
479
480pub fn write_state_files(
481 vendor_composer: &std::path::Path,
482 lock: &Lock,
483 root: &RootPackage,
484 root_manifest: &Value,
485 with_dev: bool,
486 layout: &Layout,
487) -> Result<()> {
488 std::fs::create_dir_all(vendor_composer).map_err(Error::io(vendor_composer))?;
489 let writes = [
490 ("installed.json", installed_json(lock, with_dev, layout)?),
491 (
492 "installed.php",
493 installed_php(lock, root, root_manifest, with_dev, layout)?,
494 ),
495 ("InstalledVersions.php", INSTALLED_VERSIONS_PHP.to_owned()),
496 ];
497 for (file, content) in writes {
498 let path = vendor_composer.join(file);
499 if std::fs::read(&path).is_ok_and(|existing| existing == content.as_bytes()) {
502 continue;
503 }
504 let tmp = vendor_composer.join(format!(".{file}.vivacity-tmp"));
505 std::fs::write(&tmp, content).map_err(Error::io(&tmp))?;
506 std::fs::rename(&tmp, &path).map_err(Error::io(&path))?;
507 }
508 Ok(())
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use serde_json::json;
515
516 fn sample_lock() -> Lock {
517 Lock::parse(
518 &json!({
519 "packages": [
520 {"name": "a/lib", "version": "v1.2.0", "type": "library",
521 "dist": {"type": "zip", "url": "https://x/a.zip", "reference": "abcdef1234567890"},
522 "replace": {"a/lib-compat": "self.version", "php": "*"},
523 "provide": {"psr/log-implementation": "1.0"}},
524 {"name": "a/meta", "version": "2.0.0", "type": "metapackage"}
525 ],
526 "packages-dev": [
527 {"name": "d/tool", "version": "3.1.4", "type": "library",
528 "dist": {"type": "zip", "url": "https://x/d.zip", "reference": "feedfacefeedface"}}
529 ]
530 })
531 .to_string(),
532 )
533 .expect("lock")
534 }
535
536 #[test]
537 fn inline_alias_of_the_lock_is_listed() {
538 let lock = Lock::parse(
539 &json!({
540 "packages": [
541 {"name": "voku/portable-utf8", "version": "dev-joomla-5.3", "type": "library",
542 "source": {"type": "git", "url": "https://x/u.git", "reference": "eeb3d9e390411cd31af808caa7f7de337ea3a24c"}}
543 ],
544 "packages-dev": [],
545 "aliases": [{"package": "voku/portable-utf8", "version": "dev-joomla-5.3",
546 "alias": "6.0.13", "alias_normalized": "6.0.13.0"}]
547 })
548 .to_string(),
549 )
550 .expect("lock");
551 let layout = Layout::vendor_only(std::path::Path::new("/proj"), &lock, true);
552 let root = RootPackage::from_manifest(&json!({"name": "joomla/joomla-cms"}), true);
553 let text = installed_php(&lock, &root, &json!({}), true, &layout).expect("php");
554 assert!(
555 text.contains("'aliases' => array(\n 0 => '6.0.13',\n ),"),
556 "{text}"
557 );
558 }
559
560 #[test]
561 fn installed_json_shape() {
562 let lock = sample_lock();
563 let layout = Layout::vendor_only(std::path::Path::new("/proj"), &lock, true);
564 let text = installed_json(&lock, true, &layout).expect("json");
565 let v: Value = serde_json::from_str(&text).expect("parse");
566 let names: Vec<&str> = v["packages"]
567 .as_array()
568 .expect("arr")
569 .iter()
570 .map(|p| p["name"].as_str().expect("name"))
571 .collect();
572 assert_eq!(
573 names,
574 vec!["a/lib", "a/meta", "d/tool"],
575 "global sort by name"
576 );
577 assert_eq!(v["packages"][0]["version_normalized"], "1.2.0.0");
578 assert_eq!(v["packages"][0]["installation-source"], "dist");
579 assert_eq!(v["packages"][0]["install-path"], "../a/lib");
580 assert_eq!(v["packages"][1]["install-path"], Value::Null, "metapackage");
581 assert_eq!(v["dev"], true);
582 assert_eq!(v["dev-package-names"][0], "d/tool");
583 let entry_text = text.split("\"a/lib\"").nth(1).expect("entry");
585 let vn = entry_text.find("version_normalized").expect("vn");
586 let dist = entry_text.find("\"dist\"").expect("dist");
587 assert!(vn < dist);
588
589 let lock = sample_lock();
590 let layout = Layout::vendor_only(std::path::Path::new("/proj"), &lock, false);
591 let no_dev = installed_json(&lock, false, &layout).expect("json");
592 let v: Value = serde_json::from_str(&no_dev).expect("parse");
593 assert_eq!(v["packages"].as_array().expect("arr").len(), 2);
594 assert_eq!(v["dev"], false);
595 }
596
597 #[test]
598 fn installed_php_contains_virtual_and_root_entries() {
599 let root = RootPackage {
600 name: "acme/app".to_owned(),
601 pretty_version: "1.0.0+no-version-set".to_owned(),
602 version: "1.0.0.0".to_owned(),
603 reference: None,
604 package_type: "project".to_owned(),
605 dev: true,
606 aliases: Vec::new(),
607 alias_normalized: None,
608 };
609 let lock = sample_lock();
610 let layout = Layout::vendor_only(std::path::Path::new("/proj"), &lock, true);
611 let text = installed_php(&lock, &root, &json!({}), true, &layout).expect("php");
612 assert!(text.starts_with("<?php return array(\n"));
613 assert!(text.contains("'acme/app' => array("));
614 assert!(text.contains("'a/lib-compat' => array("));
615 assert!(
616 text.contains("0 => 'v1.2.0',"),
617 "self.version resolved: {text}"
618 );
619 assert!(text.contains("'psr/log-implementation' => array("));
620 assert!(
621 !text.contains("'php' => array("),
622 "platform targets are excluded"
623 );
624 assert!(text.contains("'install_path' => __DIR__ . '/../a/lib',"));
625 assert!(
626 text.contains("'install_path' => null,"),
627 "metapackage without a path"
628 );
629 assert!(text.contains("'dev_requirement' => true,"));
630 }
631
632 #[test]
633 fn php_str_escapes() {
634 assert_eq!(php_str("a'b\\c"), r"'a\'b\\c'");
635 }
636}