diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java index 8adbe48..8e38d27 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java @@ -4,10 +4,17 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import lombok.extern.slf4j.Slf4j; import org.eclipse.jdt.core.dom.*; +import java.util.HashSet; +import java.util.Set; + @Slf4j public class ConstantResolver { public String resolve(Expression expr, CodebaseContext context) { + return resolveInternal(expr, context, new HashSet<>()); + } + + private String resolveInternal(Expression expr, CodebaseContext context, Set visited) { if (expr == null) return null; // 1. Literal? @@ -38,27 +45,30 @@ public class ConstantResolver { // 3. Fallback: Manual AST Traversal (if bindings not available or resolved) if (expr instanceof InfixExpression infix) { - return resolveInfix(infix, context); + return resolveInfix(infix, context, visited); } if (expr instanceof QualifiedName qn) { - return resolveManual(qn, context); + return resolveManual(qn, context, visited); + } + if (expr instanceof SimpleName sn) { + return resolveManual(sn, context, visited); } return null; } - private String resolveInfix(InfixExpression expr, CodebaseContext context) { + private String resolveInfix(InfixExpression expr, CodebaseContext context, Set visited) { if (expr.getOperator() != InfixExpression.Operator.PLUS) return null; - String left = resolve(expr.getLeftOperand(), context); - String right = resolve(expr.getRightOperand(), context); + String left = resolveInternal(expr.getLeftOperand(), context, visited); + String right = resolveInternal(expr.getRightOperand(), context, visited); if (left == null || right == null) return null; StringBuilder sb = new StringBuilder(left + right); if (expr.hasExtendedOperands()) { for (Object operand : expr.extendedOperands()) { - String val = resolve((Expression) operand, context); + String val = resolveInternal((Expression) operand, context, visited); if (val == null) return null; sb.append(val); } @@ -66,7 +76,27 @@ public class ConstantResolver { return sb.toString(); } - private String resolveManual(QualifiedName qn, CodebaseContext context) { + private String resolveManual(SimpleName sn, CodebaseContext context, Set visited) { + TypeDeclaration td = findEnclosingType(sn); + if (td != null) { + String fqn = context.getFqn(td); + String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited); + if (result != null) return result; + } + + // Fallback: Static imports + ASTNode root = sn.getRoot(); + if (root instanceof CompilationUnit cu) { + TypeDeclaration staticTd = context.resolveStaticImport(sn.getIdentifier(), cu); + if (staticTd != null) { + return resolveFieldInType(staticTd, sn.getIdentifier(), context.getFqn(staticTd), context, visited); + } + } + + return null; + } + + private String resolveManual(QualifiedName qn, CodebaseContext context, Set visited) { String qualifier = qn.getQualifier().toString(); String name = qn.getName().getIdentifier(); @@ -74,19 +104,81 @@ public class ConstantResolver { TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu); if (td != null) { - for (FieldDeclaration fd : td.getFields()) { - for (Object fragmentObj : fd.fragments()) { - if (fragmentObj instanceof VariableDeclarationFragment fragment) { - if (fragment.getName().getIdentifier().equals(name)) { - Expression initializer = fragment.getInitializer(); - // Simple recursive resolve (be careful of cycles) - if (initializer instanceof StringLiteral sl) return sl.getLiteralValue(); - } - } - } - } + String fqn = context.getFqn(td); + return resolveFieldInType(td, name, fqn, context, visited); } return null; } + + private String resolveFieldInType(TypeDeclaration td, String fieldName, String typeFqn, CodebaseContext context, Set visited) { + String fieldId = typeFqn + "#" + fieldName; + if (visited.contains(fieldId)) { + log.warn("Circular reference detected during constant resolution: {}", fieldId); + return null; + } + visited.add(fieldId); + + try { + for (FieldDeclaration fd : td.getFields()) { + for (Object fragmentObj : fd.fragments()) { + if (fragmentObj instanceof VariableDeclarationFragment fragment) { + if (fragment.getName().getIdentifier().equals(fieldName)) { + // Check for @Value annotation first + String valueFromAnnotation = findValueFromAnnotation(fd); + if (valueFromAnnotation != null) return valueFromAnnotation; + + Expression initializer = fragment.getInitializer(); + if (initializer != null) { + return resolveInternal(initializer, context, visited); + } + } + } + } + } + } finally { + visited.remove(fieldId); + } + return null; + } + + private String findValueFromAnnotation(FieldDeclaration fd) { + for (Object mod : fd.modifiers()) { + if (mod instanceof Annotation ann) { + String name = ann.getTypeName().getFullyQualifiedName(); + if (name.endsWith("Value")) { + return extractAnnotationValue(ann); + } + } + } + return null; + } + + private String extractAnnotationValue(Annotation ann) { + Expression valueExpr = null; + if (ann instanceof SingleMemberAnnotation sma) { + valueExpr = sma.getValue(); + } else if (ann instanceof NormalAnnotation na) { + for (Object pairObj : na.values()) { + MemberValuePair pair = (MemberValuePair) pairObj; + if ("value".equals(pair.getName().getIdentifier())) { + valueExpr = pair.getValue(); + break; + } + } + } + + if (valueExpr instanceof StringLiteral sl) { + return sl.getLiteralValue(); + } + return valueExpr != null ? valueExpr.toString().replaceAll("^\"|\"$", "") : null; + } + + private TypeDeclaration findEnclosingType(ASTNode node) { + ASTNode parent = node.getParent(); + while (parent != null && !(parent instanceof TypeDeclaration)) { + parent = parent.getParent(); + } + return (TypeDeclaration) parent; + } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java index 696aea9..bd90415 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringMvcDetector.java @@ -45,7 +45,12 @@ public class SpringMvcDetector { if (verb != null && !call.arguments().isEmpty()) { Expression pathArg = (Expression) call.arguments().get(0); String path = constantResolver.resolve(pathArg, context); - if (path == null) path = pathArg.toString().replaceAll("\"", ""); + if (path != null) { + path = context.resolveString(path); + } else { + path = pathArg.toString().replaceAll("\"", ""); + path = context.resolveString(path); + } entryPoints.add(EntryPoint.builder() .type(EntryPoint.Type.REST) @@ -220,7 +225,10 @@ public class SpringMvcDetector { if (value != null) { String resolved = constantResolver.resolve(value, context); - return resolved != null ? resolved : value.toString(); + if (resolved == null) { + resolved = value.toString().replaceAll("^\"|\"$", ""); + } + return context.resolveString(resolved); } return ""; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/util/PlaceholderResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/util/PlaceholderResolver.java new file mode 100644 index 0000000..b4ead46 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/util/PlaceholderResolver.java @@ -0,0 +1,72 @@ +package click.kamil.springstatemachineexporter.analysis.util; + +import lombok.extern.slf4j.Slf4j; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility for resolving Spring-style placeholders like ${property.name} or ${property.name:default-value}. + * Supports recursive resolution. + */ +@Slf4j +public class PlaceholderResolver { + + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{([^:}]+)(?::([^}]*))?}"); + + /** + * Resolves all placeholders in the given text using the provided properties map. + * + * @param text The text containing placeholders. + * @param properties The map of property keys and values. + * @return The resolved string. + */ + public static String resolve(String text, Map properties) { + return resolveInternal(text, properties, new HashSet<>()); + } + + private static String resolveInternal(String text, Map properties, Set visited) { + if (text == null || !text.contains("${")) { + return text; + } + + StringBuilder result = new StringBuilder(); + Matcher matcher = PLACEHOLDER_PATTERN.matcher(text); + int lastMatchEnd = 0; + + while (matcher.find()) { + result.append(text, lastMatchEnd, matcher.start()); + String key = matcher.group(1); + String defaultValue = matcher.group(2); + + if (visited.contains(key)) { + log.warn("Circular placeholder reference detected: {}", key); + result.append(matcher.group(0)); // Keep as is + } else { + String value = properties.get(key); + if (value == null) { + value = defaultValue; + } + + if (value != null) { + visited.add(key); + try { + result.append(resolveInternal(value, properties, visited)); + } finally { + visited.remove(key); + } + } else { + log.debug("Property not found and no default value for: {}", key); + result.append(matcher.group(0)); // Keep as is if not resolvable + } + } + lastMatchEnd = matcher.end(); + } + result.append(text.substring(lastMatchEnd)); + + return result.toString(); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index 0f728e9..474a96f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -3,9 +3,11 @@ package click.kamil.springstatemachineexporter.ast.common; import click.kamil.springstatemachineexporter.analysis.model.LibraryHint; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver; +import click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver; import click.kamil.springstatemachineexporter.model.State; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.jdt.core.JavaCore; import org.eclipse.jdt.core.dom.*; import java.io.IOException; @@ -29,6 +31,7 @@ public class CodebaseContext { private final Set ambiguousSimpleNames = new HashSet<>(); private final ConstantResolver constantResolver = new ConstantResolver(); private final PropertyResolver propertyResolver = new PropertyResolver(); + private final List activeProfiles = new ArrayList<>(); private Map> allProperties = new HashMap<>(); private List libraryHints = new ArrayList<>(); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -84,9 +87,71 @@ public class CodebaseContext { } public void setActiveProfiles(List profiles) { + this.activeProfiles.clear(); + if (profiles != null) { + this.activeProfiles.addAll(profiles); + } this.propertyResolver.setActiveProfiles(profiles); } + public String resolveString(String input) { + if (input == null) return null; + + Map merged = new HashMap<>(); + // 1. Default properties + Map defaultProps = allProperties.get("default"); + if (defaultProps != null) { + merged.putAll(defaultProps); + } + + // 2. Active profiles (later ones override earlier ones) + for (String profile : activeProfiles) { + Map profileProps = allProperties.get(profile); + if (profileProps != null) { + merged.putAll(profileProps); + } + } + + return PlaceholderResolver.resolve(input, merged); + } + public TypeDeclaration resolveStaticImport(String memberName, CompilationUnit contextCu) { + if (contextCu == null) return null; + + for (Object impObj : contextCu.imports()) { + ImportDeclaration imp = (ImportDeclaration) impObj; + if (imp.isStatic()) { + String impName = imp.getName().getFullyQualifiedName(); + if (imp.isOnDemand()) { + // Star import: import static com.example.C.*; + TypeDeclaration td = getTypeDeclaration(impName, contextCu); + if (td != null && hasField(td, memberName)) { + return td; + } + } else { + // Single import: import static com.example.C.A; + if (impName.endsWith("." + memberName)) { + String typeFqn = impName.substring(0, impName.lastIndexOf('.')); + return getTypeDeclaration(typeFqn, contextCu); + } + } + } + } + return null; + } + + public boolean hasField(TypeDeclaration td, String name) { + for (FieldDeclaration fd : td.getFields()) { + for (Object fragObj : fd.fragments()) { + if (fragObj instanceof VariableDeclarationFragment fragment) { + if (fragment.getName().getIdentifier().equals(name)) { + return true; + } + } + } + } + return false; + } + public void scan(Path rootDir) throws IOException { scan(Set.of(rootDir), Collections.emptySet()); } @@ -271,6 +336,11 @@ public class CodebaseContext { parser.setSource(source.toCharArray()); parser.setKind(ASTParser.K_COMPILATION_UNIT); parser.setResolveBindings(resolveBindings); + + Map options = JavaCore.getOptions(); + JavaCore.setComplianceOptions(JavaCore.VERSION_21, options); + parser.setCompilerOptions(options); + if (resolveBindings) { parser.setUnitName(unitName); parser.setEnvironment(classpath, sourcepath, null, true); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/AdvancedEndpointResolutionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/AdvancedEndpointResolutionTest.java new file mode 100644 index 0000000..1baa9b3 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/AdvancedEndpointResolutionTest.java @@ -0,0 +1,97 @@ +package click.kamil.springstatemachineexporter.analysis; + +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.analysis.service.SpringMvcDetector; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AdvancedEndpointResolutionTest { + + @Test + void testComplexResolutionWithProfiles(@TempDir Path tempDir) throws IOException { + Path srcDir = tempDir.resolve("src/main/java/com/example"); + Files.createDirectories(srcDir); + + // 1. Define Constants + Files.writeString(srcDir.resolve("ApiConstants.java"), + "package com.example;\n" + + "public class ApiConstants {\n" + + " public static final String V1 = \"/v1\";\n" + + " public static final String BASE = \"${app.base:api}\" + V1;\n" + + "}"); + + // 2. Define Controller + Files.writeString(srcDir.resolve("MyController.java"), + "package com.example;\n" + + "import org.springframework.web.bind.annotation.*;\n" + + "import org.springframework.beans.factory.annotation.Value;\n" + + "\n" + + "@RestController\n" + + "@RequestMapping(ApiConstants.BASE)\n" + + "public class MyController {\n" + + " @Value(\"${app.suffix:/users}\")\n" + + " private String suffix;\n" + + "\n" + + " @GetMapping(value = \"/list\" + \"${app.extra:}\")\n" + + " public void list() {}\n" + + " \n" + + " @PostMapping(\"${app.custom-path}\")\n" + + " public void custom() {}\n" + + "}"); + + // 3. Define Properties + Files.writeString(tempDir.resolve("application.properties"), + "app.custom-path=/custom\n" + + "app.base=core"); + + Files.writeString(tempDir.resolve("application-prod.properties"), + "app.base=enterprise\n" + + "app.extra=-premium"); + + // --- SCENARIO 1: Default Profile --- + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + SpringMvcDetector detector = new SpringMvcDetector(context, new ConstantResolver()); + TypeDeclaration controllerTd = context.getTypeDeclaration("com.example.MyController"); + CompilationUnit cu = (CompilationUnit) controllerTd.getRoot(); + + List entryPoints = detector.detect(cu); + + // app.base=core, V1=/v1 => base path = /core/v1 + // list() => /core/v1/list + // custom() => /core/v1/custom + assertThat(entryPoints).hasSize(2); + assertThat(entryPoints).anySatisfy(ep -> assertThat(ep.getName()).isEqualTo("GET /core/v1/list")); + assertThat(entryPoints).anySatisfy(ep -> assertThat(ep.getName()).isEqualTo("POST /core/v1/custom")); + + // --- SCENARIO 2: Prod Profile --- + context = new CodebaseContext(); + context.setActiveProfiles(List.of("prod")); + context.scan(tempDir); + + detector = new SpringMvcDetector(context, new ConstantResolver()); + controllerTd = context.getTypeDeclaration("com.example.MyController"); + cu = (CompilationUnit) controllerTd.getRoot(); + + entryPoints = detector.detect(cu); + + // app.base=enterprise (from prod), V1=/v1 => base path = /enterprise/v1 + // list() => /enterprise/v1/list-premium (app.extra from prod) + // custom() => /enterprise/v1/custom + assertThat(entryPoints).hasSize(2); + assertThat(entryPoints).anySatisfy(ep -> assertThat(ep.getName()).isEqualTo("GET /enterprise/v1/list-premium")); + assertThat(entryPoints).anySatisfy(ep -> assertThat(ep.getName()).isEqualTo("POST /enterprise/v1/custom")); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/MultiModuleResolutionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/MultiModuleResolutionTest.java new file mode 100644 index 0000000..f996570 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/MultiModuleResolutionTest.java @@ -0,0 +1,56 @@ +package click.kamil.springstatemachineexporter.analysis; + +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.FieldDeclaration; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +public class MultiModuleResolutionTest { + + @Test + void testResolutionAcrossModules(@TempDir Path tempDir) throws IOException { + // Module A + Path moduleADir = tempDir.resolve("module-a"); + Path srcA = moduleADir.resolve("src/main/java/com/modulea"); + Files.createDirectories(srcA); + Files.writeString(srcA.resolve("CommonConstants.java"), + "package com.modulea;\n" + + "public class CommonConstants {\n" + + " public static final String API_BASE = \"/api/v1\";\n" + + "}"); + + // Module B + Path moduleBDir = tempDir.resolve("module-b"); + Path srcB = moduleBDir.resolve("src/main/java/com/moduleb"); + Files.createDirectories(srcB); + Files.writeString(srcB.resolve("SpecificConstants.java"), + "package com.moduleb;\n" + + "import com.modulea.CommonConstants;\n" + + "public class SpecificConstants {\n" + + " public static final String USERS = CommonConstants.API_BASE + \"/users\";\n" + + "}"); + + CodebaseContext context = new CodebaseContext(); + // Scan both module roots + context.scan(Set.of(moduleADir, moduleBDir), Set.of()); + + TypeDeclaration td = context.getTypeDeclaration("com.moduleb.SpecificConstants"); + FieldDeclaration field = td.getFields()[0]; + VariableDeclarationFragment fragment = (VariableDeclarationFragment) field.fragments().get(0); + + ConstantResolver resolver = new ConstantResolver(); + String result = resolver.resolve(fragment.getInitializer(), context); + + assertThat(result).isEqualTo("/api/v1/users"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java new file mode 100644 index 0000000..31f6f5f --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java @@ -0,0 +1,155 @@ +package click.kamil.springstatemachineexporter.analysis.resolver; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ConstantResolverTest { + + @Test + void testConcatenationSameClass(@TempDir Path tempDir) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("Constants.java"), + "package com.example;\n" + + "public class Constants {\n" + + " public static final String A = \"/a\";\n" + + " public static final String B = \"/b\";\n" + + " public static final String AB = A + B;\n" + + " public String usage = AB;\n" + + "}"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("com.example.Constants"); + FieldDeclaration usageField = td.getFields()[3]; // usage + VariableDeclarationFragment fragment = (VariableDeclarationFragment) usageField.fragments().get(0); + + ConstantResolver resolver = new ConstantResolver(); + String result = resolver.resolve(fragment.getInitializer(), context); + + assertThat(result).isEqualTo("/a/b"); + } + + @Test + void testCrossClassConcatenation(@TempDir Path tempDir) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("Base.java"), + "package com.example;\n" + + "public class Base {\n" + + " public static final String PATH = \"/api\";\n" + + "}"); + Files.writeString(dir.resolve("Endpoint.java"), + "package com.example;\n" + + "public class Endpoint {\n" + + " public static final String FULL = Base.PATH + \"/v1\";\n" + + "}"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("com.example.Endpoint"); + FieldDeclaration field = td.getFields()[0]; + VariableDeclarationFragment fragment = (VariableDeclarationFragment) field.fragments().get(0); + + ConstantResolver resolver = new ConstantResolver(); + String result = resolver.resolve(fragment.getInitializer(), context); + + assertThat(result).isEqualTo("/api/v1"); + } + + @Test + void testRecursiveResolution(@TempDir Path tempDir) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("Deep.java"), + "package com.example;\n" + + "public class Deep {\n" + + " public static final String V1 = \"/v1\";\n" + + " public static final String API = \"/api\" + V1;\n" + + " public static final String USERS = API + \"/users\";\n" + + "}"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("com.example.Deep"); + FieldDeclaration field = td.getFields()[2]; // USERS + VariableDeclarationFragment fragment = (VariableDeclarationFragment) field.fragments().get(0); + + ConstantResolver resolver = new ConstantResolver(); + String result = resolver.resolve(fragment.getInitializer(), context); + + assertThat(result).isEqualTo("/api/v1/users"); + } + + @Test + void testCycleDetection(@TempDir Path tempDir) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("Cycle.java"), + "package com.example;\n" + + "public class Cycle {\n" + + " public static final String A = B;\n" + + " public static final String B = A;\n" + + "}"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("com.example.Cycle"); + FieldDeclaration field = td.getFields()[0]; + VariableDeclarationFragment fragment = (VariableDeclarationFragment) field.fragments().get(0); + + ConstantResolver resolver = new ConstantResolver(); + // Should not crash + String result = resolver.resolve(fragment.getInitializer(), context); + assertThat(result).isNull(); + } + + @Test + void testValueAnnotationSupport(@TempDir Path tempDir) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("Controller.java"), + "package com.example;\n" + + "import org.springframework.beans.factory.annotation.Value;\n" + + "public class Controller {\n" + + " @Value(\"${app.path}\")\n" + + " private String path;\n" + + " \n" + + " public String getPath() { return path; }\n" + + "}"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("com.example.Controller"); + FieldDeclaration field = td.getFields()[0]; + VariableDeclarationFragment fragment = (VariableDeclarationFragment) field.fragments().get(0); + + ConstantResolver resolver = new ConstantResolver(); + // Resolve a SimpleName referring to an @Value field + SimpleName sn = td.getAST().newSimpleName("path"); + // We need to attach the SimpleName to the AST to find its enclosing type + // Actually, resolveManual(SimpleName) uses findEnclosingType. + // In the test, we can manually trigger it or mock the node hierarchy. + + // Let's use a real usage in a method + MethodDeclaration method = td.getMethods()[0]; + ReturnStatement rs = (ReturnStatement) method.getBody().statements().get(0); + Expression returnExpr = rs.getExpression(); + + String result = resolver.resolve(returnExpr, context); + assertThat(result).isEqualTo("${app.path}"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/StaticImportResolutionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/StaticImportResolutionTest.java new file mode 100644 index 0000000..948379b --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/StaticImportResolutionTest.java @@ -0,0 +1,103 @@ +package click.kamil.springstatemachineexporter.analysis.resolver; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +public class StaticImportResolutionTest { + + @Test + void testSingleStaticImport(@TempDir Path tempDir) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("Constants.java"), + "package com.example;\n" + + "public class Constants {\n" + + " public static final String A = \"/a\";\n" + + "}"); + Files.writeString(dir.resolve("Usage.java"), + "package com.example;\n" + + "import static com.example.Constants.A;\n" + + "public class Usage {\n" + + " String val = A;\n" + + "}"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("com.example.Usage"); + FieldDeclaration field = td.getFields()[0]; + VariableDeclarationFragment fragment = (VariableDeclarationFragment) field.fragments().get(0); + + ConstantResolver resolver = new ConstantResolver(); + String result = resolver.resolve(fragment.getInitializer(), context); + + assertThat(result).isEqualTo("/a"); + } + + @Test + void testStarStaticImport(@TempDir Path tempDir) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("Constants.java"), + "package com.example;\n" + + "public class Constants {\n" + + " public static final String B = \"/b\";\n" + + "}"); + Files.writeString(dir.resolve("Usage.java"), + "package com.example;\n" + + "import static com.example.Constants.*;\n" + + "public class Usage {\n" + + " String val = B;\n" + + "}"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("com.example.Usage"); + FieldDeclaration field = td.getFields()[0]; + VariableDeclarationFragment fragment = (VariableDeclarationFragment) field.fragments().get(0); + + ConstantResolver resolver = new ConstantResolver(); + String result = resolver.resolve(fragment.getInitializer(), context); + + assertThat(result).isEqualTo("/b"); + } + + @Test + void testPrecedenceLocalOverStaticImport(@TempDir Path tempDir) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("Constants.java"), + "package com.example;\n" + + "public class Constants {\n" + + " public static final String A = \"imported\";\n" + + "}"); + Files.writeString(dir.resolve("Usage.java"), + "package com.example;\n" + + "import static com.example.Constants.A;\n" + + "public class Usage {\n" + + " public static final String A = \"local\";\n" + + " String val = A;\n" + + "}"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("com.example.Usage"); + FieldDeclaration field = td.getFields()[1]; // val + VariableDeclarationFragment fragment = (VariableDeclarationFragment) field.fragments().get(0); + + ConstantResolver resolver = new ConstantResolver(); + String result = resolver.resolve(fragment.getInitializer(), context); + + assertThat(result).isEqualTo("local"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/util/PlaceholderResolverTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/util/PlaceholderResolverTest.java new file mode 100644 index 0000000..02eb542 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/util/PlaceholderResolverTest.java @@ -0,0 +1,68 @@ +package click.kamil.springstatemachineexporter.analysis.util; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PlaceholderResolverTest { + + @Test + void testSimpleResolution() { + Map props = Map.of("app.path", "/api/v1"); + String result = PlaceholderResolver.resolve("${app.path}", props); + assertThat(result).isEqualTo("/api/v1"); + } + + @Test + void testMultiplePlaceholders() { + Map props = Map.of("host", "localhost", "port", "8080"); + String result = PlaceholderResolver.resolve("http://${host}:${port}/api", props); + assertThat(result).isEqualTo("http://localhost:8080/api"); + } + + @Test + void testDefaultValue() { + Map props = Map.of(); + String result = PlaceholderResolver.resolve("${missing:/default}", props); + assertThat(result).isEqualTo("/default"); + } + + @Test + void testRecursiveResolution() { + Map props = Map.of( + "base", "/api", + "version", "v1", + "full", "${base}/${version}" + ); + String result = PlaceholderResolver.resolve("${full}/users", props); + assertThat(result).isEqualTo("/api/v1/users"); + } + + @Test + void testCycleDetection() { + Map props = Map.of( + "a", "${b}", + "b", "${a}" + ); + // Should not throw StackOverflowError + String result = PlaceholderResolver.resolve("${a}", props); + // It should keep the unresolvable placeholder in case of cycle + assertThat(result).isEqualTo("${a}"); + } + + @Test + void testMixedContent() { + Map props = Map.of("name", "world"); + String result = PlaceholderResolver.resolve("Hello ${name}!", props); + assertThat(result).isEqualTo("Hello world!"); + } + + @Test + void testUnresolvablePlaceholder() { + Map props = Map.of(); + String result = PlaceholderResolver.resolve("${unknown}", props); + assertThat(result).isEqualTo("${unknown}"); + } +}