This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch 
WW-3784-annotated-wildcard-specificity-ordering
in repository https://gitbox.apache.org/repos/asf/struts.git

commit 429d49509b70811c21214fbc6ee5af641ba9af73
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Jul 26 05:42:00 2026 +0200

    WW-3784 feat(convention): add action-name specificity comparator
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
 .../ActionNameSpecificityComparator.java           | 98 ++++++++++++++++++++++
 .../ActionNameSpecificityComparatorTest.java       | 53 ++++++++++++
 2 files changed, 151 insertions(+)

diff --git 
a/plugins/convention/src/main/java/org/apache/struts2/convention/ActionNameSpecificityComparator.java
 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ActionNameSpecificityComparator.java
new file mode 100644
index 000000000..a0455dced
--- /dev/null
+++ 
b/plugins/convention/src/main/java/org/apache/struts2/convention/ActionNameSpecificityComparator.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *  http://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.apache.struts2.convention;
+
+import java.util.Comparator;
+
+/**
+ * Orders wildcard action-name patterns most-specific-first so that, under the 
framework's
+ * first-match-wins matching, a specific pattern (e.g. {@code some/usefull/*}) 
is evaluated
+ * before a general one (e.g. {@code some/*}).
+ *
+ * <p>Ordering keys, applied in order:</p>
+ * <ol>
+ *   <li>fewer wildcard tokens first (a {@code *}/{@code **} run, or a 
<code>{var}</code> group);</li>
+ *   <li>more literal characters first;</li>
+ *   <li>fewer path-spanning {@code **} tokens first;</li>
+ *   <li>natural (alphabetical) order of the pattern, for deterministic 
tie-breaking.</li>
+ * </ol>
+ *
+ * <p>Matcher-agnostic: it recognises both {@code *}/{@code **} 
(WildcardHelper) and
+ * <code>{var}</code> (NamedVariablePatternMatcher) wildcards.</p>
+ *
+ * @since 7.3.0 (WW-3784)
+ */
+public class ActionNameSpecificityComparator implements Comparator<String> {
+
+    @Override
+    public int compare(String a, String b) {
+        Counts ca = count(a);
+        Counts cb = count(b);
+
+        int byWildcards = Integer.compare(ca.wildcards, cb.wildcards);
+        if (byWildcards != 0) {
+            return byWildcards;
+        }
+        int byLiterals = Integer.compare(cb.literals, ca.literals); // more 
literals first
+        if (byLiterals != 0) {
+            return byLiterals;
+        }
+        int byPathWildcards = Integer.compare(ca.pathWildcards, 
cb.pathWildcards);
+        if (byPathWildcards != 0) {
+            return byPathWildcards;
+        }
+        return a.compareTo(b);
+    }
+
+    private Counts count(String pattern) {
+        int wildcards = 0;
+        int pathWildcards = 0;
+        int literals = 0;
+        int i = 0;
+        int len = pattern.length();
+        while (i < len) {
+            char c = pattern.charAt(i);
+            if (c == '*') {
+                int start = i;
+                while (i < len && pattern.charAt(i) == '*') {
+                    i++;
+                }
+                wildcards++;
+                if (i - start >= 2) {
+                    pathWildcards++;
+                }
+            } else if (c == '{') {
+                int close = pattern.indexOf('}', i);
+                if (close < 0) {
+                    literals += len - i; // malformed: treat the remainder as 
literal
+                    break;
+                }
+                wildcards++;
+                i = close + 1;
+            } else {
+                literals++;
+                i++;
+            }
+        }
+        return new Counts(wildcards, pathWildcards, literals);
+    }
+
+    private record Counts(int wildcards, int pathWildcards, int literals) {
+    }
+}
diff --git 
a/plugins/convention/src/test/java/org/apache/struts2/convention/ActionNameSpecificityComparatorTest.java
 
b/plugins/convention/src/test/java/org/apache/struts2/convention/ActionNameSpecificityComparatorTest.java
new file mode 100644
index 000000000..5722a4ae3
--- /dev/null
+++ 
b/plugins/convention/src/test/java/org/apache/struts2/convention/ActionNameSpecificityComparatorTest.java
@@ -0,0 +1,53 @@
+package org.apache.struts2.convention;
+
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class ActionNameSpecificityComparatorTest {
+
+    private final ActionNameSpecificityComparator comparator = new 
ActionNameSpecificityComparator();
+
+    @Test
+    public void moreLiteralPrefixIsMoreSpecific_ticketCase() {
+        // equal wildcard count (1 each); "some/usefull/*" has more literal 
chars -> more specific
+        assertTrue(comparator.compare("some/usefull/*", "some/*") < 0);
+    }
+
+    @Test
+    public void fewerWildcardsIsMoreSpecific() {
+        assertTrue(comparator.compare("a/*", "a/*/*") < 0);
+    }
+
+    @Test
+    public void singleStarBeatsPathStarAtEqualLiterals() {
+        // both "a/" literal (2 chars), one wildcard each; "a/*" (file) beats 
"a/**" (path)
+        assertTrue(comparator.compare("a/*", "a/**") < 0);
+    }
+
+    @Test
+    public void namedVariablesCountAsWildcards() {
+        assertTrue(comparator.compare("some/usefull/{id}", "some/{id}") < 0);
+    }
+
+    @Test
+    public void literalRanksBeforeAnyWildcard() {
+        assertTrue(comparator.compare("some/list", "some/*") < 0);
+    }
+
+    @Test
+    public void sortIsDeterministicRegardlessOfInputOrder() {
+        List<String> expected = Arrays.asList("some/usefull/*", "some/*", "*");
+        List<String> shuffled = new ArrayList<>(expected);
+        Collections.shuffle(shuffled, new Random(42));
+        shuffled.sort(comparator);
+        assertEquals(expected, shuffled);
+    }
+}

Reply via email to