DependsOnMerge.java
package network.ike.plugin.ws;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Merge freshly derived {@code depends-on} edges into a subproject's
* existing {@code workspace.yaml} block instead of replacing it
* (IKE-Network/ike-issues#964).
*
* <p>The old replace-wholesale rewrite silently discarded manual
* edits: a hand-declared {@code relationship: bundle} edge (#963), a
* {@code content} edge with no POM counterpart, and any explanatory
* comments all vanished into a routine re-derivation commit. The merge
* keeps machine ownership of what derivation actually knows —
* {@code build} edges — and treats everything else as hand-authored:
*
* <ul>
* <li><b>Non-build entries are preserved verbatim</b> (text and
* attached comments), whatever the POMs say. A preserved entry
* also suppresses emitting a derived {@code build} duplicate for
* the same target, so a {@code bundle} edge is never downgraded
* back to {@code build}.</li>
* <li><b>Existing build entries that are still derived keep their
* original text</b> — comments and {@code version-property}
* included.</li>
* <li><b>Build entries no longer derived are removed</b> (the drift
* correction this sync exists for), and newly derived targets are
* appended as fresh entries carrying their derived
* relationship.</li>
* <li><b>Derivation may emit {@code bundle}</b> for plugin-staged
* references (ike-issues#965): a {@code build} entry whose
* derivation moved to {@code bundle} is superseded — removed and
* re-added as {@code relationship: bundle} — the idiom-migration
* path from a project dependency to {@code artifactItem} staging.
* Hand-authored non-build entries still cover their target and
* are never replaced.</li>
* </ul>
*
* <p>The rewritten block gains a one-line managed marker above
* {@code depends-on:} in the style of the {@code ws:scaffold-init}
* header block, making the ownership boundary visible in the file
* itself. The marker is regenerated idempotently.
*/
final class DependsOnMerge {
private DependsOnMerge() {}
/** Managed-ownership marker written above every derived block. */
static final String MARKER =
" # ── managed: depends-on is derived from POMs by ws goals; "
+ "build edges are machine-owned, other relationships "
+ "are preserved (ike-issues#964) ──";
/**
* Outcome of a merge: the updated YAML (identical to the input when
* nothing changed), the derived targets added per relationship, the
* build targets removed (a target in both {@code removedBuild} and
* {@code addedBundle} was superseded, not dropped), and each final
* entry's relationship (for ordering-graph construction by the
* acyclicity gate).
*/
record Result(
String yaml,
List<String> addedBuild,
List<String> addedBundle,
List<String> removedBuild,
Map<String, String> finalRelationships) {
boolean changed(String original) {
return !yaml.equals(original);
}
}
/**
* One existing entry of a {@code depends-on} block: its target
* name, relationship, and verbatim text chunk (attached comment
* lines included).
*/
private record Entry(String target, String relationship, String text) {}
/**
* Matches a subproject section up to and including its
* {@code depends-on} block — an optional managed marker line, the
* {@code depends-on:} line, and every 6-space-indented continuation
* (entries, their fields, and attached comments).
*/
private static Pattern blockPattern(String subprojectName) {
return Pattern.compile(
"(" + Pattern.quote(subprojectName) + ":[\\s\\S]*?)"
+ "((?: # ── managed: depends-on[^\n]*\n)?"
+ " depends-on:.*(?:\n .*)*\n)",
Pattern.MULTILINE);
}
/**
* Merge {@code derived} into {@code subprojectName}'s block within
* {@code yaml}.
*
* @param yaml the full workspace.yaml content
* @param subprojectName the subproject whose block to merge
* @param derived the freshly derived edges (build and bundle)
* @return the merge result; {@code yaml} is returned unchanged when
* the subproject's block cannot be located
*/
static Result merge(String yaml, String subprojectName,
List<WsAddMojo.DerivedDep> derived) {
Matcher m = blockPattern(subprojectName).matcher(yaml);
if (!m.find()) {
return new Result(yaml, List.of(), List.of(), List.of(),
Map.of());
}
String block = m.group(2);
ParsedBlock parsed = parseEntries(block);
List<Entry> current = parsed.entries();
Set<String> derivedTargets = new LinkedHashSet<>();
Map<String, String> derivedVersionProperty = new LinkedHashMap<>();
Map<String, String> derivedRelationship = new LinkedHashMap<>();
for (WsAddMojo.DerivedDep dep : derived) {
derivedTargets.add(dep.subproject());
derivedRelationship.put(dep.subproject(), dep.relationship());
if (dep.versionProperty() != null) {
derivedVersionProperty.put(
dep.subproject(), dep.versionProperty());
}
}
// Survivors: every non-build entry, plus build entries still
// derived as build. A preserved entry of any relationship covers
// its target — no derived duplicate is emitted for it. A build
// entry whose derivation moved to bundle is superseded: dropped
// here and re-added below with the derived relationship (#965).
List<Entry> kept = new ArrayList<>();
List<String> removedBuild = new ArrayList<>();
Set<String> covered = new LinkedHashSet<>();
for (Entry entry : current) {
boolean build = isBuild(entry.relationship());
boolean stillBuild = "build".equalsIgnoreCase(
derivedRelationship.get(entry.target()));
if (!build || stillBuild) {
kept.add(entry);
covered.add(entry.target());
} else {
removedBuild.add(entry.target());
}
}
List<String> addedBuild = new ArrayList<>();
List<String> addedBundle = new ArrayList<>();
List<String> toAdd = new ArrayList<>();
for (String target : derivedTargets) {
if (!covered.contains(target)) toAdd.add(target);
}
StringBuilder rendered = new StringBuilder();
rendered.append(MARKER).append('\n');
if (kept.isEmpty() && toAdd.isEmpty()) {
rendered.append(" depends-on: []\n");
} else {
rendered.append(" depends-on:\n");
for (Entry entry : kept) {
rendered.append(entry.text());
}
for (String target : toAdd) {
String relationship = derivedRelationship
.getOrDefault(target, "build");
if ("bundle".equalsIgnoreCase(relationship)) {
addedBundle.add(target);
} else {
addedBuild.add(target);
}
rendered.append(" - subproject: ").append(target)
.append('\n');
rendered.append(" relationship: ")
.append(relationship).append('\n');
String property = derivedVersionProperty.get(target);
if (property != null) {
rendered.append(" version-property: ")
.append(property).append('\n');
}
}
}
// Comments trailing the last entry (or standing in for a
// deliberately absent edge) survive the rewrite — that text is
// exactly what the old replace-wholesale behavior destroyed.
rendered.append(parsed.trailingComments());
Map<String, String> finalRelationships = new LinkedHashMap<>();
for (Entry entry : kept) {
finalRelationships.put(entry.target(),
entry.relationship() == null
? "build" : entry.relationship());
}
for (String target : addedBuild) {
finalRelationships.put(target, "build");
}
for (String target : addedBundle) {
finalRelationships.put(target, "bundle");
}
String updated = yaml.substring(0, m.start(2))
+ rendered
+ yaml.substring(m.end(2));
return new Result(updated, addedBuild, addedBundle, removedBuild,
finalRelationships);
}
private static boolean isBuild(String relationship) {
return relationship == null || "build".equalsIgnoreCase(relationship);
}
/** Parsed block: entry chunks plus any trailing comment lines. */
private record ParsedBlock(List<Entry> entries, String trailingComments) {}
/**
* Split a {@code depends-on} block into per-entry chunks. Comment
* lines at entry indent attach to the entry that follows them;
* comment lines followed by no entry are the block's trailing
* comments. Deeper-indented lines belong to the current entry. The
* optional leading managed marker and the {@code depends-on:} line
* itself are not part of any chunk.
*/
private static ParsedBlock parseEntries(String block) {
List<Entry> entries = new ArrayList<>();
StringBuilder pendingComments = new StringBuilder();
StringBuilder chunk = null;
String target = null;
String relationship = null;
for (String line : block.split("\n", -1)) {
if (line.startsWith(" # ── managed: depends-on")
|| line.stripLeading().startsWith("depends-on:")) {
continue;
}
String stripped = line.stripLeading();
if (stripped.isEmpty()) continue;
if (stripped.startsWith("- subproject:")) {
if (chunk != null) {
entries.add(new Entry(target, relationship,
chunk.toString()));
}
chunk = new StringBuilder(pendingComments);
pendingComments.setLength(0);
chunk.append(line).append('\n');
target = stripped.substring("- subproject:".length()).trim();
relationship = null;
} else if (stripped.startsWith("#")) {
pendingComments.append(line).append('\n');
} else if (chunk != null) {
chunk.append(line).append('\n');
if (stripped.startsWith("relationship:")) {
relationship = stripped
.substring("relationship:".length()).trim();
}
}
// Anything before the first entry that isn't a comment
// (e.g. the "[]" of an empty list) contributes nothing.
}
if (chunk != null) {
entries.add(new Entry(target, relationship, chunk.toString()));
}
return new ParsedBlock(entries, pendingComments.toString());
}
}