1use crate::layout::Layout;
11use crate::lock::{DistKind, Lock, LockPackage};
12use serde_json::Value;
13use std::path::Path;
14
15pub const EMULATED_PLUGINS: &[&str] = &[
20 "symfony/runtime",
21 "composer/installers",
22 "pestphp/pest-plugin",
23 "dealerdirect/phpcodesniffer-composer-installer",
24 "phpstan/extension-installer",
25 "rector/extension-installer",
26 "wikimedia/composer-merge-plugin",
27 "yiisoft/yii2-composer",
28 "codeception/c3",
29 "bamarni/composer-bin-plugin",
30 "roots/wordpress-core-installer",
31 "mnsami/composer-custom-directory-installer",
32];
33
34pub const BENIGN_PLUGINS: &[&str] = &[
43 "symfony/flex",
44 "composer/package-versions-deprecated",
45 "php-http/discovery",
46 "drupal/core-project-message",
49 "drupal/core-recipe-unpack",
52 "symfony/thanks",
57 "ergebnis/composer-normalize",
60];
61
62pub const LAYOUT_PLUGINS: &[&str] = &[
65 "cweagans/composer-patches",
66 "oomphinc/composer-installers-extender",
67];
68
69pub const RESOLUTION_INERT: &[&str] = &[
77 "composer/installers",
78 "symfony/runtime",
79 "pestphp/pest-plugin",
80 "dealerdirect/phpcodesniffer-composer-installer",
81 "phpstan/extension-installer",
82 "rector/extension-installer",
83 "composer/package-versions-deprecated",
84 "ergebnis/composer-normalize",
85 "codeception/c3",
88 "bamarni/composer-bin-plugin",
92 "roots/wordpress-core-installer",
94 "php-http/discovery",
98 "drupal/core-project-message",
99 "cweagans/composer-patches",
102 "mlocati/composer-patcher",
103 "oomphinc/composer-installers-extender",
104 "mnsami/composer-custom-directory-installer",
105];
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum ResolutionCommand {
110 Update,
111 Require,
112 Remove,
113}
114
115impl ResolutionCommand {
116 pub fn name(self) -> &'static str {
117 match self {
118 ResolutionCommand::Update => "update",
119 ResolutionCommand::Require => "require",
120 ResolutionCommand::Remove => "remove",
121 }
122 }
123}
124
125pub fn resolution_effect(
130 plugin: &str,
131 command: ResolutionCommand,
132 with_install: bool,
133) -> Option<String> {
134 if RESOLUTION_INERT.contains(&plugin) {
135 return None;
136 }
137 Some(match plugin {
138 "symfony/flex" => {
145 if command == ResolutionCommand::Require {
146 "resolves Flex aliases and applies recipes on require (not emulated)".to_owned()
147 } else if with_install {
148 "applies recipes to the packages it installs (not emulated; --no-install resolves natively)".to_owned()
149 } else {
150 return None;
151 }
152 }
153 "symfony/thanks" => {
161 if command == ResolutionCommand::Update && with_install {
162 "prints a reminder after a package update, after querying GitHub (not emulated; --no-install resolves natively)".to_owned()
163 } else {
164 return None;
165 }
166 }
167 "yiisoft/yii2-composer" => {
171 if with_install {
172 "prints yiisoft/yii2's upgrade notes after an update with install (not emulated; --no-install resolves natively)".to_owned()
173 } else {
174 return None;
175 }
176 }
177 "wikimedia/composer-merge-plugin" => return None,
183 "drupal/core-recipe-unpack" => {
186 if command == ResolutionCommand::Require {
187 "unpacks recipes into composer.json on require (not emulated)".to_owned()
188 } else {
189 return None;
190 }
191 }
192 _ => "is not on vivacity's known-plugin list".to_owned(),
193 })
194}
195
196pub fn resolution_issues(
201 project_dir: &Path,
202 root_manifest: &Value,
203 command: ResolutionCommand,
204 plugins_enabled: bool,
205 with_install: bool,
206) -> Vec<ScopeIssue> {
207 let mut issues = Vec::new();
208 for name in active_plugins(project_dir, root_manifest, plugins_enabled) {
209 if let Some(effect) = resolution_effect(&name, command, with_install) {
210 issues.push(ScopeIssue::ResolutionPlugin(name, effect));
211 }
212 }
213 issues
214}
215
216pub fn active_plugins(
220 project_dir: &Path,
221 root_manifest: &Value,
222 plugins_enabled: bool,
223) -> Vec<String> {
224 if !plugins_enabled {
225 return Vec::new();
226 }
227 let mut names: Vec<String> = Vec::new();
228 let mut collect = |packages: Vec<Value>| {
229 for p in packages {
230 if p.get("type").and_then(Value::as_str) == Some("composer-plugin") {
231 if let Some(n) = p.get("name").and_then(Value::as_str) {
232 if !names.iter().any(|x| x == n) {
233 names.push(n.to_owned());
234 }
235 }
236 }
237 }
238 };
239 collect(crate::layout::installed_packages_of(
240 project_dir,
241 root_manifest,
242 ));
243 if let Some(home) = crate::fetch::composer_home() {
244 collect(crate::layout::installed_packages_at(
245 &home.join("vendor").join("composer"),
246 ));
247 }
248 names.retain(|name| {
249 matches!(
250 crate::layout::plugin_allowed(root_manifest, name),
251 crate::layout::PluginVerdict::Allowed
252 )
253 });
254 names
255}
256
257#[derive(Debug, PartialEq, Eq)]
258pub enum ScopeIssue {
259 UnknownPlugin(String),
261 ResolutionPlugin(String, String),
264 LayoutPlugin(String),
266 Layout(String),
269 NoUsableDist(String),
271 Config(String),
275 MergePlugin(String),
280}
281
282impl std::fmt::Display for ScopeIssue {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 match self {
285 ScopeIssue::UnknownPlugin(p) => {
286 write!(f, "plugin {p} is not on vivacity's known-plugin list")
287 }
288 ScopeIssue::LayoutPlugin(p) => {
289 write!(f, "plugin {p} changes the install layout (not emulated)")
290 }
291 ScopeIssue::ResolutionPlugin(p, what) => write!(f, "plugin {p} {what}"),
292 ScopeIssue::Layout(why) => write!(f, "{why}"),
293 ScopeIssue::NoUsableDist(p) => {
294 write!(
295 f,
296 "package {p} has no usable dist (no zip or tar dist, no path source)"
297 )
298 }
299 ScopeIssue::Config(why) => write!(f, "config {why} is not supported natively"),
300 ScopeIssue::MergePlugin(why) => write!(f, "wikimedia/composer-merge-plugin: {why}"),
301 }
302 }
303}
304
305#[derive(Debug, Default)]
306pub struct ScopeReport {
307 pub issues: Vec<ScopeIssue>,
309 pub skipped_plugins: Vec<String>,
311 pub layout: Option<Layout>,
313}
314
315impl ScopeReport {
316 pub fn is_native_ok(&self) -> bool {
317 self.issues.is_empty()
318 }
319}
320
321pub fn analyze(
324 project_dir: &Path,
325 lock: &Lock,
326 root_manifest: &Value,
327 with_dev: bool,
328 plugins_enabled: bool,
329) -> ScopeReport {
330 let mut report = ScopeReport::default();
331
332 for p in lock.wanted_packages(with_dev) {
333 classify_package(project_dir, p, plugins_enabled, &mut report);
334 }
335 if plugins_enabled
338 && matches!(
339 crate::layout::plugin_allowed(root_manifest, crate::yii2_composer::PLUGIN_NAME),
340 crate::layout::PluginVerdict::Allowed
341 )
342 && lock
343 .wanted_packages(with_dev)
344 .any(|p| p.name() == crate::yii2_composer::PLUGIN_NAME)
345 && lock
346 .wanted_packages(with_dev)
347 .any(|p| p.name() == crate::yii2_composer::YII2_DEV)
348 {
349 report.issues.push(ScopeIssue::UnknownPlugin(format!(
350 "{} with {} (the Yii.php shims are not emulated); it",
351 crate::yii2_composer::PLUGIN_NAME,
352 crate::yii2_composer::YII2_DEV
353 )));
354 }
355 if plugins_enabled
359 && matches!(
360 crate::layout::plugin_allowed(root_manifest, crate::bamarni_bin::PLUGIN_NAME),
361 crate::layout::PluginVerdict::Allowed
362 )
363 && lock
364 .wanted_packages(with_dev)
365 .any(|p| p.name() == crate::bamarni_bin::PLUGIN_NAME)
366 {
367 match crate::bamarni_bin::config(root_manifest.get("extra")) {
368 Ok(cfg) if cfg.forward_command => report.issues.push(ScopeIssue::UnknownPlugin(
369 format!(
370 "{} with extra.bamarni-bin.forward-command true (forwards the command to vendor-bin/*, not emulated); it",
371 crate::bamarni_bin::PLUGIN_NAME
372 ),
373 )),
374 Ok(_) => {}
375 Err(msg) => report.issues.push(ScopeIssue::Config(format!(
376 "extra.bamarni-bin ({msg})"
377 ))),
378 }
379 }
380 report.issues.extend(
381 config_issues(root_manifest)
382 .into_iter()
383 .map(ScopeIssue::Config),
384 );
385 match Layout::resolve(project_dir, lock, root_manifest, with_dev, plugins_enabled) {
386 Ok(layout) => report.layout = Some(layout),
387 Err(issues) => report
388 .issues
389 .extend(issues.into_iter().map(ScopeIssue::Layout)),
390 }
391 report
392}
393
394pub fn plugin_issues(lock: &Lock, with_dev: bool) -> Vec<ScopeIssue> {
398 let mut report = ScopeReport::default();
399 for p in lock.wanted_packages(with_dev) {
400 classify_plugin(p, &mut report);
401 }
402 report.issues
403}
404
405pub fn config_issues(root_manifest: &Value) -> Vec<String> {
410 let value = |key: &str| -> Option<Value> {
411 root_manifest
412 .get("config")
413 .and_then(|c| c.get(key))
414 .cloned()
415 .or_else(|| crate::layout::global_config_value(key))
416 };
417 let mut out = Vec::new();
418 if let Some(v) = value("preferred-install") {
419 let wants_source = match &v {
420 Value::String(s) => s == "source",
421 Value::Object(m) => m.values().any(|x| x.as_str() == Some("source")),
422 _ => false,
423 };
424 if wants_source {
425 out.push(format!("preferred-install {v}"));
426 }
427 }
428 out
429}
430
431fn classify_package(
432 project_dir: &Path,
433 p: &LockPackage,
434 plugins_enabled: bool,
435 report: &mut ScopeReport,
436) {
437 if plugins_enabled {
441 classify_plugin(p, report);
442 }
443 if p.is_metapackage() {
444 return;
445 }
446 let usable = match p.dist_kind() {
447 DistKind::Zip | DistKind::Tar => true,
448 DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
452 DistKind::Other | DistKind::Missing => false,
453 };
454 if !usable {
455 report
456 .issues
457 .push(ScopeIssue::NoUsableDist(p.name().to_owned()));
458 }
459}
460
461fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
462 if p.package_type() != "composer-plugin" {
463 return;
464 }
465 let name = p.name().to_owned();
466 if EMULATED_PLUGINS.contains(&name.as_str()) {
467 } else if BENIGN_PLUGINS.contains(&name.as_str()) {
469 report.skipped_plugins.push(name);
470 } else if LAYOUT_PLUGINS.contains(&name.as_str()) {
471 report.issues.push(ScopeIssue::LayoutPlugin(name));
472 } else {
473 report.issues.push(ScopeIssue::UnknownPlugin(name));
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use crate::lock::Lock;
481 use serde_json::json;
482
483 fn lock_with(packages: serde_json::Value) -> Lock {
484 Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
485 }
486
487 fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
488 json!({"name": name, "version": "1.0.0", "type": r#type,
489 "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
490 }
491
492 fn proj() -> std::path::PathBuf {
493 std::path::PathBuf::from("/nonexistent-vivacity-scope")
494 }
495
496 #[test]
497 fn plain_library_is_native() {
498 let lock = lock_with(json!([zip_pkg("a/b", "library")]));
499 let r = analyze(&proj(), &lock, &json!({}), true, true);
500 assert!(r.is_native_ok());
501 assert!(r.skipped_plugins.is_empty());
502 assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
503 }
504
505 #[test]
506 fn emulated_and_benign_plugins_stay_native() {
507 let lock = lock_with(json!([
508 zip_pkg("symfony/runtime", "composer-plugin"),
509 zip_pkg("symfony/flex", "composer-plugin"),
510 ]));
511 let r = analyze(&proj(), &lock, &json!({}), true, true);
512 assert!(r.is_native_ok());
513 assert_eq!(r.skipped_plugins, vec!["symfony/flex"]);
514 }
515
516 #[test]
517 fn thanks_and_normalize_are_benign_at_install() {
518 let lock = lock_with(json!([
519 zip_pkg("symfony/thanks", "composer-plugin"),
520 zip_pkg("ergebnis/composer-normalize", "composer-plugin"),
521 ]));
522 let r = analyze(&proj(), &lock, &json!({}), true, true);
523 assert!(r.is_native_ok(), "{:?}", r.issues);
524 assert_eq!(
525 r.skipped_plugins,
526 vec!["symfony/thanks", "ergebnis/composer-normalize"]
527 );
528 }
529
530 #[test]
531 fn thanks_reminder_only_on_update_with_install() {
532 use ResolutionCommand::*;
533 let effect = |c, i| resolution_effect("symfony/thanks", c, i);
534 assert!(effect(Update, true).is_some());
535 assert!(effect(Update, false).is_none());
536 assert!(effect(Require, true).is_none());
537 assert!(effect(Remove, true).is_none());
538 assert!(resolution_effect("ergebnis/composer-normalize", Update, true).is_none());
539 }
540
541 #[test]
542 fn yii2_composer_is_emulated_except_with_yii2_dev() {
543 let allow = json!({"config": {"allow-plugins": {"yiisoft/yii2-composer": true}}});
544 let lock = lock_with(json!([
545 zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
546 zip_pkg("yiisoft/yii2-gii", "yii2-extension"),
547 ]));
548 assert!(analyze(&proj(), &lock, &allow, true, true).is_native_ok());
549 let lock = lock_with(json!([
550 zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
551 zip_pkg("yiisoft/yii2-dev", "yii2-extension"),
552 ]));
553 let r = analyze(&proj(), &lock, &allow, true, true);
554 assert!(!r.is_native_ok());
555 assert!(
556 r.issues[0].to_string().contains("yiisoft/yii2-dev"),
557 "{}",
558 r.issues[0]
559 );
560 assert!(analyze(&proj(), &lock, &allow, true, false).is_native_ok());
562 }
563
564 #[test]
565 fn bamarni_bin_native_unless_it_forwards() {
566 let lock = lock_with(json!([zip_pkg(
567 "bamarni/composer-bin-plugin",
568 "composer-plugin"
569 )]));
570 let allow = |extra: serde_json::Value| json!({"config": {"allow-plugins": {"bamarni/composer-bin-plugin": true}}, "extra": extra});
571 assert!(analyze(&proj(), &lock, &allow(json!({})), true, true).is_native_ok());
572 let r = analyze(
573 &proj(),
574 &lock,
575 &allow(json!({"bamarni-bin": {"forward-command": true}})),
576 true,
577 true,
578 );
579 assert!(
580 r.issues[0].to_string().contains("forward-command true"),
581 "{}",
582 r.issues[0]
583 );
584 let r = analyze(
585 &proj(),
586 &lock,
587 &allow(json!({"bamarni-bin": {"bin-links": "no"}})),
588 true,
589 true,
590 );
591 assert!(
592 r.issues[0].to_string().contains("boolean value"),
593 "{}",
594 r.issues[0]
595 );
596 }
597
598 #[test]
599 fn no_plugins_makes_every_plugin_a_plain_library() {
600 let lock = lock_with(json!([
601 zip_pkg("acme/mystery-plugin", "composer-plugin"),
602 zip_pkg("cweagans/composer-patches", "composer-plugin"),
603 zip_pkg("symfony/flex", "composer-plugin"),
604 ]));
605 let r = analyze(&proj(), &lock, &json!({}), true, false);
606 assert!(r.is_native_ok(), "{:?}", r.issues);
607 assert!(r.skipped_plugins.is_empty());
608 assert_eq!(
609 r.layout.expect("layout").rel("acme/mystery-plugin"),
610 Some("vendor/acme/mystery-plugin")
611 );
612 }
613
614 #[test]
615 fn unknown_or_layout_plugin_is_out_of_scope() {
616 let lock = lock_with(json!([
617 zip_pkg("acme/mystery-plugin", "composer-plugin"),
618 zip_pkg("cweagans/composer-patches", "composer-plugin"),
619 ]));
620 let r = analyze(&proj(), &lock, &json!({}), true, true);
621 assert_eq!(
622 r.issues,
623 vec![
624 ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
625 ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
626 ]
627 );
628 }
629
630 #[test]
631 fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
632 let lock = lock_with(json!([
633 {"name": "a/src-only", "version": "1.0.0", "type": "library",
634 "source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
635 {"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
636 zip_pkg("composer/installers", "composer-plugin"),
637 ]));
638 let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
641 let r = analyze(&proj(), &lock, &manifest, true, true);
642 assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
643 assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
644 assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
645 }
646
647 #[test]
648 fn path_package_is_native_when_its_source_exists() {
649 let tmp = tempfile::tempdir().expect("tmp");
650 std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
651 let lock = lock_with(json!([
652 {"name": "a/here", "version": "dev-main", "type": "library",
653 "dist": {"type": "path", "url": "packages/here", "reference": "r"}},
654 {"name": "a/gone", "version": "dev-main", "type": "library",
655 "dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
656 ]));
657 let r = analyze(tmp.path(), &lock, &json!({}), true, true);
658 let expected = if cfg!(unix) {
659 vec![ScopeIssue::NoUsableDist("a/gone".into())]
660 } else {
661 vec![
662 ScopeIssue::NoUsableDist("a/here".into()),
663 ScopeIssue::NoUsableDist("a/gone".into()),
664 ]
665 };
666 assert_eq!(r.issues, expected);
667 }
668
669 #[test]
670 fn unread_config_keys_are_scope_issues() {
671 let lock = lock_with(json!([zip_pkg("a/b", "library")]));
672 let manifest = json!({"config": {"vendor-dir": "lib/", "bin-dir": "vendor/bin",
673 "preferred-install": {"acme/*": "source", "*": "dist"}}});
674 let r = analyze(&proj(), &lock, &manifest, true, true);
675 assert_eq!(
676 r.issues,
677 vec![ScopeIssue::Config(
678 "preferred-install {\"acme/*\":\"source\",\"*\":\"dist\"}".into()
679 ),]
680 );
681 let manifest = json!({"config": {"vendor-dir": "vendor", "preferred-install": "auto"}});
682 assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
683 }
684
685 #[test]
686 fn no_dev_skips_dev_packages() {
687 let lock = Lock::parse(
688 &json!({
689 "packages": [zip_pkg("a/b", "library")],
690 "packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
691 })
692 .to_string(),
693 )
694 .expect("lock");
695 assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
696 assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
697 }
698}