string resolution
This commit is contained in:
@@ -4,10 +4,17 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.eclipse.jdt.core.dom.*;
|
import org.eclipse.jdt.core.dom.*;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class ConstantResolver {
|
public class ConstantResolver {
|
||||||
|
|
||||||
public String resolve(Expression expr, CodebaseContext context) {
|
public String resolve(Expression expr, CodebaseContext context) {
|
||||||
|
return resolveInternal(expr, context, new HashSet<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveInternal(Expression expr, CodebaseContext context, Set<String> visited) {
|
||||||
if (expr == null) return null;
|
if (expr == null) return null;
|
||||||
|
|
||||||
// 1. Literal?
|
// 1. Literal?
|
||||||
@@ -38,27 +45,30 @@ public class ConstantResolver {
|
|||||||
|
|
||||||
// 3. Fallback: Manual AST Traversal (if bindings not available or resolved)
|
// 3. Fallback: Manual AST Traversal (if bindings not available or resolved)
|
||||||
if (expr instanceof InfixExpression infix) {
|
if (expr instanceof InfixExpression infix) {
|
||||||
return resolveInfix(infix, context);
|
return resolveInfix(infix, context, visited);
|
||||||
}
|
}
|
||||||
if (expr instanceof QualifiedName qn) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveInfix(InfixExpression expr, CodebaseContext context) {
|
private String resolveInfix(InfixExpression expr, CodebaseContext context, Set<String> visited) {
|
||||||
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
|
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
|
||||||
|
|
||||||
String left = resolve(expr.getLeftOperand(), context);
|
String left = resolveInternal(expr.getLeftOperand(), context, visited);
|
||||||
String right = resolve(expr.getRightOperand(), context);
|
String right = resolveInternal(expr.getRightOperand(), context, visited);
|
||||||
|
|
||||||
if (left == null || right == null) return null;
|
if (left == null || right == null) return null;
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder(left + right);
|
StringBuilder sb = new StringBuilder(left + right);
|
||||||
if (expr.hasExtendedOperands()) {
|
if (expr.hasExtendedOperands()) {
|
||||||
for (Object operand : expr.extendedOperands()) {
|
for (Object operand : expr.extendedOperands()) {
|
||||||
String val = resolve((Expression) operand, context);
|
String val = resolveInternal((Expression) operand, context, visited);
|
||||||
if (val == null) return null;
|
if (val == null) return null;
|
||||||
sb.append(val);
|
sb.append(val);
|
||||||
}
|
}
|
||||||
@@ -66,7 +76,27 @@ public class ConstantResolver {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveManual(QualifiedName qn, CodebaseContext context) {
|
private String resolveManual(SimpleName sn, CodebaseContext context, Set<String> 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<String> visited) {
|
||||||
String qualifier = qn.getQualifier().toString();
|
String qualifier = qn.getQualifier().toString();
|
||||||
String name = qn.getName().getIdentifier();
|
String name = qn.getName().getIdentifier();
|
||||||
|
|
||||||
@@ -74,19 +104,81 @@ public class ConstantResolver {
|
|||||||
TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu);
|
TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu);
|
||||||
|
|
||||||
if (td != null) {
|
if (td != null) {
|
||||||
for (FieldDeclaration fd : td.getFields()) {
|
String fqn = context.getFqn(td);
|
||||||
for (Object fragmentObj : fd.fragments()) {
|
return resolveFieldInType(td, name, fqn, context, visited);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveFieldInType(TypeDeclaration td, String fieldName, String typeFqn, CodebaseContext context, Set<String> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ public class SpringMvcDetector {
|
|||||||
if (verb != null && !call.arguments().isEmpty()) {
|
if (verb != null && !call.arguments().isEmpty()) {
|
||||||
Expression pathArg = (Expression) call.arguments().get(0);
|
Expression pathArg = (Expression) call.arguments().get(0);
|
||||||
String path = constantResolver.resolve(pathArg, context);
|
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()
|
entryPoints.add(EntryPoint.builder()
|
||||||
.type(EntryPoint.Type.REST)
|
.type(EntryPoint.Type.REST)
|
||||||
@@ -220,7 +225,10 @@ public class SpringMvcDetector {
|
|||||||
|
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
String resolved = constantResolver.resolve(value, context);
|
String resolved = constantResolver.resolve(value, context);
|
||||||
return resolved != null ? resolved : value.toString();
|
if (resolved == null) {
|
||||||
|
resolved = value.toString().replaceAll("^\"|\"$", "");
|
||||||
|
}
|
||||||
|
return context.resolveString(resolved);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, String> properties) {
|
||||||
|
return resolveInternal(text, properties, new HashSet<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String resolveInternal(String text, Map<String, String> properties, Set<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ package click.kamil.springstatemachineexporter.ast.common;
|
|||||||
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
|
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
|
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver;
|
||||||
import click.kamil.springstatemachineexporter.model.State;
|
import click.kamil.springstatemachineexporter.model.State;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.eclipse.jdt.core.JavaCore;
|
||||||
import org.eclipse.jdt.core.dom.*;
|
import org.eclipse.jdt.core.dom.*;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -29,6 +31,7 @@ public class CodebaseContext {
|
|||||||
private final Set<String> ambiguousSimpleNames = new HashSet<>();
|
private final Set<String> ambiguousSimpleNames = new HashSet<>();
|
||||||
private final ConstantResolver constantResolver = new ConstantResolver();
|
private final ConstantResolver constantResolver = new ConstantResolver();
|
||||||
private final PropertyResolver propertyResolver = new PropertyResolver();
|
private final PropertyResolver propertyResolver = new PropertyResolver();
|
||||||
|
private final List<String> activeProfiles = new ArrayList<>();
|
||||||
private Map<String, Map<String, String>> allProperties = new HashMap<>();
|
private Map<String, Map<String, String>> allProperties = new HashMap<>();
|
||||||
private List<LibraryHint> libraryHints = new ArrayList<>();
|
private List<LibraryHint> libraryHints = new ArrayList<>();
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
@@ -84,9 +87,71 @@ public class CodebaseContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void setActiveProfiles(List<String> profiles) {
|
public void setActiveProfiles(List<String> profiles) {
|
||||||
|
this.activeProfiles.clear();
|
||||||
|
if (profiles != null) {
|
||||||
|
this.activeProfiles.addAll(profiles);
|
||||||
|
}
|
||||||
this.propertyResolver.setActiveProfiles(profiles);
|
this.propertyResolver.setActiveProfiles(profiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String resolveString(String input) {
|
||||||
|
if (input == null) return null;
|
||||||
|
|
||||||
|
Map<String, String> merged = new HashMap<>();
|
||||||
|
// 1. Default properties
|
||||||
|
Map<String, String> defaultProps = allProperties.get("default");
|
||||||
|
if (defaultProps != null) {
|
||||||
|
merged.putAll(defaultProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Active profiles (later ones override earlier ones)
|
||||||
|
for (String profile : activeProfiles) {
|
||||||
|
Map<String, String> 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 {
|
public void scan(Path rootDir) throws IOException {
|
||||||
scan(Set.of(rootDir), Collections.emptySet());
|
scan(Set.of(rootDir), Collections.emptySet());
|
||||||
}
|
}
|
||||||
@@ -271,6 +336,11 @@ public class CodebaseContext {
|
|||||||
parser.setSource(source.toCharArray());
|
parser.setSource(source.toCharArray());
|
||||||
parser.setKind(ASTParser.K_COMPILATION_UNIT);
|
parser.setKind(ASTParser.K_COMPILATION_UNIT);
|
||||||
parser.setResolveBindings(resolveBindings);
|
parser.setResolveBindings(resolveBindings);
|
||||||
|
|
||||||
|
Map<String, String> options = JavaCore.getOptions();
|
||||||
|
JavaCore.setComplianceOptions(JavaCore.VERSION_21, options);
|
||||||
|
parser.setCompilerOptions(options);
|
||||||
|
|
||||||
if (resolveBindings) {
|
if (resolveBindings) {
|
||||||
parser.setUnitName(unitName);
|
parser.setUnitName(unitName);
|
||||||
parser.setEnvironment(classpath, sourcepath, null, true);
|
parser.setEnvironment(classpath, sourcepath, null, true);
|
||||||
|
|||||||
@@ -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<EntryPoint> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> props = Map.of("app.path", "/api/v1");
|
||||||
|
String result = PlaceholderResolver.resolve("${app.path}", props);
|
||||||
|
assertThat(result).isEqualTo("/api/v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testMultiplePlaceholders() {
|
||||||
|
Map<String, String> 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<String, String> props = Map.of();
|
||||||
|
String result = PlaceholderResolver.resolve("${missing:/default}", props);
|
||||||
|
assertThat(result).isEqualTo("/default");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testRecursiveResolution() {
|
||||||
|
Map<String, String> 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<String, String> 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<String, String> props = Map.of("name", "world");
|
||||||
|
String result = PlaceholderResolver.resolve("Hello ${name}!", props);
|
||||||
|
assertThat(result).isEqualTo("Hello world!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUnresolvablePlaceholder() {
|
||||||
|
Map<String, String> props = Map.of();
|
||||||
|
String result = PlaceholderResolver.resolve("${unknown}", props);
|
||||||
|
assertThat(result).isEqualTo("${unknown}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user