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> {
150 let value = |key: &str| -> Option<Value> {
151 root_manifest
152 .get("config")
153 .and_then(|c| c.get(key))
154 .cloned()
155 .or_else(|| crate::layout::global_config_value(key))
156 };
157 let mut out = Vec::new();
158 let trimmed = |v: &Value| v.as_str().map(|s| s.trim_end_matches('/').to_owned());
159 if let Some(v) = value("vendor-dir") {
160 if trimmed(&v).as_deref() != Some("vendor") {
161 out.push(format!("vendor-dir {v}"));
162 }
163 }
164 if let Some(v) = value("bin-dir") {
165 if trimmed(&v).as_deref() != Some("vendor/bin") {
166 out.push(format!("bin-dir {v}"));
167 }
168 }
169 if let Some(v) = value("preferred-install") {
170 let wants_source = match &v {
171 Value::String(s) => s == "source",
172 Value::Object(m) => m.values().any(|x| x.as_str() == Some("source")),
173 _ => false,
174 };
175 if wants_source {
176 out.push(format!("preferred-install {v}"));
177 }
178 }
179 out
180}
181
182fn classify_package(project_dir: &Path, p: &LockPackage, report: &mut ScopeReport) {
183 classify_plugin(p, report);
184 if p.is_metapackage() {
185 return;
186 }
187 let usable = match p.dist_kind() {
188 DistKind::Zip => true,
189 DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
193 DistKind::Other | DistKind::Missing => false,
194 };
195 if !usable {
196 report
197 .issues
198 .push(ScopeIssue::NoUsableDist(p.name().to_owned()));
199 }
200}
201
202fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
203 if p.package_type() != "composer-plugin" {
204 return;
205 }
206 let name = p.name().to_owned();
207 if EMULATED_PLUGINS.contains(&name.as_str()) {
208 } else if BENIGN_PLUGINS.contains(&name.as_str()) {
210 report.skipped_plugins.push(name);
211 } else if LAYOUT_PLUGINS.contains(&name.as_str()) {
212 report.issues.push(ScopeIssue::LayoutPlugin(name));
213 } else {
214 report.issues.push(ScopeIssue::UnknownPlugin(name));
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::lock::Lock;
222 use serde_json::json;
223
224 fn lock_with(packages: serde_json::Value) -> Lock {
225 Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
226 }
227
228 fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
229 json!({"name": name, "version": "1.0.0", "type": r#type,
230 "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
231 }
232
233 fn proj() -> std::path::PathBuf {
234 std::path::PathBuf::from("/nonexistent-vivacity-scope")
235 }
236
237 #[test]
238 fn plain_library_is_native() {
239 let lock = lock_with(json!([zip_pkg("a/b", "library")]));
240 let r = analyze(&proj(), &lock, &json!({}), true, true);
241 assert!(r.is_native_ok());
242 assert!(r.skipped_plugins.is_empty());
243 assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
244 }
245
246 #[test]
247 fn emulated_and_benign_plugins_stay_native() {
248 let lock = lock_with(json!([
249 zip_pkg("symfony/runtime", "composer-plugin"),
250 zip_pkg("symfony/flex", "composer-plugin"),
251 ]));
252 let r = analyze(&proj(), &lock, &json!({}), true, true);
253 assert!(r.is_native_ok());
254 assert_eq!(r.skipped_plugins, vec!["symfony/flex"]);
255 }
256
257 #[test]
258 fn unknown_or_layout_plugin_is_out_of_scope() {
259 let lock = lock_with(json!([
260 zip_pkg("acme/mystery-plugin", "composer-plugin"),
261 zip_pkg("cweagans/composer-patches", "composer-plugin"),
262 ]));
263 let r = analyze(&proj(), &lock, &json!({}), true, true);
264 assert_eq!(
265 r.issues,
266 vec![
267 ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
268 ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
269 ]
270 );
271 }
272
273 #[test]
274 fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
275 let lock = lock_with(json!([
276 {"name": "a/src-only", "version": "1.0.0", "type": "library",
277 "source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
278 {"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
279 zip_pkg("composer/installers", "composer-plugin"),
280 ]));
281 let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
284 let r = analyze(&proj(), &lock, &manifest, true, true);
285 assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
286 assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
287 assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
288 }
289
290 #[test]
291 fn path_package_is_native_when_its_source_exists() {
292 let tmp = tempfile::tempdir().expect("tmp");
293 std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
294 let lock = lock_with(json!([
295 {"name": "a/here", "version": "dev-main", "type": "library",
296 "dist": {"type": "path", "url": "packages/here", "reference": "r"}},
297 {"name": "a/gone", "version": "dev-main", "type": "library",
298 "dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
299 ]));
300 let r = analyze(tmp.path(), &lock, &json!({}), true, true);
301 let expected = if cfg!(unix) {
302 vec![ScopeIssue::NoUsableDist("a/gone".into())]
303 } else {
304 vec![
305 ScopeIssue::NoUsableDist("a/here".into()),
306 ScopeIssue::NoUsableDist("a/gone".into()),
307 ]
308 };
309 assert_eq!(r.issues, expected);
310 }
311
312 #[test]
313 fn unread_config_keys_are_scope_issues() {
314 let lock = lock_with(json!([zip_pkg("a/b", "library")]));
315 let manifest = json!({"config": {"vendor-dir": "lib/", "bin-dir": "vendor/bin",
316 "preferred-install": {"acme/*": "source", "*": "dist"}}});
317 let r = analyze(&proj(), &lock, &manifest, true, true);
318 assert_eq!(
319 r.issues,
320 vec![
321 ScopeIssue::Config("vendor-dir \"lib/\"".into()),
322 ScopeIssue::Config(
323 "preferred-install {\"acme/*\":\"source\",\"*\":\"dist\"}".into()
324 ),
325 ]
326 );
327 let manifest = json!({"config": {"vendor-dir": "vendor", "preferred-install": "auto"}});
328 assert!(analyze(&proj(), &lock, &manifest, true, true).is_native_ok());
329 }
330
331 #[test]
332 fn no_dev_skips_dev_packages() {
333 let lock = Lock::parse(
334 &json!({
335 "packages": [zip_pkg("a/b", "library")],
336 "packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
337 })
338 .to_string(),
339 )
340 .expect("lock");
341 assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
342 assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
343 }
344}