diff --git a/agent/src/main/java/com/appland/appmap/config/AppMapConfig.java b/agent/src/main/java/com/appland/appmap/config/AppMapConfig.java
index cc49f313..f896472d 100644
--- a/agent/src/main/java/com/appland/appmap/config/AppMapConfig.java
+++ b/agent/src/main/java/com/appland/appmap/config/AppMapConfig.java
@@ -143,15 +143,6 @@ static AppMapConfig load(Path configFile, boolean mustExist) {
singleton.configFile = configFile;
logger.debug("config: {}", singleton);
- int count = singleton.packages.length;
- count = Arrays.stream(singleton.packages).map(p -> p.exclude).reduce(count,
- (acc, e) -> acc += e.length, Integer::sum);
-
- int pattern_threshold = Properties.PatternThreshold;
- if (count > pattern_threshold) {
- logger.warn("{} patterns found in config, startup performance may be impacted", count);
- }
-
return singleton;
}
diff --git a/agent/src/main/java/com/appland/appmap/config/AppMapPackage.java b/agent/src/main/java/com/appland/appmap/config/AppMapPackage.java
index b5021987..1a6ff8cf 100644
--- a/agent/src/main/java/com/appland/appmap/config/AppMapPackage.java
+++ b/agent/src/main/java/com/appland/appmap/config/AppMapPackage.java
@@ -1,42 +1,96 @@
package com.appland.appmap.config;
-import static com.appland.appmap.util.ClassUtil.safeClassForName;
-
import java.util.regex.Pattern;
import org.tinylog.TaggedLogger;
-import com.appland.appmap.transform.annotations.CtClassUtil;
import com.appland.appmap.util.FullyQualifiedName;
+import com.appland.appmap.util.PrefixTrie;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import javassist.CtBehavior;
+
+/**
+ * Represents a package configuration for AppMap recording.
+ *
+ *
+ * Configuration modes (mutually exclusive):
+ *
+ * - Exclude mode: When {@code methods} is null, records all methods in
+ * the package
+ * except those matching {@code exclude} patterns.
+ * - Methods mode: When {@code methods} is set, records only methods
+ * matching the
+ * specified patterns. The {@code exclude} field is ignored in this mode.
+ *
+ *
+ * @see AppMap
+ * Java Configuration
+ */
public class AppMapPackage {
private static final TaggedLogger logger = AppMapConfig.getLogger(null);
private static String tracePrefix = Properties.DebugClassPrefix;
public String path;
+ public final String packagePrefix;
public String[] exclude = new String[] {};
public boolean shallow = false;
- public Boolean allMethods = true;
+ private final PrefixTrie excludeTrie = new PrefixTrie();
- public static class LabelConfig {
+ @JsonCreator
+ public AppMapPackage(@JsonProperty("path") String path,
+ @JsonProperty("exclude") String[] exclude,
+ @JsonProperty("shallow") Boolean shallow,
+ @JsonProperty("methods") LabelConfig[] methods) {
+ this.path = path;
+ this.exclude = exclude == null ? new String[] {} : exclude;
+ this.shallow = shallow != null && shallow;
+ this.methods = methods;
+ this.packagePrefix = this.path == null ? "!!dummy!!" : this.path + ".";
+
+ // Warn if both exclude and methods are specified (methods takes precedence)
+ if (exclude != null && exclude.length > 0 && methods != null && methods.length > 0) {
+ logger.warn("Package '{}': both 'exclude' and 'methods' are specified. " +
+ "The 'exclude' field will be ignored when 'methods' is set.", path);
+ }
+
+ // Build the exclusion trie only if we're in exclude mode
+ if (exclude != null && methods == null) {
+ for (String exclusion : exclude) {
+ // Allow exclusions to use both '.' and '#' as separators
+ // for backward compatibility
+ exclusion = exclusion.replace('#', '.');
+ if (exclusion.startsWith(this.packagePrefix)) {
+ // Absolute path: strip the package prefix
+ this.excludeTrie.insert(exclusion.substring(this.packagePrefix.length()));
+ } else {
+ // Relative path: use as-is
+ this.excludeTrie.insert(exclusion);
+ }
+ }
+ }
+ }
+ /**
+ * Configuration for matching specific methods with labels.
+ * Used in "methods mode" to specify which methods to record.
+ */
+ public static class LabelConfig {
private Pattern className = null;
private Pattern name = null;
-
private String[] labels = new String[] {};
- private Class> cls;
+ /** Empty constructor for exclude mode (no labels). */
public LabelConfig() {}
@JsonCreator
- public LabelConfig(@JsonProperty("class") String className, @JsonProperty("name") String name,
+ public LabelConfig(@JsonProperty("class") String className,
+ @JsonProperty("name") String name,
@JsonProperty("labels") String[] labels) {
+ // Anchor patterns to match whole symbols only
this.className = Pattern.compile("\\A(" + className + ")\\z");
- this.cls = safeClassForName(Thread.currentThread().getContextClassLoader(), className);
- logger.trace("this.cls: {}", this.cls);
this.name = Pattern.compile("\\A(" + name + ")\\z");
this.labels = labels;
}
@@ -45,63 +99,126 @@ public String[] getLabels() {
return this.labels;
}
- public boolean matches(FullyQualifiedName name) {
- return matches(name.className, name.methodName);
- }
-
- public boolean matches(String className, String methodName) {
- boolean traceClass = tracePrefix == null || className.startsWith(tracePrefix);
- Class> cls = safeClassForName(Thread.currentThread().getContextClassLoader(), className);
-
- if (traceClass) {
- logger.trace("this.cls: {} cls: {}, isChildOf?: {}", this.cls, cls, CtClassUtil.isChildOf(cls, this.cls));
+ /**
+ * Checks if the given fully qualified name matches this configuration.
+ * Supports matching against both simple and fully qualified class names for
+ * flexibility.
+ *
+ * @param fqn the fully qualified name to check
+ * @return true if the patterns match
+ */
+ public boolean matches(FullyQualifiedName fqn) {
+ // Try matching with simple class name (package-relative)
+ if (matches(fqn.className, fqn.methodName)) {
+ return true;
}
- return this.className.matcher(className).matches() && this.name.matcher(methodName).matches();
+ // Also try matching with fully qualified class name for better UX
+ String fullyQualifiedClassName = fqn.getClassName();
+ return matches(fullyQualifiedClassName, fqn.methodName);
}
+ /**
+ * Checks if the given class name and method name match this configuration.
+ *
+ * @param className the class name (simple or fully qualified)
+ * @param methodName the method name
+ * @return true if both patterns match
+ */
+ public boolean matches(String className, String methodName) {
+ return this.className.matcher(className).matches()
+ && this.name.matcher(methodName).matches();
+ }
}
public LabelConfig[] methods = null;
/**
- * Check if a class/method is included in the configuration.
- *
- * @param canonicalName the canonical name of the class/method to be checked
- * @return {@code true} if the class/method is included in the configuration. {@code false} if it
- * is not included or otherwise explicitly excluded.
+ * Determines if a class/method should be recorded based on this package
+ * configuration.
+ *
+ *
+ * Behavior depends on configuration mode:
+ *
+ * - Exclude mode ({@code methods} is null): Returns a LabelConfig for
+ * methods
+ * in this package that are not explicitly excluded.
+ * - Methods mode ({@code methods} is set): Returns a LabelConfig only
+ * for methods
+ * that match the specified patterns. The {@code exclude} field is ignored.
+ *
+ *
+ * @param canonicalName the fully qualified name of the method to check
+ * @return the label config if the method should be recorded, or null otherwise
*/
public LabelConfig find(FullyQualifiedName canonicalName) {
- String className = canonicalName != null ? canonicalName.getClassName() : null;
- boolean traceClass = tracePrefix == null || className.startsWith(tracePrefix);
- if (traceClass) {
- logger.trace(canonicalName);
+ // Early validation
+ if (this.path == null || canonicalName == null) {
+ return null;
}
- if (this.path == null) {
- return null;
+ // Debug logging
+ if (tracePrefix == null || canonicalName.getClassName().startsWith(tracePrefix)) {
+ logger.trace("Checking {}", canonicalName);
}
- if (canonicalName == null) {
- return null;
+ if (isExcludeMode()) {
+ return findInExcludeMode(canonicalName);
+ } else {
+ return findInMethodsMode(canonicalName);
}
+ }
- // If no method configs are set, use the old matching behavior.
- if (this.methods == null) {
- if (!canonicalName.toString().startsWith(this.path)) {
+ /**
+ * Checks if this package is configured in exclude mode (records everything
+ * except exclusions).
+ */
+ private boolean isExcludeMode() {
+ return this.methods == null;
+ }
+
+ /**
+ * Finds a method in exclude mode: match if in package and not excluded.
+ */
+ private LabelConfig findInExcludeMode(FullyQualifiedName canonicalName) {
+ String canonicalString = canonicalName.toString();
+
+ // Check if the method is in this package or a subpackage
+ if (!canonicalString.startsWith(this.path)) {
+ return null;
+ } else if (canonicalString.length() > this.path.length()) {
+ // Must either equal the path exactly or start with "path." or "path#"
+ // The "#" check is needed for unnamed packages
+ // or when path specifies a class name
+ final char nextChar = canonicalString.charAt(this.path.length());
+ if (nextChar != '.' && nextChar != '#') {
return null;
}
+ }
- return this.excludes(canonicalName) ? null : new LabelConfig();
+ // Check if it's explicitly excluded
+ if (this.excludes(canonicalName)) {
+ return null;
}
+ // Include it (no labels in exclude mode)
+ return new LabelConfig();
+ }
+
+ /**
+ * Finds a method in methods mode: match only if it matches a configured
+ * pattern.
+ */
+ private LabelConfig findInMethodsMode(FullyQualifiedName canonicalName) {
+ // Must be in the exact package (not subpackages)
if (!canonicalName.packageName.equals(this.path)) {
return null;
}
- for (LabelConfig ls : this.methods) {
- if (ls.matches(canonicalName)) {
- return ls;
+ // Check each method pattern
+ for (LabelConfig config : this.methods) {
+ if (config.matches(canonicalName)) {
+ return config;
}
}
@@ -109,35 +226,56 @@ public LabelConfig find(FullyQualifiedName canonicalName) {
}
/**
- * Returns whether or not the canonical name is explicitly excluded
- *
- * @param canonicalName the canonical name of the class/method to be checked
+ * Converts a fully qualified class name to a package-relative name.
+ * For example, "com.example.foo.Bar" with package "com.example" becomes
+ * "foo.Bar".
+ *
+ * @param fqcn the fully qualified class name
+ * @return the relative class name, or the original if it doesn't start with the
+ * package prefix
+ */
+ private String getRelativeClassName(String fqcn) {
+ if (fqcn.startsWith(this.packagePrefix)) {
+ return fqcn.substring(this.packagePrefix.length());
+ }
+ return fqcn;
+ }
+
+ /**
+ * Checks whether a behavior is explicitly excluded by this package
+ * configuration.
+ * Only meaningful in exclude mode; in methods mode, use {@link #find} instead.
+ *
+ * @param behavior the behavior to check
+ * @return true if the behavior matches an exclusion pattern
*/
public Boolean excludes(CtBehavior behavior) {
- FullyQualifiedName fqn = null;
- for (String exclusion : this.exclude) {
- if (behavior.getDeclaringClass().getName().startsWith(exclusion)) {
- return true;
- } else {
- if (fqn == null) {
- fqn = new FullyQualifiedName(behavior);
- }
- if (fqn.toString().startsWith(exclusion)) {
- return true;
- }
- }
+ String fqClass = behavior.getDeclaringClass().getName();
+ String relativeClassName = getRelativeClassName(fqClass);
+
+ // Check if the class itself is excluded
+ if (this.excludeTrie.startsWith(relativeClassName)) {
+ return true;
}
- return false;
+ // Check if the specific method is excluded
+ String methodName = behavior.getName();
+ String relativeMethodPath = String.format("%s.%s", relativeClassName, methodName);
+ return this.excludeTrie.startsWith(relativeMethodPath);
}
+ /**
+ * Checks whether a fully qualified method name is explicitly excluded.
+ * Only meaningful in exclude mode; in methods mode, use {@link #find} instead.
+ *
+ * @param canonicalName the fully qualified method name
+ * @return true if the method matches an exclusion pattern
+ */
public Boolean excludes(FullyQualifiedName canonicalName) {
- for (String exclusion : this.exclude) {
- if (canonicalName.toString().startsWith(exclusion)) {
- return true;
- }
- }
-
- return false;
+ String fqcn = canonicalName.toString();
+ String relativeName = getRelativeClassName(fqcn);
+ // Convert # to . to match the format stored in the trie
+ relativeName = relativeName.replace('#', '.');
+ return this.excludeTrie.startsWith(relativeName);
}
}
diff --git a/agent/src/main/java/com/appland/appmap/config/Properties.java b/agent/src/main/java/com/appland/appmap/config/Properties.java
index 5c4c168e..3cadf444 100644
--- a/agent/src/main/java/com/appland/appmap/config/Properties.java
+++ b/agent/src/main/java/com/appland/appmap/config/Properties.java
@@ -30,12 +30,12 @@ public class Properties {
public static final Boolean RecordingRequests = resolveProperty("appmap.recording.requests", true);
public static final String[] IgnoredPackages =
resolveProperty("appmap.recording.ignoredPackages", new String[] {"java.", "jdk.", "sun."});
+ public static final String[] ExcludedHooks =
+ resolveProperty("appmap.hooks.exclude", new String[0]);
public static final String DefaultConfigFile = "appmap.yml";
public static final String ConfigFile = resolveProperty("appmap.config.file", (String) null);
- public static final Integer PatternThreshold =
- resolveProperty("appmap.config.patternThreshold", 10);
public static final Boolean DisableValue = resolveProperty("appmap.event.disableValue", false);
public static final Integer MaxValueSize = resolveProperty("appmap.event.valueSize", 1024);
diff --git a/agent/src/main/java/com/appland/appmap/transform/ClassFileTransformer.java b/agent/src/main/java/com/appland/appmap/transform/ClassFileTransformer.java
index 2a84a138..774a610d 100644
--- a/agent/src/main/java/com/appland/appmap/transform/ClassFileTransformer.java
+++ b/agent/src/main/java/com/appland/appmap/transform/ClassFileTransformer.java
@@ -153,7 +153,21 @@ private Hook[] getHooks(String methodId) {
return methodHooks != null ? methodHooks : sortedUnkeyedHooks;
}
+ private boolean isExcludedHook(String className) {
+ for (String excluded : Properties.ExcludedHooks) {
+ if (className.equals(excluded)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private void processClass(CtClass ctClass) {
+ if (isExcludedHook(ctClass.getName())) {
+ logger.debug("excluding hook class {}", ctClass.getName());
+ return;
+ }
+
boolean traceClass = tracePrefix == null || ctClass.getName().startsWith(tracePrefix);
if (traceClass) {
diff --git a/agent/src/main/java/com/appland/appmap/util/PrefixTrie.java b/agent/src/main/java/com/appland/appmap/util/PrefixTrie.java
new file mode 100644
index 00000000..bf19d45e
--- /dev/null
+++ b/agent/src/main/java/com/appland/appmap/util/PrefixTrie.java
@@ -0,0 +1,64 @@
+package com.appland.appmap.util;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A simple Trie (Prefix Tree) for efficient prefix-based string matching.
+ * This is used to check if a class name matches any of the exclusion patterns.
+ */
+public class PrefixTrie {
+ private static class TrieNode {
+ Map children = new HashMap<>();
+ boolean isEndOfWord = false;
+ }
+
+ private final TrieNode root;
+
+ public PrefixTrie() {
+ root = new TrieNode();
+ }
+
+ /**
+ * Inserts a word into the Trie.
+ * @param word The word to insert.
+ */
+ public void insert(String word) {
+ if (word == null) {
+ return;
+ }
+ TrieNode current = root;
+ for (char ch : word.toCharArray()) {
+ current = current.children.computeIfAbsent(ch, c -> new TrieNode());
+ }
+ current.isEndOfWord = true;
+ }
+
+ /**
+ * Checks if any prefix of the given word exists in the Trie.
+ * For example, if "java." is in the Trie, this will return true for "java.lang.String".
+ * @param word The word to check.
+ * @return {@code true} if a prefix of the word is found in the Trie, {@code false} otherwise.
+ */
+ public boolean startsWith(String word) {
+ if (word == null) {
+ return false;
+ }
+ TrieNode current = root;
+ for (int i = 0; i < word.length(); i++) {
+ if (current.isEndOfWord) {
+ // We've found a stored pattern that is a prefix of the word.
+ // e.g., Trie has "java." and word is "java.lang.String"
+ return true;
+ }
+ char ch = word.charAt(i);
+ current = current.children.get(ch);
+ if (current == null) {
+ return false; // No prefix match
+ }
+ }
+ // The word itself is a prefix or an exact match for a pattern in the Trie
+ // e.g., Trie has "java.lang" and word is "java.lang"
+ return current.isEndOfWord;
+ }
+}
diff --git a/agent/src/main/java/com/appland/appmap/util/tinylog/AppMapConfigurationLoader.java b/agent/src/main/java/com/appland/appmap/util/tinylog/AppMapConfigurationLoader.java
index defb275e..d99cb64a 100644
--- a/agent/src/main/java/com/appland/appmap/util/tinylog/AppMapConfigurationLoader.java
+++ b/agent/src/main/java/com/appland/appmap/util/tinylog/AppMapConfigurationLoader.java
@@ -16,7 +16,7 @@
public class AppMapConfigurationLoader implements ConfigurationLoader {
@Override
- public Properties load() throws IOException {
+ public Properties load() {
Properties properties = new Properties();
final File localConfigFile = new File("appmap-log.local.properties");
final String[] configFiles = {"appmap-log.properties", localConfigFile.getName()};
@@ -28,6 +28,8 @@ public Properties load() throws IOException {
if (stream != null) {
properties.load(stream);
}
+ } catch (IOException e) {
+ InternalLogger.log(Level.ERROR, e, "Failed to load " + configFile + " from classloader " + cl);
}
}
}
diff --git a/agent/src/test/java/com/appland/appmap/config/AppMapConfigTest.java b/agent/src/test/java/com/appland/appmap/config/AppMapConfigTest.java
index f655ac4d..fef83891 100644
--- a/agent/src/test/java/com/appland/appmap/config/AppMapConfigTest.java
+++ b/agent/src/test/java/com/appland/appmap/config/AppMapConfigTest.java
@@ -118,6 +118,19 @@ public void loadPackagesKeyWithScalarValue() throws Exception {
String actualErr = tapSystemErr(() -> AppMapConfig.load(configFile, false));
assertTrue(actualErr.contains("AppMap: encountered syntax error in appmap.yml"));
}
-}
+ @Test
+ public void loadEmptyExcludeField() throws Exception {
+ Path configFile = tmpdir.resolve("appmap.yml");
+ final String contents = "name: test\npackages:\n- path: com.example\n exclude:\n";
+ Files.write(configFile, contents.getBytes());
+
+ AppMapConfig config = AppMapConfig.load(configFile, false);
+ assertNotNull(config);
+ assertEquals(1, config.packages.length);
+ assertEquals("com.example", config.packages[0].path);
+ assertNotNull(config.packages[0].exclude);
+ assertEquals(0, config.packages[0].exclude.length);
+ }
+}
diff --git a/agent/src/test/java/com/appland/appmap/config/AppMapPackageTest.java b/agent/src/test/java/com/appland/appmap/config/AppMapPackageTest.java
index eea6fdaf..67e1906c 100644
--- a/agent/src/test/java/com/appland/appmap/config/AppMapPackageTest.java
+++ b/agent/src/test/java/com/appland/appmap/config/AppMapPackageTest.java
@@ -1,14 +1,18 @@
package com.appland.appmap.config;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.appland.appmap.util.FullyQualifiedName;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -103,4 +107,583 @@ public void testLoadConfig() throws Exception {
assertNotNull(appMapPackage.methods);
}
}
+
+ @Nested
+ class ExcludeModeTests {
+ @Nested
+ class BasicMatching {
+ AppMapPackage pkg;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example"
+ };
+ pkg = loadYaml(yaml, AppMapPackage.class);
+ }
+
+ @Test
+ public void testMatchesMethodInPackage() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Foo", false, "bar");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNotNull(result, "Should match methods in the configured package");
+ // In exclude mode, a new LabelConfig() is returned which has an empty array for
+ // labels
+ assertNotNull(result.getLabels(), "Labels should be non-null");
+ assertEquals(0, result.getLabels().length, "Should have no labels in exclude mode");
+ }
+
+ @Test
+ public void testMatchesMethodInSubpackage() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example.sub", "Foo", false, "bar");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNotNull(result, "Should match methods in subpackages");
+ }
+
+ @Test
+ public void testDoesNotMatchMethodOutsidePackage() {
+ FullyQualifiedName fqn = new FullyQualifiedName("org.other", "Foo", false, "bar");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNull(result, "Should not match methods outside the package");
+ }
+
+ @Test
+ public void testDoesNotMatchPartialPackageName() {
+ // Package is "com.example", should not match "com.examples"
+ FullyQualifiedName fqn = new FullyQualifiedName("com.examples", "Foo", false, "bar");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNull(result, "Should not match partial package names");
+ }
+ }
+
+ @Nested
+ class WithExclusions {
+ AppMapPackage pkg;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example",
+ "exclude: [Internal, com.example.Private, Secret.sensitiveMethod]"
+ };
+ pkg = loadYaml(yaml, AppMapPackage.class);
+ }
+
+ @Test
+ public void testExcludesRelativeClassName() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Internal", false, "foo");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNull(result, "Should exclude relative class name");
+ }
+
+ @Test
+ public void testExcludesAbsoluteClassName() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Private", false, "foo");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNull(result, "Should exclude absolute class name");
+ }
+
+ @Test
+ public void testExcludesSpecificMethod() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Secret", false, "sensitiveMethod");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNull(result, "Should exclude specific method");
+ }
+
+ @Test
+ public void testDoesNotExcludeOtherMethodsInExcludedClass() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Secret", false, "publicMethod");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNotNull(result, "Should not exclude other methods in partially excluded class");
+ }
+
+ @Test
+ public void testIncludesNonExcludedClass() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Public", false, "foo");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNotNull(result, "Should include non-excluded classes");
+ }
+
+ @Test
+ public void testExcludesSubclassesOfExcludedClass() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Internal$Inner", false, "foo");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNull(result, "Should exclude nested classes of excluded class");
+ }
+ }
+
+ @Nested
+ class WithHashSeparator {
+ AppMapPackage pkg;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example",
+ "exclude: [Foo#bar, Internal#secretMethod]"
+ };
+ pkg = loadYaml(yaml, AppMapPackage.class);
+ }
+
+ @Test
+ public void testConvertsHashToDot() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Foo", false, "bar");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNull(result, "Should convert # to . for backward compatibility");
+ }
+
+ @Test
+ public void testDoesNotExcludeOtherMethods() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Foo", false, "baz");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNotNull(result, "Should not exclude methods not specified");
+ }
+ }
+ }
+
+ @Nested
+ class MethodsModeTests {
+ @Nested
+ class BasicPatternMatching {
+ AppMapPackage pkg;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example",
+ "methods:",
+ "- class: Controller",
+ " name: handle.*",
+ " labels: [controller]",
+ "- class: Service",
+ " name: process",
+ " labels: [service, business-logic]"
+ };
+ pkg = loadYaml(yaml, AppMapPackage.class);
+ }
+
+ @Test
+ public void testMatchesSimpleClassName() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "handleRequest");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNotNull(result, "Should match simple class name");
+ assertArrayEquals(new String[] { "controller" }, result.getLabels());
+ }
+
+ @Test
+ public void testMatchesMethodPattern() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "handleResponse");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNotNull(result, "Should match method name pattern");
+ assertArrayEquals(new String[] { "controller" }, result.getLabels());
+ }
+
+ @Test
+ public void testDoesNotMatchNonMatchingMethod() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "initialize");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNull(result, "Should not match non-matching method name");
+ }
+
+ @Test
+ public void testMatchesExactMethodName() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Service", false, "process");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNotNull(result, "Should match exact method name");
+ assertArrayEquals(new String[] { "service", "business-logic" }, result.getLabels());
+ }
+
+ @Test
+ public void testDoesNotMatchPartialMethodName() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Service", false, "processData");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNull(result, "Should not match partial method name (no implicit wildcards)");
+ }
+
+ @Test
+ public void testDoesNotMatchDifferentPackage() {
+ FullyQualifiedName fqn = new FullyQualifiedName("org.other", "Controller", false, "handleRequest");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNull(result, "Should not match methods in different package");
+ }
+
+ @Test
+ public void testDoesNotMatchSubpackage() {
+ // In methods mode, package must match exactly (not subpackages)
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example.sub", "Controller", false, "handleRequest");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNull(result, "Methods mode should not match subpackages");
+ }
+ }
+
+ @Nested
+ class FullyQualifiedClassNames {
+ AppMapPackage pkg;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example",
+ "methods:",
+ "- class: com.example.web.Controller",
+ " name: handle.*",
+ " labels: [web-controller]"
+ };
+ pkg = loadYaml(yaml, AppMapPackage.class);
+ }
+
+ @Test
+ public void testMatchesFullyQualifiedClassName() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "web.Controller", false, "handleGet");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNotNull(result, "Should match fully qualified class name pattern");
+ assertArrayEquals(new String[] { "web-controller" }, result.getLabels());
+ }
+ }
+
+ @Nested
+ class RegexPatterns {
+ AppMapPackage pkg;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example",
+ "methods:",
+ "- class: (Controller|Handler)",
+ " name: (get|set).*",
+ " labels: [accessor]",
+ "- class: .*Service",
+ " name: execute",
+ " labels: [service-executor]"
+ };
+ pkg = loadYaml(yaml, AppMapPackage.class);
+ }
+
+ @Test
+ public void testMatchesClassAlternation() {
+ FullyQualifiedName fqn1 = new FullyQualifiedName("com.example", "Controller", false, "getData");
+ FullyQualifiedName fqn2 = new FullyQualifiedName("com.example", "Handler", false, "setData");
+
+ AppMapPackage.LabelConfig result1 = pkg.find(fqn1);
+ AppMapPackage.LabelConfig result2 = pkg.find(fqn2);
+
+ assertNotNull(result1, "Should match first class alternative");
+ assertNotNull(result2, "Should match second class alternative");
+ assertArrayEquals(new String[] { "accessor" }, result1.getLabels());
+ assertArrayEquals(new String[] { "accessor" }, result2.getLabels());
+ }
+
+ @Test
+ public void testMatchesClassWildcard() {
+ FullyQualifiedName fqn1 = new FullyQualifiedName("com.example", "UserService", false, "execute");
+ FullyQualifiedName fqn2 = new FullyQualifiedName("com.example", "OrderService", false, "execute");
+
+ AppMapPackage.LabelConfig result1 = pkg.find(fqn1);
+ AppMapPackage.LabelConfig result2 = pkg.find(fqn2);
+
+ assertNotNull(result1, "Should match first service");
+ assertNotNull(result2, "Should match second service");
+ assertArrayEquals(new String[] { "service-executor" }, result1.getLabels());
+ assertArrayEquals(new String[] { "service-executor" }, result2.getLabels());
+ }
+ }
+
+ @Nested
+ class IgnoresExcludeField {
+ AppMapPackage pkg;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example",
+ "exclude: [Controller]", // This should be ignored
+ "methods:",
+ "- class: Controller",
+ " name: handleRequest",
+ " labels: [controller]"
+ };
+ pkg = loadYaml(yaml, AppMapPackage.class);
+ }
+
+ @Test
+ public void testIgnoresExcludeWhenMethodsIsSet() {
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "handleRequest");
+ AppMapPackage.LabelConfig result = pkg.find(fqn);
+ assertNotNull(result, "Should ignore exclude field when methods is set");
+ assertArrayEquals(new String[] { "controller" }, result.getLabels());
+ }
+ }
+ }
+
+ @Nested
+ class EdgeCases {
+ @Test
+ public void testNullPath() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: null"
+ };
+ AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class);
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Foo", false, "bar");
+ assertNull(pkg.find(fqn), "Should handle null path gracefully");
+ }
+
+ @Test
+ public void testNullCanonicalName() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example"
+ };
+ AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class);
+ assertNull(pkg.find(null), "Should handle null canonical name gracefully");
+ }
+
+ @Test
+ public void testEmptyExclude() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example",
+ "exclude: []"
+ };
+ AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class);
+ assertEquals(0, pkg.exclude.length, "Should handle empty exclude array");
+ }
+
+ @Test
+ public void testNullExclude() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example",
+ "exclude:"
+ };
+ AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class);
+ assertNotNull(pkg.exclude, "Should initialize exclude to empty array");
+ assertEquals(0, pkg.exclude.length, "Should handle null exclude array");
+ }
+
+ @Test
+ public void testNoExclude() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example"
+ };
+ AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class);
+ assertNotNull(pkg.exclude, "Should initialize exclude to empty array");
+ assertEquals(0, pkg.exclude.length);
+ }
+
+ @Test
+ public void testShallowDefault() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example"
+ };
+ AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class);
+ assertFalse(pkg.shallow, "shallow should default to false");
+ }
+
+ @Test
+ public void testShallowTrue() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example",
+ "shallow: true"
+ };
+ AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class);
+ assertTrue(pkg.shallow, "shallow should be set to true");
+ }
+ }
+
+ @Nested
+ class EnhancedLabelConfigTests {
+ @Test
+ public void testEmptyLabelConfig() {
+ AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig();
+ // Empty constructor uses field initialization, which is an empty array
+ assertNotNull(lc.getLabels(), "Empty LabelConfig should have non-null labels");
+ assertEquals(0, lc.getLabels().length, "Empty LabelConfig should have empty labels array");
+ }
+
+ @Test
+ public void testLabelConfigWithLabels() throws Exception {
+ String[] yaml = {
+ "---",
+ "class: Foo",
+ "name: bar",
+ "labels: [test, example]"
+ };
+ AppMapPackage.LabelConfig lc = loadYaml(yaml, AppMapPackage.LabelConfig.class);
+ assertNotNull(lc.getLabels());
+ assertEquals(2, lc.getLabels().length);
+ assertEquals("test", lc.getLabels()[0]);
+ assertEquals("example", lc.getLabels()[1]);
+ }
+
+ @Test
+ public void testLabelConfigMatchesSimpleClass() {
+ AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Controller", "handle.*", new String[] { "web" });
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "handleGet");
+ assertTrue(lc.matches(fqn), "Should match simple class name");
+ }
+
+ @Test
+ public void testLabelConfigMatchesFullyQualifiedClass() {
+ AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("com.example.Controller", "handle.*",
+ new String[] { "web" });
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "handleGet");
+ assertTrue(lc.matches(fqn), "Should match fully qualified class name");
+ }
+
+ @Test
+ public void testLabelConfigDoesNotMatchWrongClass() {
+ AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Controller", "handle.*", new String[] { "web" });
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Service", false, "handleGet");
+ assertFalse(lc.matches(fqn), "Should not match wrong class");
+ }
+
+ @Test
+ public void testLabelConfigDoesNotMatchWrongMethod() {
+ AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Controller", "handle.*", new String[] { "web" });
+ FullyQualifiedName fqn = new FullyQualifiedName("com.example", "Controller", false, "process");
+ assertFalse(lc.matches(fqn), "Should not match wrong method");
+ }
+
+ @Test
+ public void testLabelConfigMatchesExactPattern() {
+ AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Foo", "bar", new String[] { "test" });
+ assertTrue(lc.matches("Foo", "bar"), "Should match exact patterns");
+ }
+
+ @Test
+ public void testLabelConfigDoesNotMatchPartialClass() {
+ // Pattern "Foo" should not match "Foo1" due to anchoring
+ AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Foo", "bar", new String[] { "test" });
+ assertFalse(lc.matches("Foo1", "bar"), "Should not match partial class name");
+ }
+
+ @Test
+ public void testLabelConfigDoesNotMatchPartialMethod() {
+ // Pattern "bar" should not match "bar!" due to anchoring
+ AppMapPackage.LabelConfig lc = new AppMapPackage.LabelConfig("Foo", "bar", new String[] { "test" });
+ assertFalse(lc.matches("Foo", "bar!"), "Should not match partial method name");
+ }
+ }
+
+ @Nested
+ class ExcludesMethodTests {
+ @Test
+ public void testExcludesFullyQualifiedName() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example",
+ "exclude: [Internal, Private.secret]"
+ };
+ AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class);
+
+ FullyQualifiedName fqn1 = new FullyQualifiedName("com.example", "Internal", false, "foo");
+ FullyQualifiedName fqn2 = new FullyQualifiedName("com.example", "Private", false, "secret");
+ FullyQualifiedName fqn3 = new FullyQualifiedName("com.example", "Public", false, "method");
+
+ assertTrue(pkg.excludes(fqn1), "Should exclude Internal class");
+ assertTrue(pkg.excludes(fqn2), "Should exclude Private.secret method");
+ assertFalse(pkg.excludes(fqn3), "Should not exclude Public class");
+ }
+ }
+
+ @Nested
+ class ComplexScenarios {
+ @Test
+ public void testMultipleMethodConfigs() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example.api",
+ "methods:",
+ "- class: .*Controller",
+ " name: handle.*",
+ " labels: [web, controller]",
+ "- class: .*Service",
+ " name: execute.*",
+ " labels: [service]",
+ "- class: Repository",
+ " name: (find|save|delete).*",
+ " labels: [data-access, repository]"
+ };
+ AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class);
+
+ FullyQualifiedName controller = new FullyQualifiedName("com.example.api", "UserController", false, "handleGet");
+ FullyQualifiedName service = new FullyQualifiedName("com.example.api", "UserService", false, "executeQuery");
+ FullyQualifiedName repo = new FullyQualifiedName("com.example.api", "Repository", false, "findById");
+
+ AppMapPackage.LabelConfig result1 = pkg.find(controller);
+ AppMapPackage.LabelConfig result2 = pkg.find(service);
+ AppMapPackage.LabelConfig result3 = pkg.find(repo);
+
+ assertNotNull(result1);
+ assertArrayEquals(new String[] { "web", "controller" }, result1.getLabels());
+
+ assertNotNull(result2);
+ assertArrayEquals(new String[] { "service" }, result2.getLabels());
+
+ assertNotNull(result3);
+ assertArrayEquals(new String[] { "data-access", "repository" }, result3.getLabels());
+ }
+
+ @Test
+ public void testComplexExclusionPatterns() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: com.example",
+ "exclude:",
+ " - internal",
+ " - util.Helper",
+ " - com.example.test.Mock",
+ " - Secret.getPassword",
+ " - Cache.clear"
+ };
+ AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class);
+
+ FullyQualifiedName internal = new FullyQualifiedName("com.example.internal", "Foo", false, "bar");
+ FullyQualifiedName helper = new FullyQualifiedName("com.example.util", "Helper", false, "help");
+ FullyQualifiedName mock = new FullyQualifiedName("com.example.test", "Mock", false, "setup");
+ FullyQualifiedName secretGet = new FullyQualifiedName("com.example", "Secret", false, "getPassword");
+ FullyQualifiedName secretSet = new FullyQualifiedName("com.example", "Secret", false, "setPassword");
+ FullyQualifiedName cacheClear = new FullyQualifiedName("com.example", "Cache", false, "clear");
+ FullyQualifiedName cacheGet = new FullyQualifiedName("com.example", "Cache", false, "get");
+
+ assertNull(pkg.find(internal), "Should exclude internal package");
+ assertNull(pkg.find(helper), "Should exclude util.Helper");
+ assertNull(pkg.find(mock), "Should exclude test.Mock");
+ assertNull(pkg.find(secretGet), "Should exclude Secret.getPassword");
+ assertNotNull(pkg.find(secretSet), "Should not exclude Secret.setPassword");
+ assertNull(pkg.find(cacheClear), "Should exclude Cache.clear");
+ assertNotNull(pkg.find(cacheGet), "Should not exclude Cache.get");
+ }
+
+ @Test
+ public void testUnnamedPackage() throws Exception {
+ String[] yaml = {
+ "---",
+ "path: HelloWorld"
+ };
+ AppMapPackage pkg = loadYaml(yaml, AppMapPackage.class);
+
+ // Test a method in the unnamed package (empty package name)
+ FullyQualifiedName method = new FullyQualifiedName("", "HelloWorld", false, "getGreetingWithPunctuation");
+
+ AppMapPackage.LabelConfig result = pkg.find(method);
+ assertNotNull(result, "Should find method in unnamed package when path specifies the class name");
+
+ // Test that other classes in the unnamed package are not matched
+ FullyQualifiedName otherClass = new FullyQualifiedName("", "OtherClass", false, "someMethod");
+ assertNull(pkg.find(otherClass), "Should not match other classes in the unnamed package");
+ }
+ }
}
\ No newline at end of file
diff --git a/agent/src/test/java/com/appland/appmap/util/PrefixTrieTest.java b/agent/src/test/java/com/appland/appmap/util/PrefixTrieTest.java
new file mode 100644
index 00000000..a28cbe43
--- /dev/null
+++ b/agent/src/test/java/com/appland/appmap/util/PrefixTrieTest.java
@@ -0,0 +1,388 @@
+package com.appland.appmap.util;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+public class PrefixTrieTest {
+
+ @Nested
+ class BasicOperations {
+ private PrefixTrie trie;
+
+ @BeforeEach
+ void setUp() {
+ trie = new PrefixTrie();
+ }
+
+ @Test
+ void testEmptyTrie() {
+ assertFalse(trie.startsWith("anything"), "Empty trie should not match any string");
+ assertFalse(trie.startsWith(""), "Empty trie should not match empty string");
+ }
+
+ @Test
+ void testSingleInsertExactMatch() {
+ trie.insert("foo");
+ assertTrue(trie.startsWith("foo"), "Should match exact string");
+ }
+
+ @Test
+ void testSingleInsertPrefixMatch() {
+ trie.insert("foo");
+ assertTrue(trie.startsWith("foobar"), "Should match when pattern is a prefix");
+ assertTrue(trie.startsWith("foo.bar"), "Should match when pattern is a prefix");
+ }
+
+ @Test
+ void testSingleInsertNoMatch() {
+ trie.insert("foo");
+ assertFalse(trie.startsWith("bar"), "Should not match unrelated string");
+ assertFalse(trie.startsWith("fo"), "Should not match partial prefix");
+ assertFalse(trie.startsWith("f"), "Should not match single character");
+ }
+
+ @Test
+ void testEmptyStringInsert() {
+ trie.insert("");
+ assertTrue(trie.startsWith(""), "Should match empty string when empty string is inserted");
+ assertTrue(trie.startsWith("anything"), "Empty pattern at root matches non-empty strings");
+ }
+
+ @Test
+ void testNullHandling() {
+ trie.insert(null);
+ assertFalse(trie.startsWith(null), "Null should not match anything");
+
+ trie.insert("foo");
+ assertFalse(trie.startsWith(null), "Null should not match even when trie has entries");
+ }
+ }
+
+ @Nested
+ class MultiplePatterns {
+ private PrefixTrie trie;
+
+ @BeforeEach
+ void setUp() {
+ trie = new PrefixTrie();
+ }
+
+ @Test
+ void testMultipleDistinctPatterns() {
+ trie.insert("foo");
+ trie.insert("bar");
+ trie.insert("baz");
+
+ assertTrue(trie.startsWith("foobar"), "Should match first pattern");
+ assertTrue(trie.startsWith("barbell"), "Should match second pattern");
+ assertTrue(trie.startsWith("bazinga"), "Should match third pattern");
+ assertFalse(trie.startsWith("qux"), "Should not match uninserted pattern");
+ }
+
+ @Test
+ void testOverlappingPatterns() {
+ trie.insert("foo");
+ trie.insert("foobar");
+
+ assertTrue(trie.startsWith("foo"), "Should match shorter pattern");
+ assertTrue(trie.startsWith("foobar"), "Should match longer pattern");
+ assertTrue(trie.startsWith("foobarbaz"), "Should match shortest prefix (foo)");
+ }
+
+ @Test
+ void testPrefixOfPrefix() {
+ trie.insert("a");
+ trie.insert("ab");
+ trie.insert("abc");
+
+ assertTrue(trie.startsWith("a"), "Should match 'a'");
+ assertTrue(trie.startsWith("ab"), "Should match 'ab'");
+ assertTrue(trie.startsWith("abc"), "Should match 'abc'");
+ assertTrue(trie.startsWith("abcd"), "Should match via 'a' prefix");
+ assertFalse(trie.startsWith("b"), "Should not match 'b'");
+ }
+ }
+
+ @Nested
+ class PackageScenarios {
+ private PrefixTrie trie;
+
+ @BeforeEach
+ void setUp() {
+ trie = new PrefixTrie();
+ }
+
+ @Test
+ void testPackageExclusion() {
+ trie.insert("internal.");
+
+ assertTrue(trie.startsWith("internal.Foo"), "Should match class in excluded package");
+ assertTrue(trie.startsWith("internal.sub.Bar"), "Should match class in excluded subpackage");
+ assertFalse(trie.startsWith("internal"), "Should not match package name without separator");
+ assertFalse(trie.startsWith("internals.Foo"), "Should not match similar package with separator");
+ }
+
+ @Test
+ void testPackageBoundary() {
+ trie.insert("test.");
+
+ assertTrue(trie.startsWith("test.Foo"), "Should match class in test package");
+ assertTrue(trie.startsWith("test.sub.Bar"), "Should match class in test subpackage");
+ assertFalse(trie.startsWith("test"), "Should not match package name without separator");
+ assertFalse(trie.startsWith("testing"), "Should not match similar package");
+ }
+
+ @Test
+ void testClassExclusion() {
+ trie.insert("util.Helper.");
+
+ assertTrue(trie.startsWith("util.Helper.method"), "Should match method in excluded class");
+ assertFalse(trie.startsWith("util.Helper"), "Should not match class name without separator");
+ assertFalse(trie.startsWith("util.HelperUtils"), "Should not match similar class name");
+ assertFalse(trie.startsWith("util"), "Should not match package alone");
+ }
+
+ @Test
+ void testMethodExclusion() {
+ trie.insert("Cache.clear");
+
+ assertTrue(trie.startsWith("Cache.clear"), "Should match method exactly");
+ assertTrue(trie.startsWith("Cache.clearAll"), "Will match since 'Cache.clear' is a prefix of 'Cache.clearAll'");
+ assertFalse(trie.startsWith("Cache"), "Should not match class alone");
+ }
+
+ @Test
+ void testMixedExclusions() {
+ trie.insert("internal"); // whole package
+ trie.insert("util.Helper"); // specific class
+ trie.insert("Cache.clear"); // specific method
+ trie.insert("test."); // package with separator
+
+ assertTrue(trie.startsWith("internal.Foo.bar"), "Should match package exclusion");
+ assertTrue(trie.startsWith("util.Helper.method"), "Should match class exclusion");
+ assertTrue(trie.startsWith("Cache.clear"), "Should match method exclusion");
+ assertTrue(trie.startsWith("test.Foo"), "Should match package with separator");
+
+ assertFalse(trie.startsWith("util.Other"), "Should not match other class in util");
+ assertFalse(trie.startsWith("Cache.get"), "Should not match other method in Cache");
+ }
+ }
+
+ @Nested
+ class HierarchicalPatterns {
+ private PrefixTrie trie;
+
+ @BeforeEach
+ void setUp() {
+ trie = new PrefixTrie();
+ }
+
+ @Test
+ void testDeeplyNestedPackages() {
+ trie.insert("com.example.internal");
+
+ assertTrue(trie.startsWith("com.example.internal"), "Should match exact package");
+ assertTrue(trie.startsWith("com.example.internal.Foo"), "Should match class in package");
+ assertTrue(trie.startsWith("com.example.internal.sub.Bar"), "Should match class in subpackage");
+ assertFalse(trie.startsWith("com.example"), "Should not match parent package");
+ assertFalse(trie.startsWith("com.example.public"), "Should not match sibling package");
+ }
+
+ @Test
+ void testMultipleLevelsOfExclusion() {
+ trie.insert("com");
+ trie.insert("com.example");
+ trie.insert("com.example.foo");
+
+ assertTrue(trie.startsWith("com.anything"), "Should match via 'com' prefix");
+ assertTrue(trie.startsWith("com.example.anything"), "Should match via 'com' prefix");
+ assertTrue(trie.startsWith("com.example.foo.Bar"), "Should match via 'com' prefix");
+ }
+
+ @Test
+ void testFullyQualifiedNames() {
+ trie.insert("com.example.MyClass.myMethod");
+
+ assertTrue(trie.startsWith("com.example.MyClass.myMethod"), "Should match exact FQN");
+ assertFalse(trie.startsWith("com.example.MyClass.otherMethod"), "Should not match different method");
+ assertFalse(trie.startsWith("com.example.MyClass"), "Should not match just the class");
+ }
+ }
+
+ @Nested
+ class EdgeCases {
+ private PrefixTrie trie;
+
+ @BeforeEach
+ void setUp() {
+ trie = new PrefixTrie();
+ }
+
+ @Test
+ void testSingleCharacterPatterns() {
+ trie.insert("a");
+
+ assertTrue(trie.startsWith("a"), "Should match single character");
+ assertTrue(trie.startsWith("abc"), "Should match when single char is prefix");
+ assertFalse(trie.startsWith("b"), "Should not match different character");
+ }
+
+ @Test
+ void testSpecialCharacters() {
+ trie.insert("foo$bar");
+ trie.insert("baz#qux");
+
+ assertTrue(trie.startsWith("foo$bar"), "Should match pattern with $");
+ assertTrue(trie.startsWith("foo$barbaz"), "Should match when $ pattern is prefix");
+ assertTrue(trie.startsWith("baz#qux"), "Should match pattern with #");
+ assertFalse(trie.startsWith("foo"), "Should not match partial before special char");
+ }
+
+ @Test
+ void testDuplicateInsertions() {
+ trie.insert("foo");
+ trie.insert("foo");
+ trie.insert("foo");
+
+ assertTrue(trie.startsWith("foobar"), "Should still work after duplicate insertions");
+ }
+
+ @Test
+ void testLongStrings() {
+ String longPattern = "com.example.very.long.package.name.with.many.segments.MyClass.myMethod";
+ trie.insert(longPattern);
+
+ assertTrue(trie.startsWith(longPattern), "Should match long pattern exactly");
+ assertTrue(trie.startsWith(longPattern + ".extra"), "Should match long pattern as prefix");
+ assertFalse(trie.startsWith("com.example.very.long.package"), "Should not match partial");
+ }
+
+ @Test
+ void testUnicodeCharacters() {
+ trie.insert("café");
+ trie.insert("日本語");
+
+ assertTrue(trie.startsWith("café.method"), "Should match unicode pattern");
+ assertTrue(trie.startsWith("日本語.クラス"), "Should match Japanese characters");
+ }
+ }
+
+ @Nested
+ class PrefixMatchingBehavior {
+ private PrefixTrie trie;
+
+ @BeforeEach
+ void setUp() {
+ trie = new PrefixTrie();
+ }
+
+ @Test
+ void testExactMatchIsPrefix() {
+ trie.insert("exact");
+
+ assertTrue(trie.startsWith("exact"), "Exact match should return true");
+ }
+
+ @Test
+ void testLongerThanPattern() {
+ trie.insert("short");
+
+ assertTrue(trie.startsWith("short.longer.path"), "Longer string should match");
+ }
+
+ @Test
+ void testShorterThanPattern() {
+ trie.insert("verylongpattern");
+
+ assertFalse(trie.startsWith("verylong"), "Shorter string should not match");
+ assertFalse(trie.startsWith("very"), "Much shorter string should not match");
+ }
+
+ @Test
+ void testFirstMatchWins() {
+ trie.insert("foo");
+ trie.insert("foobar");
+ trie.insert("foobarbaz");
+
+ // When checking "foobarbazqux", it should match "foo" first
+ assertTrue(trie.startsWith("foobarbazqux"), "Should match shortest prefix");
+ }
+
+ @Test
+ void testNoPartialPrefixMatch() {
+ trie.insert("complete");
+
+ assertFalse(trie.startsWith("comp"), "Should not match partial prefix");
+ assertFalse(trie.startsWith("compl"), "Should not match partial prefix");
+ assertFalse(trie.startsWith("complet"), "Should not match partial prefix");
+ assertTrue(trie.startsWith("complete"), "Should match complete pattern");
+ assertTrue(trie.startsWith("complete.more"), "Should match with additional text");
+ }
+ }
+
+ @Nested
+ class RealWorldScenarios {
+ private PrefixTrie trie;
+
+ @BeforeEach
+ void setUp() {
+ trie = new PrefixTrie();
+ }
+
+ @Test
+ void testCommonExclusionPatterns() {
+ // Typical AppMap exclusion patterns
+ trie.insert("internal");
+ trie.insert("test");
+ trie.insert("generated");
+ trie.insert("impl.Helper");
+ trie.insert("util.StringUtil.intern");
+
+ // Should match
+ assertTrue(trie.startsWith("internal.SecretClass.method"));
+ assertTrue(trie.startsWith("test.MockService.setup"));
+ assertTrue(trie.startsWith("generated.AutoValue_Foo"));
+ assertTrue(trie.startsWith("impl.Helper.doSomething"));
+ assertTrue(trie.startsWith("util.StringUtil.intern"));
+
+ // Should not match
+ assertFalse(trie.startsWith("impl.OtherClass"));
+ assertFalse(trie.startsWith("util.StringUtil.format"));
+ assertFalse(trie.startsWith("public.ApiClass"));
+ }
+
+ @Test
+ void testJavaStandardLibraryExclusions() {
+ trie.insert("java.");
+ trie.insert("javax.");
+ trie.insert("sun.");
+ trie.insert("com.sun.");
+
+ assertTrue(trie.startsWith("java.lang.String"));
+ assertTrue(trie.startsWith("javax.servlet.HttpServlet"));
+ assertTrue(trie.startsWith("sun.misc.Unsafe"));
+ assertTrue(trie.startsWith("com.sun.management.GarbageCollectorMXBean"));
+
+ assertFalse(trie.startsWith("javalin.Context"));
+ assertFalse(trie.startsWith("com.example.Service"));
+ }
+
+ @Test
+ void testFrameworkInternalExclusions() {
+ trie.insert("org.springframework.cglib");
+ trie.insert("org.hibernate.internal");
+ trie.insert("net.bytebuddy");
+
+ assertTrue(trie.startsWith("org.springframework.cglib.Enhancer"));
+ assertTrue(trie.startsWith("org.hibernate.internal.SessionImpl"));
+ assertTrue(trie.startsWith("net.bytebuddy.ByteBuddy"));
+
+ assertFalse(trie.startsWith("org.springframework.web.Controller"));
+ assertFalse(trie.startsWith("org.hibernate.Session"));
+ }
+ }
+}