DependencyVersionAligner.java
package network.ike.plugin.ws;
import org.openrewrite.Cursor;
import org.openrewrite.xml.XmlParser;
import org.openrewrite.xml.XmlVisitor;
import org.openrewrite.xml.tree.Xml;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* AST-aware alignment of a POM's dependency versions to workspace
* reactor versions, using OpenRewrite's XML LST (no regex on POMs).
*
* <p>Owns the {@code ws:add} version-alignment edit
* (IKE-Network/ike-issues#826). The contract:
* <ul>
* <li><b>Property indirection is preserved.</b> A dependency whose
* {@code <version>} is a {@code ${...}} property reference is
* never rewritten to a literal. When the property's resolved
* value already matches the reactor version, the POM is left
* untouched; when it is stale, the mismatch is reported via
* {@link Result#propertyUpdates()} so the caller can update the
* property <em>value</em> where it is declared.</li>
* <li><b>Only literal versions are rewritten in place</b>, and only
* for dependencies outside {@code <dependencyManagement>} whose
* {@code groupId:artifactId} matches a workspace artifact.</li>
* <li>Built-in references (any {@code ${project.*}}) and mixed
* expressions (literal text combined with {@code ${...}}) are
* never touched.</li>
* </ul>
*
* @see network.ike.plugin.PomRewriter
*/
final class DependencyVersionAligner {
private DependencyVersionAligner() {}
private static final XmlParser PARSER = new XmlParser();
/** A whole-string single property reference: {@code ${name}}. */
private static final Pattern SINGLE_PROPERTY_REFERENCE =
Pattern.compile("\\$\\{\\s*([^}]+?)\\s*}");
/**
* Outcome of one alignment pass over a single POM.
*
* @param pom the (possibly updated) POM text; equal to
* the input when no literal rewrite applied
* @param propertyUpdates property name → reactor version for each
* property-referenced dependency version
* whose resolved value did not match the
* reactor version; the caller updates these
* where the property is declared
*/
record Result(String pom, Map<String, String> propertyUpdates) {}
/**
* Align dependency versions in one POM against the workspace
* artifact-version map.
*
* <p>Dependencies inside {@code <dependencyManagement>} are never
* modified (BOM imports and version constraints are not build
* edges — see {@code ws:add}'s derivation contract, #239).
*
* @param pomContent the raw POM text
* @param artifactVersions {@code groupId:artifactId} → reactor
* version for all workspace-produced
* artifacts
* @param effectiveProperties the property values in scope for this
* POM (its own {@code <properties>}
* overlaid on the subproject root's),
* used to decide whether a property
* reference is already aligned
* @return the updated POM text plus any stale-property updates the
* caller must apply where the properties are declared
*/
static Result align(String pomContent,
Map<String, String> artifactVersions,
Map<String, String> effectiveProperties) {
Xml.Document doc = PARSER.parse(pomContent)
.findFirst()
.map(t -> (Xml.Document) t)
.orElse(null);
if (doc == null) {
return new Result(pomContent, Map.of());
}
Map<String, String> propertyUpdates = new LinkedHashMap<>();
Xml.Document updated = (Xml.Document) new XmlVisitor<Integer>() {
@Override
public Xml.Tag visitTag(Xml.Tag tag, Integer ctx) {
Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx);
if (!"dependency".equals(t.getName())
|| insideDependencyManagement(getCursor())) {
return t;
}
String gid = t.getChildValue("groupId").orElse(null);
String aid = t.getChildValue("artifactId").orElse(null);
String target = artifactVersions.get(gid + ":" + aid);
if (target == null) {
return t;
}
String declared = t.getChildValue("version").orElse(null);
if (declared == null || declared.isBlank()) {
return t;
}
String propertyName = singlePropertyReference(declared);
if (propertyName != null) {
// Never replace a property reference with a literal
// (#826). ${project.*} is built-in and never ours to
// manage; anything else that resolves to a stale
// value is aligned at its declaration site instead.
if (!propertyName.startsWith("project.")) {
String resolved =
effectiveProperties.get(propertyName);
if (resolved != null && !resolved.equals(target)) {
propertyUpdates.putIfAbsent(propertyName, target);
}
}
return t;
}
if (declared.contains("${")) {
// Mixed literal/expression version — too ambiguous
// to rewrite safely; leave as declared.
return t;
}
if (!declared.equals(target)) {
return t.withChildValue("version", target);
}
return t;
}
}.visitNonNull(doc, 0);
return new Result(updated.printAll(), propertyUpdates);
}
/**
* Extract the property name when the value is exactly one
* {@code ${...}} reference (ignoring surrounding whitespace), or
* null for literals and mixed expressions.
*
* @param value the declared version text
* @return the referenced property name, or null
*/
static String singlePropertyReference(String value) {
Matcher m = SINGLE_PROPERTY_REFERENCE.matcher(value.trim());
return (m.matches()) ? m.group(1) : null;
}
/**
* Whether the cursor's current tag sits anywhere inside a
* {@code <dependencyManagement>} block.
*
* @param cursor the visitor cursor positioned at a tag
* @return true when an ancestor tag is {@code dependencyManagement}
*/
private static boolean insideDependencyManagement(Cursor cursor) {
for (Cursor c = cursor.getParent(); c != null; c = c.getParent()) {
if (c.getValue() instanceof Xml.Tag tag
&& "dependencyManagement".equals(tag.getName())) {
return true;
}
}
return false;
}
}