You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@struts.apache.org by lu...@apache.org on 2026/07/26 06:26:21 UTC

(struts) 03/08: WW-3784 feat(convention): add action-name specificity comparator

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 <lu...@apache.org>
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 <no...@anthropic.com>
---
 .../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);
+    }
+}