Skip to main content

vivacity_core/
phpcs_installer.rs

1//! Emulation of `dealerdirect/phpcodesniffer-composer-installer`
2//! (docs/reference/plugins/phpcodesniffer-composer-installer/, MIT; 0.7.2
3//! to 1.2.1 behave alike on an install): on `post-install-cmd` /
4//! `post-update-cmd`, when `squizlabs/php_codesniffer` is installed, the
5//! plugin registers every coding standard found in the packages of type
6//! `phpcodesniffer-standard` (and in the project when the root package is
7//! one) as PHP_CodeSniffer's `installed_paths`, through
8//! `phpcs --config-set installed_paths <a>,<b>`:
9//!
10//! - `Finder::files()->name('ruleset.xml')` at a depth between `min` (0 for
11//!   phpcs >= 3.0.0, 1 before) and `max` (`extra.phpcodesniffer-search-depth`,
12//!   3 by default) below each search path, VCS directories skipped;
13//! - a standard's path is the ruleset's directory, then its parent unless
14//!   it is the project itself, made relative to the phpcs package with
15//!   `findShortestPath(..., directories: true)`;
16//! - the paths already registered are kept when their directory still
17//!   exists, the new ones appended, the whole sorted (`sort()`, bytewise)
18//!   and written by phpcs into `<phpcs>/CodeSniffer.conf`:
19//!   `<?php\n $phpCodeSnifferConfig = ` + `var_export` + `;\n?>`;
20//! - nothing happens when nothing changed, or when no search path exists.
21
22use crate::error::{Error, Result};
23use crate::layout::Layout;
24use crate::lock::LockPackage;
25use crate::pathutil::{find_shortest_path, normalize_path};
26use serde_json::Value;
27use std::path::{Path, PathBuf};
28
29pub const PLUGIN_NAME: &str = "dealerdirect/phpcodesniffer-composer-installer";
30const PHPCS: &str = "squizlabs/php_codesniffer";
31const STANDARD_TYPE: &str = "phpcodesniffer-standard";
32const VCS_DIRS: &[&str] = &[
33    ".svn",
34    "_svn",
35    "CVS",
36    "_darcs",
37    ".arch-params",
38    ".monotone",
39    ".bzr",
40    ".git",
41    ".hg",
42];
43
44/// `Plugin::onDependenciesChangedEvent` after an install: `packages` are
45/// the installed packages (the local repository), `root_manifest` the
46/// project's composer.json.
47pub fn register_standards(
48    project_dir: &Path,
49    layout: &Layout,
50    packages: &[&LockPackage],
51    root_manifest: &Value,
52) -> Result<()> {
53    let Some(phpcs) = packages.iter().find(|p| p.name() == PHPCS) else {
54        return Ok(());
55    };
56    let Some(phpcs_dir) = layout.abs(phpcs.name()) else {
57        return Ok(());
58    };
59    let cwd = normalize_path(&project_dir.to_string_lossy());
60    let phpcs_path = normalize_path(&phpcs_dir.to_string_lossy());
61    let conf = phpcs_dir.join("CodeSniffer.conf");
62
63    // loadInstalledPaths + cleanInstalledPaths
64    let mut config = read_config(&conf)?;
65    let mut installed: Vec<String> = config
66        .iter()
67        .find(|(k, _)| k == "installed_paths")
68        .map(|(_, v)| v.split(',').map(str::to_owned).collect())
69        .unwrap_or_default();
70    let mut changed = false;
71    installed.retain(|p| {
72        let dir = if crate::pathutil::is_absolute_path(p) {
73            PathBuf::from(p)
74        } else {
75            phpcs_dir.join(p)
76        };
77        let keep = std::fs::canonicalize(&dir).is_ok_and(|d| d.is_dir());
78        if !keep {
79            changed = true;
80        }
81        keep
82    });
83
84    // updateInstalledPaths
85    let mut search: Vec<PathBuf> = Vec::new();
86    if root_manifest.get("type").and_then(Value::as_str) == Some(STANDARD_TYPE) {
87        search.push(project_dir.to_path_buf());
88    }
89    for p in packages {
90        if p.package_type() == STANDARD_TYPE {
91            if let Some(dir) = layout.abs(p.name()) {
92                search.push(dir);
93            }
94        }
95    }
96    if !search.is_empty() {
97        let min_depth = if crate::version::normalize_pretty(phpcs.version())
98            .ok()
99            .is_some_and(|v| {
100                crate::version::Version::parse(&v)
101                    .is_ok_and(|v| v >= crate::version::Version::parse("3.0.0.0").expect("version"))
102            }) {
103            0
104        } else {
105            1
106        };
107        let max_depth = root_manifest
108            .get("extra")
109            .and_then(|e| e.get("phpcodesniffer-search-depth"))
110            .and_then(Value::as_u64)
111            .map(|d| d as usize)
112            .unwrap_or(3);
113        let mut rulesets: Vec<PathBuf> = Vec::new();
114        for base in &search {
115            find_rulesets(base, 0, min_depth, max_depth, &mut rulesets);
116        }
117        for ruleset in rulesets {
118            let mut standards = normalize_path(&ruleset.to_string_lossy());
119            if standards != cwd {
120                standards = crate::pathutil::php_dirname(&standards);
121            }
122            let relative = find_shortest_path(&phpcs_path, &standards, true);
123            if !installed.contains(&relative) {
124                installed.push(relative);
125                changed = true;
126            }
127        }
128    }
129    if !changed {
130        return Ok(());
131    }
132    // saveInstalledPaths: `--config-set` (sorted, comma-joined) or
133    // `--config-delete`; phpcs rewrites the whole file either way.
134    config.retain(|(k, _)| k != "installed_paths");
135    if !installed.is_empty() {
136        installed.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
137        config.push(("installed_paths".to_owned(), installed.join(",")));
138    }
139    write_config(&conf, &config)
140}
141
142/// The `ruleset.xml` files at a depth in `[min, max]` below `base`
143/// (Finder: depth 0 is a direct child), VCS directories skipped, unreadable
144/// directories ignored; the directory holding each ruleset.
145fn find_rulesets(dir: &Path, depth: usize, min: usize, max: usize, out: &mut Vec<PathBuf>) {
146    let Ok(entries) = std::fs::read_dir(dir) else {
147        return;
148    };
149    let mut names: Vec<std::ffi::OsString> = entries
150        .filter_map(|e| e.ok().map(|e| e.file_name()))
151        .collect();
152    names.sort();
153    for name in names {
154        let path = dir.join(&name);
155        if path.is_dir() {
156            let n = name.to_string_lossy();
157            if VCS_DIRS.contains(&n.as_ref()) {
158                continue;
159            }
160            if depth < max {
161                find_rulesets(&path, depth + 1, min, max, out);
162            }
163        } else if name == "ruleset.xml" && depth >= min && depth <= max && path.is_file() {
164            out.push(dir.to_path_buf());
165        }
166    }
167}
168
169/// `CodeSniffer.conf` as `getAllConfigData` reads it: the string entries of
170/// `$phpCodeSnifferConfig`, in order. Absent file: nothing.
171fn read_config(conf: &Path) -> Result<Vec<(String, String)>> {
172    let Ok(text) = std::fs::read_to_string(conf) else {
173        return Ok(Vec::new());
174    };
175    let mut out = Vec::new();
176    let re = regex_lite(&text);
177    for (k, v) in re {
178        out.push((k, v));
179    }
180    Ok(out)
181}
182
183/// `'key' => 'value',` pairs of a var_export'ed array of strings.
184fn regex_lite(text: &str) -> Vec<(String, String)> {
185    let mut out = Vec::new();
186    for line in text.lines() {
187        let line = line.trim();
188        let Some(rest) = line.strip_prefix('\'') else {
189            continue;
190        };
191        let Some((key, rest)) = rest.split_once("' => '") else {
192            continue;
193        };
194        let Some(value) = rest.strip_suffix("',") else {
195            continue;
196        };
197        out.push((
198            key.replace("\\'", "'").replace("\\\\", "\\"),
199            value.replace("\\'", "'").replace("\\\\", "\\"),
200        ));
201    }
202    out
203}
204
205fn write_config(conf: &Path, config: &[(String, String)]) -> Result<()> {
206    let mut map = serde_json::Map::new();
207    for (k, v) in config {
208        map.insert(k.clone(), Value::String(v.clone()));
209    }
210    let exported = crate::runtime_stub::php_var_export(&Value::Object(map), 0);
211    let text = format!("<?php\n $phpCodeSnifferConfig = {exported};\n?>");
212    // Inside a package cloned from the store (a hardlink on Linux): a new
213    // inode through a temporary file and a rename, never an in-place write.
214    let tmp = conf.with_extension("conf.vivacity-tmp");
215    std::fs::write(&tmp, text).map_err(Error::io(&tmp))?;
216    std::fs::rename(&tmp, conf).map_err(Error::io(conf))
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn config_round_trip_like_phpcs() {
225        let tmp = tempfile::tempdir().expect("tmp");
226        let conf = tmp.path().join("CodeSniffer.conf");
227        write_config(
228            &conf,
229            &[("installed_paths".into(), "../../a/b,../../c/d".into())],
230        )
231        .unwrap();
232        assert_eq!(
233            std::fs::read_to_string(&conf).unwrap(),
234            "<?php\n $phpCodeSnifferConfig = array (\n  'installed_paths' => '../../a/b,../../c/d',\n);\n?>"
235        );
236        assert_eq!(
237            read_config(&conf).unwrap(),
238            vec![(
239                "installed_paths".to_owned(),
240                "../../a/b,../../c/d".to_owned()
241            )]
242        );
243    }
244
245    #[test]
246    fn rulesets_by_depth() {
247        let tmp = tempfile::tempdir().expect("tmp");
248        let p = tmp.path();
249        std::fs::create_dir_all(p.join("Std/.git")).unwrap();
250        std::fs::write(p.join("ruleset.xml"), "").unwrap(); // depth 0
251        std::fs::write(p.join("Std/ruleset.xml"), "").unwrap(); // depth 1
252        std::fs::write(p.join("Std/.git/ruleset.xml"), "").unwrap(); // skipped
253        let mut out = Vec::new();
254        find_rulesets(p, 0, 1, 3, &mut out);
255        assert_eq!(out, vec![p.join("Std")]);
256        let mut out = Vec::new();
257        find_rulesets(p, 0, 0, 3, &mut out);
258        assert_eq!(out, vec![p.join("Std"), p.to_path_buf()]);
259    }
260}