From 87b555e8b64ef3a30237c60156dc672ce3593876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Mon, 22 Dec 2025 10:23:59 +0100 Subject: [PATCH 1/3] feat(agent): Add option to exclude specific hook classes Introduces a new configuration property `appmap.hooks.exclude` to allow disabling specific AppMap hook classes by their fully qualified name. This addresses issues where certain hooks, such as `SqlQuery`, might cause `NoClassDefFoundError` due to classloading conflicts or unexpected interactions with the target application's environment. The new property can be set via a system property `-Dappmap.hooks.exclude=` or an environment variable `APPMAP_HOOKS_EXCLUDE=`. The agent's `ClassFileTransformer` now checks this exclusion list during hook processing, preventing the instrumentation of specified hook classes. --- .../java/com/appland/appmap/config/Properties.java | 2 ++ .../appmap/transform/ClassFileTransformer.java | 14 ++++++++++++++ 2 files changed, 16 insertions(+) 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..866d9248 100644 --- a/agent/src/main/java/com/appland/appmap/config/Properties.java +++ b/agent/src/main/java/com/appland/appmap/config/Properties.java @@ -30,6 +30,8 @@ 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"; 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) { From 31d9fba28749e0757087db41358497d5fc6b2311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Fri, 2 Jan 2026 14:36:16 +0100 Subject: [PATCH 2/3] fix: Don't throw when loading logging config fails --- .../appmap/util/tinylog/AppMapConfigurationLoader.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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); } } } From dec4b99211d09af6e36474621c2d5867e4910922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Rzepecki?= Date: Mon, 29 Dec 2025 15:28:55 +0100 Subject: [PATCH 3/3] refactor(agent): Refactor and optimize AppMapPackage This commit comprehensively refactors the AppMapPackage class to improve readability, performance, and maintainability. Replace linear exclusion matching with a PrefixTrie data structure, reducing lookup complexity from O(N*M) to O(M), where N is the number of exclusion patterns and M is the length of the class name. This provides dramatic performance improvements for configurations with many exclusion patterns. Exclusion patterns can now be specified relative to the package path (e.g., "internal" instead of "com.example.internal"), improving configuration clarity while maintaining backward compatibility. Add comprehensive documentation explaining the two mutually exclusive configuration modes (exclude mode vs. methods mode). Refactor the complex find() method into three clear helpers with explicit mode detection. Add a warning when both 'exclude' and 'methods' are specified, making the precedence rules explicit to users. Enhance LabelConfig to support matching against both simple and fully qualified class names for better user experience. Remove unused class resolution logic. Add 42 comprehensive tests covering both configuration modes, edge cases, regex patterns, backward compatibility, and complex scenarios. - Fix NullPointerException when 'exclude' field is empty in appmap.yml - Fix package boundary matching (prevent "com.examples" matching "com.example") - Remove unused 'allMethods' field (added in 2022, never implemented) - Remove obsolete pattern threshold warning (no longer needed with PrefixTrie) - Clean up unused imports --- .../appland/appmap/config/AppMapConfig.java | 9 - .../appland/appmap/config/AppMapPackage.java | 266 ++++++-- .../com/appland/appmap/config/Properties.java | 2 - .../com/appland/appmap/util/PrefixTrie.java | 64 ++ .../appmap/config/AppMapConfigTest.java | 15 +- .../appmap/config/AppMapPackageTest.java | 583 ++++++++++++++++++ .../appland/appmap/util/PrefixTrieTest.java | 388 ++++++++++++ 7 files changed, 1251 insertions(+), 76 deletions(-) create mode 100644 agent/src/main/java/com/appland/appmap/util/PrefixTrie.java create mode 100644 agent/src/test/java/com/appland/appmap/util/PrefixTrieTest.java 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 866d9248..3cadf444 100644 --- a/agent/src/main/java/com/appland/appmap/config/Properties.java +++ b/agent/src/main/java/com/appland/appmap/config/Properties.java @@ -36,8 +36,6 @@ public class Properties { 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/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/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")); + } + } +}