diff --git a/build.gradle b/build.gradle index 116feba772b..99d6063c702 100644 --- a/build.gradle +++ b/build.gradle @@ -385,6 +385,7 @@ dependencies { testImplementation "org.testfx:testfx-core:4.0.16-alpha" testImplementation "org.testfx:testfx-junit5:4.0.16-alpha" testImplementation "org.hamcrest:hamcrest-library:3.0" + testImplementation "com.github.javaparser:javaparser-symbol-solver-core:3.26.2" // recommended by https://github.com/wiremock/wiremock/issues/2149#issuecomment-1835775954 testImplementation 'org.wiremock:wiremock-standalone:3.3.1' diff --git a/src/test/java/org/jabref/model/entry/field/FieldEditorsMultilinePropertyTest.java b/src/test/java/org/jabref/model/entry/field/FieldEditorsMultilinePropertyTest.java new file mode 100644 index 00000000000..f41d94549f3 --- /dev/null +++ b/src/test/java/org/jabref/model/entry/field/FieldEditorsMultilinePropertyTest.java @@ -0,0 +1,236 @@ +package org.jabref.model.entry.field; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.ImportDeclaration; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.expr.MethodCallExpr; +import com.github.javaparser.ast.expr.ObjectCreationExpr; +import com.github.javaparser.ast.stmt.IfStmt; +import com.github.javaparser.ast.stmt.ReturnStmt; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class FieldEditorsMultilinePropertyTest { + + private static final Pattern FIELD_PROPERTY_PATTERN = Pattern.compile("fieldProperties\\.contains\\s*\\(\\s*FieldProperty\\.(\\w+)\\s*\\)"); + private static final Pattern STANDARD_FIELD_PATTERN = Pattern.compile("==\\s*StandardField\\.(\\w+)"); + private static final Pattern INTERNAL_FIELD_PATTERN = Pattern.compile("==\\s*InternalField\\.(\\w+)"); + private static JavaParser PARSER; + + @BeforeAll + public static void setUp() { + ParserConfiguration configuration = new ParserConfiguration(); + configuration.setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_21); + PARSER = new JavaParser(configuration); + } + + /** + * This test is somewhat fragile, as it depends on the structure of FieldEditors.java. + * If the structure of FieldEditors.java is changed, this test might fail. + * This test performs the following steps: + * 1. Use Java parser to parse FieldEditors.java and check all if statements in the getForField method. + * 2. Match the conditions of if statements to extract the field properties. + * 3. Match the created FieldEditor class name with field properties extracted from step 2. This creates a map where: + * - The key is the file path of the FieldEditor class (for example: ....UrlEditor.java) + * - The value is the list of properties of the FieldEditor class (for example: [FieldProperty.EXTERNAL]) + * 4. For every class in the map, when its properties contain MULTILINE_TEXT, check whether it: + * a) Holds a TextInputControl field + * b) Has an EditorTextArea object creation + */ + @Test + public void fieldEditorsMatchMultilineProperty() throws Exception { + Map> result = getEditorsWithPropertiesInFieldEditors(); + for (Map.Entry> entry : result.entrySet()) { + // Now we have the file path and its properties, going to analyze the target Editor class + Path filePath = entry.getKey(); + List properties = entry.getValue(); + CompilationUnit cu = PARSER.parse(filePath) + .getResult() + .orElseThrow(() -> + new NullPointerException("Failed to parse " + + filePath + + ", java parser returned null CompilationUnit" + + ", please check if the file exists")); + + if (!implementedFieldEditorFX(cu)) { + continue; // Make sure the class implements FieldEditorFX interface + } + + if (properties.contains(FieldProperty.MULTILINE_TEXT)) { + // If the editor has MULTILINE_TEXT property, we are going to check if the class holds a `TextInputControl` field + // and have performed Text Area creation + assertTrue(holdTextInputControlField(cu) && hasEditorTextAreaCreationExisted(cu), + "Class " + filePath + " should hold a TextInputControl field and have EditorTextArea creation"); + } + } + } + + /** + * Parse FieldEditors.java to get all field editors and their properties in function getForField + * + * @return a map of field editor file path and its properties + */ + private static Map> getEditorsWithPropertiesInFieldEditors() throws Exception { + final String filePath = "src/main/java/org/jabref/gui/fieldeditors/FieldEditors.java"; + Map> result = new HashMap<>(); + CompilationUnit cu = PARSER.parse(Paths.get(filePath)) + .getResult() + .orElseThrow(() -> + new NullPointerException("Failed to parse FieldEditors.java")); + + // Locate getForField method in FieldEditors.java + MethodDeclaration getForFieldCall = cu.findAll(MethodDeclaration.class).stream() + .filter(methodDeclaration -> "getForField".equals(methodDeclaration.getNameAsString())) + .findFirst() + .orElseThrow(() -> new Exception("Failed to find getForField method in FieldEditors.java")); + + // Analyze all if statements in getForField method + getForFieldCall.findAll(IfStmt.class).forEach(ifStmt -> { + String condition = ifStmt.getCondition().toString(); + List properties = new ArrayList<>(); + // Match `fieldProperties.contains(FieldProperty.XXX)` + Matcher propertyMatcher = FIELD_PROPERTY_PATTERN.matcher(condition); + while (propertyMatcher.find()) { + String propertyName = propertyMatcher.group(1); + FieldProperty property = FieldProperty.valueOf(propertyName); + properties.add(property); + } + // Match `== StandardField.XXX` + Matcher standardFieldMatcher = STANDARD_FIELD_PATTERN.matcher(condition); + if (standardFieldMatcher.find()) { + String fieldName = standardFieldMatcher.group(1); + StandardField standardField = StandardField.valueOf(fieldName); + properties.addAll(standardField.getProperties()); + } + // Match `== InternalField.XXX` + Matcher internalFieldMatcher = INTERNAL_FIELD_PATTERN.matcher(condition); + if (internalFieldMatcher.find()) { + String fieldName = internalFieldMatcher.group(1); + InternalField internalField = InternalField.valueOf(fieldName); + properties.addAll(internalField.getProperties()); + } + + // Check if the return statement contains an object creation + // If so, extract the created class name and its path + ifStmt.getThenStmt().stream() + .filter(ReturnStmt.class::isInstance) + .map(ReturnStmt.class::cast) + .findFirst() + .flatMap(returnStmt -> + // Try to find the object creation in the return statement + returnStmt.stream() + .filter(ObjectCreationExpr.class::isInstance) + .map(ObjectCreationExpr.class::cast) + .findFirst()).ifPresent(creationExpr -> { + String createdClassName = creationExpr.getTypeAsString().replace("<>", ""); + cu.findAll(ImportDeclaration.class) + .stream() + .filter(importDeclaration -> importDeclaration.getNameAsString().endsWith(createdClassName)) + .findFirst() + .ifPresentOrElse(importDeclaration -> { + String classPath = importDeclaration.getNameAsString(); + Path classFilePath = Paths.get("src/main/java/" + classPath.replace(".", "/") + ".java"); + result.put(classFilePath, properties); + }, () -> { + Path classFilePath = Paths.get("src/main/java/org/jabref/gui/fieldeditors/" + createdClassName + ".java"); + result.put(classFilePath, properties); + }); + }); + }); + + return result; + } + + /** + * Check if the class implements FieldEditorFX interface + * + * @param cu CompilationUnit + * @return true if the class implements FieldEditorFX interface + */ + private static boolean implementedFieldEditorFX(CompilationUnit cu) { + return cu.findAll(ClassOrInterfaceDeclaration.class).stream() + .anyMatch(classDecl -> classDecl.getImplementedTypes().stream() + .anyMatch(type -> Objects.equals("FieldEditorFX", type.getNameAsString()))); + } + + /** + * Check if the class has a new EditorTextArea creation + * + * @param cu CompilationUnit + * @return true if the class has a new EditorTextArea creation + */ + private static boolean hasEditorTextAreaCreationExisted(CompilationUnit cu) { + return cu.findAll(ObjectCreationExpr.class).stream() + .anyMatch(creation -> Objects.equals("EditorTextArea", creation.getType().toString())); + } + + /** + * Check if the class holds a TextInputControl field + * + * @param cu CompilationUnit + * @return true if the class holds a TextInputControl field + */ + private static boolean holdTextInputControlField(CompilationUnit cu) { + // Since the class implements FieldEditorFX, we are going to check the first parameter when call + // establishBinding method, which should be a TextInputControl + AtomicBoolean hasTextInputControlField = new AtomicBoolean(false); + cu.findAll(MethodCallExpr.class) + .stream() + .filter(methodCallExpr -> "establishBinding".equals(methodCallExpr.getNameAsString())) + .findFirst() + .ifPresent(methodCallExpr -> { + if (!methodCallExpr.getArguments().isEmpty()) { + String firstArgument = methodCallExpr.getArgument(0).toString(); + cu.findAll(FieldDeclaration.class) + .stream() + .filter(fieldDeclaration -> fieldDeclaration.getVariables().stream() + .anyMatch(variableDeclarator -> variableDeclarator.getNameAsString().equals(firstArgument))) + .findFirst() + .ifPresent(fieldDeclaration -> { + String classType = fieldDeclaration.getElementType().asString(); + if ("TextInputControl".equals(classType)) { + hasTextInputControlField.set(true); + } + }); + } + }); + return hasTextInputControlField.get(); + } + + private static boolean holdEditorTextField(CompilationUnit compilationUnit) { + AtomicBoolean hasEditorTextField = new AtomicBoolean(false); + compilationUnit.findAll(MethodCallExpr.class).stream() + .filter(methodCallExpr -> "establishBinding".equals(methodCallExpr.getNameAsString())) + .findFirst() + .ifPresent(establishBindingCall -> { + String firstArg = establishBindingCall.getArgument(0).toString(); + compilationUnit.findAll(FieldDeclaration.class).stream() + .filter(field -> field.getVariable(0).getNameAsString().equals(firstArg)) + .findFirst() + .ifPresent(fieldDeclaration -> { + String fieldType = fieldDeclaration.getElementType().asString(); + if ("EditorTextField".equals(fieldType)) { + hasEditorTextField.set(true); + } + }); + }); + return hasEditorTextField.get(); + } +}