use crate::layout::Layout;
use crate::lock::{DistKind, Lock, LockPackage};
use serde_json::Value;
use std::path::Path;
pub const EMULATED_PLUGINS: &[&str] = &[
"symfony/runtime",
"composer/installers",
"pestphp/pest-plugin",
"dealerdirect/phpcodesniffer-composer-installer",
"phpstan/extension-installer",
"rector/extension-installer",
"wikimedia/composer-merge-plugin",
"yiisoft/yii2-composer",
"codeception/c3",
"bamarni/composer-bin-plugin",
"roots/wordpress-core-installer",
"mnsami/composer-custom-directory-installer",
];
pub const BENIGN_PLUGINS: &[&str] = &[
"symfony/flex",
"composer/package-versions-deprecated",
"php-http/discovery",
"drupal/core-project-message",
"drupal/core-recipe-unpack",
"symfony/thanks",
"ergebnis/composer-normalize",
];
pub const LAYOUT_PLUGINS: &[&str] = &[
"cweagans/composer-patches",
"oomphinc/composer-installers-extender",
];
pub const RESOLUTION_INERT: &[&str] = &[
"composer/installers",
"symfony/runtime",
"pestphp/pest-plugin",
"dealerdirect/phpcodesniffer-composer-installer",
"phpstan/extension-installer",
"rector/extension-installer",
"composer/package-versions-deprecated",
"ergebnis/composer-normalize",
"codeception/c3",
"bamarni/composer-bin-plugin",
"roots/wordpress-core-installer",
"php-http/discovery",
"drupal/core-project-message",
"cweagans/composer-patches",
"mlocati/composer-patcher",
"oomphinc/composer-installers-extender",
"mnsami/composer-custom-directory-installer",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolutionCommand {
Update,
Require,
Remove,
}
impl ResolutionCommand {
pub fn name(self) -> &'static str {
match self {
ResolutionCommand::Update => "update",
ResolutionCommand::Require => "require",
ResolutionCommand::Remove => "remove",
}
}
}
pub fn resolution_effect(
plugin: &str,
command: ResolutionCommand,
with_install: bool,
) -> Option<String> {
if RESOLUTION_INERT.contains(&plugin) {
return None;
}
Some(match plugin {
"symfony/flex" => {
if command == ResolutionCommand::Require {
"resolves Flex aliases and applies recipes on require (not emulated)".to_owned()
} else if with_install {
"applies recipes to the packages it installs (not emulated; --no-install resolves natively)".to_owned()
} else {
return None;
}
}
"symfony/thanks" => {
if command == ResolutionCommand::Update && with_install {
"prints a reminder after a package update, after querying GitHub (not emulated; --no-install resolves natively)".to_owned()
} else {
return None;
}
}
"yiisoft/yii2-composer" => {
if with_install {
"prints yiisoft/yii2's upgrade notes after an update with install (not emulated; --no-install resolves natively)".to_owned()
} else {
return None;
}
}
"wikimedia/composer-merge-plugin" => return None,
"drupal/core-recipe-unpack" => {
if command == ResolutionCommand::Require {
"unpacks recipes into composer.json on require (not emulated)".to_owned()
} else {
return None;
}
}
_ => "is not on vivacity's known-plugin list".to_owned(),
})
}
pub fn resolution_issues(
project_dir: &Path,
root_manifest: &Value,
command: ResolutionCommand,
plugins_enabled: bool,
with_install: bool,
) -> Vec<ScopeIssue> {
let mut issues = Vec::new();
for name in active_plugins(project_dir, root_manifest, plugins_enabled) {
if let Some(effect) = resolution_effect(&name, command, with_install) {
issues.push(ScopeIssue::ResolutionPlugin(name, effect));
}
}
issues
}
pub fn active_plugins(
project_dir: &Path,
root_manifest: &Value,
plugins_enabled: bool,
) -> Vec<String> {
if !plugins_enabled {
return Vec::new();
}
let mut names: Vec<String> = Vec::new();
let mut collect = |packages: Vec<Value>| {
for p in packages {
if p.get("type").and_then(Value::as_str) == Some("composer-plugin") {
if let Some(n) = p.get("name").and_then(Value::as_str) {
if !names.iter().any(|x| x == n) {
names.push(n.to_owned());
}
}
}
}
};
collect(crate::layout::installed_packages_of(
project_dir,
root_manifest,
));
if let Some(home) = crate::fetch::composer_home() {
collect(crate::layout::installed_packages_at(
&home.join("vendor").join("composer"),
));
}
names.retain(|name| {
matches!(
crate::layout::plugin_allowed(root_manifest, name),
crate::layout::PluginVerdict::Allowed
)
});
names
}
#[derive(Debug, PartialEq, Eq)]
pub enum ScopeIssue {
UnknownPlugin(String),
ResolutionPlugin(String, String),
LayoutPlugin(String),
Layout(String),
NoUsableDist(String),
Config(String),
MergePlugin(String),
}
impl std::fmt::Display for ScopeIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScopeIssue::UnknownPlugin(p) => {
write!(f, "plugin {p} is not on vivacity's known-plugin list")
}
ScopeIssue::LayoutPlugin(p) => {
write!(f, "plugin {p} changes the install layout (not emulated)")
}
ScopeIssue::ResolutionPlugin(p, what) => write!(f, "plugin {p} {what}"),
ScopeIssue::Layout(why) => write!(f, "{why}"),
ScopeIssue::NoUsableDist(p) => {
write!(
f,
"package {p} has no usable dist (no zip or tar dist, no path source)"
)
}
ScopeIssue::Config(why) => write!(f, "config {why} is not supported natively"),
ScopeIssue::MergePlugin(why) => write!(f, "wikimedia/composer-merge-plugin: {why}"),
}
}
}
#[derive(Debug, Default)]
pub struct ScopeReport {
pub issues: Vec<ScopeIssue>,
pub skipped_plugins: Vec<String>,
pub layout: Option<Layout>,
}
impl ScopeReport {
pub fn is_native_ok(&self) -> bool {
self.issues.is_empty()
}
}
pub fn analyze(
project_dir: &Path,
lock: &Lock,
root_manifest: &Value,
with_dev: bool,
plugins_enabled: bool,
) -> ScopeReport {
let mut report = ScopeReport::default();
for p in lock.wanted_packages(with_dev) {
classify_package(project_dir, p, plugins_enabled, &mut report);
}
if plugins_enabled
&& matches!(
crate::layout::plugin_allowed(root_manifest, crate::yii2_composer::PLUGIN_NAME),
crate::layout::PluginVerdict::Allowed
)
&& lock
.wanted_packages(with_dev)
.any(|p| p.name() == crate::yii2_composer::PLUGIN_NAME)
&& lock
.wanted_packages(with_dev)
.any(|p| p.name() == crate::yii2_composer::YII2_DEV)
{
report.issues.push(ScopeIssue::UnknownPlugin(format!(
"{} with {} (the Yii.php shims are not emulated); it",
crate::yii2_composer::PLUGIN_NAME,
crate::yii2_composer::YII2_DEV
)));
}
if plugins_enabled
&& matches!(
crate::layout::plugin_allowed(root_manifest, crate::bamarni_bin::PLUGIN_NAME),
crate::layout::PluginVerdict::Allowed
)
&& lock
.wanted_packages(with_dev)
.any(|p| p.name() == crate::bamarni_bin::PLUGIN_NAME)
{
match crate::bamarni_bin::config(root_manifest.get("extra")) {
Ok(cfg) if cfg.forward_command => report.issues.push(ScopeIssue::UnknownPlugin(
format!(
"{} with extra.bamarni-bin.forward-command true (forwards the command to vendor-bin/*, not emulated); it",
crate::bamarni_bin::PLUGIN_NAME
),
)),
Ok(_) => {}
Err(msg) => report.issues.push(ScopeIssue::Config(format!(
"extra.bamarni-bin ({msg})"
))),
}
}
report.issues.extend(
config_issues(root_manifest)
.into_iter()
.map(ScopeIssue::Config),
);
match Layout::resolve(project_dir, lock, root_manifest, with_dev, plugins_enabled) {
Ok(layout) => report.layout = Some(layout),
Err(issues) => report
.issues
.extend(issues.into_iter().map(ScopeIssue::Layout)),
}
report
}
pub fn plugin_issues(lock: &Lock, with_dev: bool) -> Vec<ScopeIssue> {
let mut report = ScopeReport::default();
for p in lock.wanted_packages(with_dev) {
classify_plugin(p, &mut report);
}
report.issues
}
pub fn config_issues(root_manifest: &Value) -> Vec<String> {
let value = |key: &str| -> Option<Value> {
root_manifest
.get("config")
.and_then(|c| c.get(key))
.cloned()
.or_else(|| crate::layout::global_config_value(key))
};
let mut out = Vec::new();
if let Some(v) = value("preferred-install") {
let wants_source = match &v {
Value::String(s) => s == "source",
Value::Object(m) => m.values().any(|x| x.as_str() == Some("source")),
_ => false,
};
if wants_source {
out.push(format!("preferred-install {v}"));
}
}
out
}
fn classify_package(
project_dir: &Path,
p: &LockPackage,
plugins_enabled: bool,
report: &mut ScopeReport,
) {
if plugins_enabled {
classify_plugin(p, report);
}
if p.is_metapackage() {
return;
}
let usable = match p.dist_kind() {
DistKind::Zip | DistKind::Tar => true,
DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
DistKind::Other | DistKind::Missing => false,
};
if !usable {
report
.issues
.push(ScopeIssue::NoUsableDist(p.name().to_owned()));
}
}
fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
if p.package_type() != "composer-plugin" {
return;
}
let name = p.name().to_owned();
if EMULATED_PLUGINS.contains(&name.as_str()) {
} else if BENIGN_PLUGINS.contains(&name.as_str()) {
report.skipped_plugins.push(name);
} else if LAYOUT_PLUGINS.contains(&name.as_str()) {
report.issues.push(ScopeIssue::LayoutPlugin(name));
} else {
report.issues.push(ScopeIssue::UnknownPlugin(name));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lock::Lock;
use serde_json::json;
fn lock_with(packages: serde_json::Value) -> Lock {
Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
}
fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
json!({"name": name, "version": "1.0.0", "type": r#type,
"dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
}
fn proj() -> std::path::PathBuf {
std::path::PathBuf::from("/nonexistent-vivacity-scope")
}
#[test]
fn plain_library_is_native() {
let lock = lock_with(json!([zip_pkg("a/b", "library")]));
let r = analyze(&proj(), &lock, &json!({}), true, true);
assert!(r.is_native_ok());
assert!(r.skipped_plugins.is_empty());
assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
}
#[test]
fn emulated_and_benign_plugins_stay_native() {
let lock = lock_with(json!([
zip_pkg("symfony/runtime", "composer-plugin"),
zip_pkg("symfony/flex", "composer-plugin"),
]));
let r = analyze(&proj(), &lock, &json!({}), true, true);
assert!(r.is_native_ok());
assert_eq!(r.skipped_plugins, vec!["symfony/flex"]);
}
#[test]
fn thanks_and_normalize_are_benign_at_install() {
let lock = lock_with(json!([
zip_pkg("symfony/thanks", "composer-plugin"),
zip_pkg("ergebnis/composer-normalize", "composer-plugin"),
]));
let r = analyze(&proj(), &lock, &json!({}), true, true);
assert!(r.is_native_ok(), "{:?}", r.issues);
assert_eq!(
r.skipped_plugins,
vec!["symfony/thanks", "ergebnis/composer-normalize"]
);
}
#[test]
fn thanks_reminder_only_on_update_with_install() {
use ResolutionCommand::*;
let effect = |c, i| resolution_effect("symfony/thanks", c, i);
assert!(effect(Update, true).is_some());
assert!(effect(Update, false).is_none());
assert!(effect(Require, true).is_none());
assert!(effect(Remove, true).is_none());
assert!(resolution_effect("ergebnis/composer-normalize", Update, true).is_none());
}
#[test]
fn yii2_composer_is_emulated_except_with_yii2_dev() {
let allow = json!({"config": {"allow-plugins": {"yiisoft/yii2-composer": true}}});
let lock = lock_with(json!([
zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
zip_pkg("yiisoft/yii2-gii", "yii2-extension"),
]));
assert!(analyze(&proj(), &lock, &allow, true, true).is_native_ok());
let lock = lock_with(json!([
zip_pkg("yiisoft/yii2-composer", "composer-plugin"),
zip_pkg("yiisoft/yii2-dev", "yii2-extension"),
]));
let r = analyze(&proj(), &lock, &allow, true, true);
assert!(!r.is_native_ok());
assert!(
r.issues[0].to_string().contains("yiisoft/yii2-dev"),
"{}",
r.issues[0]
);
assert!(analyze(&proj(), &lock, &allow, true, false).is_native_ok());
}
#[test]
fn bamarni_bin_native_unless_it_forwards() {
let lock = lock_with(json!([zip_pkg(
"bamarni/composer-bin-plugin",
"composer-plugin"
)]));
let allow = |extra: serde_json::Value| json!({"config": {"allow-plugins": {"bamarni/composer-bin-plugin": true}}, "extra": extra});
assert!(analyze(&proj(), &lock, &allow(json!({})), true, true).is_native_ok());
let r = analyze(
&proj(),
&lock,
&allow(json!({"bamarni-bin": {"forward-command": true}})),
true,
true,
);
assert!(
r.issues[0].to_string().contains("forward-command true"),
"{}",
r.issues[0]
);
let r = analyze(
&proj(),
&lock,
&allow(json!({"bamarni-bin": {"bin-links": "no"}})),
true,
true,
);
assert!(
r.issues[0].to_string().contains("boolean value"),
"{}",
r.issues[0]
);
}
#[test]
fn no_plugins_makes_every_plugin_a_plain_library() {
let lock = lock_with(json!([
zip_pkg("acme/mystery-plugin", "composer-plugin"),
zip_pkg("cweagans/composer-patches", "composer-plugin"),
zip_pkg("symfony/flex", "composer-plugin"),
]));
let r = analyze(&proj(), &lock, &json!({}), true, false);
assert!(r.is_native_ok(), "{:?}", r.issues);
assert!(r.skipped_plugins.is_empty());
assert_eq!(
r.layout.expect("layout").rel("acme/mystery-plugin"),
Some("vendor/acme/mystery-plugin")
);
}
#[test]
fn unknown_or_layout_plugin_is_out_of_scope() {
let lock = lock_with(json!([
zip_pkg("acme/mystery-plugin", "composer-plugin"),
zip_pkg("cweagans/composer-patches", "composer-plugin"),
]));
let r = analyze(&proj(), &lock, &json!({}), true, true);
assert_eq!(
r.issues,
vec![
ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
]
);
}
#[test]
fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
let lock = lock_with(json!([
{"name": "a/src-only", "version": "1.0.0", "type": "library",
"source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
{"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
zip_pkg("composer/installers", "composer-plugin"),
]));
let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
let r = analyze(&proj(), &lock, &manifest, true, true);
assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
}
#[test]
fn path_package_is_native_when_its_source_exists() {
let tmp = tempfile::tempdir().expect("tmp");
std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
let lock = lock_with(json!([
{"name": "a/here", "version": "dev-main", "type": "library",
"dist": {"type": "path", "url": "packages/here", "reference": "r"}},
{"name": "a/gone", "version": "dev-main", "type": "library",
"dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
]));
let r = analyze(tmp.path(), &lock, &json!({}), true, true);
let expected = if cfg!(unix) {
vec![ScopeIssue::NoUsableDist("a/gone".into())]
} else {
vec![
ScopeIssue::NoUsableDist("a/here".into()),
ScopeIssue::NoUsableDist("a/gone".into()),
]
};
assert_eq!(r.issues, expected);
}
#[test]
fn unread_config_keys_are_scope_issues() {
let lock = lock_with(json!([zip_pkg("a/b", "library")]));
let manifest = json!({"config": {"vendor-dir": "lib/", "bin-dir": "vendor/bin",
"preferred-install": {"acme/*": "source", "*": "dist"}}});
let r = analyze(&proj(), &lock, &manifest, true, true);
assert_eq!(
r.issues,
vec![ScopeIssue::Config(
"preferred-install {\"acme/*\":\"source\",\"*\":\"dist\"}".into()
),]
);
let manifest = json!({"config": {"vendor-dir": "vendor", "preferred-install": "auto"}});
assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
}
#[test]
fn no_dev_skips_dev_packages() {
let lock = Lock::parse(
&json!({
"packages": [zip_pkg("a/b", "library")],
"packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
})
.to_string(),
)
.expect("lock");
assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
}
}