diff --git a/cdlang/src/main/java/de/monticore/cd/codegen/trafo/JavaAssociationRoleNameTrafo.java b/cdlang/src/main/java/de/monticore/cd/codegen/trafo/JavaAssociationRoleNameTrafo.java new file mode 100644 index 000000000..876d0ead4 --- /dev/null +++ b/cdlang/src/main/java/de/monticore/cd/codegen/trafo/JavaAssociationRoleNameTrafo.java @@ -0,0 +1,107 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.cd.codegen.trafo; + +import de.monticore.cd4code.CD4CodeMill; +import de.monticore.cdassociation._ast.ASTCDRole; +import de.monticore.cdassociation._symboltable.ICDAssociationScope; +import de.monticore.cdassociation._visitor.CDAssociationVisitor2; +import de.monticore.cdbasis._ast.ASTCDClass; +import de.monticore.cdbasis._ast.ASTCDCompilationUnit; +import de.monticore.cdbasis._visitor.CDBasisVisitor2; +import de.monticore.symbols.oosymbols._symboltable.OOTypeSymbol; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Adapts association roles to Java identifiers before fields and methods are generated. */ +public class JavaAssociationRoleNameTrafo implements CDAssociationVisitor2, CDBasisVisitor2 { + + // Avoid SourceVersion because Gradle's isolated generator classloader does not expose + // the java.compiler module. + // Includes reserved keywords, the underscore, and literals; contextual keywords are legal fields. + protected static final Set JAVA_RESERVED_NAMES = Set.of("abstract", "assert", "boolean", + "break", "byte", "case", "catch", "char", "class", "const", "continue", "default", "do", + "double", "else", "enum", "extends", "final", "finally", "float", "for", "goto", "if", + "implements", "import", "instanceof", "int", "interface", "long", "native", "new", "package", + "private", "protected", "public", "return", "short", "static", "strictfp", "super", "switch", + "synchronized", "this", "throw", "throws", "transient", "try", "void", "volatile", "while", + "_", "true", "false", "null"); + + protected final List roles = new ArrayList<>(); + protected final Map> hierarchies = new LinkedHashMap<>(); + + @Override + public void visit(ASTCDClass node) { + Set ancestors = new LinkedHashSet<>(); + collectAncestors(node.getSymbol(), ancestors); + hierarchies.put(node.getSymbol(), ancestors); + } + + protected void collectAncestors(OOTypeSymbol type, Set ancestors) { + if (ancestors.add(type)) { + type.getSuperTypesList().forEach(superType -> { + if (superType.getTypeInfo() instanceof OOTypeSymbol ooSuperTypeInfo) { + collectAncestors(ooSuperTypeInfo, ancestors); + } + }); + } + } + + @Override + public void visit(ASTCDRole node) { + if (JAVA_RESERVED_NAMES.contains(node.getName())) { + roles.add(node); + } + } + + public void transform(Collection asts) { + roles.clear(); + hierarchies.clear(); + var traverser = CD4CodeMill.inheritanceTraverser(); + traverser.add4CDAssociation(this); + asts.forEach(ast -> ast.accept(traverser)); + if (roles.isEmpty()) { + return; + } + // Only inspect inheritance when a role actually needs a Java identifier. + var hierarchyTraverser = CD4CodeMill.inheritanceTraverser(); + hierarchyTraverser.add4CDBasis(this); + asts.forEach(ast -> ast.accept(hierarchyTraverser)); + for (ASTCDRole role : roles) { + var symbol = role.getSymbol(); + var scope = symbol.getEnclosingScope(); + Set occupied = new LinkedHashSet<>(); + scope.getLocalFieldSymbols().forEach(field -> occupied.add(field.getName())); + scope.getLocalCDRoleSymbols().forEach(other -> occupied.add(other.getName())); + // Reserve inherited members and members of subclasses, independent of traversal order. + hierarchies.forEach((type, ancestors) -> { + if (ancestors.stream().anyMatch(ancestor -> ancestor.getSpannedScope() == scope)) { + ancestors.forEach(ancestor -> collectNames(ancestor, occupied)); + } + }); + String name = role.getName() + "_"; + while (occupied.contains(name)) { + name += "_"; + } + // Re-index the scope as well as updating the AST and symbol used by navigable setters. + scope.remove(symbol); + role.setName(name); + symbol.setName(name); + symbol.setFullName(null); + scope.add(symbol); + } + } + + protected void collectNames(OOTypeSymbol type, Set names) { + type.getFieldList().forEach(field -> names.add(field.getName())); + if (type.getSpannedScope() instanceof ICDAssociationScope) { + ((ICDAssociationScope) type.getSpannedScope()).getLocalCDRoleSymbols().forEach(role -> names + .add(role.getName())); + } + } + +} diff --git a/cdlang/src/main/java/de/monticore/cdgen/CDGenTool.java b/cdlang/src/main/java/de/monticore/cdgen/CDGenTool.java index f311afe7c..3e79396d3 100644 --- a/cdlang/src/main/java/de/monticore/cdgen/CDGenTool.java +++ b/cdlang/src/main/java/de/monticore/cdgen/CDGenTool.java @@ -6,6 +6,7 @@ import de.monticore.cd.codegen.CdUtilsPrinter; import de.monticore.cd.codegen.DecoratorConfig; import de.monticore.cd.codegen.trafo.DefaultVisibilityPublicTrafo; +import de.monticore.cd.codegen.trafo.JavaAssociationRoleNameTrafo; import de.monticore.cd.codegen.trafo.TOPTrafo; import de.monticore.cd.methodtemplates.CD4C; import de.monticore.cd4analysis._util.CD4AnalysisTypeDispatcher; @@ -331,6 +332,7 @@ public GeneratorSetup newConfiguredGeneratorSetup(List additionalTemplateP public CDAssociationCreateFieldsFromAllRoles performFieldsFromRolesTrafo( Collection asts) { + new JavaAssociationRoleNameTrafo().transform(asts); CDAssociationCreateFieldsFromAllRoles roleTrafo = new CDAssociationCreateFieldsFromNavigableRoles(); final CD4CodeTraverser traverser = CD4CodeMill.inheritanceTraverser(); diff --git a/cdlang/src/main/java/de/monticore/cdgen/cocos/CDAssociationReferenceUnique.java b/cdlang/src/main/java/de/monticore/cdgen/cocos/CDAssociationReferenceUnique.java new file mode 100644 index 000000000..dedd5ff8a --- /dev/null +++ b/cdlang/src/main/java/de/monticore/cdgen/cocos/CDAssociationReferenceUnique.java @@ -0,0 +1,102 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.cdgen.cocos; + +import de.monticore.cdassociation._ast.ASTCDAssocSide; +import de.monticore.cdassociation._ast.ASTCDAssociation; +import de.monticore.cdbasis._ast.ASTCDDefinition; +import de.monticore.symbols.basicsymbols._symboltable.TypeSymbol; +import org.apache.commons.lang3.StringUtils; + +import java.util.*; + +/** + * Checks that generated association references are unique. + */ +public class CDAssociationReferenceUnique extends CDAssociationUniqueInHierarchy { + + @Override + public void check(ASTCDDefinition node) { + List alreadyChecked = new ArrayList<>(); + + // we check for each pair of associations + for (ASTCDAssociation assoc2 : node.getCDAssociationsList()) { + for (ASTCDAssociation assoc1 : alreadyChecked) { + for (AssociationReference ref1 : getAssociationReferences(assoc1)) { + for (AssociationReference ref2 : getAssociationReferences(assoc2)) { + if (ref1.name.equals(ref2.name)) { + checkRef(node, ref1.sourceType, ref2.sourceType, assoc2); + } + } + } + } + + alreadyChecked.add(assoc2); + } + } + + @Override + protected void checkRef(ASTCDDefinition node, TypeSymbol type1, TypeSymbol type2, + ASTCDAssociation assoc1) { + if (type1 == null || type2 == null) { + return; + } + super.checkRef(node, type1, type2, assoc1); + } + + protected List getAssociationReferences(ASTCDAssociation assoc) { + List references = new ArrayList<>(); + + boolean navigableLeft = assoc.getCDAssocDir().isDefinitiveNavigableLeft(); + boolean navigableRight = assoc.getCDAssocDir().isDefinitiveNavigableRight(); + boolean undirected = !navigableLeft && !navigableRight; + + if (navigableRight || undirected) { + references.add(new AssociationReference(findTypeByFullName(assoc.getLeftQualifiedName()), + deriveReferenceName(assoc, AssocSide.RIGHT))); + } + if (navigableLeft || undirected) { + references.add(new AssociationReference(findTypeByFullName(assoc.getRightQualifiedName()), + deriveReferenceName(assoc, AssocSide.LEFT))); + } + + return references; + } + + protected String deriveReferenceName(ASTCDAssociation assoc, AssocSide side) { + ASTCDAssocSide assocSide; + if (side.equals(AssocSide.LEFT)) { + assocSide = assoc.getLeft(); + } + else { + assocSide = assoc.getRight(); + } + if (assoc.isPresentName()) { + return StringUtils.uncapitalize(assoc.getName()); + } + else if (assocSide.isPresentCDRole()) { + return assocSide.getCDRole().getName(); + } + else { + return StringUtils.uncapitalize(assocSide.getMCQualifiedType().getMCQualifiedName() + .getBaseName()); + } + } + + protected enum AssocSide { + LEFT, RIGHT; + } + + protected static class AssociationReference { + + protected final TypeSymbol sourceType; + + protected final String name; + + protected AssociationReference(TypeSymbol sourceType, String name) { + this.sourceType = sourceType; + this.name = name; + } + + } + +} diff --git a/cdlang/src/test/java/de/monticore/cd/cdgen/DefaultCD2PojoDecoratorTest.java b/cdlang/src/test/java/de/monticore/cd/cdgen/DefaultCD2PojoDecoratorTest.java index 0f81a741c..b1f2a6692 100644 --- a/cdlang/src/test/java/de/monticore/cd/cdgen/DefaultCD2PojoDecoratorTest.java +++ b/cdlang/src/test/java/de/monticore/cd/cdgen/DefaultCD2PojoDecoratorTest.java @@ -2,16 +2,27 @@ package de.monticore.cd.cdgen; import de.monticore.cd.codegen.DecoratorConfig; +import de.monticore.cd.codegen.trafo.JavaAssociationRoleNameTrafo; import de.monticore.cd4code.CD4CodeMill; import de.monticore.generating.GeneratorSetup; import de.monticore.generating.templateengine.GlobalExtensionManagement; import de.monticore.generating.templateengine.TemplateController; import de.monticore.generating.templateengine.TemplateHookPoint; import de.monticore.runtime.junit.MCAssertions; - +import de.se_rwth.commons.logging.Log; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaFileObject; +import javax.tools.ToolProvider; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * Tests the decorators configured by CD2Java. The @@ -19,6 +30,144 @@ */ public class DefaultCD2PojoDecoratorTest extends AbstractDecoratorTest { + @ParameterizedTest + @ValueSource(booleans = { false, true }) + public void testReservedRolesAcrossCDs(boolean reverseOrder) throws Exception { + String packageName = "crosscdroles" + reverseOrder; + var base = CD4CodeMill.parser().parse_String("package " + packageName + + "; classdiagram BaseModel { public class Base { protected int class_; } }").orElseThrow(); + var target = CD4CodeMill.parser().parse_String(""" + package %s; + classdiagram TargetModel { + public class A extends %s.BaseModel.Base {} + public class Class {} + public association A -> Class; + } + """.formatted(packageName, packageName)).orElseThrow(); + var child = CD4CodeMill.parser().parse_String("package " + packageName + + "; classdiagram ChildModel { public class Child extends " + packageName + + ".TargetModel.A { protected int class__; } }").orElseThrow(); + var asts = reverseOrder ? List.of(child, target, base) : List.of(base, target, child); + tool.trafoBeforeSymtab(asts); + tool.initializeSymbolTable(false, true); + asts.forEach(ast -> tool.createSymbolTable(ast)); + asts.forEach(tool::completeSymbolTable); + outputDir = new File(outputDir, "crossCDRoles" + reverseOrder); + var glex = new GlobalExtensionManagement(); + var setup = tool.newConfiguredGeneratorSetup(getAdditionalTemplatesPath(), getHandWrittenPath(), + outputDir.getAbsolutePath(), glex); + tool.decorateAndGenerate(glex, config -> initializeDecConf(glex, config, setup), setup, + () -> tool.initDecoratedGlobalScope(false), decorated -> {}, asts); + var owner = target.getCDDefinition().getCDClassesList().get(0).getSymbol(); + Assertions.assertEquals(1, owner.getFieldList("class___").size()); + Assertions.assertEquals(1, owner.getCDRoleList("class___").size()); + Assertions.assertTrue(owner.getCDRoleList("class").isEmpty()); + MCAssertions.assertNoFindings(); + compileGeneratedSources(); + } + + @Test + public void testRoleNormalizationUpdatesCachedNamesAndIsIdempotent() throws Exception { + var ast = CD4CodeMill.parser().parse_String( + "classdiagram CachedRoles { class A {} class Class {} association A -> Class; }") + .orElseThrow(); + tool.trafoBeforeSymtab(List.of(ast)); + tool.initializeSymbolTable(false, true); + tool.createSymbolTable(ast); + tool.completeSymbolTable(ast); + var owner = ast.getCDDefinition().getCDClassesList().get(0).getSymbol(); + var role = owner.getCDRoleList("class").get(0); + String originalName = role.getFullName(); + var trafo = new JavaAssociationRoleNameTrafo(); + trafo.transform(List.of(ast)); + trafo.transform(List.of(ast)); + Assertions.assertEquals("class_", role.getName()); + Assertions.assertEquals(originalName + "_", role.getFullName()); + Assertions.assertSame(role, owner.getCDRoleList("class_").get(0)); + Assertions.assertTrue(owner.getCDRoleList("class").isEmpty()); + MCAssertions.assertNoFindings(); + } + + @Test + public void testReservedJavaRoleNames() throws Exception { + outputDir = new File(outputDir, "reservedRoles"); + // Includes reserved but unused keywords and literals, which are not legal identifiers either. + String keywords = "abstract assert boolean break byte case catch char class const continue " + + "default do double else enum extends final finally float for goto if implements import " + + "instanceof int interface long native new package private protected public return short " + + "static strictfp super switch synchronized this throw throws transient try void volatile " + + "while true false null"; + StringBuilder model = new StringBuilder( + "classdiagram ReservedRoles { <> public class A {} "); + for (String keyword : keywords.split(" ")) { + String type = Character.toUpperCase(keyword.charAt(0)) + keyword.substring(1); + model.append("public class ").append(type).append(" {} public association A -> ").append(type) + .append("; "); + } + model.append("public class Underscore {} public association A -> (_) Underscore; }"); + var ast = CD4CodeMill.parser().parse_String(model.toString()).orElseThrow(); + doTest(ast); + var owner = ast.getCDDefinition().getCDClassesList().get(0); + Assertions.assertEquals(1, owner.getSymbol().getFieldList("__").size()); + Assertions.assertEquals(1, owner.getSymbol().getCDRoleList("__").size()); + Assertions.assertTrue(owner.getSymbol().getCDRoleList("_").isEmpty()); + for (String keyword : keywords.split(" ")) { + Assertions.assertEquals(1, owner.getSymbol().getFieldList(keyword + "_").size()); + Assertions.assertEquals(1, owner.getSymbol().getCDRoleList(keyword + "_").size()); + Assertions.assertTrue(owner.getSymbol().getCDRoleList(keyword).isEmpty()); + } + MCAssertions.assertNoFindings(); + compileGeneratedSources(); + } + + @Test + public void testReservedRolesAvoidCollisions() throws Exception { + outputDir = new File(outputDir, "roleCollisions"); + var ast = CD4CodeMill.parser().parse_String(""" + classdiagram RoleCollisions { + public class Base { protected int public_; } + <> public class A extends Base { protected int public__; } + public class Child extends A { protected int public___; } + public class Public {} + public class Class {} + public class Other {} + public association A <-> Public; + public association A -> Class [0..1]; + public association A -> (public____) Other [*]; + public association A -> (class_) Other [*] {ordered}; + public association Public <-> Class [*]; + } + """).orElseThrow(); + doTest(ast); + var owner = ast.getCDDefinition().getCDClassesList().get(1); + for (String name : List.of("public_____", "class__", "public____", "class_")) { + Assertions.assertEquals(1, owner.getSymbol().getFieldList(name).size(), name); + Assertions.assertEquals(1, owner.getSymbol().getCDRoleList(name).size(), name); + } + MCAssertions.assertNoFindings(); + compileGeneratedSources(); + } + + protected void compileGeneratedSources() throws Exception { + var compiler = ToolProvider.getSystemJavaCompiler(); + Assertions.assertNotNull(compiler, "The generation regression test requires a JDK"); + var diagnostics = new DiagnosticCollector(); + Path classes = outputDir.toPath().resolve("compiled"); + Files.createDirectories(classes); + try ( + var files = Files.walk(outputDir.toPath()); + var manager = compiler.getStandardFileManager(diagnostics, null, null) + ) { + List sources = files.filter(path -> path.toString().endsWith(".java")).map(Path::toFile) + .collect(Collectors.toList()); + String loggingPath = new File(Log.class.getProtectionDomain().getCodeSource().getLocation() + .toURI()).getAbsolutePath(); + Assertions.assertTrue(compiler.getTask(null, manager, diagnostics, List.of("-d", classes + .toString(), "-classpath", loggingPath), null, manager.getJavaFileObjectsFromFiles( + sources)).call(), diagnostics.getDiagnostics().toString()); + } + } + @Test public void testAll() throws Exception { var opt = CD4CodeMill.parser().parse_String("classdiagram TestDefaultCD2Pojo {\n" diff --git a/cdlang/src/test/java/de/monticore/cdgen/cocos/CDAssociationReferenceUniqueTest.java b/cdlang/src/test/java/de/monticore/cdgen/cocos/CDAssociationReferenceUniqueTest.java new file mode 100644 index 000000000..06ee230d0 --- /dev/null +++ b/cdlang/src/test/java/de/monticore/cdgen/cocos/CDAssociationReferenceUniqueTest.java @@ -0,0 +1,69 @@ +/* (c) https://github.com/MontiCore/monticore */ +package de.monticore.cdgen.cocos; + +import de.monticore.cd4code._cocos.CD4CodeCoCoChecker; +import de.monticore.cd4code.cocos.AbstractJavaGenCoCoTest; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +public class CDAssociationReferenceUniqueTest extends AbstractJavaGenCoCoTest { + + @Override + protected CD4CodeCoCoChecker createChecker() { + CD4CodeCoCoChecker checker = new CD4CodeCoCoChecker(); + checker.addCoCo(new CDAssociationReferenceUnique()); + return checker; + } + + private static final String ERROR_CODE = "0xCDCE1"; + + @Test + public void testUniqueNames() throws IOException { + String model = "classdiagram UniqueAssocs {" + " class A; class B;" + " association A -> B;" + + " association A (l) -> (r) B;" + "}"; + runTest(model, false); + } + + @Test + public void testDuplicatesWithExplicitRoles() throws IOException { + String model = "classdiagram DuplicateAssocs {" + " class A; class B;" + + " association A -> B;" + " association A -> (b) B;" + "}"; + runTestForErrorCode(model, ERROR_CODE); + } + + @Test + public void testDuplicatesWithImplicitRoles() throws IOException { + String model = "classdiagram DuplicateAssocs {" + " class A; class B;" + + " association A -> B;" + " association A -> B;" + "}"; + runTestForErrorCode(model, ERROR_CODE); + } + + @Test + public void testUniqueExplicitRole() throws IOException { + String model = "classdiagram UniqueAssocs {" + " class A; class B;" + " association A -> B;" + + " association A -> (other) B;" + "}"; + runTest(model, false); + } + + @Test + public void testDuplicatesInReverse() throws IOException { + String model = "classdiagram DuplicateAssocs {" + " class A; class B;" + + " association A -> B;" + " association B <- A;" + "}"; + runTestForErrorCode(model, ERROR_CODE); + } + + @Test + public void testUniqueAssocName() throws IOException { + String model = "classdiagram UniqueAssocs {" + " class A; class B;" + + " association assoc1 A -> B;" + " association assoc2 A -> B;" + "}"; + runTest(model, false); + } + + @Test + public void testUniqueAssocNameWithSameRole() throws IOException { + String model = "classdiagram UniqueAssocs {" + " class A; class B;" + + " association assoc1 A -> (b) B;" + " association assoc2 A -> (b) B;" + "}"; + runTest(model, false); + } + +} diff --git a/cdtool/cdgradle/src/test/resources/cdgradle-it/src/main/cds/MyCD.cd b/cdtool/cdgradle/src/test/resources/cdgradle-it/src/main/cds/MyCD.cd index 24c8339ea..497167162 100644 --- a/cdtool/cdgradle/src/test/resources/cdgradle-it/src/main/cds/MyCD.cd +++ b/cdtool/cdgradle/src/test/resources/cdgradle-it/src/main/cds/MyCD.cd @@ -23,6 +23,10 @@ classdiagram MyCD { association A <-> B; + // Exercise Java keyword normalization in the isolated Gradle generator classloader. + public class Public {} + public association A -> Public; + // Test BiDirectional association generation class A1; class A2;