ReactorPom.java
package network.ike.plugin.ws;
import org.openrewrite.xml.XmlParser;
import org.openrewrite.xml.XmlVisitor;
import org.openrewrite.xml.tree.Content;
import org.openrewrite.xml.tree.Xml;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* AST-aware editor for a workspace <em>reactor</em> POM's subproject
* membership, using OpenRewrite's XML LST so formatting, comments, and
* whitespace survive each edit (no regex on POMs).
*
* <p>Where {@link network.ike.plugin.PomRewriter} owns release-path POM
* edits (dependency/parent/plugin/property versions), this class owns
* the workspace-reactor concern: the top-level {@code <subprojects>}
* block and the legacy {@code with-<name>} file-activated profiles that
* preceded it.
*
* <h2>Two membership encodings</h2>
* <ul>
* <li><b>Modern</b> — unconditional top-level
* {@code <subprojects><subproject>name</subproject></subprojects>}.
* {@code ike-workspace-extension}'s {@code SubprojectPruneTransformer}
* (IKE-Network/ike-issues#460) prunes entries whose directory is
* absent at model-read time, so the declaration is safe even when
* a subproject has not been cloned yet.</li>
* <li><b>Legacy</b> — one {@code <profile>} per subproject, id
* {@code with-<name>}, {@code <file><exists>}-activated, declaring
* the member inside the profile's own {@code <subprojects>}. This
* is what {@code ws:add} historically generated; this class
* migrates it away.</li>
* </ul>
*
* <p>All methods are pure: they take POM text and return POM text,
* leaving the input unchanged when there is nothing to do.
*
* @see network.ike.plugin.ws.reconcile.ReactorSubprojectsReconciler
* @see network.ike.plugin.PomRewriter
*/
public final class ReactorPom {
private ReactorPom() {}
private static final XmlParser PARSER = new XmlParser();
/**
* One legacy subproject-membership profile: its {@code <id>} and the
* subproject name(s) declared inside its {@code <subprojects>} block.
*
* @param id the profile id (e.g. {@code with-komet})
* @param subprojects the member name(s) the profile activates
*/
public record ProfileEntry(String id, List<String> subprojects) {}
// ── Reads ────────────────────────────────────────────────────
/**
* List the reactor POM's top-level {@code <subprojects>} entries in
* declaration order.
*
* @param pomContent the raw POM text
* @return the subproject names; empty when there is no top-level
* {@code <subprojects>} block
*/
public static List<String> listSubprojects(String pomContent) {
Xml.Document doc = parse(pomContent);
if (doc == null) {
return List.of();
}
Optional<Xml.Tag> subprojects = doc.getRoot().getChild("subprojects");
if (subprojects.isEmpty()) {
return List.of();
}
List<String> names = new ArrayList<>();
for (Xml.Tag sub : subprojects.get().getChildren("subproject")) {
sub.getValue().map(String::trim)
.filter(v -> !v.isEmpty())
.ifPresent(names::add);
}
return names;
}
/**
* List the reactor POM's legacy subproject-membership profiles — the
* {@code <profile>}s that declare one or more members inside their own
* {@code <subprojects>} block (the {@code with-<name>} pattern that
* {@code ws:add} generated before the #460 migration).
*
* <p>A profile qualifies when it carries a non-empty
* {@code <subprojects>}; the {@code with-} id and {@code <file><exists>}
* activation are the convention but are not required for the match, so
* a hand-edited profile is recognized too.
*
* @param pomContent the raw POM text
* @return the qualifying profiles in declaration order; empty when none
*/
public static List<ProfileEntry> listSubprojectProfiles(String pomContent) {
Xml.Document doc = parse(pomContent);
if (doc == null) {
return List.of();
}
Optional<Xml.Tag> profiles = doc.getRoot().getChild("profiles");
if (profiles.isEmpty()) {
return List.of();
}
List<ProfileEntry> result = new ArrayList<>();
for (Xml.Tag profile : profiles.get().getChildren("profile")) {
Optional<Xml.Tag> sp = profile.getChild("subprojects");
if (sp.isEmpty()) {
continue;
}
List<String> members = new ArrayList<>();
for (Xml.Tag sub : sp.get().getChildren("subproject")) {
sub.getValue().map(String::trim)
.filter(v -> !v.isEmpty())
.ifPresent(members::add);
}
if (members.isEmpty()) {
continue;
}
String id = profile.getChildValue("id").map(String::trim).orElse(null);
result.add(new ProfileEntry(id, members));
}
return result;
}
// ── Writes ───────────────────────────────────────────────────
/**
* Set the reactor POM's top-level {@code <subprojects>} block to
* exactly {@code names}, in the given order. Replaces an existing
* block in place (preserving its surrounding whitespace) or inserts a
* new one — before {@code <profiles>} when present, otherwise at the
* end of {@code <project>}.
*
* <p>This single operation covers add ({@code ws:add}), remove
* ({@code ws:remove}), and full reconciliation against
* {@code workspace.yaml}: callers compute the target list and let this
* method materialize it idempotently.
*
* @param pomContent the raw POM text
* @param names the exact subproject membership to declare
* @return updated POM text; unchanged when the block already matches
*/
public static String setSubprojects(String pomContent, List<String> names) {
if (listSubprojects(pomContent).equals(names)) {
return pomContent;
}
Xml.Document doc = parse(pomContent);
if (doc == null) {
return pomContent;
}
String blockXml = renderSubprojectsBlock(names);
Xml.Document updated = (Xml.Document) new XmlVisitor<Integer>() {
@Override
public Xml visitTag(Xml.Tag tag, Integer ctx) {
Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
if (!"project".equals(t.getName())) {
return t;
}
Optional<Xml.Tag> existing = t.getChild("subprojects");
if (existing.isPresent()) {
Xml.Tag block = Xml.Tag.build(blockXml)
.withPrefix(existing.get().getPrefix());
return replaceChild(t, existing.get(), block);
}
Xml.Tag block = Xml.Tag.build(blockXml).withPrefix("\n\n ");
List<Content> content = new ArrayList<>(t.getContent());
content.add(insertionIndex(content), block);
return t.withContent(content);
}
}.visitNonNull(doc, 0);
return print(updated);
}
/**
* Remove a single {@code <profile>} (matched by {@code <id>}) from the
* reactor POM, and drop the {@code <profiles>} block entirely if it
* becomes empty.
*
* @param pomContent the raw POM text
* @param profileId the profile id to remove (e.g. {@code with-komet})
* @return updated POM text; unchanged when no such profile exists
*/
public static String removeProfile(String pomContent, String profileId) {
Xml.Document doc = parse(pomContent);
if (doc == null) {
return pomContent;
}
Xml.Document updated = (Xml.Document) new XmlVisitor<Integer>() {
@Override
public Xml visitTag(Xml.Tag tag, Integer ctx) {
Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
if (!"project".equals(t.getName())) {
return t;
}
Optional<Xml.Tag> profilesOpt = t.getChild("profiles");
if (profilesOpt.isEmpty()) {
return t;
}
Xml.Tag profiles = profilesOpt.get();
boolean present = profiles.getChildren("profile").stream()
.anyMatch(p -> profileId.equals(
p.getChildValue("id").map(String::trim).orElse(null)));
if (!present) {
return t;
}
List<Content> keptProfileContent = profiles.getContent().stream()
.filter(c -> !(c instanceof Xml.Tag p
&& "profile".equals(p.getName())
&& profileId.equals(p.getChildValue("id")
.map(String::trim).orElse(null))))
.map(c -> (Content) c)
.toList();
boolean anyProfilesLeft = keptProfileContent.stream()
.anyMatch(c -> c instanceof Xml.Tag p
&& "profile".equals(p.getName()));
if (!anyProfilesLeft) {
// Drop the whole <profiles> element (and the whitespace
// node immediately preceding it) so no empty husk lingers.
List<Content> projectContent =
new ArrayList<>(t.getContent());
int idx = projectContent.indexOf(profiles);
projectContent.remove(idx);
if (idx > 0
&& projectContent.get(idx - 1) instanceof Xml.CharData) {
projectContent.remove(idx - 1);
}
return t.withContent(projectContent);
}
return replaceChild(t, profiles,
profiles.withContent(keptProfileContent));
}
}.visitNonNull(doc, 0);
return print(updated);
}
// ── Rendering / tree helpers ─────────────────────────────────
/**
* Render the {@code <subprojects>} block body for {@code names},
* indented two levels (4-space block, 8-space entries) to match the
* workspace POM template emitted by {@code WorkspaceBootstrap}.
*/
private static String renderSubprojectsBlock(List<String> names) {
if (names.isEmpty()) {
return "<subprojects>\n </subprojects>";
}
StringBuilder sb = new StringBuilder("<subprojects>\n");
for (String name : names) {
sb.append(" <subproject>").append(name)
.append("</subproject>\n");
}
sb.append(" </subprojects>");
return sb.toString();
}
/**
* Choose where to insert a freshly created {@code <subprojects>} block
* in {@code <project>} content: just before the {@code <profiles>}
* element (and its leading whitespace) when present, otherwise before
* the trailing whitespace that precedes {@code </project>}.
*/
private static int insertionIndex(List<Content> content) {
for (int i = 0; i < content.size(); i++) {
if (content.get(i) instanceof Xml.Tag t
&& "profiles".equals(t.getName())) {
return (i > 0 && content.get(i - 1) instanceof Xml.CharData)
? i - 1 : i;
}
}
int last = content.size();
if (last > 0 && content.get(last - 1) instanceof Xml.CharData) {
return last - 1;
}
return last;
}
private static Xml.Tag replaceChild(Xml.Tag parent, Xml.Tag oldChild,
Xml.Tag newChild) {
List<Content> newContent = new ArrayList<>(parent.getContent().size());
for (Content c : parent.getContent()) {
newContent.add(c == oldChild ? newChild : c);
}
return parent.withContent(newContent);
}
private static Xml.Document parse(String pomContent) {
return PARSER.parse(pomContent)
.findFirst()
.map(t -> (Xml.Document) t)
.orElse(null);
}
private static String print(Xml.Document doc) {
return doc.printAll();
}
}