1use crate::layout::Layout;
11use crate::lock::{DistKind, Lock, LockPackage};
12use serde_json::Value;
13use std::path::Path;
14
15pub const EMULATED_PLUGINS: &[&str] = &["symfony/runtime", "composer/installers"];
20
21pub const BENIGN_PLUGINS: &[&str] = &[
25 "symfony/flex",
26 "composer/package-versions-deprecated",
27 "php-http/discovery",
28 "dealerdirect/phpcodesniffer-composer-installer",
29 "phpstan/extension-installer",
30 "rector/extension-installer",
31 "pestphp/pest-plugin",
32 "drupal/core-project-message",
35 "drupal/core-recipe-unpack",
38];
39
40pub const LAYOUT_PLUGINS: &[&str] = &[
43 "cweagans/composer-patches",
44 "oomphinc/composer-installers-extender",
45 "mnsami/composer-custom-directory-installer",
46];
47
48#[derive(Debug, PartialEq, Eq)]
49pub enum ScopeIssue {
50 UnknownPlugin(String),
52 LayoutPlugin(String),
54 Layout(String),
57 NoUsableDist(String),
59}
60
61impl std::fmt::Display for ScopeIssue {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 ScopeIssue::UnknownPlugin(p) => {
65 write!(f, "plugin {p} is not on vivacity's known-plugin list")
66 }
67 ScopeIssue::LayoutPlugin(p) => {
68 write!(f, "plugin {p} changes the install layout (not emulated)")
69 }
70 ScopeIssue::Layout(why) => write!(f, "{why}"),
71 ScopeIssue::NoUsableDist(p) => {
72 write!(f, "package {p} has no usable dist (no zip, no path source)")
73 }
74 }
75 }
76}
77
78#[derive(Debug, Default)]
79pub struct ScopeReport {
80 pub issues: Vec<ScopeIssue>,
82 pub skipped_plugins: Vec<String>,
84 pub layout: Option<Layout>,
86}
87
88impl ScopeReport {
89 pub fn is_native_ok(&self) -> bool {
90 self.issues.is_empty()
91 }
92}
93
94pub fn analyze(
97 project_dir: &Path,
98 lock: &Lock,
99 root_manifest: &Value,
100 with_dev: bool,
101 plugins_enabled: bool,
102) -> ScopeReport {
103 let mut report = ScopeReport::default();
104
105 for p in lock.wanted_packages(with_dev) {
106 classify_package(project_dir, p, &mut report);
107 }
108 match Layout::resolve(project_dir, lock, root_manifest, with_dev, plugins_enabled) {
109 Ok(layout) => report.layout = Some(layout),
110 Err(issues) => report
111 .issues
112 .extend(issues.into_iter().map(ScopeIssue::Layout)),
113 }
114 report
115}
116
117pub fn plugin_issues(lock: &Lock, with_dev: bool) -> Vec<ScopeIssue> {
121 let mut report = ScopeReport::default();
122 for p in lock.wanted_packages(with_dev) {
123 classify_plugin(p, &mut report);
124 }
125 report.issues
126}
127
128fn classify_package(project_dir: &Path, p: &LockPackage, report: &mut ScopeReport) {
129 classify_plugin(p, report);
130 if p.is_metapackage() {
131 return;
132 }
133 let usable = match p.dist_kind() {
134 DistKind::Zip => true,
135 DistKind::Path => cfg!(unix) && p.dist_url().is_some_and(|u| project_dir.join(u).is_dir()),
139 DistKind::Other | DistKind::Missing => false,
140 };
141 if !usable {
142 report
143 .issues
144 .push(ScopeIssue::NoUsableDist(p.name().to_owned()));
145 }
146}
147
148fn classify_plugin(p: &LockPackage, report: &mut ScopeReport) {
149 if p.package_type() != "composer-plugin" {
150 return;
151 }
152 let name = p.name().to_owned();
153 if EMULATED_PLUGINS.contains(&name.as_str()) {
154 } else if BENIGN_PLUGINS.contains(&name.as_str()) {
156 report.skipped_plugins.push(name);
157 } else if LAYOUT_PLUGINS.contains(&name.as_str()) {
158 report.issues.push(ScopeIssue::LayoutPlugin(name));
159 } else {
160 report.issues.push(ScopeIssue::UnknownPlugin(name));
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::lock::Lock;
168 use serde_json::json;
169
170 fn lock_with(packages: serde_json::Value) -> Lock {
171 Lock::parse(&json!({ "packages": packages, "packages-dev": [] }).to_string()).expect("lock")
172 }
173
174 fn zip_pkg(name: &str, r#type: &str) -> serde_json::Value {
175 json!({"name": name, "version": "1.0.0", "type": r#type,
176 "dist": {"type": "zip", "url": "https://x/y.zip", "reference": "r"}})
177 }
178
179 fn proj() -> std::path::PathBuf {
180 std::path::PathBuf::from("/nonexistent-vivacity-scope")
181 }
182
183 #[test]
184 fn plain_library_is_native() {
185 let lock = lock_with(json!([zip_pkg("a/b", "library")]));
186 let r = analyze(&proj(), &lock, &json!({}), true, true);
187 assert!(r.is_native_ok());
188 assert!(r.skipped_plugins.is_empty());
189 assert_eq!(r.layout.expect("layout").rel("a/b"), Some("vendor/a/b"));
190 }
191
192 #[test]
193 fn emulated_and_benign_plugins_stay_native() {
194 let lock = lock_with(json!([
195 zip_pkg("symfony/runtime", "composer-plugin"),
196 zip_pkg("symfony/flex", "composer-plugin"),
197 ]));
198 let r = analyze(&proj(), &lock, &json!({}), true, true);
199 assert!(r.is_native_ok());
200 assert_eq!(r.skipped_plugins, vec!["symfony/flex"]);
201 }
202
203 #[test]
204 fn unknown_or_layout_plugin_is_out_of_scope() {
205 let lock = lock_with(json!([
206 zip_pkg("acme/mystery-plugin", "composer-plugin"),
207 zip_pkg("cweagans/composer-patches", "composer-plugin"),
208 ]));
209 let r = analyze(&proj(), &lock, &json!({}), true, true);
210 assert_eq!(
211 r.issues,
212 vec![
213 ScopeIssue::UnknownPlugin("acme/mystery-plugin".into()),
214 ScopeIssue::LayoutPlugin("cweagans/composer-patches".into()),
215 ]
216 );
217 }
218
219 #[test]
220 fn installers_without_allow_plugins_and_sourceless_dist_are_out_of_scope() {
221 let lock = lock_with(json!([
222 {"name": "a/src-only", "version": "1.0.0", "type": "library",
223 "source": {"type": "git", "url": "https://g/x.git", "reference": "r"}},
224 {"name": "a/meta", "version": "1.0.0", "type": "metapackage"},
225 zip_pkg("composer/installers", "composer-plugin"),
226 ]));
227 let manifest = json!({"extra": {"installer-paths": {"web/modules/{$name}": []}}});
230 let r = analyze(&proj(), &lock, &manifest, true, true);
231 assert_eq!(r.issues.len(), 2, "{:?}", r.issues);
232 assert_eq!(r.issues[0], ScopeIssue::NoUsableDist("a/src-only".into()));
233 assert!(matches!(&r.issues[1], ScopeIssue::Layout(m) if m.contains("allow-plugins")));
234 }
235
236 #[test]
237 fn path_package_is_native_when_its_source_exists() {
238 let tmp = tempfile::tempdir().expect("tmp");
239 std::fs::create_dir_all(tmp.path().join("packages/here")).expect("mkdir");
240 let lock = lock_with(json!([
241 {"name": "a/here", "version": "dev-main", "type": "library",
242 "dist": {"type": "path", "url": "packages/here", "reference": "r"}},
243 {"name": "a/gone", "version": "dev-main", "type": "library",
244 "dist": {"type": "path", "url": "packages/gone", "reference": "r"}},
245 ]));
246 let r = analyze(tmp.path(), &lock, &json!({}), true, true);
247 let expected = if cfg!(unix) {
248 vec![ScopeIssue::NoUsableDist("a/gone".into())]
249 } else {
250 vec![
251 ScopeIssue::NoUsableDist("a/here".into()),
252 ScopeIssue::NoUsableDist("a/gone".into()),
253 ]
254 };
255 assert_eq!(r.issues, expected);
256 }
257
258 #[test]
259 fn no_dev_skips_dev_packages() {
260 let lock = Lock::parse(
261 &json!({
262 "packages": [zip_pkg("a/b", "library")],
263 "packages-dev": [zip_pkg("acme/mystery-plugin", "composer-plugin")]
264 })
265 .to_string(),
266 )
267 .expect("lock");
268 assert!(analyze(&proj(), &lock, &json!({}), false, true).is_native_ok());
269 assert!(!analyze(&proj(), &lock, &json!({}), true, true).is_native_ok());
270 }
271}