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