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 for key in ENTRY_KEY_ORDER {
150 if let Some(v) = src.remove(key) {
151 entry.insert(key.to_owned(), v);
152 }
153 }
154 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#[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>>, dev_requirement: Option<bool>,
196 aliases: Vec<String>,
197 replaced: Vec<String>,
198 provided: Vec<String>,
199}
200
201fn 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
218pub 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 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 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 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 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 {
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 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 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 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 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}