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];
28
29pub const BENIGN_PLUGINS: &[&str] = &[
38 "symfony/flex",
39 "composer/package-versions-deprecated",
40 "php-http/discovery",
41 "drupal/core-project-message",
44 "drupal/core-recipe-unpack",
47];
48
49pub const LAYOUT_PLUGINS: &[&str] = &[
52 "cweagans/composer-patches",
53 "oomphinc/composer-installers-extender",
54 "mnsami/composer-custom-directory-installer",
55];
56
57#[derive(Debug, PartialEq, Eq)]
58pub enum ScopeIssue {
59 UnknownPlugin(String),
61 LayoutPlugin(String),
63 Layout(String),
66 NoUsableDist(String),
68 Config(String),
72 MergePlugin(String),
77}
78
79impl std::fmt::Display for ScopeIssue {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 ScopeIssue::UnknownPlugin(p) => {
83 write!(f, "plugin {p} is not on vivacity's known-plugin list")
84 }
85 ScopeIssue::LayoutPlugin(p) => {
86 write!(f, "plugin {p} changes the install layout (not emulated)")
87 }
88 ScopeIssue::Layout(why) => write!(f, "{why}"),
89 ScopeIssue::NoUsableDist(p) => {
90 write!(f, "package {p} has no usable dist (no zip, no path source)")
91 }
92 ScopeIssue::Config(why) => write!(f, "config {why} is not supported natively"),
93 ScopeIssue::MergePlugin(why) => write!(f, "wikimedia/composer-merge-plugin: {why}"),
94 }
95 }
96}
97
98#[derive(Debug, Default)]
99pub struct ScopeReport {
100 pub issues: Vec<ScopeIssue>,
102 pub skipped_plugins: Vec<String>,
104 pub layout: Option<Layout>,
106}
107
108impl ScopeReport {
109 pub fn is_native_ok(&self) -> bool {
110 self.issues.is_empty()
111 }
112}
113
114pub fn analyze(
117 project_dir: &Path,
118 lock: &Lock,
119 root_manifest: &Value,
120 with_dev: bool,
121 plugins_enabled: bool,
122) -> ScopeReport {
123 let mut report = ScopeReport::default();
124
125 for p in lock.wanted_packages(with_dev) {
126 classify_package(project_dir, p, plugins_enabled, &mut report);
127 }
128 report.issues.extend(
129 config_issues(root_manifest)
130 .into_iter()
131 .map(ScopeIssue::Config),
132 );
133 match Layout::resolve(project_dir, lock, root_manifest, with_dev, plugins_enabled) {
134 Ok(layout) => report.layout = Some(layout),
135 Err(issues) => report
136 .issues
137 .extend(issues.into_iter().map(ScopeIssue::Layout)),
138 }
139 report
140}
141
142pub fn plugin_issues(lock: &Lock, with_dev: bool) -> Vec<ScopeIssue> {
146 let mut report = ScopeReport::default();
147 for p in lock.wanted_packages(with_dev) {
148 classify_plugin(p, &mut report);
149 }
150 report.issues
151}
152
153pub fn config_issues(root_manifest: &Value) -> Vec<String> {
158 let value = |key: &str| -> Option<Value> {
159 root_manifest
160 .get("config")
161 .and_then(|c| c.get(key))
162 .cloned()
163 .or_else(|| crate::layout::global_config_value(key))
164 };
165 let mut out = Vec::new();
166 if let Some(v) = value("preferred-install") {
167 let wants_source = match &v {
168 Value::String(s) => s == "source",
169 Value::Object(m) => m.values().any(|x| x.as_str() == Some("source")),
170 _ => false,
171 };
172 if wants_source {
173 out.push(format!("preferred-install {v}"));
174 }
175 }
176 out
177}
178
179fn classify_package(
180 project_dir: &Path,
181 p: &LockPackage,
182 plugins_enabled: bool,
183 report: &mut ScopeReport,
184) {
185 if plugins_enabled {
189 classify_plugin(p, report);
190 }
191 if p.is_metapackage() {
192 return;
193 }
194 let usable = match p.dist_kind() {
195 DistKind::Zip => true,
196 DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
200 DistKind::Other | DistKind::Missing => false,
201 };
202 if !usable {
203 report
204 .issues
205 .push(ScopeIssue::NoUsableDist(p.name().to_owned()));
206 }
207}
208
209fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
210 if p.package_type() != "composer-plugin" {
211 return;
212 }
213 let name = p.name().to_owned();
214 if EMULATED_PLUGINS.contains(&name.as_str()) {
215 } else if BENIGN_PLUGINS.contains(&name.as_str()) {
217 report.skipped_plugins.push(name);
218 } else if LAYOUT_PLUGINS.contains(&name.as_str()) {
219 report.issues.push(ScopeIssue::LayoutPlugin(name));
220 } else {
221 report.issues.push(ScopeIssue::UnknownPlugin(name));
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::lock::Lock;
229 use serde_json::json;
230
231 fn lock_with(packages: serde_json::Value) -> Lock {
232 Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
233 }
234
235 fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
236 json!({"name": name, "version": "1.0.0", "type": r#type,
237 "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
238 }
239
240 fn proj() -> std::path::PathBuf {
241 std::path::PathBuf::from("/nonexistent-vivacity-scope")
242 }
243
244 #[test]
245 fn plain_library_is_native() {
246 let lock = lock_with(json!([zip_pkg("a/b", "library")]));
247 let r = analyze(&proj(), &lock, &json!({}), true, true);
248 assert!(r.is_native_ok());
249 assert!(r.skipped_plugins.is_empty());
250 assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
251 }
252
253 #[test]
254 fn emulated_and_benign_plugins_stay_native() {
255 let lock = lock_with(json!([
256 zip_pkg("symfony/runtime", "composer-plugin"),
257 zip_pkg("symfony/flex", "composer-plugin"),
258 ]));
259 let r = analyze(&proj(), &lock, &json!({}), true, true);
260 assert!(r.is_native_ok());
261 assert_eq!(r.skipped_plugins, vec!["symfony/flex"]);
262 }
263
264 #[test]
265 fn no_plugins_makes_every_plugin_a_plain_library() {
266 let lock = lock_with(json!([
267 zip_pkg("acme/mystery-plugin", "composer-plugin"),
268 zip_pkg("cweagans/composer-patches", "composer-plugin"),
269 zip_pkg("symfony/flex", "composer-plugin"),
270 ]));
271 let r = analyze(&proj(), &lock, &json!({}), true, false);
272 assert!(r.is_native_ok(), "{:?}", r.issues);
273 assert!(r.skipped_plugins.is_empty());
274 assert_eq!(
275 r.layout.expect("layout").rel("acme/mystery-plugin"),
276 Some("vendor/acme/mystery-plugin")
277 );
278 }
279
280 #[test]
281 fn unknown_or_layout_plugin_is_out_of_scope() {
282 let lock = lock_with(json!([
283 zip_pkg("acme/mystery-plugin", "composer-plugin"),
284 zip_pkg("cweagans/composer-patches", "composer-plugin"),
285 ]));
286 let r = analyze(&proj(), &lock, &json!({}), true, true);
287 assert_eq!(
288 r.issues,
289 vec![
290 ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
291 ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
292 ]
293 );
294 }
295
296 #[test]
297 fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
298 let lock = lock_with(json!([
299 {"name": "a/src-only", "version": "1.0.0", "type": "library",
300 "source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
301 {"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
302 zip_pkg("composer/installers", "composer-plugin"),
303 ]));
304 let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
307 let r = analyze(&proj(), &lock, &manifest, true, true);
308 assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
309 assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
310 assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
311 }
312
313 #[test]
314 fn path_package_is_native_when_its_source_exists() {
315 let tmp = tempfile::tempdir().expect("tmp");
316 std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
317 let lock = lock_with(json!([
318 {"name": "a/here", "version": "dev-main", "type": "library",
319 "dist": {"type": "path", "url": "packages/here", "reference": "r"}},
320 {"name": "a/gone", "version": "dev-main", "type": "library",
321 "dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
322 ]));
323 let r = analyze(tmp.path(), &lock, &json!({}), true, true);
324 let expected = if cfg!(unix) {
325 vec![ScopeIssue::NoUsableDist("a/gone".into())]
326 } else {
327 vec![
328 ScopeIssue::NoUsableDist("a/here".into()),
329 ScopeIssue::NoUsableDist("a/gone".into()),
330 ]
331 };
332 assert_eq!(r.issues, expected);
333 }
334
335 #[test]
336 fn unread_config_keys_are_scope_issues() {
337 let lock = lock_with(json!([zip_pkg("a/b", "library")]));
338 let manifest = json!({"config": {"vendor-dir": "lib/", "bin-dir": "vendor/bin",
339 "preferred-install": {"acme/*": "source", "*": "dist"}}});
340 let r = analyze(&proj(), &lock, &manifest, true, true);
341 assert_eq!(
342 r.issues,
343 vec![ScopeIssue::Config(
344 "preferred-install {\"acme/*\":\"source\",\"*\":\"dist\"}".into()
345 ),]
346 );
347 let manifest = json!({"config": {"vendor-dir": "vendor", "preferred-install": "auto"}});
348 assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
349 }
350
351 #[test]
352 fn no_dev_skips_dev_packages() {
353 let lock = Lock::parse(
354 &json!({
355 "packages": [zip_pkg("a/b", "library")],
356 "packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
357 })
358 .to_string(),
359 )
360 .expect("lock");
361 assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
362 assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
363 }
364}