diff --git a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportJvmModelInferrerTest.java b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportJvmModelInferrerTest.java index 8df23a81a8..a541021e58 100644 --- a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportJvmModelInferrerTest.java +++ b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportJvmModelInferrerTest.java @@ -15,11 +15,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.Method; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; @@ -27,11 +25,8 @@ import org.eclipse.emf.ecore.EObject; import org.eclipse.emf.ecore.resource.Resource; import org.eclipse.emf.ecore.resource.impl.ResourceImpl; -import org.eclipse.emf.ecore.util.EcoreUtil; import org.eclipse.xtext.common.types.JvmDeclaredType; import org.eclipse.xtext.common.types.JvmGenericType; -import org.eclipse.xtext.resource.XtextResource; -import org.eclipse.xtext.resource.XtextResourceSet; import org.eclipse.xtext.xbase.jvmmodel.AbstractModelInferrer; import org.eclipse.xtext.xbase.jvmmodel.IJvmDeclaredTypeAcceptor; import org.eclipse.xtext.xbase.jvmmodel.JvmTypesBuilder; @@ -163,35 +158,15 @@ public void accept(final T type, final Procedure1 - * The inferrer resolves the grammar belonging to an export model by loading the {@code .xtext} file next to it. An empty - * resource is registered under that URI so the lookup resolves without demand-loading a grammar; the documentation under - * test does not depend on the grammar. Both resources have to share one resource set, hence the explicit set up here - * instead of the usual test utility call, which creates a new resource set per invocation. + * Parses the header-less export model and installs its derived state; the documentation under test does not depend on the + * grammar, see {@link ExportTestUtil#parseWithoutGrammar(URI, String)}. * * @return the loaded resource, never {@code null} * @throws IOException * if the model cannot be parsed */ private Resource parseHeaderlessModel() throws IOException { - final URI modelUri = getTargetSourceUri(MODEL_NAME + ".export"); - final URI grammarUri = modelUri.trimFileExtension().appendFileExtension("xtext"); - final Resource grammarResource = new ResourceImpl(grammarUri) { - @Override - public boolean isLoaded() { - return true; - } - }; - final XtextResourceSet resourceSet = getXtextTestUtil().getResourceSet(); - resourceSet.getResources().add(grammarResource); - resourceSet.getURIResourceMap().put(grammarUri, grammarResource); - - final XtextResource resource = (XtextResource) resourceSet.createResource(modelUri); - resourceSet.getResources().add(resource); - resource.load(new ByteArrayInputStream(MODEL_SOURCE.getBytes(StandardCharsets.UTF_8)), null); - EcoreUtil.resolveAll(resource); - return resource; + return getXtextTestUtil().parseWithoutGrammar(getTargetSourceUri(MODEL_NAME + ".export"), MODEL_SOURCE); } /** diff --git a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/resource/ExportResourceDescriptionManagerTest.java b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/resource/ExportResourceDescriptionManagerTest.java new file mode 100644 index 0000000000..b4b5144afb --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/export/resource/ExportResourceDescriptionManagerTest.java @@ -0,0 +1,165 @@ +/******************************************************************************* + * Copyright (c) 2026 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.xtext.export.resource; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; + +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.InternalEObject; +import org.eclipse.xtext.common.types.JvmGenericType; +import org.eclipse.xtext.common.types.TypesFactory; +import org.eclipse.xtext.naming.QualifiedName; +import org.eclipse.xtext.resource.EObjectDescription; +import org.eclipse.xtext.resource.IEObjectDescription; +import org.eclipse.xtext.resource.IReferenceDescription; +import org.eclipse.xtext.resource.IResourceDescription; +import org.eclipse.xtext.resource.XtextResource; +import org.eclipse.xtext.resource.impl.AbstractResourceDescription; +import org.eclipse.xtext.resource.impl.DefaultResourceDescriptionDelta; +import org.eclipse.xtext.resource.impl.ResourceDescriptionsData; +import org.junit.jupiter.api.Test; + +import com.avaloq.tools.ddk.xtext.test.export.util.ExportTestUtil; +import com.avaloq.tools.ddk.xtext.test.jupiter.AbstractXtextTest; + + +/** + * Tests that the classes named in {@code extension} declarations are dependencies of an export model: a change to such a + * class must make the model affected. + */ +@SuppressWarnings("nls") +public class ExportResourceDescriptionManagerTest extends AbstractXtextTest { + + /** Fully qualified name of the extension class; it need not exist, since the dependency is recorded by name. */ + private static final String EXTENSION_CLASS = "com.acme.export.util.NamingExtensions"; + + private static final String WITH_EXTENSION = "WithExtension"; + + private static final String ECORE_IMPORT = "import \"http://www.eclipse.org/emf/2002/Ecore\" as ecore\n\n"; + + private static final String ECLASS_EXPORT = "export ecore::EClass as name\n"; + + private static final String MODEL_WITH_EXTENSION = ECORE_IMPORT + "extension com::acme::^export::util::NamingExtensions\n\n" + ECLASS_EXPORT; + + private static final String MODEL_WITHOUT_EXTENSION = ECORE_IMPORT + ECLASS_EXPORT; + + /** Fully qualified names of the types named in the casts of {@link #MODEL_WITH_CAST}; they need not exist either. */ + private static final String CAST_TYPE = "com.acme.export.model.Node"; + + private static final String ELEMENT_TYPE = "com.acme.export.model.Item"; + + private static final String MODEL_WITH_CAST = ECORE_IMPORT + "export ecore::EClass as name {\n" + + " data node = (com::acme::^export::model::Node) this, items = (List[com::acme::^export::model::Item]) this;\n}\n"; + + @Override + protected ExportTestUtil getXtextTestUtil() { + return ExportTestUtil.getInstance(); + } + + /** + * This test builds its sources in memory and has no test source file. {@inheritDoc} + */ + @Override + protected String getTestSourceFileName() { + return null; + } + + @Test + public void testExtensionClassIsImportedName() throws IOException { + final XtextResource resource = parse(WITH_EXTENSION, MODEL_WITH_EXTENSION); + final IResourceDescription description = resource.getResourceServiceProvider().getResourceDescriptionManager().getResourceDescription(resource); + assertTrue(imports(description, EXTENSION_CLASS), "The extension class must be an imported name of the export model."); + } + + @Test + public void testChangedExpressionTypeAffectsModel() throws IOException { + final XtextResource resource = parse("WithCast", MODEL_WITH_CAST); + assertTrue(resource.getErrors().isEmpty(), () -> "The model must parse: " + resource.getErrors()); + final IResourceDescription.Manager manager = resource.getResourceServiceProvider().getResourceDescriptionManager(); + final IResourceDescription candidate = manager.getResourceDescription(resource); + assertTrue(imports(candidate, CAST_TYPE), "A type named in a cast must be an imported name of the export model."); + assertTrue(imports(candidate, ELEMENT_TYPE), "The element type of a collection type must be an imported name of the export model."); + assertTrue(manager.isAffected(List.of(javaTypeChange(CAST_TYPE)), candidate, new ResourceDescriptionsData(List.of(candidate))), + "A change to a type named in an expression must affect the export model."); + } + + @Test + public void testChangedExtensionClassAffectsModel() throws IOException { + final XtextResource resource = parse(WITH_EXTENSION, MODEL_WITH_EXTENSION); + final IResourceDescription.Manager manager = resource.getResourceServiceProvider().getResourceDescriptionManager(); + assertInstanceOf(ExportResourceDescriptionManager.class, manager, "Export must bind its own resource description manager."); + final IResourceDescription candidate = manager.getResourceDescription(resource); + assertTrue(manager.isAffected(List.of(javaTypeChange(EXTENSION_CLASS)), candidate, new ResourceDescriptionsData(List.of(candidate))), + "A change to an extension class must affect the export model."); + } + + @Test + public void testChangedClassDoesNotAffectModelWithoutExtension() throws IOException { + final XtextResource resource = parse("WithoutExtension", MODEL_WITHOUT_EXTENSION); + final IResourceDescription.Manager manager = resource.getResourceServiceProvider().getResourceDescriptionManager(); + final IResourceDescription candidate = manager.getResourceDescription(resource); + assertFalse(manager.isAffected(List.of(javaTypeChange(EXTENSION_CLASS)), candidate, new ResourceDescriptionsData(List.of(candidate))), + "A class the export model does not declare must not affect it."); + } + + + /** + * Returns a change of the given Java type shaped like the ones JDT reports: the resource URI {@code java:/Objects/}, + * exporting one object named by the fully qualified name. + */ + private static IResourceDescription.Delta javaTypeChange(final String fqn) { + final URI uri = URI.createURI("java:/Objects/" + fqn); + final JvmGenericType type = TypesFactory.eINSTANCE.createJvmGenericType(); + ((InternalEObject) type).eSetProxyURI(uri.appendFragment(fqn)); + final IEObjectDescription exported = EObjectDescription.create(QualifiedName.create(fqn.split("\\.")), type); + final IResourceDescription changed = new AbstractResourceDescription() { + @Override + protected List computeExportedObjects() { + return List.of(exported); + } + + @Override + public Iterable getImportedNames() { + return List.of(); + } + + @Override + public Iterable getReferenceDescriptions() { + return List.of(); + } + + @Override + public URI getURI() { + return uri; + } + }; + return new DefaultResourceDescriptionDelta(null, changed); + } + + private static boolean imports(final IResourceDescription description, final String fqn) { + final QualifiedName expected = QualifiedName.create(fqn.split("\\.")).toLowerCase(); + for (final QualifiedName name : description.getImportedNames()) { + if (expected.equals(name)) { + return true; + } + } + return false; + } + + private XtextResource parse(final String name, final String source) throws IOException { + return getXtextTestUtil().parseWithoutGrammar(getTargetSourceUri(name + ".export"), source); + } +} diff --git a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/ExportTestSuite.java b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/ExportTestSuite.java index 92cf96062e..6eafeed481 100644 --- a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/ExportTestSuite.java +++ b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/ExportTestSuite.java @@ -15,6 +15,7 @@ import com.avaloq.tools.ddk.xtext.export.formatting.ExportFormattingTest; import com.avaloq.tools.ddk.xtext.export.jvmmodel.ExportJvmModelInferrerTest; +import com.avaloq.tools.ddk.xtext.export.resource.ExportResourceDescriptionManagerTest; import com.avaloq.tools.ddk.xtext.export.scoping.ExportScopingTest; import com.avaloq.tools.ddk.xtext.export.validation.ExportValidationOkTest; import com.avaloq.tools.ddk.xtext.export.validation.ExportValidationTest; @@ -24,6 +25,6 @@ * Empty class serving only as holder for JUnit 5 suite annotations. */ @Suite -@SelectClasses({ExportFormattingTest.class, ExportValidationTest.class, ExportValidationOkTest.class, ExportScopingTest.class, ExportJvmModelInferrerTest.class}) +@SelectClasses({ExportFormattingTest.class, ExportValidationTest.class, ExportValidationOkTest.class, ExportScopingTest.class, ExportJvmModelInferrerTest.class, ExportResourceDescriptionManagerTest.class}) public class ExportTestSuite { } diff --git a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/util/ExportTestUtil.java b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/util/ExportTestUtil.java index 260bb5aef9..f1d7eacf01 100644 --- a/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/util/ExportTestUtil.java +++ b/com.avaloq.tools.ddk.xtext.export.test/src/com/avaloq/tools/ddk/xtext/test/export/util/ExportTestUtil.java @@ -10,6 +10,17 @@ *******************************************************************************/ package com.avaloq.tools.ddk.xtext.test.export.util; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.emf.ecore.resource.impl.ResourceImpl; +import org.eclipse.emf.ecore.util.EcoreUtil; +import org.eclipse.xtext.resource.XtextResource; +import org.eclipse.xtext.resource.XtextResourceSet; + import com.avaloq.tools.ddk.xtext.export.ExportConstants; import com.avaloq.tools.ddk.xtext.export.ui.internal.ExportActivator; import com.avaloq.tools.ddk.xtext.test.jupiter.AbstractXtextTestUtil; @@ -42,4 +53,39 @@ protected Injector getInjector() { return ExportActivator.getInstance().getInjector(ExportConstants.GRAMMAR); } + /** + * Parses an export model from the given source and installs its derived state. + *

+ * The inferrer resolves the grammar belonging to an export model by loading the {@code .xtext} file next to it. An empty + * resource is registered under that URI so the lookup resolves without demand-loading a grammar; the model must therefore + * not depend on its grammar. Both resources share one new resource set. + *

+ * + * @param modelUri + * the URI of the export model, must not be {@code null} + * @param source + * the source of the export model, must not be {@code null} + * @return the loaded resource, never {@code null} + * @throws IOException + * if the model cannot be parsed + */ + public XtextResource parseWithoutGrammar(final URI modelUri, final String source) throws IOException { + final URI grammarUri = modelUri.trimFileExtension().appendFileExtension("xtext"); //$NON-NLS-1$ + final Resource grammarResource = new ResourceImpl(grammarUri) { + @Override + public boolean isLoaded() { + return true; + } + }; + final XtextResourceSet resourceSet = getResourceSet(); + resourceSet.getResources().add(grammarResource); + resourceSet.getURIResourceMap().put(grammarUri, grammarResource); + + final XtextResource resource = (XtextResource) resourceSet.createResource(modelUri); + resourceSet.getResources().add(resource); + resource.load(new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8)), null); + EcoreUtil.resolveAll(resource); + return resource; + } + } diff --git a/com.avaloq.tools.ddk.xtext.export/META-INF/MANIFEST.MF b/com.avaloq.tools.ddk.xtext.export/META-INF/MANIFEST.MF index 2f1495a0a6..f60522c69a 100644 --- a/com.avaloq.tools.ddk.xtext.export/META-INF/MANIFEST.MF +++ b/com.avaloq.tools.ddk.xtext.export/META-INF/MANIFEST.MF @@ -38,6 +38,7 @@ Export-Package: com.avaloq.tools.ddk.xtext.export, com.avaloq.tools.ddk.xtext.export.formatting, com.avaloq.tools.ddk.xtext.export.serializer, com.avaloq.tools.ddk.xtext.export.generator, - com.avaloq.tools.ddk.xtext.export.jvmmodel + com.avaloq.tools.ddk.xtext.export.jvmmodel, + com.avaloq.tools.ddk.xtext.export.resource Automatic-Module-Name: com.avaloq.tools.ddk.xtext.export Import-Package: org.apache.log4j diff --git a/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/ExportRuntimeModule.java b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/ExportRuntimeModule.java index 3cb93e319e..15dcb27d8b 100644 --- a/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/ExportRuntimeModule.java +++ b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/ExportRuntimeModule.java @@ -13,12 +13,14 @@ import org.eclipse.xtext.formatting.ILineSeparatorInformation; import org.eclipse.xtext.generator.IOutputConfigurationProvider; import org.eclipse.xtext.naming.IQualifiedNameConverter; +import org.eclipse.xtext.resource.IResourceDescription; import org.eclipse.xtext.xbase.compiler.JvmModelGenerator; import com.avaloq.tools.ddk.xtext.export.conversion.ExportValueConverterService; import com.avaloq.tools.ddk.xtext.export.generator.ExportJvmModelGenerator; import com.avaloq.tools.ddk.xtext.export.generator.ExportOutputConfigurationProvider; import com.avaloq.tools.ddk.xtext.export.naming.ExportQualifiedNameConverter; +import com.avaloq.tools.ddk.xtext.export.resource.ExportResourceDescriptionManager; import com.avaloq.tools.ddk.xtext.formatting.LfLineSeparatorInformation; @@ -70,6 +72,16 @@ public Class bindJvmModelGenerator() { return ExportJvmModelGenerator.class; } + /** + * Binds the description manager which records the Java types the model names as its dependencies. + * + * @return the export specific resource description manager + */ + @Override + public Class bindIResourceDescription$Manager() { // NOPMD + return ExportResourceDescriptionManager.class; + } + @Override public Class bindIFormatter() { return com.avaloq.tools.ddk.xtext.export.formatting.ExportFormatter.class; diff --git a/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/resource/ExportResourceDescriptionManager.java b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/resource/ExportResourceDescriptionManager.java new file mode 100644 index 0000000000..cd141fc518 --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/resource/ExportResourceDescriptionManager.java @@ -0,0 +1,46 @@ +/******************************************************************************* + * Copyright (c) 2026 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.xtext.export.resource; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.xtext.naming.QualifiedName; + +import com.avaloq.tools.ddk.xtext.export.export.ExportModel; +import com.avaloq.tools.ddk.xtext.export.export.Extension; +import com.avaloq.tools.ddk.xtext.expression.resource.AbstractExpressionModelResourceDescriptionManager; +import com.google.inject.Singleton; + + +/** + * Resource description manager for export models, whose {@code extension} classes and expression types are dependencies. + */ +@Singleton +public class ExportResourceDescriptionManager extends AbstractExpressionModelResourceDescriptionManager { + + @Override + protected Set getNamedJavaTypes(final Resource resource) { + if (resource.getContents().isEmpty() || !(resource.getContents().get(0) instanceof ExportModel model)) { + return Set.of(); + } + final Set names = new LinkedHashSet<>(); + for (final Extension declaration : model.getExtensions()) { + if (declaration.getExtension() != null) { + names.add(javaName(List.of(declaration.getExtension().split("::")))); //$NON-NLS-1$ + } + } + addExpressionTypeNames(model, names); + return names; + } +} diff --git a/com.avaloq.tools.ddk.xtext.expression/META-INF/MANIFEST.MF b/com.avaloq.tools.ddk.xtext.expression/META-INF/MANIFEST.MF index fcdb8562b9..5dcc4ad844 100644 --- a/com.avaloq.tools.ddk.xtext.expression/META-INF/MANIFEST.MF +++ b/com.avaloq.tools.ddk.xtext.expression/META-INF/MANIFEST.MF @@ -36,6 +36,7 @@ Export-Package: com.avaloq.tools.ddk.xtext.expression, com.avaloq.tools.ddk.xtext.expression.generator, com.avaloq.tools.ddk.xtext.expression.parser.antlr, com.avaloq.tools.ddk.xtext.expression.parser.antlr.internal, + com.avaloq.tools.ddk.xtext.expression.resource, com.avaloq.tools.ddk.xtext.expression.scoping, com.avaloq.tools.ddk.xtext.expression.serializer, com.avaloq.tools.ddk.xtext.expression.services, diff --git a/com.avaloq.tools.ddk.xtext.expression/src/com/avaloq/tools/ddk/xtext/expression/resource/AbstractExpressionModelResourceDescriptionManager.java b/com.avaloq.tools.ddk.xtext.expression/src/com/avaloq/tools/ddk/xtext/expression/resource/AbstractExpressionModelResourceDescriptionManager.java new file mode 100644 index 0000000000..fd68c6292b --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.expression/src/com/avaloq/tools/ddk/xtext/expression/resource/AbstractExpressionModelResourceDescriptionManager.java @@ -0,0 +1,90 @@ +/******************************************************************************* + * Copyright (c) 2026 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.xtext.expression.resource; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.xtext.naming.QualifiedName; +import org.eclipse.xtext.resource.IDefaultResourceDescriptionStrategy; +import org.eclipse.xtext.resource.IResourceDescription; +import org.eclipse.xtext.xbase.resource.XbaseResourceDescription; +import org.eclipse.xtext.xbase.resource.XbaseResourceDescriptionManager; + +import com.avaloq.tools.ddk.xtext.expression.expression.Identifier; + + +/** + * Resource description manager whose descriptions also import the Java types a model names but only resolves while its Java + * is generated, so that a change to such a type re-queues the model. + */ +public abstract class AbstractExpressionModelResourceDescriptionManager extends XbaseResourceDescriptionManager { + + @Override + protected IResourceDescription createResourceDescription(final Resource resource, final IDefaultResourceDescriptionStrategy strategy) { + return new XbaseResourceDescription(resource, strategy, getCache(), typeResolver, nameConverter) { + private Set importedNames; + + @Override + public Iterable getImportedNames() { + if (importedNames == null) { + final Set names = new LinkedHashSet<>(); + super.getImportedNames().forEach(names::add); + names.addAll(getNamedJavaTypes(resource)); + importedNames = Collections.unmodifiableSet(names); + } + return importedNames; + } + }; + } + + /** + * Returns the names, as created by {@link #javaName(List)}, of the Java types the model in the given resource names. + * + * @param resource + * the resource, must not be {@code null} + * @return the names, never {@code null} + */ + protected abstract Set getNamedJavaTypes(Resource resource); + + /** + * Adds the names of the qualified types named in the expressions below the given element, such as casts and the receivers + * of static calls. + * + * @param root + * the element whose contents are searched, must not be {@code null} + * @param names + * the set to add the names to, must not be {@code null} + */ + protected static void addExpressionTypeNames(final EObject root, final Set names) { + root.eAllContents().forEachRemaining(content -> { + if (content instanceof Identifier identifier && identifier.getId().size() > 1) { + names.add(javaName(identifier.getId())); + } + }); + } + + /** + * Returns the name of a Java type in the form in which changed Java types are reported: dot-separated segments without + * escape characters, in lower case. + * + * @param segments + * the segments of the qualified type name, must not be {@code null} + * @return the name, never {@code null} + */ + protected static QualifiedName javaName(final List segments) { + return QualifiedName.create(segments.stream().map(segment -> segment.startsWith("^") ? segment.substring(1) : segment).toList()).toLowerCase(); //$NON-NLS-1$ + } +} diff --git a/com.avaloq.tools.ddk.xtext.generator.test/META-INF/MANIFEST.MF b/com.avaloq.tools.ddk.xtext.generator.test/META-INF/MANIFEST.MF index 9c2afb4d97..2fb805d12c 100644 --- a/com.avaloq.tools.ddk.xtext.generator.test/META-INF/MANIFEST.MF +++ b/com.avaloq.tools.ddk.xtext.generator.test/META-INF/MANIFEST.MF @@ -14,6 +14,7 @@ Require-Bundle: com.avaloq.tools.ddk.test.core, com.avaloq.tools.ddk.xtext.scope, com.avaloq.tools.ddk.xtext.test.core, org.eclipse.xtext, + org.eclipse.xtext.common.types, org.mockito.mockito-core, com.avaloq.tools.ddk.xtext.ui, org.eclipse.xtext.xtext.generator, diff --git a/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/expression/ScopeResourceDescriptionManagerTest.java b/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/expression/ScopeResourceDescriptionManagerTest.java new file mode 100644 index 0000000000..0c870d9b3d --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/expression/ScopeResourceDescriptionManagerTest.java @@ -0,0 +1,170 @@ +/******************************************************************************* + * Copyright (c) 2026 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.xtext.generator.expression; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.InternalEObject; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.emf.ecore.util.EcoreUtil; +import org.eclipse.xtext.common.types.JvmGenericType; +import org.eclipse.xtext.common.types.TypesFactory; +import org.eclipse.xtext.naming.QualifiedName; +import org.eclipse.xtext.resource.EObjectDescription; +import org.eclipse.xtext.resource.IEObjectDescription; +import org.eclipse.xtext.resource.IReferenceDescription; +import org.eclipse.xtext.resource.IResourceDescription; +import org.eclipse.xtext.resource.IResourceFactory; +import org.eclipse.xtext.resource.XtextResourceSet; +import org.eclipse.xtext.resource.impl.AbstractResourceDescription; +import org.eclipse.xtext.resource.impl.DefaultResourceDescriptionDelta; +import org.eclipse.xtext.resource.impl.ResourceDescriptionsData; +import org.junit.jupiter.api.Test; + +import com.avaloq.tools.ddk.xtext.scope.ScopeStandaloneSetup; +import com.google.inject.Injector; + + +/** + * Tests that the Java types a scope model and the scope models it includes name are imported names of the model, so that a + * change to such a type affects it. + */ +@SuppressWarnings("nls") +public class ScopeResourceDescriptionManagerTest { + + private static final String ECORE_IMPORT = "\n\nimport \"http://www.eclipse.org/emf/2002/Ecore\" as ecore\n\n"; + + private static final String BASE = "scoping test.Base" + ECORE_IMPORT + "extension com::acme::util::BaseExtensions\n\n" + + "inject com.acme.util.Helper as helper\n\n" + "naming {\n ecore::EClass = factory com::acme::util::Names.nameOf(this);\n}\n"; + + private static final String MAIN = "scoping test.Main with test.Base" + ECORE_IMPORT + "extension com::acme::^scope::MainExtensions\n"; + + private static final String PLAIN = "scoping test.Plain" + ECORE_IMPORT; + + private static final String MAIN_EXTENSION_CLASS = "com.acme.scope.MainExtensions"; + + private static final String INCLUDED_EXTENSION_CLASS = "com.acme.util.BaseExtensions"; + + private static final String INJECTED_TYPE = "com.acme.util.Helper"; + + private static final String FACTORY_CLASS = "com.acme.util.Names"; + + private static final String NOT_NAMED = "A type the model does not name must not be an imported name."; + + /** Resources are created with this injector rather than through the global registry, which the shared test runtime uses. */ + private static final Injector INJECTOR = new ScopeStandaloneSetup().createInjector(); + + @Test + public void testOwnAndIncludedDependenciesAreImportedNames() throws IOException { + final IResourceDescription main = describe(loadAll().main()); + assertTrue(imports(main, MAIN_EXTENSION_CLASS), "The model's own extension class must be an imported name."); + assertTrue(imports(main, INCLUDED_EXTENSION_CLASS), "The included model's extension class must be an imported name."); + assertTrue(imports(main, INJECTED_TYPE), "A type injected by an included model must be an imported name."); + assertTrue(imports(main, FACTORY_CLASS), "A factory type named in an included model must be an imported name."); + } + + @Test + public void testChangedTypesAffectModel() throws IOException { + final IResourceDescription main = describe(loadAll().main()); + assertTrue(isAffected(MAIN_EXTENSION_CLASS, main), "A change to the model's own extension class must affect it."); + assertTrue(isAffected(INJECTED_TYPE, main), "A change to a type only an included model names must affect the model."); + } + + @Test + public void testModelWithoutDependenciesIsNotAffected() throws IOException { + final IResourceDescription plain = describe(loadAll().plain()); + assertFalse(imports(plain, MAIN_EXTENSION_CLASS), NOT_NAMED); + assertFalse(imports(plain, FACTORY_CLASS), NOT_NAMED); + assertFalse(isAffected(INCLUDED_EXTENSION_CLASS, plain), "A change to a type named only by other models must not affect the model."); + } + + /** The three models, loaded into one resource set so that names leaking between models would be detected. */ + private record Models(Resource main, Resource plain) { + } + + private static Models loadAll() throws IOException { + final XtextResourceSet resourceSet = INJECTOR.getInstance(XtextResourceSet.class); + resourceSet.getResourceFactoryRegistry().getExtensionToFactoryMap().put("scope", INJECTOR.getInstance(IResourceFactory.class)); + final Resource base = load(resourceSet, "Base", BASE); + final Resource main = load(resourceSet, "Main", MAIN); + final Resource plain = load(resourceSet, "Plain", PLAIN); + EcoreUtil.resolveAll(resourceSet); + for (final Resource resource : List.of(base, main, plain)) { + assertTrue(resource.getErrors().isEmpty(), () -> "The model must parse: " + resource.getURI() + " " + resource.getErrors()); + } + return new Models(main, plain); + } + + private static Resource load(final XtextResourceSet resourceSet, final String name, final String source) throws IOException { + final Resource resource = resourceSet.createResource(URI.createURI("memory:/" + name + ".scope")); + resource.load(new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8)), null); + return resource; + } + + private static IResourceDescription describe(final Resource resource) { + return INJECTOR.getInstance(IResourceDescription.Manager.class).getResourceDescription(resource); + } + + private static boolean isAffected(final String fqn, final IResourceDescription candidate) { + return INJECTOR.getInstance(IResourceDescription.Manager.class).isAffected(List.of(javaTypeChange(fqn)), candidate, + new ResourceDescriptionsData(List.of(candidate))); + } + + /** + * Returns a change of the given Java type shaped like the ones JDT reports: the resource URI {@code java:/Objects/}, + * exporting one object named by the dot-separated fully qualified name. + */ + private static IResourceDescription.Delta javaTypeChange(final String fqn) { + final URI uri = URI.createURI("java:/Objects/" + fqn); + final JvmGenericType type = TypesFactory.eINSTANCE.createJvmGenericType(); + ((InternalEObject) type).eSetProxyURI(uri.appendFragment(fqn)); + final IEObjectDescription exported = EObjectDescription.create(QualifiedName.create(fqn.split("\\.")), type); + final IResourceDescription changed = new AbstractResourceDescription() { + @Override + protected List computeExportedObjects() { + return List.of(exported); + } + + @Override + public Iterable getImportedNames() { + return List.of(); + } + + @Override + public Iterable getReferenceDescriptions() { + return List.of(); + } + + @Override + public URI getURI() { + return uri; + } + }; + return new DefaultResourceDescriptionDelta(null, changed); + } + + private static boolean imports(final IResourceDescription description, final String fqn) { + final QualifiedName expected = QualifiedName.create(fqn.split("\\.")).toLowerCase(); + for (final QualifiedName name : description.getImportedNames()) { + if (expected.equals(name)) { + return true; + } + } + return false; + } +} diff --git a/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/test/generator/GeneratorTestSuite.java b/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/test/generator/GeneratorTestSuite.java index 64074a5e47..cbaeea987b 100644 --- a/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/test/generator/GeneratorTestSuite.java +++ b/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/test/generator/GeneratorTestSuite.java @@ -16,6 +16,7 @@ import com.avaloq.tools.ddk.xtext.generator.expression.ExportExpressionCodeGenerationTest; import com.avaloq.tools.ddk.xtext.generator.expression.ExpressionsExtentionsTest; import com.avaloq.tools.ddk.xtext.generator.expression.ScopeExpressionCodeGenerationTest; +import com.avaloq.tools.ddk.xtext.generator.expression.ScopeResourceDescriptionManagerTest; import com.avaloq.tools.ddk.xtext.generator.test.util.EClassComparatorTest; import com.avaloq.tools.ddk.xtext.generator.test.util.GraphTest; import com.avaloq.tools.ddk.xtext.generator.xbase.test.XbaseGeneratorFragmentTest; @@ -32,6 +33,7 @@ LineEndingDeterminismTest.class, ExpressionsExtentionsTest.class, ScopeExpressionCodeGenerationTest.class, + ScopeResourceDescriptionManagerTest.class, EClassComparatorTest.class, GraphTest.class, XbaseGeneratorFragmentTest.class diff --git a/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/ScopeRuntimeModule.java b/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/ScopeRuntimeModule.java index 56b0ddd137..015be9073a 100644 --- a/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/ScopeRuntimeModule.java +++ b/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/ScopeRuntimeModule.java @@ -16,12 +16,14 @@ import org.eclipse.xtext.naming.IQualifiedNameConverter; import org.eclipse.xtext.resource.IDefaultResourceDescriptionStrategy; import org.eclipse.xtext.resource.ILocationInFileProvider; +import org.eclipse.xtext.resource.IResourceDescription; import com.avaloq.tools.ddk.xtext.formatting.LfLineSeparatorInformation; import com.avaloq.tools.ddk.xtext.scope.conversion.ScopeValueConverterService; import com.avaloq.tools.ddk.xtext.scope.linking.ScopeLinkingService; import com.avaloq.tools.ddk.xtext.scope.naming.ScopeQualifiedNameConverter; import com.avaloq.tools.ddk.xtext.scope.resource.ScopeLocationInFileProvider; +import com.avaloq.tools.ddk.xtext.scope.resource.ScopeResourceDescriptionManager; import com.avaloq.tools.ddk.xtext.scope.resource.ScopeResourceDescriptionStrategy; @@ -85,6 +87,16 @@ public Class bindIDefaultResource return ScopeResourceDescriptionStrategy.class; } + /** + * Binds the description manager which records the Java types the model names as its dependencies. + * + * @return the scope specific resource description manager + */ + @Override + public Class bindIResourceDescription$Manager() { // NOPMD + return ScopeResourceDescriptionManager.class; + } + @Override public Class bindIFormatter() { return com.avaloq.tools.ddk.xtext.scope.formatting.ScopeFormatter.class; diff --git a/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/resource/ScopeResourceDescriptionManager.java b/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/resource/ScopeResourceDescriptionManager.java new file mode 100644 index 0000000000..7dfc41c370 --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/resource/ScopeResourceDescriptionManager.java @@ -0,0 +1,76 @@ +/******************************************************************************* + * Copyright (c) 2026 Avaloq Group AG and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Avaloq Group AG - initial API and implementation + *******************************************************************************/ +package com.avaloq.tools.ddk.xtext.scope.resource; + +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.xtext.naming.QualifiedName; + +import com.avaloq.tools.ddk.xtext.expression.resource.AbstractExpressionModelResourceDescriptionManager; +import com.avaloq.tools.ddk.xtext.scope.scope.Extension; +import com.avaloq.tools.ddk.xtext.scope.scope.Injection; +import com.avaloq.tools.ddk.xtext.scope.scope.ScopeModel; +import com.google.inject.Singleton; + + +/** + * Resource description manager for scope models, whose {@code extension} classes, injected types and expression types, and + * those of the models they include, are dependencies. + */ +@Singleton +public class ScopeResourceDescriptionManager extends AbstractExpressionModelResourceDescriptionManager { + + @Override + protected Set getNamedJavaTypes(final Resource resource) { + if (resource.getContents().isEmpty() || !(resource.getContents().get(0) instanceof ScopeModel model)) { + return Set.of(); + } + final Set names = new LinkedHashSet<>(); + addNamedJavaTypes(model, new HashSet<>(), names); + return names; + } + + /** + * Adds the Java types named by the given scope model and, transitively, by the models it includes. + * + * @param model + * the scope model, must not be {@code null} + * @param visited + * the models already visited, which guards against include cycles, must not be {@code null} + * @param names + * the set to add the names to, must not be {@code null} + */ + private static void addNamedJavaTypes(final ScopeModel model, final Set visited, final Set names) { + if (!visited.add(model)) { + return; + } + for (final Extension declaration : model.getExtensions()) { + if (declaration.getExtension() != null) { + names.add(javaName(List.of(declaration.getExtension().split("::")))); //$NON-NLS-1$ + } + } + for (final Injection injection : model.getInjections()) { + if (injection.getType() != null) { + names.add(javaName(List.of(injection.getType().split("\\.")))); //$NON-NLS-1$ + } + } + addExpressionTypeNames(model, names); + for (final ScopeModel included : model.getIncludedScopes()) { + if (!included.eIsProxy()) { + addNamedJavaTypes(included, visited, names); + } + } + } +}