diff --git a/rewrite-xml/src/main/java/org/openrewrite/xml/ChangeNamespaceValue.java b/rewrite-xml/src/main/java/org/openrewrite/xml/ChangeNamespaceValue.java index b821a95421e..6813ed343f2 100644 --- a/rewrite-xml/src/main/java/org/openrewrite/xml/ChangeNamespaceValue.java +++ b/rewrite-xml/src/main/java/org/openrewrite/xml/ChangeNamespaceValue.java @@ -19,14 +19,26 @@ import lombok.Value; import org.openrewrite.*; import org.openrewrite.internal.ListUtils; +import org.openrewrite.internal.StringUtils; import org.openrewrite.internal.lang.Nullable; +import org.openrewrite.marker.Markers; import org.openrewrite.xml.tree.Xml; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.openrewrite.Tree.randomId; + @Value @EqualsAndHashCode(callSuper = false) public class ChangeNamespaceValue extends Recipe { private static final String XMLNS_PREFIX = "xmlns"; private static final String VERSION_PREFIX = "version"; + private static final String SCHEMA_LOCATION_MATCH_PATTERN = "(?m)(.*)(%s)(\\s+)(.*)"; + private static final String SCHEMA_LOCATION_REPLACEMENT_PATTERN = "$1%s$3%s"; + private static final String MSG_TAG_UPDATED = "msg-tag-updated"; @Override public String getDisplayName() { @@ -65,22 +77,73 @@ public String getDescription() { String versionMatcher; @Nullable - @Option(displayName = "Search All Namespaces", + @Option(displayName = "Search all namespaces", description = "Specify whether evaluate all namespaces. Defaults to true", example = "true", required = false) Boolean searchAllNamespaces; + @Nullable + @Option(displayName = "New Resource version", + description = "The new version of the resource", + example = "2.0") + String newVersion; + + @Option(displayName = "Schema location", + description = "The new value to be used for the namespace schema location.", + example = "newfoo.bar.attribute.value.string", + required = false) + @Nullable + String newSchemaLocation; + + public static final String XML_SCHEMA_INSTANCE_PREFIX = "xsi"; + public static final String XML_SCHEMA_INSTANCE_URI = "http://www.w3.org/2001/XMLSchema-instance"; + + /** + * Find the tag that contains the declaration of the {@link #XML_SCHEMA_INSTANCE_URI} namespace. + * + * @param cursor the cursor to search from + * @return the tag that contains the declaration of the given namespace URI. + */ + public static Xml.Tag findTagContainingXmlSchemaInstanceNamespace(Cursor cursor) { + while (cursor != null) { + if (cursor.getValue() instanceof Xml.Document) { + return ((Xml.Document) cursor.getValue()).getRoot(); + } + Xml.Tag tag = cursor.firstEnclosing(Xml.Tag.class); + if (tag != null) { + if (tag.getNamespaces().containsValue(XML_SCHEMA_INSTANCE_URI)) { + return tag; + } + } + cursor = cursor.getParent(); + } + + // Should never happen + throw new IllegalArgumentException("Could not find tag containing namespace '" + XML_SCHEMA_INSTANCE_URI + "' or the enclosing Xml.Document instance."); + } + @Override public TreeVisitor getVisitor() { XPathMatcher elementNameMatcher = elementName != null ? new XPathMatcher(elementName) : null; return new XmlIsoVisitor() { + @Override + public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { + Xml.Document d = super.visitDocument(document, ctx); + if (ctx.pollMessage(MSG_TAG_UPDATED, false)) { + d = d.withRoot(addOrUpdateSchemaLocation(d.getRoot(), getCursor())); + } + return d; + } + @Override public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { Xml.Tag t = super.visitTag(tag, ctx); if (matchesElementName(getCursor()) && matchesVersion(t)) { t = t.withAttributes(ListUtils.map(t.getAttributes(), this::maybeReplaceNamespaceAttribute)); + t = t.withAttributes(ListUtils.map(t.getAttributes(), this::maybeReplaceVersionAttribute)); + ctx.putMessage(MSG_TAG_UPDATED, true); } return t; @@ -114,6 +177,18 @@ private Xml.Attribute maybeReplaceNamespaceAttribute(Xml.Attribute attribute) { return attribute; } + private Xml.Attribute maybeReplaceVersionAttribute(Xml.Attribute attribute) { + if (isVersionAttribute(attribute) && newVersion != null) { + return attribute.withValue( + new Xml.Attribute.Value(attribute.getId(), + "", + attribute.getMarkers(), + attribute.getValue().getQuote(), + newVersion)); + } + return attribute; + } + private boolean isXmlnsAttribute(Xml.Attribute attribute) { boolean searchAll = searchAllNamespaces == null || Boolean.TRUE.equals(searchAllNamespaces); return searchAll && attribute.getKeyAsString().startsWith(XMLNS_PREFIX) || @@ -129,6 +204,9 @@ private boolean isOldValue(Xml.Attribute attribute) { } private boolean isVersionMatch(Xml.Attribute attribute) { + if (versionMatcher == null) { + return true; + } String[] versions = versionMatcher.split(","); double dversion = Double.parseDouble(attribute.getValueAsString()); for (String splitVersion : versions) { @@ -149,6 +227,87 @@ private boolean isVersionMatch(Xml.Attribute attribute) { } return false; } + + private Xml.Tag addOrUpdateSchemaLocation(Xml.Tag root, Cursor cursor) { + if (StringUtils.isBlank(newSchemaLocation)) { + return root; + } + Xml.Tag newRoot = maybeAddNamespace(root); + Optional maybeSchemaLocation = maybeGetSchemaLocation(cursor, newRoot); + if (maybeSchemaLocation.isPresent() && oldValue != null) { + newRoot = updateSchemaLocation(newRoot, maybeSchemaLocation.get()); + } else if (!maybeSchemaLocation.isPresent()) { + newRoot = addSchemaLocation(newRoot); + } + return newRoot; + } + + private Optional maybeGetSchemaLocation(Cursor cursor, Xml.Tag tag) { + Xml.Tag schemaLocationTag = findTagContainingXmlSchemaInstanceNamespace(cursor); + Map namespaces = tag.getNamespaces(); + for (Xml.Attribute attribute : schemaLocationTag.getAttributes()) { + String attributeNamespace = namespaces.get(Xml.extractNamespacePrefix(attribute.getKeyAsString())); + if(XML_SCHEMA_INSTANCE_URI.equals(attributeNamespace) + && attribute.getKeyAsString().endsWith("schemaLocation")) { + return Optional.of(attribute); + } + } + + return Optional.empty(); + } + + private Xml.Tag maybeAddNamespace(Xml.Tag root) { + Map namespaces = root.getNamespaces(); + if (namespaces.containsValue(newValue) && !namespaces.containsValue(XML_SCHEMA_INSTANCE_URI)) { + namespaces.put(XML_SCHEMA_INSTANCE_PREFIX, XML_SCHEMA_INSTANCE_URI); + root = root.withNamespaces(namespaces); + } + return root; + } + + private Xml.Tag updateSchemaLocation(Xml.Tag newRoot, Xml.Attribute attribute) { + if(oldValue == null) { + return newRoot; + } + String oldSchemaLocation = attribute.getValueAsString(); + Matcher pattern = Pattern.compile(String.format(SCHEMA_LOCATION_MATCH_PATTERN, Pattern.quote(oldValue))) + .matcher(oldSchemaLocation); + if (pattern.find()) { + String newSchemaLocationValue = pattern.replaceFirst( + String.format(SCHEMA_LOCATION_REPLACEMENT_PATTERN, newValue, newSchemaLocation) + ); + Xml.Attribute newAttribute = attribute.withValue(attribute.getValue().withValue(newSchemaLocationValue)); + newRoot = newRoot.withAttributes(ListUtils.map(newRoot.getAttributes(), a -> a == attribute ? newAttribute : a)); + } + return newRoot; + } + + private Xml.Tag addSchemaLocation(Xml.Tag newRoot) { + return newRoot.withAttributes( + ListUtils.concat( + newRoot.getAttributes(), + new Xml.Attribute( + randomId(), + " ", + Markers.EMPTY, + new Xml.Ident( + randomId(), + "", + Markers.EMPTY, + String.format("%s:schemaLocation", XML_SCHEMA_INSTANCE_PREFIX) + ), + "", + new Xml.Attribute.Value( + randomId(), + "", + Markers.EMPTY, + Xml.Attribute.Value.Quote.Double, + String.format("%s %s", newValue, newSchemaLocation) + ) + ) + ) + ); + } }; } } diff --git a/rewrite-xml/src/main/java/org/openrewrite/xml/XPathMatcher.java b/rewrite-xml/src/main/java/org/openrewrite/xml/XPathMatcher.java index 9aa28ad3ec2..7c6066b72f9 100644 --- a/rewrite-xml/src/main/java/org/openrewrite/xml/XPathMatcher.java +++ b/rewrite-xml/src/main/java/org/openrewrite/xml/XPathMatcher.java @@ -37,7 +37,7 @@ public class XPathMatcher { // Regular expression to support conditional tags like `plugin[artifactId='maven-compiler-plugin']` or foo[@bar='baz'] - private static final Pattern PATTERN = Pattern.compile("([-\\w]+)\\[(@)?([-\\w]+)='([-\\w.]+)']"); + private static final Pattern PATTERN = Pattern.compile("([-\\w]+|\\*)\\[((local-name|namespace-uri)\\(\\)|(@)?([-\\w]+|\\*))='([-\\w.]+)']"); private final String expression; private final boolean startsWithSlash; @@ -82,6 +82,9 @@ public boolean matches(Cursor cursor) { if (part.charAt(index + 1) == '@') { partWithCondition = part; tagForCondition = path.get(i); + } else if (part.contains("(") && part.contains(")")) { //if is function + partWithCondition = part; + tagForCondition = path.get(i); } } else if (i < path.size() && i > 0 && parts[i - 1].endsWith("]")) { String partBefore = parts[i - 1]; @@ -94,6 +97,8 @@ public boolean matches(Cursor cursor) { partWithCondition = partBefore; tagForCondition = path.get(parts.length - i); } + } else if (part.endsWith(")")) { // is xpath method + // TODO: implement other xpath methods } String partName; @@ -101,7 +106,7 @@ public boolean matches(Cursor cursor) { Matcher matcher; if (tagForCondition != null && partWithCondition.endsWith("]") && (matcher = PATTERN.matcher( partWithCondition)).matches()) { - String optionalPartName = matchesCondition(matcher, tagForCondition); + String optionalPartName = matchesCondition(matcher, tagForCondition, cursor); if (optionalPartName == null) { return false; } @@ -176,7 +181,7 @@ public boolean matches(Cursor cursor) { Matcher matcher; if (tag != null && part.endsWith("]") && (matcher = PATTERN.matcher(part)).matches()) { - String optionalPartName = matchesCondition(matcher, tag); + String optionalPartName = matchesCondition(matcher, tag, cursor); if (optionalPartName == null) { return false; } @@ -191,7 +196,7 @@ public boolean matches(Cursor cursor) { "*".equals(part.substring(1))); } - if (path.size() < i + 1 || (tag != null && !tag.getName().equals(partName) && !"*".equals(part))) { + if (path.size() < i + 1 || (tag != null && !tag.getName().equals(partName) && !partName.equals("*") && !"*".equals(part))) { return false; } } @@ -201,21 +206,34 @@ public boolean matches(Cursor cursor) { } @Nullable - private String matchesCondition(Matcher matcher, Xml.Tag tag) { + private String matchesCondition(Matcher matcher, Xml.Tag tag, Cursor cursor) { String name = matcher.group(1); - boolean isAttribute = Objects.equals(matcher.group(2), "@"); - String selector = matcher.group(3); - String value = matcher.group(4); + boolean isAttribute = matcher.group(4) != null; // either group4 != null, or group 2 startsWith @ + String selector = isAttribute ? matcher.group(5) : matcher.group(2); + boolean isFunction = selector.endsWith("()"); + String value = matcher.group(6); boolean matchCondition = false; if (isAttribute) { for (Xml.Attribute a : tag.getAttributes()) { - if (a.getKeyAsString().equals(selector) && a.getValueAsString().equals(value)) { + if ((a.getKeyAsString().equals(selector) || "*".equals(selector)) && a.getValueAsString().equals(value)) { matchCondition = true; break; } } - } else { + } else if (isFunction) { + if (!name.equals("*") && !tag.getLocalName().equals(name)) { + matchCondition = false; + } else if (selector.equals("local-name()")) { + if (tag.getLocalName().equals(value)) { + matchCondition = true; + } + } else if (selector.equals("namespace-uri()")) { + if (tag.getNamespaceUri(cursor).get().equals(value)) { + matchCondition = true; + } + } + } else { // other [] conditions for (Xml.Tag t : FindTags.find(tag, selector)) { if (t.getValue().map(v -> v.equals(value)).orElse(false)) { matchCondition = true; diff --git a/rewrite-xml/src/main/java/org/openrewrite/xml/search/FindNamespacePrefix.java b/rewrite-xml/src/main/java/org/openrewrite/xml/search/FindNamespacePrefix.java new file mode 100644 index 00000000000..95623523636 --- /dev/null +++ b/rewrite-xml/src/main/java/org/openrewrite/xml/search/FindNamespacePrefix.java @@ -0,0 +1,90 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.xml.search; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Option; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.StringUtils; +import org.openrewrite.internal.lang.Nullable; +import org.openrewrite.marker.SearchResult; +import org.openrewrite.xml.XPathMatcher; +import org.openrewrite.xml.XmlVisitor; +import org.openrewrite.xml.tree.Xml; + +import java.util.HashSet; +import java.util.Set; + +@Value +@EqualsAndHashCode(callSuper = false) +public class FindNamespacePrefix extends Recipe { + + @Option(displayName = "Namespace prefix", + description = "The Namespace Prefix to find.", + example = "http://www.w3.org/2001/XMLSchema-instance") + String namespacePrefix; + + @Option(displayName = "XPath", + description = "An XPath expression used to find namespace URIs.", + example = "/dependencies/dependency", + required = false) + @Nullable + String xPath; + + @Override + public String getDisplayName() { + return "Find XML namespace prefixes"; + } + + @Override + public String getDescription() { + return "Find XML namespace prefixes, optionally restricting the search by a XPath expression."; + } + + @Override + public TreeVisitor getVisitor() { + XPathMatcher matcher = StringUtils.isBlank(xPath) ? null : new XPathMatcher(xPath); + return new XmlVisitor() { + + @Override + public Xml visitTag(Xml.Tag tag, ExecutionContext ctx) { + Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx); + if (tag.getNamespaces().containsKey(namespacePrefix) && (matcher == null || matcher.matches(getCursor()))) { + t = SearchResult.found(t); + } + return t; + } + }; + } + + public static Set find(Xml x, String namespacePrefix, @Nullable String xPath) { + XPathMatcher matcher = StringUtils.isBlank(xPath) ? null : new XPathMatcher(xPath); + Set ts = new HashSet<>(); + new XmlVisitor>() { + @Override + public Xml visitTag(Xml.Tag tag, Set ts) { + if (tag.getNamespaces().containsKey(namespacePrefix) && (matcher == null || matcher.matches(getCursor()))) { + ts.add(tag); + } + return super.visitTag(tag, ts); + } + }.visit(x, ts); + return ts; + } +} diff --git a/rewrite-xml/src/main/java/org/openrewrite/xml/search/HasNamespaceUri.java b/rewrite-xml/src/main/java/org/openrewrite/xml/search/HasNamespaceUri.java new file mode 100644 index 00000000000..2745aff2264 --- /dev/null +++ b/rewrite-xml/src/main/java/org/openrewrite/xml/search/HasNamespaceUri.java @@ -0,0 +1,90 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.xml.search; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Option; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.StringUtils; +import org.openrewrite.internal.lang.Nullable; +import org.openrewrite.marker.SearchResult; +import org.openrewrite.xml.XPathMatcher; +import org.openrewrite.xml.XmlVisitor; +import org.openrewrite.xml.tree.Xml; + +import java.util.HashSet; +import java.util.Set; + +@Value +@EqualsAndHashCode(callSuper = false) +public class HasNamespaceUri extends Recipe { + + @Option(displayName = "Namespace URI", + description = "The Namespace URI to find.", + example = "http://www.w3.org/2001/XMLSchema-instance") + String namespaceUri; + + @Option(displayName = "XPath", + description = "An XPath expression used to find namespace URIs.", + example = "/dependencies/dependency", + required = false) + @Nullable + String xPath; + + @Override + public String getDisplayName() { + return "Find XML namespace URIs"; + } + + @Override + public String getDescription() { + return "Find XML namespace URIs, optionally restricting the search by a XPath expression."; + } + + @Override + public TreeVisitor getVisitor() { + XPathMatcher matcher = StringUtils.isBlank(xPath) ? null : new XPathMatcher(xPath); + return new XmlVisitor() { + + @Override + public Xml visitTag(Xml.Tag tag, ExecutionContext ctx) { + Xml.Tag t = (Xml.Tag) super.visitTag(tag, ctx); + if (tag.getNamespaces().containsValue(namespaceUri) && (matcher == null || matcher.matches(getCursor()))) { + t = SearchResult.found(t); + } + return t; + } + }; + } + + public static Set find(Xml x, String namespaceUri, @Nullable String xPath) { + XPathMatcher matcher = StringUtils.isBlank(xPath) ? null : new XPathMatcher(xPath); + Set ts = new HashSet<>(); + new XmlVisitor>() { + @Override + public Xml visitTag(Xml.Tag tag, Set ts) { + if (tag.getNamespaces().containsValue(namespaceUri) && (matcher == null || matcher.matches(getCursor()))) { + ts.add(tag); + } + return super.visitTag(tag, ts); + } + }.visit(x, ts); + return ts; + } +} diff --git a/rewrite-xml/src/main/java/org/openrewrite/xml/tree/Xml.java b/rewrite-xml/src/main/java/org/openrewrite/xml/tree/Xml.java index ff3be4ba794..caa74a3e842 100755 --- a/rewrite-xml/src/main/java/org/openrewrite/xml/tree/Xml.java +++ b/rewrite-xml/src/main/java/org/openrewrite/xml/tree/Xml.java @@ -20,6 +20,8 @@ import org.apache.commons.text.StringEscapeUtils; import org.intellij.lang.annotations.Language; import org.openrewrite.*; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.internal.StringUtils; import org.openrewrite.internal.WhitespaceValidationService; import org.openrewrite.internal.lang.Nullable; import org.openrewrite.marker.Markers; @@ -32,10 +34,7 @@ import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Path; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; import static java.util.Collections.emptyList; @@ -75,20 +74,61 @@ default

boolean isAcceptable(TreeVisitor v, P p) { */ Xml withPrefixUnsafe(String prefix); + static boolean isNamespaceDefinitionAttribute(String name) { + return name.startsWith("xmlns"); + } + + static String getAttributeNameForPrefix(String namespacePrefix) { + return namespacePrefix.isEmpty() ? "xmlns" : "xmlns:" + namespacePrefix; + } + + /** + * Extract the namespace prefix from a namespace definition attribute name (xmlns* attributes). + * + * @param name the attribute name or null if not a namespace definition attribute + * @return the namespace prefix + */ + static @Nullable String extractPrefixFromNamespaceDefinition(String name) { + if (!isNamespaceDefinitionAttribute(name)) { + return null; + } + return "xmlns".equals(name) ? "" : extractLocalName(name); + } + + /** + * Extract the namespace prefix from a tag or attribute name. + * + * @param name the tag or attribute name + * @return the namespace prefix (empty string for the default namespace) + */ + static String extractNamespacePrefix(String name) { + int colon = name.indexOf(':'); + return colon == -1 ? "" : name.substring(0, colon); + } + + /** + * Extract the local name from a tag or attribute name. + * + * @param name the tag or attribute name + * @return the local name + */ + static String extractLocalName(String name) { + int colon = name.indexOf(':'); + return colon == -1 ? name : name.substring(colon + 1); + } + + @Getter @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) @EqualsAndHashCode(callSuper = false, onlyExplicitlyIncluded = true) @RequiredArgsConstructor class Document implements Xml, SourceFile { - @Getter @With @EqualsAndHashCode.Include UUID id; - @Getter @With Path sourcePath; - @Getter @With String prefixUnsafe; @@ -102,26 +142,21 @@ public String getPrefix() { return prefixUnsafe; } - @Getter @With Markers markers; - @Getter @Nullable // for backwards compatibility @With(AccessLevel.PRIVATE) String charsetName; @With - @Getter boolean charsetBomMarked; @With - @Getter @Nullable Checksum checksum; @With - @Getter @Nullable FileAttributes fileAttributes; @@ -130,20 +165,24 @@ public Charset getCharset() { return charsetName == null ? StandardCharsets.UTF_8 : Charset.forName(charsetName); } + @SuppressWarnings("unchecked") @Override - public SourceFile withCharset(Charset charset) { + public Xml.Document withCharset(Charset charset) { return withCharsetName(charset.name()); } - @Getter @With Prolog prolog; - @Getter - @With Tag root; - @Getter + public Document withRoot(Tag root) { + if (this.root == root) { + return this; + } + return new Document(id, sourcePath, prefixUnsafe, markers, charsetName, charsetBomMarked, checksum, fileAttributes, prolog, root, eof); + } + String eof; public Document withEof(String eof) { @@ -275,6 +314,7 @@ public

Xml acceptXml(XmlVisitor

v, P p) { } } + @SuppressWarnings("unused") @Value @EqualsAndHashCode(callSuper = false, onlyExplicitlyIncluded = true) class Tag implements Xml, Content { @@ -285,6 +325,123 @@ class Tag implements Xml, Content { @With String prefixUnsafe; + /** + * The map returned by this method is a view of the Tag's attributes. + * Modifying the map will NOT modify the tag's attributes. + * + * @return a map of namespace prefixes (without the xmlns prefix) to URIs for this tag. + */ + public Map getNamespaces() { + final Map namespaces = new LinkedHashMap<>(attributes.size()); + if (!attributes.isEmpty()) { + for (Attribute attribute : attributes) { + if(isNamespaceDefinitionAttribute(attribute.getKeyAsString())) { + namespaces.put( + extractPrefixFromNamespaceDefinition(attribute.getKeyAsString()), + attribute.getValueAsString()); + } + } + } + return namespaces; + } + + /** + * Gets a map containing all namespaces defined in the current scope, including all parent scopes. + * + * @param cursor the cursor to search from + * @return a map containing all namespaces defined in the current scope, including all parent scopes. + */ + public Map getAllNamespaces(Cursor cursor) { + Map namespaces = getNamespaces(); + while (cursor != null) { + Xml.Tag enclosing = cursor.firstEnclosing(Xml.Tag.class); + if (enclosing != null) { + for (Map.Entry ns : enclosing.getNamespaces().entrySet()) { + if (namespaces.containsValue(ns.getKey())) { + throw new IllegalStateException(java.lang.String.format("Cannot have two namespaces with the same prefix (%s): '%s' and '%s'", ns.getKey(), namespaces.get(ns.getKey()), ns.getValue())); + } + namespaces.put(ns.getKey(), ns.getValue()); + } + } + cursor = cursor.getParent(); + } + + return namespaces; + } + + public Tag withNamespaces(Map namespaces) { + Map currentNamespaces = getNamespaces(); + if (currentNamespaces.equals(namespaces)) { + return this; + } + + List attributes = this.attributes; + if (attributes.isEmpty()) { + for (Map.Entry ns : namespaces.entrySet()) { + String key = getAttributeNameForPrefix(ns.getKey()); + attributes = ListUtils.concat(attributes, new Xml.Attribute( + randomId(), + "", + Markers.EMPTY, + new Xml.Ident( + randomId(), + "", + Markers.EMPTY, + key + ), + "", + new Xml.Attribute.Value( + randomId(), + "", + Markers.EMPTY, + Xml.Attribute.Value.Quote.Double, ns.getValue() + ) + )); + } + } else { + Map attributeByKey = attributes.stream() + .collect(Collectors.toMap( + Attribute::getKeyAsString, + a -> a + )); + + for (Map.Entry ns : namespaces.entrySet()) { + String key = getAttributeNameForPrefix(ns.getKey()); + if (attributeByKey.containsKey(key)) { + Xml.Attribute attribute = attributeByKey.get(key); + if (!ns.getValue().equals(attribute.getValueAsString())) { + ListUtils.map(attributes, a -> a.getKeyAsString().equals(key) + ? attribute.withValue(new Xml.Attribute.Value(randomId(), "", Markers.EMPTY, Xml.Attribute.Value.Quote.Double, ns.getValue())) + : a + ); + } + } else { + attributes = ListUtils.concat(attributes, new Xml.Attribute( + randomId(), + " ", + Markers.EMPTY, + new Xml.Ident( + randomId(), + "", + Markers.EMPTY, + key + ), + "", + new Xml.Attribute.Value( + randomId(), + "", + Markers.EMPTY, + Xml.Attribute.Value.Quote.Double, ns.getValue() + ) + )); + } + } + } + + return new Tag(id, prefixUnsafe, markers, name, attributes, content, closing, + beforeTagDelimiterPrefix); + } + @Override public Tag withPrefix(String prefix) { return WithPrefix.onlyIfNotEqual(this, prefix); @@ -461,6 +618,29 @@ public Tag withContent(@Nullable List content) { @With String beforeTagDelimiterPrefix; + /** + * @return The local name for this tag, without any namespace prefix. + */ + public String getLocalName() { + return extractLocalName(name); + } + + /** + * @return The namespace prefix for this tag, if any. + */ + public Optional getNamespacePrefix() { + String extractedNamespacePrefix = extractNamespacePrefix(name); + return Optional.ofNullable(StringUtils.isNotEmpty(extractedNamespacePrefix) ? extractedNamespacePrefix : null); + } + + /** + * @return The namespace URI for this tag, if any. + */ + public Optional getNamespaceUri(Cursor cursor) { + Optional maybeNamespacePrefix = getNamespacePrefix(); + return maybeNamespacePrefix.flatMap(s -> Optional.ofNullable(getAllNamespaces(cursor).get(s))); + } + @Override public

Xml acceptXml(XmlVisitor

v, P p) { return v.visitTag(this, p); diff --git a/rewrite-xml/src/test/java/org/openrewrite/xml/ChangeNamespaceValueTest.java b/rewrite-xml/src/test/java/org/openrewrite/xml/ChangeNamespaceValueTest.java index fc668a0fb8a..38d6e10dfd4 100644 --- a/rewrite-xml/src/test/java/org/openrewrite/xml/ChangeNamespaceValueTest.java +++ b/rewrite-xml/src/test/java/org/openrewrite/xml/ChangeNamespaceValueTest.java @@ -27,7 +27,7 @@ class ChangeNamespaceValueTest implements RewriteTest { @Test void replaceVersion24Test() { rewriteRun( - spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://java.sun.com/xml/ns/j2ee", "2.4", false)) + spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://java.sun.com/xml/ns/j2ee", "2.4", false, null, null)) .expectedCyclesThatMakeChanges(2), xml( """ @@ -53,7 +53,7 @@ void replaceVersion24Test() { @Test void replaceVersion25Test() { rewriteRun( - spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://java.sun.com/xml/ns/java", "2.5,3.0", false)) + spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://java.sun.com/xml/ns/java", "2.5,3.0", false, null, null)) .expectedCyclesThatMakeChanges(2), xml( """ @@ -79,7 +79,7 @@ void replaceVersion25Test() { @Test void replaceVersion30Test() { rewriteRun( - spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://java.sun.com/xml/ns/java", "2.5,3.0", false)) + spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://java.sun.com/xml/ns/java", "2.5,3.0", false, null, null)) .expectedCyclesThatMakeChanges(2), xml( """ @@ -105,7 +105,7 @@ void replaceVersion30Test() { @Test void replaceVersion31Test() { rewriteRun( - spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://xmlns.jcp.org/xml/ns/javaee", "3.1+", false)) + spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://xmlns.jcp.org/xml/ns/javaee", "3.1+", false, null, null)) .expectedCyclesThatMakeChanges(2), xml( """ @@ -131,7 +131,7 @@ void replaceVersion31Test() { @Test void replaceVersion32Test() { rewriteRun( - spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://xmlns.jcp.org/xml/ns/javaee", "3.1+", false)) + spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://xmlns.jcp.org/xml/ns/javaee", "3.1+", false, null, null)) .expectedCyclesThatMakeChanges(2), xml( """ @@ -157,7 +157,7 @@ void replaceVersion32Test() { @Test void invalidVersionTest() { rewriteRun( - spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://java.sun.com/xml/ns/j2ee", "2.5", false)), + spec -> spec.recipe(new ChangeNamespaceValue("web-app", null, "http://java.sun.com/xml/ns/j2ee", "2.5", false, null, null)), xml( """ spec.recipe(new ChangeNamespaceValue(null, "http://old.namespace", "https://new.namespace", null, true)), + spec -> spec.recipe(new ChangeNamespaceValue(null, "http://old.namespace", "https://new.namespace", null, true, null, null)), xml( """ spec.recipe(new ChangeNamespaceValue(null, "http://old.namespace", "https://new.namespace", null, true)), + spec -> spec.recipe(new ChangeNamespaceValue(null, "http://old.namespace", "https://new.namespace", null, true, null, null)), xml( """ spec.recipe(new ChangeNamespaceValue(null, "http://non.existant.namespace", "https://new.namespace", null, true)), + spec -> spec.recipe(new ChangeNamespaceValue(null, "http://non.existant.namespace", "https://new.namespace", null, true, null, null)), xml( """ spec.recipe(new ChangeNamespaceValue("web-app", "http://java.sun.com/xml/ns/j2ee", "http://java.sun.com/xml/ns/javaee", "2.4", true, "2.5", "http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd")), + xml( + """ + + testWebDDNamespace + + """, + """ + + testWebDDNamespace + + """ + ) + ); + } + + @Test + void replaceNamespaceUriAndAddMissingSchemaLocation() { + rewriteRun( + spec -> spec.recipe(new ChangeNamespaceValue("web-app", "http://java.sun.com/xml/ns/j2ee", "http://java.sun.com/xml/ns/javaee", "2.4", true, "2.5", "http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd")), + xml( + """ + + testWebDDNamespace + + """, + """ + + testWebDDNamespace + + """ + ) + ); + } } diff --git a/rewrite-xml/src/test/java/org/openrewrite/xml/CreateXmlFileTest.java b/rewrite-xml/src/test/java/org/openrewrite/xml/CreateXmlFileTest.java index 843b3a66b6f..02e216acd83 100644 --- a/rewrite-xml/src/test/java/org/openrewrite/xml/CreateXmlFileTest.java +++ b/rewrite-xml/src/test/java/org/openrewrite/xml/CreateXmlFileTest.java @@ -15,6 +15,7 @@ */ package org.openrewrite.xml; +import org.intellij.lang.annotations.Language; import org.junit.jupiter.api.Test; import org.openrewrite.DocumentExample; import org.openrewrite.test.RewriteTest; @@ -26,6 +27,7 @@ class CreateXmlFileTest implements RewriteTest { @DocumentExample @Test void hasCreatedFile() { + @Language("xml") String fileContents = """ diff --git a/rewrite-xml/src/test/java/org/openrewrite/xml/XPathMatcherTest.java b/rewrite-xml/src/test/java/org/openrewrite/xml/XPathMatcherTest.java index 0836406afad..1fefbf336ae 100755 --- a/rewrite-xml/src/test/java/org/openrewrite/xml/XPathMatcherTest.java +++ b/rewrite-xml/src/test/java/org/openrewrite/xml/XPathMatcherTest.java @@ -159,20 +159,44 @@ void matchPom() { pomXml2)).isTrue(); } + private final SourceFile attributeXml = new XmlParser().parse( + """ + + + baz + + """ + ).toList().get(0); + @Test void attributePredicate() { - SourceFile xml = new XmlParser().parse( - """ - - - baz - - """ - ).toList().get(0); - assertThat(match("/root/element1[@foo='bar']", xml)).isTrue(); - assertThat(match("/root/element1[@foo='baz']", xml)).isFalse(); - assertThat(match("/root/element1[foo='bar']", xml)).isFalse(); - assertThat(match("/root/element1[foo='baz']", xml)).isTrue(); + assertThat(match("/root/element1[@foo='bar']", attributeXml)).isTrue(); + assertThat(match("/root/element1[@foo='baz']", attributeXml)).isFalse(); + assertThat(match("/root/element1[foo='bar']", attributeXml)).isFalse(); + assertThat(match("/root/element1[foo='baz']", attributeXml)).isTrue(); + } + + @Test + void wildcards() { + // condition with wildcard attribute + assertThat(match("/root/element1[@*='bar']", attributeXml)).isTrue(); + assertThat(match("/root/element1[@*='baz']", attributeXml)).isFalse(); + + // condition with wildcard element + assertThat(match("/root/element1[*='bar']", attributeXml)).isFalse(); + assertThat(match("/root/element1[*='baz']", attributeXml)).isTrue(); + + // absolute xpath with wildcard element + assertThat(match("/root/*[@foo='bar']", attributeXml)).isTrue(); + assertThat(match("/root/*[@*='bar']", attributeXml)).isTrue(); + assertThat(match("/root/*[@foo='baz']", attributeXml)).isFalse(); + assertThat(match("/root/*[@*='baz']", attributeXml)).isFalse(); + + // relative xpath with wildcard element + assertThat(match("//*[@foo='bar']", attributeXml)).isTrue(); + assertThat(match("//*[@foo='baz']", attributeXml)).isFalse(); +// assertThat(match("//*[foo='bar']", attributeXml)).isFalse(); // TODO: fix relative xpath with condition + assertThat(match("//*[foo='baz']", attributeXml)).isTrue(); } @Test @@ -188,6 +212,8 @@ void relativePathsWithConditions() { """ ).toList().get(0); +// assertThat(match("//element1[foo='bar']", xml)).isFalse(); // TODO: fix - was already failing before * changes + assertThat(match("//element1[foo='baz']", xml)).isTrue(); assertThat(match("//element1[@foo='bar']", xml)).isTrue(); assertThat(match("//element1[foo='baz']/test", xml)).isTrue(); assertThat(match("//element1[foo='baz']/baz", xml)).isFalse(); @@ -204,7 +230,7 @@ void matchFunctions() { // Namespace functions assertThat(match("/*[local-name()='element1']", namespacedXml)).isFalse(); - assertThat(match("//*[local-name()='element1']", namespacedXml)).isFalse(); + assertThat(match("//*[local-name()='element1']", namespacedXml)).isTrue(); assertThat(match("/root/*[local-name()='element1']", namespacedXml)).isTrue(); assertThat(match("/root/*[namespace-uri()='http://www.example.com/namespace2']", namespacedXml)).isTrue(); assertThat(match("/*[namespace-uri()='http://www.example.com/namespace2']", namespacedXml)).isFalse(); @@ -223,6 +249,22 @@ void matchFunctions() { assertThat(match("count(/root/*)", namespacedXml)).isTrue(); } + @Test + void testMatchLocalName() { + assertThat(match("/*[local-name()='root']", namespacedXml)).isTrue(); + assertThat(match("/*[local-name()='element1']", namespacedXml)).isFalse(); + assertThat(match("/*[local-name()='element2']", namespacedXml)).isFalse(); + assertThat(match("//*[local-name()='element1']", namespacedXml)).isTrue(); + assertThat(match("//*[local-name()='element2']", namespacedXml)).isTrue(); + assertThat(match("//*[local-name()='dne']", namespacedXml)).isFalse(); + + assertThat(match("/root[local-name()='root']", namespacedXml)).isTrue(); + assertThat(match("//element1[local-name()='element1']", namespacedXml)).isTrue(); + assertThat(match("//element2[local-name()='element2']", namespacedXml)).isFalse(); + assertThat(match("//ns2:element2[local-name()='element2']", namespacedXml)).isTrue(); + assertThat(match("//dne[local-name()='dne']", namespacedXml)).isFalse(); + } + private boolean match(String xpath, SourceFile x) { XPathMatcher matcher = new XPathMatcher(xpath); return !TreeVisitor.collect(new XmlVisitor<>() { diff --git a/rewrite-xml/src/test/java/org/openrewrite/xml/internal/XmlTest.java b/rewrite-xml/src/test/java/org/openrewrite/xml/internal/XmlTest.java new file mode 100644 index 00000000000..d029151e856 --- /dev/null +++ b/rewrite-xml/src/test/java/org/openrewrite/xml/internal/XmlTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.xml.internal; + +import org.junit.jupiter.api.Test; +import org.openrewrite.xml.tree.Xml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class XmlTest { + + @Test + void isNamespaceDefinitionAttributeTests() { + assertThat(Xml.isNamespaceDefinitionAttribute("xmlns:test")).isTrue(); + assertThat(Xml.isNamespaceDefinitionAttribute("test")).isFalse(); + } + + @Test + void getAttributeNameForPrefix() { + assertThat(Xml.getAttributeNameForPrefix("test")).isEqualTo("xmlns:test"); + assertThat(Xml.getAttributeNameForPrefix("")).isEqualTo("xmlns"); + } + + @Test + void extractNamespacePrefix() { + assertEquals("test", Xml.extractNamespacePrefix("test:tag")); + assertEquals("", Xml.extractNamespacePrefix("tag")); + } + + @Test + void extractLocalName() { + assertEquals("tag", Xml.extractLocalName("test:tag")); + assertEquals("tag", Xml.extractLocalName("tag")); + } + + @Test + void extractPrefixFromNamespaceDefinition() { + assertEquals("test", Xml.extractPrefixFromNamespaceDefinition("xmlns:test")); + assertEquals("", Xml.extractPrefixFromNamespaceDefinition("xmlns")); + assertThat(Xml.extractPrefixFromNamespaceDefinition("test")).isEqualTo(null); + assertThat(Xml.extractPrefixFromNamespaceDefinition("a:test")).isEqualTo(null); + } +} diff --git a/rewrite-xml/src/test/java/org/openrewrite/xml/search/FindNamespacePrefixTest.java b/rewrite-xml/src/test/java/org/openrewrite/xml/search/FindNamespacePrefixTest.java new file mode 100644 index 00000000000..2e76cdb0c49 --- /dev/null +++ b/rewrite-xml/src/test/java/org/openrewrite/xml/search/FindNamespacePrefixTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2020 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.xml.search; + +import org.intellij.lang.annotations.Language; +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.xml.tree.Xml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.openrewrite.xml.Assertions.xml; + +class FindNamespacePrefixTest implements RewriteTest { + + @DocumentExample + @Test + void rootElement() { + rewriteRun( + spec -> spec.recipe(new FindNamespacePrefix("xsi", null)), + xml( + source, + """ + + + + + + + + + """ + ) + ); + } + + @Test + void nestedElement() { + rewriteRun( + spec -> spec.recipe(new FindNamespacePrefix("jaxws", null)), + xml( + source, + """ + + + + + + + + + """ + ) + ); + } + + @Test + void noMatchOnNamespacePrefix() { + rewriteRun( + spec -> spec.recipe(new FindNamespacePrefix("foo", null)), + xml(source) + ); + } + + @Test + void noMatchOnXPath() { + rewriteRun( + spec -> spec.recipe(new FindNamespacePrefix("xsi", "/jaxws:client")), + xml(source) + ); + } + + @Test + void staticFind() { + rewriteRun( + xml( + source, + spec -> spec.beforeRecipe(xml -> assertThat(FindNamespacePrefix.find(xml, "xsi", null)) + .isNotEmpty() + .hasSize(1) + .hasOnlyElementsOfType(Xml.Tag.class) + ) + ) + ); + } + + @Language("xml") + private final String source = """ + + + + + + + + + """; +} diff --git a/rewrite-xml/src/test/java/org/openrewrite/xml/search/HasNamespaceUriTest.java b/rewrite-xml/src/test/java/org/openrewrite/xml/search/HasNamespaceUriTest.java new file mode 100644 index 00000000000..fe788ae0465 --- /dev/null +++ b/rewrite-xml/src/test/java/org/openrewrite/xml/search/HasNamespaceUriTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2020 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.xml.search; + +import org.intellij.lang.annotations.Language; +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.xml.tree.Xml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.openrewrite.xml.Assertions.xml; + +class HasNamespaceUriTest implements RewriteTest { + + @DocumentExample + @Test + void rootElement() { + rewriteRun( + spec -> spec.recipe(new HasNamespaceUri("http://www.w3.org/2001/XMLSchema-instance", null)), + xml( + source, + """ + + + + + + + + + """ + ) + ); + } + + @Test + void nestedElement() { + rewriteRun( + spec -> spec.recipe(new HasNamespaceUri("http://cxf.apache.org/jaxws", null)), + xml( + source, + """ + + + + + + + + + """ + ) + ); + } + + @Test + void noMatchOnNamespaceUri() { + rewriteRun( + spec -> spec.recipe(new HasNamespaceUri("foo", null)), + xml(source) + ); + } + + @Test + void noMatchOnXPath() { + rewriteRun( + spec -> spec.recipe(new HasNamespaceUri("xsi", "/jaxws:client")), + xml(source) + ); + } + + @Test + void staticFind() { + rewriteRun( + xml( + source, + spec -> spec.beforeRecipe(xml -> assertThat(HasNamespaceUri.find(xml, "http://www.w3.org/2001/XMLSchema-instance", null)) + .isNotEmpty() + .hasSize(1) + .hasOnlyElementsOfType(Xml.Tag.class) + ) + ) + ); + } + + @Language("xml") + private final String source = """ + + + + + + + + + """; +}