use crate::constraint::{parse_constraints, Constraint};
use crate::intervals::is_subset_of;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::path::Path;
use vivacity_core::glob::glob_plain;
use vivacity_core::phparray::{array_merge, array_merge_recursive, merge_deep};
pub const PLUGIN_NAME: &str = "wikimedia/composer-merge-plugin";
#[derive(Debug, Clone)]
pub struct Settings {
pub includes: Vec<String>,
pub requires: Vec<String>,
pub recurse: bool,
pub replace: bool,
pub ignore_duplicates: bool,
pub merge_dev: bool,
pub merge_extra: bool,
pub merge_extra_deep: bool,
pub merge_replace: bool,
}
fn patterns(v: Option<&Value>) -> Vec<String> {
match v {
Some(Value::String(s)) => vec![s.clone()],
Some(Value::Array(a)) => a
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect(),
_ => Vec::new(),
}
}
fn flag(cfg: Option<&Value>, key: &str, default: bool) -> bool {
match cfg.and_then(|c| c.get(key)) {
None | Some(Value::Null) => default,
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_f64() != Some(0.0),
Some(Value::String(s)) => !(s.is_empty() || s == "0"),
Some(Value::Array(a)) => !a.is_empty(),
Some(Value::Object(o)) => !o.is_empty(),
}
}
impl Settings {
pub fn from_manifest(manifest: &Value) -> Settings {
let cfg = manifest.get("extra").and_then(|e| e.get("merge-plugin"));
Settings {
includes: patterns(cfg.and_then(|c| c.get("include"))),
requires: patterns(cfg.and_then(|c| c.get("require"))),
recurse: flag(cfg, "recurse", true),
replace: flag(cfg, "replace", false),
ignore_duplicates: flag(cfg, "ignore-duplicates", false),
merge_dev: flag(cfg, "merge-dev", true),
merge_extra: flag(cfg, "merge-extra", false),
merge_extra_deep: flag(cfg, "merge-extra-deep", false),
merge_replace: flag(cfg, "merge-replace", true),
}
}
pub fn declared(manifest: &Value) -> bool {
let s = Settings::from_manifest(manifest);
!(s.includes.is_empty() && s.requires.is_empty())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MergedLink {
pub target: String,
pub constraint: Constraint,
pub pretty: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct Merged {
pub manifest: Value,
pub requires: Vec<MergedLink>,
pub requires_dev: Vec<MergedLink>,
pub merged_names: Vec<String>,
pub files: Vec<String>,
pub include_links: Vec<IncludeLinks>,
pub repositories: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct IncludeLinks {
pub file: String,
pub require: Vec<(String, String)>,
pub require_dev: Vec<(String, String)>,
}
struct Merger<'a> {
project_dir: &'a Path,
settings: Settings,
with_dev: bool,
root_version: String,
root_pretty_version: String,
root: Value,
requires: BTreeMap<String, MergedLink>,
requires_dev: BTreeMap<String, MergedLink>,
require_order: Vec<String>,
require_dev_order: Vec<String>,
merged_names: Vec<String>,
loaded: Vec<String>,
files: Vec<String>,
include_links: Vec<IncludeLinks>,
repositories: Vec<String>,
}
pub fn merge(
project_dir: &Path,
manifest: &Value,
root_version: &str,
root_pretty_version: &str,
with_dev: bool,
) -> Result<Merged, String> {
let settings = Settings::from_manifest(manifest);
let mut m = Merger {
project_dir,
settings: settings.clone(),
with_dev: with_dev && settings.merge_dev,
root_version: root_version.to_owned(),
root_pretty_version: root_pretty_version.to_owned(),
root: manifest.clone(),
requires: BTreeMap::new(),
requires_dev: BTreeMap::new(),
require_order: Vec::new(),
require_dev_order: Vec::new(),
merged_names: Vec::new(),
loaded: Vec::new(),
files: Vec::new(),
include_links: Vec::new(),
repositories: Vec::new(),
};
for key in ["require", "require-dev"] {
let (map, order) = if key == "require" {
(&mut m.requires, &mut m.require_order)
} else {
(&mut m.requires_dev, &mut m.require_dev_order)
};
if let Some(links) = manifest.get(key).and_then(Value::as_object) {
for (target, pretty) in links {
let Some(pretty) = pretty.as_str() else {
continue;
};
let name = target.to_lowercase();
let source = if pretty == "self.version" {
root_version
} else {
pretty
};
let parsed = parse_constraints(source)
.map_err(|e| format!("composer.json {key}.{target}: {e}"))?;
map.insert(
name.clone(),
MergedLink {
target: name.clone(),
constraint: parsed.constraint,
pretty: pretty.to_owned(),
text: pretty.to_owned(),
},
);
order.push(name);
}
}
}
let includes = settings.includes.clone();
let requires = settings.requires.clone();
m.merge_files(&includes, false)?;
m.merge_files(&requires, true)?;
for (key, map, order) in [
("require", &m.requires, &m.require_order),
("require-dev", &m.requires_dev, &m.require_dev_order),
] {
if map.is_empty() {
continue;
}
let mut out = Map::new();
for name in order {
if let Some(l) = map.get(name) {
out.insert(name.clone(), Value::String(l.text.clone()));
}
}
m.root[key] = Value::Object(out);
}
let mut merged_names: Vec<String> = Vec::new();
for n in m.merged_names {
if !merged_names.contains(&n) {
merged_names.push(n);
}
}
Ok(Merged {
manifest: m.root,
requires: m
.require_order
.iter()
.filter_map(|n| m.requires.get(n).cloned())
.collect(),
requires_dev: m
.require_dev_order
.iter()
.filter_map(|n| m.requires_dev.get(n).cloned())
.collect(),
merged_names,
files: m.files,
include_links: m.include_links,
repositories: m.repositories,
})
}
impl Merger<'_> {
fn merge_files(&mut self, patterns: &[String], required: bool) -> Result<(), String> {
let mut paths: Vec<String> = Vec::new();
for pattern in patterns {
let found = glob_plain(pattern, self.project_dir);
if required && found.is_empty() {
return Err(format!(
"merge-plugin: No files matched required '{pattern}'"
));
}
paths.extend(found);
}
for path in paths {
self.merge_file(&path)?;
}
Ok(())
}
fn merge_file(&mut self, path: &str) -> Result<(), String> {
if self.loaded.iter().any(|p| p == path) {
return Ok(());
}
let text = std::fs::read_to_string(self.project_dir.join(path))
.map_err(|e| format!("merge-plugin: {path}: {e}"))?;
let json: Value = serde_json::from_str(&text)
.map_err(|e| format!("merge-plugin: \"{path}\" does not contain valid JSON\n{e}"))?;
if matches!(&json, Value::Array(a) if a.is_empty()) {
self.loaded.push(path.to_owned());
return Ok(());
}
if !json.is_object() {
return Err(format!(
"merge-plugin: \"{path}\" does not contain valid JSON"
));
}
let base = {
let d = vivacity_core::pathutil::php_dirname(path);
if d == "." {
String::new()
} else {
format!("{d}/")
}
};
let name = json
.get("name")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| format!("merge-plugin/{}", path.replace('/', "-")));
self.files.push(path.to_owned());
self.loaded.push(path.to_owned());
let raw_links = |key: &str| -> Vec<(String, String)> {
json.get(key)
.and_then(Value::as_object)
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_str().map(|c| (k.clone(), c.to_owned())))
.collect()
})
.unwrap_or_default()
};
self.include_links.push(IncludeLinks {
file: path.to_owned(),
require: raw_links("require"),
require_dev: if self.with_dev {
raw_links("require-dev")
} else {
Vec::new()
},
});
if json.get("repositories").is_some_and(|r| !php_empty(r)) {
self.repositories.push(path.to_owned());
}
self.merge_requires("require", &json, &name, path)?;
self.merge_package_links("conflict", &json, &name, path)?;
if self.settings.merge_replace {
self.merge_package_links("replace", &json, &name, path)?;
}
self.merge_package_links("provide", &json, &name, path)?;
self.merge_autoload("autoload", &json, &base);
self.merge_extra(&json);
if self.with_dev {
self.merge_requires("require-dev", &json, &name, path)?;
self.merge_autoload("autoload-dev", &json, &base);
}
if self.settings.recurse {
let inner = Settings::from_manifest(&json);
let rebase = |p: &String| format!("{base}{p}");
let includes: Vec<String> = inner.includes.iter().map(rebase).collect();
let requires: Vec<String> = inner.requires.iter().map(rebase).collect();
self.merge_files(&includes, false)?;
self.merge_files(&requires, true)?;
}
Ok(())
}
fn self_version(
&self,
key: &str,
source: &str,
pretty: &str,
path: &str,
) -> Result<(Constraint, String), String> {
let parse = |text: &str| {
parse_constraints(text)
.map(|p| p.constraint)
.map_err(|e| format!("merge-plugin: {path} {key}: {e}"))
};
if pretty != "self.version" {
return Ok((parse(pretty)?, pretty.to_owned()));
}
let lower = source.to_lowercase();
let existing: Option<String> = match key {
"require" => self.requires.get(&lower).map(|l| l.pretty.clone()),
"require-dev" => self.requires_dev.get(&lower).map(|l| l.pretty.clone()),
_ => self
.root
.get(key)
.and_then(Value::as_object)
.and_then(|m| m.get(&lower))
.and_then(Value::as_str)
.map(str::to_owned),
};
if let Some(existing) = existing {
return Ok((parse(&existing)?, existing));
}
Ok((parse(&self.root_version)?, self.root_pretty_version.clone()))
}
fn merge_requires(
&mut self,
key: &str,
json: &Value,
source: &str,
path: &str,
) -> Result<(), String> {
let Some(links) = json.get(key).and_then(Value::as_object) else {
return Ok(());
};
if links.is_empty() {
return Ok(());
}
let ignore = self.settings.ignore_duplicates;
let replace = self.settings.replace;
for (target, pretty) in links {
let Some(pretty) = pretty.as_str() else {
continue;
};
let name = target.to_lowercase();
let (constraint, pretty) = self.self_version(key, source, pretty, path)?;
let incoming = MergedLink {
target: name.clone(),
constraint,
text: pretty.clone(),
pretty,
};
let (map, order) = if key == "require" {
(&mut self.requires, &mut self.require_order)
} else {
(&mut self.requires_dev, &mut self.require_dev_order)
};
match map.get(&name) {
Some(_) if ignore => {}
Some(_) if replace => {
map.insert(name.clone(), incoming);
self.merged_names.push(name);
}
Some(origin) => {
let merged = merge_constraints(origin, &incoming);
map.insert(name.clone(), merged);
self.merged_names.push(name);
}
None => {
map.insert(name.clone(), incoming);
order.push(name.clone());
self.merged_names.push(name);
}
}
}
Ok(())
}
fn merge_package_links(
&mut self,
key: &str,
json: &Value,
source: &str,
path: &str,
) -> Result<(), String> {
let Some(links) = json.get(key).and_then(Value::as_object) else {
return Ok(());
};
if links.is_empty() {
return Ok(());
}
let mut out: Map<String, Value> = self
.root
.get(key)
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
for (target, pretty) in links {
let Some(pretty) = pretty.as_str() else {
continue;
};
let (_, pretty) = self.self_version(key, source, pretty, path)?;
let name = target.to_lowercase();
if let Some(existing) = out.keys().find(|k| k.to_lowercase() == name).cloned() {
out.insert(existing, Value::String(pretty));
} else {
out.insert(name, Value::String(pretty));
}
}
self.root[key] = Value::Object(out);
Ok(())
}
fn merge_autoload(&mut self, key: &str, json: &Value, base: &str) {
let Some(autoload) = json.get(key) else {
return;
};
if !autoload.is_object() || autoload.as_object().is_some_and(|o| o.is_empty()) {
return;
}
let rebased = rebase(autoload, base);
let current = self
.root
.get(key)
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
self.root[key] = array_merge_recursive(¤t, &rebased);
}
fn merge_extra(&mut self, json: &Value) {
let Some(extra) = json.get("extra").and_then(Value::as_object) else {
return;
};
let mut extra = extra.clone();
extra.remove("merge-plugin");
if !self.settings.merge_extra || extra.is_empty() {
return;
}
let extra = Value::Object(extra);
let root_extra = self
.root
.get("extra")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new()));
let (first, second) = if self.settings.replace {
(&root_extra, &extra)
} else {
(&extra, &root_extra)
};
self.root["extra"] = if self.settings.merge_extra_deep {
merge_deep(first, second)
} else {
array_merge(first, second)
};
}
}
fn merge_constraints(origin: &MergedLink, incoming: &MergedLink) -> MergedLink {
if is_subset_of(&origin.constraint, &incoming.constraint) {
return origin.clone();
}
if is_subset_of(&incoming.constraint, &origin.constraint) {
return incoming.clone();
}
MergedLink {
target: origin.target.clone(),
constraint: Constraint::create(
vec![origin.constraint.clone(), incoming.constraint.clone()],
true,
),
pretty: format!("{}, {}", origin.pretty, incoming.pretty),
text: conjunction_text(&origin.text, &incoming.text),
}
}
fn rebase(v: &Value, base: &str) -> Value {
match v {
Value::String(s) => Value::String(format!("{base}{s}")),
Value::Array(a) => Value::Array(a.iter().map(|x| rebase(x, base)).collect()),
Value::Object(o) => Value::Object(
o.iter()
.map(|(k, x)| (k.clone(), rebase(x, base)))
.collect(),
),
other => other.clone(),
}
}
fn conjunction_text(a: &str, b: &str) -> String {
let mut out = Vec::new();
for x in split_or(a) {
for y in split_or(b) {
out.push(format!("{x}, {y}"));
}
}
out.join(" || ")
}
fn split_or(text: &str) -> Vec<&str> {
text.split("||")
.flat_map(|s| s.split('|'))
.map(str::trim)
.filter(|s| !s.is_empty())
.collect()
}
fn php_empty(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Bool(b) => !b,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty() || s == "0",
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn write(dir: &Path, rel: &str, v: &Value) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, serde_json::to_string_pretty(v).unwrap()).unwrap();
}
fn root() -> Value {
json!({
"name": "acme/app",
"require": {"monolog/monolog": "^2.8", "psr/log": "^3.0"},
"require-dev": {"phpunit/phpunit": "^10"},
"autoload": {"psr-4": {"App\\": "src/"}, "files": ["helpers.php"]},
"conflict": {"old/lib": "<1.0"},
"extra": {"merge-plugin": {"include": ["modules/*/composer.json", "composer.ext.json"], "require": ["required/composer.json"]}, "keep": 1, "shared": {"a": 1}}
})
}
#[test]
fn merges_every_section_like_the_plugin() {
let tmp = tempfile::tempdir().unwrap();
let d = tmp.path();
write(
d,
"modules/Base/composer.json",
&json!({
"name": "acme/base",
"require": {"monolog/monolog": "^2.11 || ^1.0", "guzzlehttp/guzzle": "^7.5", "acme/self": "self.version"},
"require-dev": {"mockery/mockery": "^1.6"},
"autoload": {"psr-4": {"App\\": "src/", "Base\\": "lib/"}, "files": ["boot.php"], "exclude-from-classmap": ["tests/"]},
"autoload-dev": {"psr-4": {"BaseTests\\": "tests/"}},
"provide": {"psr/log-implementation": "3.0"},
"replace": {"acme/base-compat": "self.version"},
"conflict": {"old/lib": "<2.0", "bad/lib": "*"},
"extra": {"merge-plugin": {"include": ["nested/composer.json"]}, "shared": {"b": 2}, "only-include": true}
}),
);
write(
d,
"modules/Base/nested/composer.json",
&json!({
"require": {"nested/dep": "^1.0"},
"autoload": {"classmap": ["Legacy/"]}
}),
);
write(
d,
"composer.ext.json",
&json!({"require": {"psr/log": "^3.0"}}),
);
write(
d,
"required/composer.json",
&json!({"require": {"symfony/yaml": "^7.4"}}),
);
let m = merge(d, &root(), "1.2.3.0", "1.2.3", true).unwrap();
assert_eq!(
m.files,
vec![
"modules/Base/composer.json",
"modules/Base/nested/composer.json",
"composer.ext.json",
"required/composer.json"
]
);
let mono = m
.requires
.iter()
.find(|l| l.target == "monolog/monolog")
.unwrap();
assert_eq!(mono.pretty, "^2.8, ^2.11 || ^1.0");
assert!(mono.constraint.matches_version("2.11.0.0"));
assert!(
!mono.constraint.matches_version("1.5.0.0"),
"1.x is outside ^2.8"
);
assert!(!mono.constraint.matches_version("2.10.0.0"), "below ^2.11");
assert_eq!(
m.manifest["require"]["monolog/monolog"],
json!("^2.8, ^2.11 || ^2.8, ^1.0")
);
let reparsed = parse_constraints("^2.8, ^2.11 || ^2.8, ^1.0")
.unwrap()
.constraint;
for v in ["2.11.0.0", "2.10.0.0", "1.5.0.0", "3.0.0.0"] {
assert_eq!(
reparsed.matches_version(v),
mono.constraint.matches_version(v),
"{v}"
);
}
assert_eq!(m.manifest["require"]["psr/log"], json!("^3.0"));
assert_eq!(m.manifest["require"]["acme/self"], json!("1.2.3"));
assert_eq!(m.manifest["replace"]["acme/base-compat"], json!("1.2.3"));
let order: Vec<&str> = m.requires.iter().map(|l| l.target.as_str()).collect();
assert_eq!(
order,
vec![
"monolog/monolog",
"psr/log",
"guzzlehttp/guzzle",
"acme/self",
"nested/dep",
"symfony/yaml"
]
);
assert_eq!(
m.requires_dev
.iter()
.map(|l| l.target.as_str())
.collect::<Vec<_>>(),
vec!["phpunit/phpunit", "mockery/mockery"]
);
assert_eq!(
m.merged_names,
vec![
"monolog/monolog",
"guzzlehttp/guzzle",
"acme/self",
"mockery/mockery",
"nested/dep",
"psr/log",
"symfony/yaml"
]
);
assert_eq!(
m.manifest["autoload"],
json!({
"psr-4": {"App\\": ["src/", "modules/Base/src/"], "Base\\": "modules/Base/lib/"},
"files": ["helpers.php", "modules/Base/boot.php"],
"exclude-from-classmap": ["modules/Base/tests/"],
"classmap": ["modules/Base/nested/Legacy/"]
})
);
assert_eq!(
m.manifest["autoload-dev"],
json!({"psr-4": {"BaseTests\\": "modules/Base/tests/"}})
);
assert_eq!(
m.manifest["conflict"],
json!({"old/lib": "<2.0", "bad/lib": "*"})
);
assert_eq!(
m.manifest["provide"],
json!({"psr/log-implementation": "3.0"})
);
assert_eq!(m.manifest["extra"]["keep"], json!(1));
assert!(m.manifest["extra"].get("only-include").is_none());
}
#[test]
fn duplicate_rules_dev_off_and_extra() {
let tmp = tempfile::tempdir().unwrap();
let d = tmp.path();
write(
d,
"inc/composer.json",
&json!({
"require": {"monolog/monolog": "^2.11"},
"require-dev": {"x/y": "*"},
"autoload-dev": {"psr-4": {"T\\": "t/"}},
"extra": {"shared": {"b": 2}, "keep": 2, "new": 3}
}),
);
let mut r = root();
r["extra"]["merge-plugin"] = json!({"include": ["inc/composer.json"], "ignore-duplicates": true, "merge-dev": false, "merge-extra": true});
let m = merge(d, &r, "1.0.0.0", "1.0.0", true).unwrap();
assert_eq!(
m.manifest["require"]["monolog/monolog"],
json!("^2.8"),
"ignored duplicate"
);
assert!(m.merged_names.is_empty());
assert!(
m.requires_dev.iter().all(|l| l.target != "x/y"),
"merge-dev false"
);
assert!(m.manifest.get("autoload-dev").is_none());
assert_eq!(m.manifest["extra"]["keep"], json!(1));
assert_eq!(m.manifest["extra"]["shared"], json!({"a": 1}));
assert_eq!(m.manifest["extra"]["new"], json!(3));
r["extra"]["merge-plugin"] = json!({"include": ["inc/composer.json"], "replace": true, "merge-extra": true, "merge-extra-deep": true});
let m = merge(d, &r, "1.0.0.0", "1.0.0", false).unwrap();
assert_eq!(m.manifest["require"]["monolog/monolog"], json!("^2.11"));
assert_eq!(m.manifest["extra"]["keep"], json!(2));
assert_eq!(m.manifest["extra"]["shared"], json!({"a": 1, "b": 2}));
write(
d,
"inc/composer.json",
&json!({"require": {"monolog/monolog": "^2.9"}}),
);
r["extra"]["merge-plugin"] = json!({"include": ["inc/composer.json"]});
let m = merge(d, &r, "1.0.0.0", "1.0.0", false).unwrap();
assert_eq!(m.manifest["require"]["monolog/monolog"], json!("^2.9"));
}
#[test]
fn required_pattern_must_match_and_invalid_json_fails() {
let tmp = tempfile::tempdir().unwrap();
let d = tmp.path();
let mut r = root();
r["extra"]["merge-plugin"] = json!({"require": ["nowhere/*.json"]});
assert_eq!(
merge(d, &r, "1.0.0.0", "1.0.0", true).unwrap_err(),
"merge-plugin: No files matched required 'nowhere/*.json'"
);
std::fs::create_dir_all(d.join("inc")).unwrap();
std::fs::write(d.join("inc/composer.json"), "{ nope").unwrap();
r["extra"]["merge-plugin"] = json!({"include": "inc/composer.json"});
assert!(merge(d, &r, "1.0.0.0", "1.0.0", true)
.unwrap_err()
.contains("does not contain valid JSON"));
std::fs::write(
d.join("inc/composer.json"),
r#"{"require": {"x/y": "^^1"}}"#,
)
.unwrap();
assert!(merge(d, &r, "1.0.0.0", "1.0.0", true)
.unwrap_err()
.contains("inc/composer.json require"));
std::fs::write(d.join("inc/composer.json"), "[]").unwrap();
let mut r2 = r.clone();
r2["require"]["acme/app-alias"] = json!("self.version");
let m = merge(d, &r2, "1.0.0.0", "1.0.0", true).unwrap();
let l = m
.requires
.iter()
.find(|l| l.target == "acme/app-alias")
.unwrap();
assert_eq!(l.pretty, "self.version");
assert!(l.constraint.matches_version("1.0.0.0"));
r["extra"]["merge-plugin"] = json!({"include": ["absent/*.json"]});
let m = merge(d, &r, "1.0.0.0", "1.0.0", true).unwrap();
assert_eq!(m.manifest, r);
assert!(m.files.is_empty());
}
}