Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;


Expand All @@ -38,6 +41,12 @@
* the sole interface contributed by
* {@link com.avaloq.tools.ddk.xtext.export.jvmmodel.ExportJvmModelInferrer}, so classes keep the
* default spacing.
* <p>
* 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 {

Expand All @@ -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}
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;


Expand Down Expand Up @@ -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) {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Resource> context = new ThreadLocal<>();

Expand Down Expand Up @@ -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.
* <p>
* 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.
* </p>
*
* @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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,29 +47,141 @@ 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<ProjectScope> 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<Object, Object> 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.
* <p>
* The operation can {@link #memoize(Object, Supplier) memoize} values for the duration of this call.
* </p>
* <p>
* 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.
* </p>
*
* @param project
* context project, can also be {@code null}
* @param runnable
* 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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder, since we already depend on Guava, does Guava not offer us a good fit for this use case without rolling our own cache?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, I looked into it. Most of this code is the scope handling (a thread-local scope for one generation pass, reused when calls nest, dropped in finally), which Guava doesn't cover; the memo table itself is ~6 lines. Guava's Cache turns out to fit that part poorly: it rejects null (a missing GenPackage is a valid, memoizable result here), and it wraps loader exceptions in (Unchecked)ExecutionException, which would change the errors generation reports. computeIfAbsent has the same null problem, and it fails on re-entrant calls. Happy to switch to Cache with Optional values if you prefer the library type, though.

} else {
CURRENT_SCOPE.set(enclosingScope);
}
ResourceLoaderFactory.setCurrentThreadResourceLoader(oldResourceLoader);
if (resourceLoader instanceof CustomResourceLoader) {
((CustomResourceLoader) resourceLoader).close();
}
}
}

/**
* 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.
* <p>
* 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.
* </p>
*
* @param <T>
* 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> T memoize(final Object key, final Supplier<? extends T> supplier) {
final ProjectScope scope = CURRENT_SCOPE.get();
if (scope == null) {
return supplier.get();
}
final Map<Object, Object> 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading