diff --git a/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/generator/ExportJvmModelGenerator.java b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/generator/ExportJvmModelGenerator.java index 15108052e..818ad0945 100644 --- a/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/generator/ExportJvmModelGenerator.java +++ b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/generator/ExportJvmModelGenerator.java @@ -12,9 +12,11 @@ import java.util.List; +import org.eclipse.emf.ecore.resource.Resource; import org.eclipse.xtext.common.types.JvmDeclaredType; import org.eclipse.xtext.common.types.JvmGenericType; import org.eclipse.xtext.common.types.JvmMember; +import org.eclipse.xtext.generator.IFileSystemAccess; import org.eclipse.xtext.generator.trace.LocationData; import org.eclipse.xtext.nodemodel.INode; import org.eclipse.xtext.util.ITextRegionWithLineInformation; @@ -23,6 +25,7 @@ import org.eclipse.xtext.xbase.compiler.TreeAppendableUtil; import org.eclipse.xtext.xbase.compiler.output.ITreeAppendable; +import com.avaloq.tools.ddk.xtext.expression.generator.GeneratorSupport; import com.google.inject.Inject; @@ -38,6 +41,12 @@ * the sole interface contributed by * {@link com.avaloq.tools.ddk.xtext.export.jvmmodel.ExportJvmModelInferrer}, so classes keep the * default spacing. + *

+ * All types of a resource are generated with one project resource loader: the inferrer renders every + * method body and field initializer during emission within + * {@link GeneratorSupport#executeWithProjectResourceLoader(org.eclipse.core.resources.IProject, Runnable) + * GeneratorSupport#executeWithProjectResourceLoader}, and establishing that resource loader once for the + * whole resource lets those nested calls reuse it instead of building a class loader for every body. */ public class ExportJvmModelGenerator extends JvmModelGenerator { @@ -50,6 +59,18 @@ public class ExportJvmModelGenerator extends JvmModelGenerator { @Inject private TreeAppendableUtil treeAppendableUtil; + @Inject + private GeneratorSupport generatorSupport; + + @Override + public void doGenerate(final Resource input, final IFileSystemAccess fsa) { + if (input.getContents().stream().anyMatch(JvmDeclaredType.class::isInstance)) { + generatorSupport.executeWithProjectResourceLoaderOf(input, () -> super.doGenerate(input, fsa)); + } else { + super.doGenerate(input, fsa); // no inferred type, hence no body to render: do not build a class loader + } + } + /** * {@inheritDoc} *

diff --git a/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/generator/ExportModelTypeResolver.java b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/generator/ExportModelTypeResolver.java index 60dfdb0cd..fa1ab56fb 100644 --- a/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/generator/ExportModelTypeResolver.java +++ b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/generator/ExportModelTypeResolver.java @@ -26,6 +26,7 @@ import com.avaloq.tools.ddk.xtext.export.export.ExportModel; import com.avaloq.tools.ddk.xtext.export.export.ExportPackage; import com.avaloq.tools.ddk.xtext.export.export.Import; +import com.avaloq.tools.ddk.xtext.expression.generator.GeneratorSupport; import com.avaloq.tools.ddk.xtext.util.EObjectUtil; @@ -146,7 +147,30 @@ private EClassifier findClassifier(final String name) { */ public static ExportModelTypeResolver forElement(final EObject element) { final ExportModel exportModel = EcoreUtil2.getContainerOfType(element, ExportModel.class); - return exportModel == null ? null : new ExportModelTypeResolver(exportModel); + return exportModel == null ? null : forModel(exportModel); + } + + /** + * Returns the resolver for the given export model. The resolver is immutable and determined by the model and the EPackages + * visible to it, so within a generation pass it is {@link GeneratorSupport#memoize(Object, java.util.function.Supplier) + * memoized} and shared by all expressions of the model instead of being rebuilt - which resolves every visible EPackage - + * for each of them. + * + * @param model + * the export model, must not be {@code null} + * @return the resolver, never {@code null} + */ + public static ExportModelTypeResolver forModel(final ExportModel model) { + return GeneratorSupport.memoize(new Key(model), () -> new ExportModelTypeResolver(model)); + } + + /** + * Key under which the resolver of an export model is memoized. + * + * @param model + * the export model + */ + private record Key(ExportModel model) { } } diff --git a/com.avaloq.tools.ddk.xtext.expression/src/com/avaloq/tools/ddk/xtext/expression/generator/GenModelUtilX.java b/com.avaloq.tools.ddk.xtext.expression/src/com/avaloq/tools/ddk/xtext/expression/generator/GenModelUtilX.java index 7a18e33d8..bcbe6d6cf 100644 --- a/com.avaloq.tools.ddk.xtext.expression/src/com/avaloq/tools/ddk/xtext/expression/generator/GenModelUtilX.java +++ b/com.avaloq.tools.ddk.xtext.expression/src/com/avaloq/tools/ddk/xtext/expression/generator/GenModelUtilX.java @@ -56,10 +56,9 @@ public class GenModelUtilX { private ResourceDescriptionsProvider resourceDescriptionsProvider; /** - * The current model resource used for GenPackage lookups. This utility is normally a Guice singleton, so - * multiple Xtext workers (the editor reconciler, the workspace builder, content assist, etc.) share the same - * instance and would otherwise race on a single field. Storing the context per-thread isolates each worker's - * lookups from the others. + * The current model resource used for GenPackage lookups. This utility is not a singleton: every injection point gets + * its own instance, and only instances given a resource through {@link #setResource(Resource)} use one. The context is + * stored per thread so that callers sharing an instance on different threads do not see each other's context. */ private final ThreadLocal context = new ThreadLocal<>(); @@ -236,9 +235,53 @@ private String instanceClassNameFromRegistry(final EClassifier classifier) { return counterpart.getInstanceClassName(); } + /** + * Returns the {@link GenPackage} of the {@link EPackage} containing the given element. + *

+ * With a context resource set, a GenPackage found is + * {@link GeneratorSupport#memoize(Object, java.util.function.Supplier) memoized} for the generation pass, so that the many + * literal and instance class names a generated file refers to do not each repeat the index lookup and the scan over all + * indexed GenPackages it can fall back to. Once found, the GenPackage depends only on the context resource and the + * EPackage. A lookup that finds nothing is not memoized: its final fallback only searches the GenModels already loaded + * into the resource set, so it may succeed later in the same pass. + *

+ * + * @param element + * the model element, must not be {@code null} + * @return the GenPackage, or {@code null} if none is found + */ public GenPackage genPackage(final EModelElement element) { final EPackage ePackage = EcoreUtil2.getContainerOfType(element, EPackage.class); final Resource ctx = context.get(); + if (ctx == null || ePackage == null) { + return lookUpGenPackage(element, ePackage, ctx); + } + return GeneratorSupport.memoize(new GenPackageKey(ctx, ePackage), () -> lookUpGenPackage(element, ePackage, ctx)); + } + + /** + * Key under which the GenPackage of an EPackage is memoized for a context resource. + * + * @param context + * the context resource + * @param ePackage + * the EPackage + */ + private record GenPackageKey(Resource context, EPackage ePackage) { + } + + /** + * Looks up the {@link GenPackage} of the given EPackage. + * + * @param element + * the model element, must not be {@code null} + * @param ePackage + * the EPackage containing the element, may be {@code null} + * @param ctx + * the context resource, may be {@code null} + * @return the GenPackage, or {@code null} if none is found + */ + private GenPackage lookUpGenPackage(final EModelElement element, final EPackage ePackage, final Resource ctx) { if (globalScopeProvider != null && ctx != null) { final IScope scope = globalScopeProvider.getScope(ctx, GenModelPackage.Literals.GEN_MODEL__GEN_PACKAGES, null); if (scope != null && ePackage != null) { diff --git a/com.avaloq.tools.ddk.xtext.expression/src/com/avaloq/tools/ddk/xtext/expression/generator/GeneratorSupport.java b/com.avaloq.tools.ddk.xtext.expression/src/com/avaloq/tools/ddk/xtext/expression/generator/GeneratorSupport.java index decf92f15..137d641aa 100644 --- a/com.avaloq.tools.ddk.xtext.expression/src/com/avaloq/tools/ddk/xtext/expression/generator/GeneratorSupport.java +++ b/com.avaloq.tools.ddk.xtext.expression/src/com/avaloq/tools/ddk/xtext/expression/generator/GeneratorSupport.java @@ -15,14 +15,21 @@ import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.IWorkspaceRoot; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IPath; +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.resource.Resource; import org.eclipse.emf.mwe.core.resources.ResourceLoader; import org.eclipse.emf.mwe.core.resources.ResourceLoaderFactory; import org.eclipse.emf.mwe.core.resources.ResourceLoaderImpl; @@ -40,9 +47,38 @@ public class GeneratorSupport { /** Class-wide logger. */ private static final Logger LOG = LogManager.getLogger(GeneratorSupport.class); + /** + * The {@link #executeWithProjectResourceLoader} call whose resource loader is installed on the current thread, if any. + * Nested calls for the same project and resource loader run within it and do not replace it. + */ + private static final ThreadLocal CURRENT_SCOPE = new ThreadLocal<>(); + + /** + * One {@link #executeWithProjectResourceLoader} call: the project, the resource loader installed for it and the values + * memoized while it runs. + * + * @param project + * the project, may be {@code null} + * @param resourceLoader + * the resource loader installed for the project + * @param memoizedValues + * the values memoized while the call runs + */ + private record ProjectScope(IProject project, ResourceLoader resourceLoader, Map memoizedValues) { + } + /** * Executes a given operation using a custom resource loader which will load resources using the classpath of the given project, provided that it is a Java * project. + *

+ * The operation can {@link #memoize(Object, Supplier) memoize} values for the duration of this call. + *

+ *

+ * If the current thread already executes an operation with the resource loader of the same project, the given operation + * runs directly within that call: it uses the resource loader already installed and shares the memoized values, instead + * of building another class loader from the project's resolved classpath. A generator can thus establish the resource + * loader once for a whole resource, and the method bodies it renders nested within that call reuse it. + *

* * @param project * context project, can also be {@code null} @@ -50,12 +86,24 @@ public class GeneratorSupport { * operation to run */ public void executeWithProjectResourceLoader(final IProject project, final Runnable runnable) { + ProjectScope enclosingScope = CURRENT_SCOPE.get(); + if (enclosingScope != null && Objects.equals(enclosingScope.project(), project) + && enclosingScope.resourceLoader() == ResourceLoaderFactory.getCurrentThreadResourceLoader()) { + runnable.run(); + return; + } ResourceLoader oldResourceLoader = ResourceLoaderFactory.getCurrentThreadResourceLoader(); ResourceLoader resourceLoader = createResourceLoader(project); try { ResourceLoaderFactory.setCurrentThreadResourceLoader(resourceLoader); + CURRENT_SCOPE.set(new ProjectScope(project, resourceLoader, new HashMap<>())); runnable.run(); } finally { + if (enclosingScope == null) { + CURRENT_SCOPE.remove(); + } else { + CURRENT_SCOPE.set(enclosingScope); + } ResourceLoaderFactory.setCurrentThreadResourceLoader(oldResourceLoader); if (resourceLoader instanceof CustomResourceLoader) { ((CustomResourceLoader) resourceLoader).close(); @@ -63,6 +111,77 @@ public void executeWithProjectResourceLoader(final IProject project, final Runna } } + /** + * Executes a given operation using a custom resource loader which will load resources using the classpath of the project + * containing the given resource, see {@link #executeWithProjectResourceLoader(IProject, Runnable)}. + * + * @param resource + * context resource, must not be {@code null}; if it is not a workspace resource no project is used + * @param runnable + * operation to run + */ + public void executeWithProjectResourceLoaderOf(final Resource resource, final Runnable runnable) { + executeWithProjectResourceLoader(projectOf(resource), runnable); + } + + /** + * Returns the workspace project containing the given resource, if any. + * + * @param resource + * the resource, must not be {@code null} + * @return the containing project, or {@code null} if it cannot be determined + */ + private IProject projectOf(final Resource resource) { + final URI uri = resource.getURI(); + if (uri.isPlatformResource()) { + final IResource member = ResourcesPlugin.getWorkspace().getRoot().findMember(uri.toPlatformString(true)); + if (member != null) { + return member.getProject(); + } + } + return null; + } + + /** + * Returns the value memoized under the given key by the {@link #executeWithProjectResourceLoader} call whose resource + * loader is installed on the current thread, computing and memoizing it on first request. That is the outermost call for + * the current project and resource loader, since nested calls for them run within it. Outside of such a call the value is + * computed on every request. + *

+ * The memoized values are discarded when that call returns. Callers of {@link #executeWithProjectResourceLoader} that + * use memoized values must therefore limit the call to one generation pass, so that the values never outlive the index + * and classpath state they were computed from. Only memoize values that are fully determined by the key and by that + * state. A {@code null} value is not memoized but computed again on the next request, so that a lookup which found + * nothing can succeed once more state has been loaded. + *

+ * + * @param + * the type of the value + * @param key + * the key identifying the value, must not be {@code null} and must implement {@link Object#equals(Object)} + * and {@link Object#hashCode()} consistently + * @param supplier + * computes the value, must not be {@code null}; the value may be {@code null} + * @return the memoized or computed value, may be {@code null} + */ + @SuppressWarnings("unchecked") + public static T memoize(final Object key, final Supplier supplier) { + final ProjectScope scope = CURRENT_SCOPE.get(); + if (scope == null) { + return supplier.get(); + } + final Map memoizedValues = scope.memoizedValues(); + final Object memoized = memoizedValues.get(key); + if (memoized != null) { + return (T) memoized; + } + final T value = supplier.get(); + if (value != null) { + memoizedValues.put(key, value); + } + return value; + } + /** * Custom resource loader which declares a {@link #close()} method to {@link URLClassLoader#close() close} the underlying class loader. */ 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 2fb805d12..28eb818ac 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 @@ -15,6 +15,7 @@ Require-Bundle: com.avaloq.tools.ddk.test.core, com.avaloq.tools.ddk.xtext.test.core, org.eclipse.xtext, org.eclipse.xtext.common.types, + org.eclipse.emf.mwe.core, 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/GeneratorSupportTest.java b/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/expression/GeneratorSupportTest.java new file mode 100644 index 000000000..e7292ea90 --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/expression/GeneratorSupportTest.java @@ -0,0 +1,113 @@ +/******************************************************************************* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.concurrent.atomic.AtomicInteger; + +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.mwe.core.resources.ResourceLoader; +import org.eclipse.emf.mwe.core.resources.ResourceLoaderFactory; +import org.eclipse.emf.mwe.core.resources.ResourceLoaderImpl; +import org.junit.jupiter.api.Test; + +import com.avaloq.tools.ddk.xtext.expression.generator.GeneratorSupport; + + +/** + * Tests the lifetime of the values memoized through {@link GeneratorSupport#memoize(Object, java.util.function.Supplier)}. + */ +@SuppressWarnings("nls") +public class GeneratorSupportTest { + + private static final String KEY = "key"; + private static final String NULL_KEY = "null"; + + private final GeneratorSupport generatorSupport = new GeneratorSupport(); + + /** A resource outside of the workspace, so that no project and no class loader for its classpath are involved. */ + private final Resource resource = new ResourceImpl(URI.createURI("memory:/model.scope")); + + private final AtomicInteger computations = new AtomicInteger(); + + private Integer compute() { + return computations.incrementAndGet(); + } + + private Integer computeNull() { + computations.incrementAndGet(); + return null; + } + + @Test + void testMemoizeOutsideOfResourceLoaderComputesOnEveryRequest() { + assertEquals(1, GeneratorSupport.memoize(KEY, this::compute)); + assertEquals(2, GeneratorSupport.memoize(KEY, this::compute)); + } + + @Test + void testMemoizeWithinResourceLoaderComputesOnce() { + generatorSupport.executeWithProjectResourceLoaderOf(resource, () -> { + assertEquals(1, GeneratorSupport.memoize(KEY, this::compute)); + assertEquals(1, GeneratorSupport.memoize(KEY, this::compute)); + }); + assertEquals(1, computations.get()); + } + + @Test + void testNullIsNotMemoized() { + generatorSupport.executeWithProjectResourceLoaderOf(resource, () -> { + assertNull(GeneratorSupport.memoize(NULL_KEY, this::computeNull)); + assertNull(GeneratorSupport.memoize(NULL_KEY, this::computeNull)); + assertEquals(3, GeneratorSupport.memoize(NULL_KEY, this::compute)); + assertEquals(3, GeneratorSupport.memoize(NULL_KEY, this::compute)); + }); + assertEquals(3, computations.get()); + } + + @Test + void testMemoizedValuesAreDroppedWhenResourceLoaderCallReturns() { + generatorSupport.executeWithProjectResourceLoaderOf(resource, () -> GeneratorSupport.memoize(KEY, this::compute)); + generatorSupport.executeWithProjectResourceLoaderOf(resource, () -> assertEquals(2, GeneratorSupport.memoize(KEY, this::compute))); + assertEquals(3, GeneratorSupport.memoize(KEY, this::compute)); + } + + @Test + void testNestedResourceLoaderCallForSameProjectSharesMemoizedValues() { + generatorSupport.executeWithProjectResourceLoaderOf(resource, () -> { + GeneratorSupport.memoize(KEY, this::compute); + generatorSupport.executeWithProjectResourceLoaderOf(resource, () -> assertEquals(1, GeneratorSupport.memoize(KEY, this::compute))); + assertEquals(1, GeneratorSupport.memoize(KEY, this::compute)); + }); + assertEquals(1, computations.get()); + } + + @Test + void testNestedResourceLoaderCallWithReplacedLoaderUsesOwnScopeAndRestoresEnclosingOne() { + generatorSupport.executeWithProjectResourceLoaderOf(resource, () -> { + final ResourceLoader enclosingLoader = ResourceLoaderFactory.getCurrentThreadResourceLoader(); + GeneratorSupport.memoize(KEY, this::compute); + ResourceLoaderFactory.setCurrentThreadResourceLoader(new ResourceLoaderImpl(getClass().getClassLoader())); + try { + generatorSupport.executeWithProjectResourceLoaderOf(resource, () -> assertEquals(2, GeneratorSupport.memoize(KEY, this::compute))); + } finally { + ResourceLoaderFactory.setCurrentThreadResourceLoader(enclosingLoader); + } + assertEquals(1, GeneratorSupport.memoize(KEY, this::compute)); + }); + assertEquals(2, computations.get()); + } + +} diff --git a/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/expression/JvmModelGeneratorBindingTest.java b/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/expression/JvmModelGeneratorBindingTest.java new file mode 100644 index 000000000..b22b9dae5 --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/expression/JvmModelGeneratorBindingTest.java @@ -0,0 +1,40 @@ +/******************************************************************************* + * 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.assertInstanceOf; + +import org.eclipse.xtext.generator.IGenerator; +import org.junit.jupiter.api.Test; + +import com.avaloq.tools.ddk.xtext.export.ExportStandaloneSetup; +import com.avaloq.tools.ddk.xtext.export.generator.ExportJvmModelGenerator; +import com.avaloq.tools.ddk.xtext.scope.ScopeStandaloneSetup; +import com.avaloq.tools.ddk.xtext.scope.generator.ScopeJvmModelGenerator; + + +/** + * Tests that the Scope and Export languages generate with their own JVM model generators, which establish the project + * resource loader once per generated resource. + */ +public class JvmModelGeneratorBindingTest { + + @Test + void testScopeGeneratorIsBound() { + assertInstanceOf(ScopeJvmModelGenerator.class, new ScopeStandaloneSetup().createInjector().getInstance(IGenerator.class)); + } + + @Test + void testExportGeneratorIsBound() { + assertInstanceOf(ExportJvmModelGenerator.class, new ExportStandaloneSetup().createInjector().getInstance(IGenerator.class)); + } + +} 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 cbaeea987..5f24eb253 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 @@ -15,6 +15,8 @@ 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.GeneratorSupportTest; +import com.avaloq.tools.ddk.xtext.generator.expression.JvmModelGeneratorBindingTest; 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; @@ -32,6 +34,8 @@ LfPrintWriterTest.class, LineEndingDeterminismTest.class, ExpressionsExtentionsTest.class, + GeneratorSupportTest.class, + JvmModelGeneratorBindingTest.class, ScopeExpressionCodeGenerationTest.class, ScopeResourceDescriptionManagerTest.class, EClassComparatorTest.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 015be9073..c48af5de0 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 @@ -17,9 +17,11 @@ import org.eclipse.xtext.resource.IDefaultResourceDescriptionStrategy; import org.eclipse.xtext.resource.ILocationInFileProvider; import org.eclipse.xtext.resource.IResourceDescription; +import org.eclipse.xtext.xbase.compiler.JvmModelGenerator; import com.avaloq.tools.ddk.xtext.formatting.LfLineSeparatorInformation; import com.avaloq.tools.ddk.xtext.scope.conversion.ScopeValueConverterService; +import com.avaloq.tools.ddk.xtext.scope.generator.ScopeJvmModelGenerator; 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; @@ -97,6 +99,15 @@ public Class bindIDefaultResource return ScopeResourceDescriptionManager.class; } + /** + * Binds the generator emitting the Java source of the inferred scope types. + * + * @return the scope specific JVM model generator + */ + public Class bindJvmModelGenerator() { + return ScopeJvmModelGenerator.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/generator/ScopeJvmModelGenerator.java b/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/generator/ScopeJvmModelGenerator.java new file mode 100644 index 000000000..973c0c31e --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/generator/ScopeJvmModelGenerator.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.scope.generator; + +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.xtext.common.types.JvmDeclaredType; +import org.eclipse.xtext.generator.IFileSystemAccess; +import org.eclipse.xtext.xbase.compiler.JvmModelGenerator; + +import com.avaloq.tools.ddk.xtext.expression.generator.GeneratorSupport; +import com.google.inject.Inject; + + +/** + * Emits the Java source of the inferred scope types, generating all types of a resource with one project resource loader. + *

+ * {@link com.avaloq.tools.ddk.xtext.scope.jvmmodel.ScopeJvmModelInferrer} renders every method body during emission + * within {@link GeneratorSupport#executeWithProjectResourceLoader(org.eclipse.core.resources.IProject, Runnable) + * GeneratorSupport#executeWithProjectResourceLoader}. Establishing that resource loader once for the whole resource here + * lets those nested calls reuse it, instead of resolving the project's classpath and building a class loader for every + * body. + *

+ */ +public class ScopeJvmModelGenerator extends JvmModelGenerator { + + @Inject + private GeneratorSupport generatorSupport; + + @Override + public void doGenerate(final Resource input, final IFileSystemAccess fsa) { + if (input.getContents().stream().anyMatch(JvmDeclaredType.class::isInstance)) { + generatorSupport.executeWithProjectResourceLoaderOf(input, () -> super.doGenerate(input, fsa)); + } else { + super.doGenerate(input, fsa); // no inferred type, hence no body to render: do not build a class loader + } + } + +} diff --git a/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/generator/ScopeModelTypeResolver.java b/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/generator/ScopeModelTypeResolver.java index c0b8cfbce..0a84c0a1c 100644 --- a/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/generator/ScopeModelTypeResolver.java +++ b/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/generator/ScopeModelTypeResolver.java @@ -27,6 +27,7 @@ import com.avaloq.tools.ddk.xtext.scope.scope.Import; import com.avaloq.tools.ddk.xtext.scope.scope.ScopeModel; import com.avaloq.tools.ddk.xtext.scope.scope.ScopePackage; +import com.avaloq.tools.ddk.xtext.expression.generator.GeneratorSupport; import com.avaloq.tools.ddk.xtext.util.EObjectUtil; @@ -182,7 +183,30 @@ private EClassifier findClassifier(final String name) { */ public static ScopeModelTypeResolver forElement(final EObject element) { final ScopeModel scopeModel = EcoreUtil2.getContainerOfType(element, ScopeModel.class); - return scopeModel == null ? null : new ScopeModelTypeResolver(scopeModel); + return scopeModel == null ? null : forModel(scopeModel); + } + + /** + * Returns the resolver for the given scope model. The resolver is immutable and determined by the model and the EPackages + * visible to it, so within a generation pass it is {@link GeneratorSupport#memoize(Object, java.util.function.Supplier) + * memoized} and shared by all expressions of the model instead of being rebuilt - which resolves every visible EPackage - + * for each of them. + * + * @param model + * the scope model, must not be {@code null} + * @return the resolver, never {@code null} + */ + public static ScopeModelTypeResolver forModel(final ScopeModel model) { + return GeneratorSupport.memoize(new Key(model), () -> new ScopeModelTypeResolver(model)); + } + + /** + * Key under which the resolver of a scope model is memoized. + * + * @param model + * the scope model + */ + private record Key(ScopeModel model) { } } diff --git a/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/jvmmodel/ScopeExpressionTranslator.java b/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/jvmmodel/ScopeExpressionTranslator.java index 1102f5bf2..d2e808a88 100644 --- a/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/jvmmodel/ScopeExpressionTranslator.java +++ b/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/jvmmodel/ScopeExpressionTranslator.java @@ -413,7 +413,7 @@ public ScopeTranslationContext newCompilationContext(final String implicitVariab context.setSourceElement(sourceElement); context.setImplicitVariableName(implicitVariableName); final ScopeModel model = contextModel(sourceElement); - context.setModelTypeResolver(model == null ? null : new ScopeModelTypeResolver(model)); + context.setModelTypeResolver(model == null ? null : ScopeModelTypeResolver.forModel(model)); if (implicitType != null) { final JvmFormalParameter parameter = newTrialParameter(implicitVariableName, genModelUtil.instanceClassName(implicitType), sourceElement); context.setImplicitVariable(parameter);