string resolution

This commit is contained in:
2026-06-17 15:15:20 +02:00
parent f48cdf080a
commit 566a814671
9 changed files with 741 additions and 20 deletions

View File

@@ -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"));
}
}

View File

@@ -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");
}
}

View File

@@ -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}");
}
}

View File

@@ -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");
}
}

View File

@@ -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}");
}
}