From d8d3f02035f8b74f500fdd5a859a831f39b18d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dinis=20Ferreira?= Date: Thu, 24 Sep 2026 21:02:10 +0200 Subject: [PATCH 1/4] perf(scope,export): share the model type resolver within a generation pass ScopeExpressionTranslator and ExportExpressionTranslator built a new Scope/ExportModelTypeResolver for every compilation context, i.e. for nearly every expression rendered into a generated method body. Each construction queries the IMPORT__PACKAGE scope and EcoreUtil.resolves every EPackage visible to the model (the EMF registry plus every EPackage in the index), so the cost was O(visible EPackages) per expression. The pre-#1405 generator built one CompilationContext per generated file. GeneratorSupport gains memoize(key, supplier): values memoized by the innermost executeWithProjectResourceLoader call on the current thread, discarded when that call returns; outside of such a call nothing is memoized. The translators memoize the resolver under a key holding the model. Equivalence: a resolver is immutable once constructed and is fully determined by its model and by the EPackages visible to that model, i.e. by the index and the resource set. Neither changes while one body is rendered, and EcoreUtil.resolve returns the same (already loaded) instances when it runs again, so a resolver built by the first compilation context is equal to the one each later context would have built. Lifetime: the memoized values live on a ThreadLocal of the executeWithProjectResourceLoader call and are dropped in its finally block. Today every method body is rendered inside its own such call, so a resolver is shared by the compilation contexts of one body and never outlives it - never across resources or builds, where the index and the classpath may change. Compilation contexts created outside such a call (tests, inference) still build a fresh resolver each time. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../jvmmodel/ExportExpressionTranslator.java | 30 +++++++++++- .../generator/GeneratorSupport.java | 49 +++++++++++++++++++ .../jvmmodel/ScopeExpressionTranslator.java | 29 ++++++++++- 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportExpressionTranslator.java b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportExpressionTranslator.java index e137e79de2..9de89244f7 100644 --- a/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportExpressionTranslator.java +++ b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportExpressionTranslator.java @@ -60,6 +60,7 @@ import com.avaloq.tools.ddk.xtext.expression.expression.StringLiteral; import com.avaloq.tools.ddk.xtext.expression.expression.TypeSelectExpression; import com.avaloq.tools.ddk.xtext.expression.generator.GenModelUtilX; +import com.avaloq.tools.ddk.xtext.expression.generator.GeneratorSupport; import com.google.common.collect.Iterables; import com.google.inject.Inject; @@ -91,6 +92,10 @@ public class ExportExpressionTranslator { @Inject private GenModelUtilX genModelUtil; + /** Memoizes the model type resolver of an export model for the generation pass. */ + @Inject + private GeneratorSupport generatorSupport; + /** * Translates the given expression into an equivalent {@link XExpression}. * @@ -297,7 +302,8 @@ public ExportTranslationContext newCompilationContext(final String implicitVaria final ExportTranslationContext context = new ExportTranslationContext(); context.setSourceElement(sourceElement); context.setImplicitVariableName(implicitVariableName); - context.setModelTypeResolver(ExportModelTypeResolver.forElement(sourceElement)); + final ExportModel model = EcoreUtil2.getContainerOfType(sourceElement, ExportModel.class); + context.setModelTypeResolver(model == null ? null : modelTypeResolver(model)); if (implicitType != null) { final JvmFormalParameter parameter = newTrialParameter(implicitVariableName, genModelUtil.instanceClassName(implicitType), sourceElement); context.setImplicitVariable(parameter); @@ -311,6 +317,28 @@ public ExportTranslationContext newCompilationContext(final String implicitVaria return context; } + /** + * Returns the model type resolver of the given export model. The resolver is immutable and determined by the model and + * the EPackages visible to it, so it is shared by all compilation contexts created for the model during one generation + * pass 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} + */ + private ExportModelTypeResolver modelTypeResolver(final ExportModel model) { + return generatorSupport.memoize(new ModelTypeResolverKey(model), () -> new ExportModelTypeResolver(model)); + } + + /** + * Key under which the model type resolver of an export model is memoized. + * + * @param model + * the export model + */ + private record ModelTypeResolverKey(ExportModel model) { + } + /** * Fallback for expression types that are not (yet) supported by the translator. * 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 decf92f151..d1d3bd7772 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,6 +15,9 @@ import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -40,9 +43,15 @@ public class GeneratorSupport { /** Class-wide logger. */ private static final Logger LOG = LogManager.getLogger(GeneratorSupport.class); + /** The values memoized by the innermost {@link #executeWithProjectResourceLoader} call running on the current thread. */ + private static final ThreadLocal> MEMOIZED_VALUES = new ThreadLocal<>(); + /** * 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. + *

* * @param project * context project, can also be {@code null} @@ -52,10 +61,17 @@ public class GeneratorSupport { public void executeWithProjectResourceLoader(final IProject project, final Runnable runnable) { ResourceLoader oldResourceLoader = ResourceLoaderFactory.getCurrentThreadResourceLoader(); ResourceLoader resourceLoader = createResourceLoader(project); + Map oldMemoizedValues = MEMOIZED_VALUES.get(); try { ResourceLoaderFactory.setCurrentThreadResourceLoader(resourceLoader); + MEMOIZED_VALUES.set(new HashMap<>()); runnable.run(); } finally { + if (oldMemoizedValues == null) { + MEMOIZED_VALUES.remove(); + } else { + MEMOIZED_VALUES.set(oldMemoizedValues); + } ResourceLoaderFactory.setCurrentThreadResourceLoader(oldResourceLoader); if (resourceLoader instanceof CustomResourceLoader) { ((CustomResourceLoader) resourceLoader).close(); @@ -63,6 +79,39 @@ public void executeWithProjectResourceLoader(final IProject project, final Runna } } + /** + * Returns the value memoized under the given key by the innermost {@link #executeWithProjectResourceLoader} call running + * on the current thread, computing and memoizing it on first request. Outside of such a call the value is computed on + * every request. + *

+ * The memoized values are discarded when that call returns, so they live for one generation pass at most and 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. + *

+ * + * @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 T memoize(final Object key, final Supplier supplier) { + final Map memoizedValues = MEMOIZED_VALUES.get(); + if (memoizedValues == null) { + return supplier.get(); + } + if (memoizedValues.containsKey(key)) { + return (T) memoizedValues.get(key); + } + final T value = supplier.get(); + 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.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 1102f5bf23..e88ef85494 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 @@ -57,6 +57,7 @@ import com.avaloq.tools.ddk.xtext.expression.expression.StringLiteral; import com.avaloq.tools.ddk.xtext.expression.expression.TypeSelectExpression; import com.avaloq.tools.ddk.xtext.expression.generator.GenModelUtilX; +import com.avaloq.tools.ddk.xtext.expression.generator.GeneratorSupport; import com.avaloq.tools.ddk.xtext.scope.generator.ScopeModelTypeResolver; import com.avaloq.tools.ddk.xtext.scope.generator.ScopeProviderX; import com.avaloq.tools.ddk.xtext.scope.scope.Extension; @@ -104,6 +105,10 @@ public class ScopeExpressionTranslator { @Inject private ScopeProviderX scopeProviderX; + /** Memoizes the model type resolver of a scope model for the generation pass. */ + @Inject + private GeneratorSupport generatorSupport; + /** * The scope model currently being generated. Extension declarations and imported model packages are resolved * against it rather than against the model an individual expression happens to be declared in: a scope provider is @@ -413,7 +418,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 : modelTypeResolver(model)); if (implicitType != null) { final JvmFormalParameter parameter = newTrialParameter(implicitVariableName, genModelUtil.instanceClassName(implicitType), sourceElement); context.setImplicitVariable(parameter); @@ -427,6 +432,28 @@ public ScopeTranslationContext newCompilationContext(final String implicitVariab return context; } + /** + * Returns the model type resolver of the given scope model. The resolver is immutable and determined by the model and + * the EPackages visible to it, so it is shared by all compilation contexts created for the model during one generation + * pass 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} + */ + private ScopeModelTypeResolver modelTypeResolver(final ScopeModel model) { + return generatorSupport.memoize(new ModelTypeResolverKey(model), () -> new ScopeModelTypeResolver(model)); + } + + /** + * Key under which the model type resolver of a scope model is memoized. + * + * @param model + * the scope model + */ + private record ModelTypeResolverKey(ScopeModel model) { + } + /** * Fallback for expression types that are not (yet) supported by the translator. * From 360b356bcf496f724c8fa95b529016c7e89e2155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dinis=20Ferreira?= Date: Thu, 24 Sep 2026 21:03:35 +0200 Subject: [PATCH 2/4] perf(scope,export): build one project resource loader per generated resource Since the Scope and Export inferrers render their method bodies lazily during emission (442409439 and the export equivalent), every body and field initializer goes through its own GeneratorSupport.executeWithProjectResourceLoader call, which resolves the project's full classpath and builds a new URLClassLoader - once per body instead of once per file as before. executeWithProjectResourceLoader is now re-entrant: when the current thread already executes within a call for the same project and that call's resource loader is still the installed one, the operation runs directly within it, sharing its resource loader and memoized values. The new ScopeJvmModelGenerator (bound in ScopeRuntimeModule) and ExportJvmModelGenerator wrap JvmModelGenerator.doGenerate(Resource, ..) in one such call for the resource's project, so the bodies rendered while the resource is emitted reuse that loader. A resource without any inferred type (e.g. a header-less export model whose grammar cannot be loaded) renders no body, so it is generated without the wrapper and still builds no class loader at all. Equivalence: the nested call would have installed a loader built from the same project's resolved classpath (GeneratorSupport.projectOf is the inferrers' projectOf applied to the same resource URI), and a generation pass does not change the classpath. No DDK code reads the thread's resource loader outside of these calls, so installing it for the whole of doGenerate rather than for each body does not change any other emitted text. Inference is untouched: bodies are still rendered only during emission, preserving the intent of 442409439. Callers that emit without doGenerate (e.g. JvmModelGenerator.generateType in tests) keep the previous behaviour of one loader per body. Lifetime: the loader and the memoized values (see the previous commit) live for one doGenerate call of one resource and are closed/dropped in its finally block - never across resources or builds. Memoized model type resolvers are therefore now shared by all bodies of the resource. GeneratorSupportTest covers memoize inside and outside of a resource loader call, that the values are dropped when the call returns, and that a nested call for the same project shares them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../generator/ExportJvmModelGenerator.java | 21 +++++ .../generator/GeneratorSupport.java | 79 +++++++++++++++-- .../expression/GeneratorSupportTest.java | 85 +++++++++++++++++++ .../test/generator/GeneratorTestSuite.java | 2 + .../ddk/xtext/scope/ScopeRuntimeModule.java | 11 +++ .../generator/ScopeJvmModelGenerator.java | 46 ++++++++++ 6 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/expression/GeneratorSupportTest.java create mode 100644 com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/generator/ScopeJvmModelGenerator.java 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 15108052e1..818ad09451 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.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 d1d3bd7772..8516af6d97 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 @@ -17,15 +17,19 @@ 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; @@ -43,8 +47,22 @@ public class GeneratorSupport { /** Class-wide logger. */ private static final Logger LOG = LogManager.getLogger(GeneratorSupport.class); - /** The values memoized by the innermost {@link #executeWithProjectResourceLoader} call running on the current thread. */ - private static final ThreadLocal> MEMOIZED_VALUES = new ThreadLocal<>(); + /** The innermost {@link #executeWithProjectResourceLoader} call running on the current thread, if any. */ + 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 @@ -52,6 +70,12 @@ public class GeneratorSupport { *

* 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} @@ -59,18 +83,23 @@ 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); - Map oldMemoizedValues = MEMOIZED_VALUES.get(); try { ResourceLoaderFactory.setCurrentThreadResourceLoader(resourceLoader); - MEMOIZED_VALUES.set(new HashMap<>()); + CURRENT_SCOPE.set(new ProjectScope(project, resourceLoader, new HashMap<>())); runnable.run(); } finally { - if (oldMemoizedValues == null) { - MEMOIZED_VALUES.remove(); + if (enclosingScope == null) { + CURRENT_SCOPE.remove(); } else { - MEMOIZED_VALUES.set(oldMemoizedValues); + CURRENT_SCOPE.set(enclosingScope); } ResourceLoaderFactory.setCurrentThreadResourceLoader(oldResourceLoader); if (resourceLoader instanceof CustomResourceLoader) { @@ -79,6 +108,37 @@ 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 innermost {@link #executeWithProjectResourceLoader} call running * on the current thread, computing and memoizing it on first request. Outside of such a call the value is computed on @@ -100,10 +160,11 @@ public void executeWithProjectResourceLoader(final IProject project, final Runna */ @SuppressWarnings("unchecked") public T memoize(final Object key, final Supplier supplier) { - final Map memoizedValues = MEMOIZED_VALUES.get(); - if (memoizedValues == null) { + final ProjectScope scope = CURRENT_SCOPE.get(); + if (scope == null) { return supplier.get(); } + final Map memoizedValues = scope.memoizedValues(); if (memoizedValues.containsKey(key)) { return (T) memoizedValues.get(key); } 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 0000000000..a287ffa13e --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/expression/GeneratorSupportTest.java @@ -0,0 +1,85 @@ +/******************************************************************************* + * 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.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)); + assertNull(generatorSupport.memoize(NULL_KEY, this::computeNull)); + assertNull(generatorSupport.memoize(NULL_KEY, this::computeNull)); + }); + assertEquals(2, 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()); + } + +} 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 cbaeea987b..f8123b9350 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,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.GeneratorSupportTest; 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 +33,7 @@ LfPrintWriterTest.class, LineEndingDeterminismTest.class, ExpressionsExtentionsTest.class, + GeneratorSupportTest.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 015be9073a..c48af5de04 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 0000000000..973c0c31e4 --- /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 + } + } + +} From 57b6cbc1578e2327383ad673b569b938db5d3a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dinis=20Ferreira?= Date: Thu, 24 Sep 2026 21:04:31 +0200 Subject: [PATCH 3/4] perf(expression): memoize GenModelUtilX GenPackage lookups per generation pass GenModelUtilX.genPackage - behind qualifiedPackageInterfaceName, literalIdentifier, instanceClassName, genClass and genDataType - queried the global GenPackage scope and EcoreUtil.resolved the result on every call, falling back to an index query and to a scan over all indexed GenPackages. The methods were marked "/*cached*/" in the Xtend source, a leftover of Xpand's "cached" keyword; neither the Xtend nor the Java version ever cached anything, so a generated file repeated the lookup for every literal and instance class name it contains. genPackage now memoizes the lookup through GeneratorSupport.memoize under a key of the context resource and the EPackage, whenever a context resource is set; without one (the lookup then depends on the element's own resource set) it is not memoized. Equivalence: with a context resource, the lookup reads only that resource, its resource set and the index, and uses the element only to find its EPackage. Within one generation pass the index and the resource set's content do not change; the only side effect, EcoreUtil.resolve loading a .genmodel into the context's resource set, makes a repeated lookup return the very instance the first one returned. The context is part of the key, so switching the context during the pass cannot serve a GenPackage looked up for another resource. A failing lookup is not memoized and fails again, as before. Lifetime: GeneratorSupport.memoize only memoizes within an executeWithProjectResourceLoader call, i.e. within one doGenerate of one resource (previous commit), and drops the values when it returns - never across resources or builds. Lookups outside of such a call or without a context resource are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../expression/generator/GenModelUtilX.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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 7a18e33d8d..e650e87b18 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 @@ -54,6 +54,8 @@ public class GenModelUtilX { private IGlobalScopeProvider globalScopeProvider; @Inject private ResourceDescriptionsProvider resourceDescriptionsProvider; + @Inject + private GeneratorSupport generatorSupport; /** * The current model resource used for GenPackage lookups. This utility is normally a Guice singleton, so @@ -236,9 +238,51 @@ 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, the result depends only on that resource and the EPackage: it is looked up in the index + * and resolved in the context's resource set. It is therefore + * {@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. + *

+ * + * @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) { From edff097e2c713a1180b81da787616893f3a87c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dinis=20Ferreira?= Date: Sat, 26 Sep 2026 02:02:02 +0200 Subject: [PATCH 4/4] perf(expression): address review of generation-pass memoization - Do not memoize null values: a GenPackage lookup that finds nothing may succeed later in the same pass, once more GenModels are loaded. - Make GeneratorSupport.memoize static and drop the injected instances in GenModelUtilX and the expression translators. - Memoize the model type resolvers in their own factories (forModel), so ExportExpressionTranslator keeps using ExportModelTypeResolver.forElement. - Correct the lifetime Javadoc of memoize and CURRENT_SCOPE, and the GenModelUtilX context comment (the utility is not a singleton). - Test the Scope/Export generator bindings, a nested call with a replaced resource loader, and that null values are not memoized. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LgVRfQCTxSDwGrN8J5s6fd --- .../generator/ExportModelTypeResolver.java | 26 ++++++++- .../jvmmodel/ExportExpressionTranslator.java | 30 +---------- .../expression/generator/GenModelUtilX.java | 21 ++++---- .../generator/GeneratorSupport.java | 31 +++++++---- .../META-INF/MANIFEST.MF | 1 + .../expression/GeneratorSupportTest.java | 54 ++++++++++++++----- .../JvmModelGeneratorBindingTest.java | 40 ++++++++++++++ .../test/generator/GeneratorTestSuite.java | 2 + .../generator/ScopeModelTypeResolver.java | 26 ++++++++- .../jvmmodel/ScopeExpressionTranslator.java | 29 +--------- 10 files changed, 166 insertions(+), 94 deletions(-) create mode 100644 com.avaloq.tools.ddk.xtext.generator.test/src/com/avaloq/tools/ddk/xtext/generator/expression/JvmModelGeneratorBindingTest.java 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 60dfdb0cdf..fa1ab56fbe 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.export/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportExpressionTranslator.java b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportExpressionTranslator.java index 9de89244f7..e137e79de2 100644 --- a/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportExpressionTranslator.java +++ b/com.avaloq.tools.ddk.xtext.export/src/com/avaloq/tools/ddk/xtext/export/jvmmodel/ExportExpressionTranslator.java @@ -60,7 +60,6 @@ import com.avaloq.tools.ddk.xtext.expression.expression.StringLiteral; import com.avaloq.tools.ddk.xtext.expression.expression.TypeSelectExpression; import com.avaloq.tools.ddk.xtext.expression.generator.GenModelUtilX; -import com.avaloq.tools.ddk.xtext.expression.generator.GeneratorSupport; import com.google.common.collect.Iterables; import com.google.inject.Inject; @@ -92,10 +91,6 @@ public class ExportExpressionTranslator { @Inject private GenModelUtilX genModelUtil; - /** Memoizes the model type resolver of an export model for the generation pass. */ - @Inject - private GeneratorSupport generatorSupport; - /** * Translates the given expression into an equivalent {@link XExpression}. * @@ -302,8 +297,7 @@ public ExportTranslationContext newCompilationContext(final String implicitVaria final ExportTranslationContext context = new ExportTranslationContext(); context.setSourceElement(sourceElement); context.setImplicitVariableName(implicitVariableName); - final ExportModel model = EcoreUtil2.getContainerOfType(sourceElement, ExportModel.class); - context.setModelTypeResolver(model == null ? null : modelTypeResolver(model)); + context.setModelTypeResolver(ExportModelTypeResolver.forElement(sourceElement)); if (implicitType != null) { final JvmFormalParameter parameter = newTrialParameter(implicitVariableName, genModelUtil.instanceClassName(implicitType), sourceElement); context.setImplicitVariable(parameter); @@ -317,28 +311,6 @@ public ExportTranslationContext newCompilationContext(final String implicitVaria return context; } - /** - * Returns the model type resolver of the given export model. The resolver is immutable and determined by the model and - * the EPackages visible to it, so it is shared by all compilation contexts created for the model during one generation - * pass 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} - */ - private ExportModelTypeResolver modelTypeResolver(final ExportModel model) { - return generatorSupport.memoize(new ModelTypeResolverKey(model), () -> new ExportModelTypeResolver(model)); - } - - /** - * Key under which the model type resolver of an export model is memoized. - * - * @param model - * the export model - */ - private record ModelTypeResolverKey(ExportModel model) { - } - /** * Fallback for expression types that are not (yet) supported by the translator. * 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 e650e87b18..bcbe6d6cff 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 @@ -54,14 +54,11 @@ public class GenModelUtilX { private IGlobalScopeProvider globalScopeProvider; @Inject private ResourceDescriptionsProvider resourceDescriptionsProvider; - @Inject - private GeneratorSupport generatorSupport; /** - * 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<>(); @@ -241,10 +238,12 @@ private String instanceClassNameFromRegistry(final EClassifier classifier) { /** * Returns the {@link GenPackage} of the {@link EPackage} containing the given element. *

- * With a context resource set, the result depends only on that resource and the EPackage: it is looked up in the index - * and resolved in the context's resource set. It is therefore - * {@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. + * 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 @@ -257,7 +256,7 @@ public GenPackage genPackage(final EModelElement element) { if (ctx == null || ePackage == null) { return lookUpGenPackage(element, ePackage, ctx); } - return generatorSupport.memoize(new GenPackageKey(ctx, ePackage), () -> lookUpGenPackage(element, ePackage, ctx)); + return GeneratorSupport.memoize(new GenPackageKey(ctx, ePackage), () -> lookUpGenPackage(element, ePackage, ctx)); } /** 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 8516af6d97..137d641aa3 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 @@ -47,7 +47,10 @@ public class GeneratorSupport { /** Class-wide logger. */ private static final Logger LOG = LogManager.getLogger(GeneratorSupport.class); - /** The innermost {@link #executeWithProjectResourceLoader} call running on the current thread, if any. */ + /** + * 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<>(); /** @@ -140,13 +143,16 @@ private IProject projectOf(final Resource resource) { } /** - * Returns the value memoized under the given key by the innermost {@link #executeWithProjectResourceLoader} call running - * on the current thread, computing and memoizing it on first request. Outside of such a call the value is computed on - * every request. + * 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, so they live for one generation pass at most and 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. + * 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 @@ -159,17 +165,20 @@ private IProject projectOf(final Resource resource) { * @return the memoized or computed value, may be {@code null} */ @SuppressWarnings("unchecked") - public T memoize(final Object key, final Supplier supplier) { + 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(); - if (memoizedValues.containsKey(key)) { - return (T) memoizedValues.get(key); + final Object memoized = memoizedValues.get(key); + if (memoized != null) { + return (T) memoized; } final T value = supplier.get(); - memoizedValues.put(key, value); + if (value != null) { + memoizedValues.put(key, value); + } return value; } 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 2fb805d12c..28eb818acd 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 index a287ffa13e..e7292ea900 100644 --- 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 @@ -18,6 +18,9 @@ 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; @@ -50,36 +53,61 @@ private Integer computeNull() { @Test void testMemoizeOutsideOfResourceLoaderComputesOnEveryRequest() { - assertEquals(1, generatorSupport.memoize(KEY, this::compute)); - assertEquals(2, generatorSupport.memoize(KEY, this::compute)); + 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)); - assertNull(generatorSupport.memoize(NULL_KEY, this::computeNull)); - assertNull(generatorSupport.memoize(NULL_KEY, this::computeNull)); + assertEquals(1, GeneratorSupport.memoize(KEY, this::compute)); + assertEquals(1, GeneratorSupport.memoize(KEY, this::compute)); }); - assertEquals(2, computations.get()); + 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)); + 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)); + 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 0000000000..b22b9dae55 --- /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 f8123b9350..5f24eb2539 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.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; @@ -34,6 +35,7 @@ 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/generator/ScopeModelTypeResolver.java b/com.avaloq.tools.ddk.xtext.scope/src/com/avaloq/tools/ddk/xtext/scope/generator/ScopeModelTypeResolver.java index c0b8cfbce2..0a84c0a1c7 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 e88ef85494..d2e808a885 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 @@ -57,7 +57,6 @@ import com.avaloq.tools.ddk.xtext.expression.expression.StringLiteral; import com.avaloq.tools.ddk.xtext.expression.expression.TypeSelectExpression; import com.avaloq.tools.ddk.xtext.expression.generator.GenModelUtilX; -import com.avaloq.tools.ddk.xtext.expression.generator.GeneratorSupport; import com.avaloq.tools.ddk.xtext.scope.generator.ScopeModelTypeResolver; import com.avaloq.tools.ddk.xtext.scope.generator.ScopeProviderX; import com.avaloq.tools.ddk.xtext.scope.scope.Extension; @@ -105,10 +104,6 @@ public class ScopeExpressionTranslator { @Inject private ScopeProviderX scopeProviderX; - /** Memoizes the model type resolver of a scope model for the generation pass. */ - @Inject - private GeneratorSupport generatorSupport; - /** * The scope model currently being generated. Extension declarations and imported model packages are resolved * against it rather than against the model an individual expression happens to be declared in: a scope provider is @@ -418,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 : modelTypeResolver(model)); + context.setModelTypeResolver(model == null ? null : ScopeModelTypeResolver.forModel(model)); if (implicitType != null) { final JvmFormalParameter parameter = newTrialParameter(implicitVariableName, genModelUtil.instanceClassName(implicitType), sourceElement); context.setImplicitVariable(parameter); @@ -432,28 +427,6 @@ public ScopeTranslationContext newCompilationContext(final String implicitVariab return context; } - /** - * Returns the model type resolver of the given scope model. The resolver is immutable and determined by the model and - * the EPackages visible to it, so it is shared by all compilation contexts created for the model during one generation - * pass 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} - */ - private ScopeModelTypeResolver modelTypeResolver(final ScopeModel model) { - return generatorSupport.memoize(new ModelTypeResolverKey(model), () -> new ScopeModelTypeResolver(model)); - } - - /** - * Key under which the model type resolver of a scope model is memoized. - * - * @param model - * the scope model - */ - private record ModelTypeResolverKey(ScopeModel model) { - } - /** * Fallback for expression types that are not (yet) supported by the translator. *