From 065b3d5102321ba1c072c95c20fb9fa710c1b738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dinis=20Ferreira?= Date: Tue, 8 Sep 2026 15:47:59 +0200 Subject: [PATCH] fix(format): stabilize fingerprints without losing inherited changes Saving a byte-identical `.format` file changed its exported fingerprints, because they contained the Java identity of the parent EMF object, which differs on every reload. Dependent formats were reindexed and regenerated although nothing had changed. In the reproducer, saving an unchanged `Xtype.format` also reindexed its dependent `Xbase.format`. Derive every fingerprint from source text and resource location, never from object identity. A local declaration is fingerprinted by its own token text. A format configuration is fingerprinted by the resource URI and complete source text of its own resource and of every configuration it transitively extends, so an identical reload is stable while a real change propagates through an intermediate format that declares nothing locally. Raw comments and delimiters participate because they move the source locations recorded in the generated code and traces. An inheritance cycle terminates through a visited set; an unresolved base contributes its proxy URI and ends the chain, so it is distinguishable from an absent base and a later resolution changes the fingerprint exactly once. Add a focused test class (eight tests) and a real Eclipse workspace test. The latter checks identical LF/CRLF saves, a content-preserving rename of a base, and compares all generated Java and trace bytes between incremental and full builds after constant, rule, declaration, comment and line-ending edits. The focused tests additionally cover empty configurations, cycles, unresolved and resolved bases, base selection and superclass changes. Bump the Format bundle from 17.3.3 to 17.3.4 and the SDK feature from 19.2.0 to 19.2.1, including its update-site entry. Closes #1534. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Opus 5.5 --- com.avaloq.tools.ddk.feature/feature.xml | 2 +- com.avaloq.tools.ddk.feature/pom.xml | 2 +- .../builder/FormatIncrementalBuildTest.java | 242 ++++++++++++++++++ ...FormatResourceDescriptionStrategyTest.java | 162 ++++++++++++ .../xtext/test/format/FormatTestSuite.java | 4 +- .../META-INF/MANIFEST.MF | 2 +- com.avaloq.tools.ddk.xtext.format/pom.xml | 2 +- .../FormatResourceDescriptionStrategy.java | 58 ++++- ddk-repository/category.xml | 2 +- 9 files changed, 457 insertions(+), 19 deletions(-) create mode 100644 com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/format/builder/FormatIncrementalBuildTest.java create mode 100644 com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/format/resource/FormatResourceDescriptionStrategyTest.java diff --git a/com.avaloq.tools.ddk.feature/feature.xml b/com.avaloq.tools.ddk.feature/feature.xml index 535be10137..84feafab7b 100644 --- a/com.avaloq.tools.ddk.feature/feature.xml +++ b/com.avaloq.tools.ddk.feature/feature.xml @@ -2,7 +2,7 @@ diff --git a/com.avaloq.tools.ddk.feature/pom.xml b/com.avaloq.tools.ddk.feature/pom.xml index 52f529f547..50162e1c55 100644 --- a/com.avaloq.tools.ddk.feature/pom.xml +++ b/com.avaloq.tools.ddk.feature/pom.xml @@ -7,7 +7,7 @@ 18.0.1-SNAPSHOT ../ddk-parent - 19.2.0-SNAPSHOT + 19.2.1-SNAPSHOT com.avaloq.tools.ddk.feature eclipse-feature diff --git a/com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/format/builder/FormatIncrementalBuildTest.java b/com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/format/builder/FormatIncrementalBuildTest.java new file mode 100644 index 0000000000..3beb7b1636 --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/format/builder/FormatIncrementalBuildTest.java @@ -0,0 +1,242 @@ +/******************************************************************************* + * 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.format.builder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IWorkspace; +import org.eclipse.core.resources.IWorkspaceDescription; +import org.eclipse.core.resources.IncrementalProjectBuilder; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.jdt.core.JavaCore; +import org.eclipse.xtext.builder.builderState.IBuilderState; +import org.eclipse.xtext.resource.IResourceDescription; +import org.eclipse.xtext.resource.IResourceDescription.Event.Listener; +import org.eclipse.xtext.ui.XtextProjectHelper; +import org.eclipse.xtext.ui.shared.Access; +import org.eclipse.xtext.ui.testing.util.JavaProjectSetupUtil; +import org.eclipse.xtext.ui.util.PluginProjectFactory; +import org.junit.jupiter.api.Test; + +import com.avaloq.tools.ddk.xtext.format.FormatConstants; +import com.avaloq.tools.ddk.xtext.format.ui.internal.FormatActivator; +import com.google.inject.Injector; + +/** Exercises the real index, linking and builder with a three-level Format inheritance chain. */ +@SuppressWarnings("nls") +public class FormatIncrementalBuildTest { + + private static final String PROJECT = "format.fingerprint.regression"; + private static final String BASE = "formatter for org.eclipse.xtext.xbase.Xtype\nconst int SPACING = 1;\nID { rule : no_space around; }\n"; + private static final String CHILD = "formatter for org.eclipse.xtext.xbase.Xbase with org.eclipse.xtext.xbase.Xtype\n"; + private static final String GRANDCHILD = "formatter for org.eclipse.xtext.xbase.annotations.XbaseWithAnnotations with org.eclipse.xtext.xbase.Xbase\nconst int LOCAL = 3;\n"; + + private static final String BASE_PATH = "src/repro/Xtype.format"; + + private static final String CHILD_FILE = "Xbase.format"; + + private static final String GRANDCHILD_FILE = "XbaseWithAnnotations.format"; + + private static final String LF = "\n"; + + private static final String CRLF = "\r\n"; + + @Test + public void incrementalOutputMatchesFullBuildAndIdenticalSavesStayLocal() throws Exception { + final boolean autoBuild = setAutoBuilding(false); + IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(PROJECT); + List indexed = new ArrayList<>(); + Listener listener = event -> { + for (IResourceDescription.Delta delta : event.getDeltas()) { + if (delta.getUri().isPlatformResource() && PROJECT.equals(delta.getUri().segment(1)) && "format".equals(delta.getUri().fileExtension())) { + indexed.add(delta.getUri().lastSegment()); + } + } + }; + IBuilderState index = Access.getIBuilderState().get(); + index.addListener(listener); + try { + createFixture(project); + + indexed.clear(); + write(project, BASE_PATH, BASE); + build(IncrementalProjectBuilder.INCREMENTAL_BUILD); + assertFalse(indexed.contains(CHILD_FILE) || indexed.contains(GRANDCHILD_FILE), + "identical save must not rebuild descendants: " + indexed); + assertMatchesFullBuild(project); + + for (String source : List.of(BASE.replace("SPACING = 1", "SPACING = 2"), + "// shifted source location\n" + BASE, BASE.replace("no_space around", "linewrap before"), + BASE.replace(LF, CRLF), BASE.replace("ID {", "const int ADDED = 4;\nID {"), BASE.replace("SPACING", "RENAMED"), BASE)) { + write(project, BASE_PATH, source); + build(IncrementalProjectBuilder.INCREMENTAL_BUILD); + assertMatchesFullBuild(project); + } + String crlf = BASE.replace(LF, CRLF); + write(project, BASE_PATH, crlf); + build(IncrementalProjectBuilder.INCREMENTAL_BUILD); + assertMatchesFullBuild(project); + indexed.clear(); + final Map unchanged = outputs(project); + write(project, BASE_PATH, crlf); + build(IncrementalProjectBuilder.INCREMENTAL_BUILD); + assertFalse(indexed.contains(CHILD_FILE) || indexed.contains(GRANDCHILD_FILE), "identical CRLF save must stay local"); + assertEquals(unchanged, outputs(project), "identical save must preserve all generated bytes"); + } finally { + index.removeListener(listener); + if (project.exists()) { + project.delete(true, true, null); + } + setAutoBuilding(autoBuild); + } + } + + @Test + public void renamedBaseWithUnchangedContentMatchesFullBuild() throws Exception { + final boolean autoBuild = setAutoBuilding(false); + IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(PROJECT); + try { + createFixture(project); + // The with clause names the grammar, not the file, so the dependents still resolve after the rename. + IFile renamed = project.getFile("src/repro/XtypeRenamed.format"); + project.getFile(BASE_PATH).move(renamed.getFullPath(), IResource.FORCE, null); + assertTrue(renamed.exists() && !project.getFile(BASE_PATH).exists(), "rename must move the base file"); + build(IncrementalProjectBuilder.INCREMENTAL_BUILD); + assertMatchesFullBuild(project); + } finally { + if (project.exists()) { + project.delete(true, true, null); + } + setAutoBuilding(autoBuild); + } + } + + private boolean setAutoBuilding(final boolean enabled) throws CoreException, InterruptedException { + IWorkspace workspace = ResourcesPlugin.getWorkspace(); + IWorkspaceDescription description = workspace.getDescription(); + final boolean previous = description.isAutoBuilding(); + description.setAutoBuilding(enabled); + workspace.setDescription(description); + Job.getJobManager().join(ResourcesPlugin.FAMILY_AUTO_BUILD, null); + return previous; + } + + private void createFixture(final IProject project) throws CoreException { + if (project.exists()) { + project.delete(true, true, null); + } + createProject(); + write(project, BASE_PATH, BASE); + write(project, "src/repro/Xbase.format", CHILD); + write(project, "src/repro/XbaseWithAnnotations.format", GRANDCHILD); + for (String language : List.of("Xtype", "Xbase", "annotations.XbaseWithAnnotations")) { + String simple = language.substring(language.lastIndexOf('.') + 1); + String pkg = "org.eclipse.xtext.xbase." + (language.startsWith("annotations.") ? "annotations." : "") + "formatting"; + write(project, "src/" + pkg.replace('.', '/') + "/" + simple + "Formatter.java", + "package " + pkg + ";\npublic abstract class " + simple + "Formatter extends Abstract" + simple + "Formatter {}\n"); + } + build(IncrementalProjectBuilder.FULL_BUILD); + build(IncrementalProjectBuilder.INCREMENTAL_BUILD); + assertNoErrors(project); + assertFalse(outputs(project).isEmpty(), "fixture must produce Java and traces"); + assertTrue(outputs(project).keySet().stream().anyMatch(n -> n.endsWith("._trace")), "trace comparison must not be vacuous"); + } + + private void createProject() throws CoreException { + Injector injector = FormatActivator.getInstance().getInjector(FormatConstants.GRAMMAR); + PluginProjectFactory factory = injector.getInstance(PluginProjectFactory.class); + factory.setProjectName(PROJECT); + factory.addFolders(List.of("src", "src-gen")); + factory.addBuilderIds(JavaCore.BUILDER_ID, "org.eclipse.pde.ManifestBuilder", "org.eclipse.pde.SchemaBuilder", XtextProjectHelper.BUILDER_ID); + factory.addProjectNatures(JavaCore.NATURE_ID, "org.eclipse.pde.PluginNature", XtextProjectHelper.NATURE_ID); + factory.addRequiredBundles(List.of("org.eclipse.xtext", "org.eclipse.xtext.xbase", "org.eclipse.xtext.xbase.lib", "org.eclipse.emf.ecore", + "com.avaloq.tools.ddk.xtext", "com.avaloq.tools.ddk.xtext.format", "org.eclipse.core.runtime")); + IProject project = factory.createProject(new NullProgressMonitor(), null); + JavaProjectSetupUtil.addJreClasspathEntry(JavaCore.create(project)); + } + + private void assertMatchesFullBuild(final IProject project) throws CoreException { + assertNoErrors(project); + Map incremental = outputs(project); + build(IncrementalProjectBuilder.FULL_BUILD); + assertNoErrors(project); + assertEquals(outputs(project), incremental, "incremental Java and trace bytes must match full-build output"); + } + + private void assertNoErrors(final IProject project) throws CoreException { + List errors = new ArrayList<>(); + for (IMarker marker : project.findMarkers(IMarker.PROBLEM, true, IResource.DEPTH_INFINITE)) { + if (marker.getAttribute(IMarker.SEVERITY, 0) == IMarker.SEVERITY_ERROR) { + errors.add(marker.getResource().getProjectRelativePath() + ": " + marker.getAttribute(IMarker.MESSAGE, "")); + } + } + assertTrue(errors.isEmpty(), "fixture must build without errors: " + errors); + } + + private Map outputs(final IProject project) throws CoreException { + Map result = new TreeMap<>(); + project.getFolder("src-gen").accept(resource -> { + if (resource instanceof IFile file) { + try (InputStream in = file.getContents()) { + result.put(file.getProjectRelativePath().toString(), HexFormat.of().formatHex(in.readAllBytes())); + } catch (java.io.IOException exception) { + throw new java.io.UncheckedIOException(exception); + } + } + return true; + }); + return result; + } + + private void build(final int kind) throws CoreException { + ResourcesPlugin.getWorkspace().build(kind, new NullProgressMonitor()); + } + + private void write(final IProject project, final String path, final String content) throws CoreException { + IFile file = project.getFile(path); + createFolder(file.getParent()); + try (InputStream in = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) { + if (file.exists()) { + file.setContents(in, IResource.FORCE, null); + } else { + file.create(in, true, null); + } + } catch (java.io.IOException exception) { + throw new java.io.UncheckedIOException(exception); + } + } + + private void createFolder(final org.eclipse.core.resources.IContainer container) throws CoreException { + if (container instanceof IFolder folder && !folder.exists()) { + createFolder(folder.getParent()); + folder.create(true, true, null); + } + } +} diff --git a/com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/format/resource/FormatResourceDescriptionStrategyTest.java b/com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/format/resource/FormatResourceDescriptionStrategyTest.java new file mode 100644 index 0000000000..61c266f7a9 --- /dev/null +++ b/com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/format/resource/FormatResourceDescriptionStrategyTest.java @@ -0,0 +1,162 @@ +/******************************************************************************* + * 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.format.resource; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.InternalEObject; +import org.eclipse.xtext.resource.IDefaultResourceDescriptionStrategy; +import org.eclipse.xtext.resource.IEObjectDescription; +import org.eclipse.xtext.resource.XtextResourceSet; +import org.eclipse.xtext.testing.InjectWith; +import org.eclipse.xtext.testing.extensions.InjectionExtension; +import org.eclipse.xtext.testing.util.ParseHelper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import com.avaloq.tools.ddk.xtext.format.FormatInjectorProvider; +import com.avaloq.tools.ddk.xtext.format.format.FormatConfiguration; +import com.avaloq.tools.ddk.xtext.format.format.FormatFactory; +import com.google.inject.Inject; +import com.google.inject.Provider; + +/** Regression fixtures for stable exports and transitive Format configuration changes. */ +@ExtendWith(InjectionExtension.class) +@InjectWith(FormatInjectorProvider.class) +@SuppressWarnings("nls") +public class FormatResourceDescriptionStrategyTest { + + private static final String LF = "\n"; + + private static final String CRLF = "\r\n"; + + private static final String BASE_NAME = "Base"; + + private static final String CHILD_NAME = "Child"; + + private static final String EMPTY_NAME = "Empty"; + + @Inject + private ParseHelper parser; + + @Inject + private IDefaultResourceDescriptionStrategy strategy; + + @Inject + private Provider resourceSets; + + @Test + public void identicalReloadsHaveIdenticalExports() throws Exception { + for (String newline : List.of(LF, CRLF)) { + String source = "formatter for example.Base" + newline + "const int SPACING = 1;" + newline; + assertEquals(exports(parse(BASE_NAME, source)), exports(parse(BASE_NAME, source)), "object identity must not affect the index"); + } + } + + @Test + public void inheritedChangesPropagateThroughEmptyIntermediateFormats() throws Exception { + FormatConfiguration base = parse(BASE_NAME, "formatter for example.Base\nconst int SPACING = 1;\n"); + FormatConfiguration child = parse(CHILD_NAME, "formatter for example.Child with example.Base\n"); + FormatConfiguration grandchild = parse("Grandchild", "formatter for example.Grandchild with example.Child\n"); + child.setExtendedFormatConfiguration(base); + grandchild.setExtendedFormatConfiguration(child); + List before = exports(grandchild); + FormatConfiguration changed = parse(BASE_NAME, "formatter for example.Base\nconst int SPACING = 2;\n"); + child.setExtendedFormatConfiguration(changed); + assertNotEquals(before, exports(grandchild), "a real change must reach the grandchild, even through an empty child"); + } + + @Test + public void inheritedSourceLocationsParticipateInExports() throws Exception { + String source = "formatter for example.Base\nconst int SPACING = 1;\n"; + FormatConfiguration child = parse(CHILD_NAME, "formatter for example.Child with example.Base\nconst int LOCAL = 0;\n"); + child.setExtendedFormatConfiguration(parse(BASE_NAME, source)); + List before = exports(child); + child.setExtendedFormatConfiguration(parse(BASE_NAME, "// inserted line\n" + source)); + assertNotEquals(before, exports(child), "inherited source references must be regenerated after a line shift"); + before = exports(child); + child.setExtendedFormatConfiguration(parse(BASE_NAME, ("// inserted line\n" + source).replace(LF, CRLF))); + assertNotEquals(before, exports(child), "line-ending changes affect trace offsets"); + } + + @Test + public void configurationWithoutLocalDeclarationsStillHasAContentFingerprint() throws Exception { + assertNotEquals(exports(parse(EMPTY_NAME, "formatter for example.Empty\n")), + exports(parse(EMPTY_NAME, "// inserted line\nformatter for example.Empty\n"))); + } + + @Test + public void cyclicInheritanceTerminates() throws Exception { + FormatConfiguration first = parse("First", "formatter for example.First\n"); + FormatConfiguration second = parse("Second", "formatter for example.Second\n"); + first.setExtendedFormatConfiguration(second); + second.setExtendedFormatConfiguration(first); + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> assertEquals(exports(first), exports(first))); + } + + @Test + public void unresolvedBaseCanBecomeResolvedWithoutLosingItsContentChange() throws Exception { + FormatConfiguration child = parse(CHILD_NAME, "formatter for example.Child with example.Base\n"); + FormatConfiguration proxy = FormatFactory.eINSTANCE.createFormatConfiguration(); + ((InternalEObject) proxy).eSetProxyURI(URI.createURI("synthetic:/Base.format#/0")); + child.setExtendedFormatConfiguration(proxy); + List unresolved = exports(child); + assertEquals(unresolved, exports(child), "unresolved proxies must have a stable fingerprint"); + child.setExtendedFormatConfiguration(parse(BASE_NAME, "formatter for example.Base\nconst int SPACING = 1;\n")); + assertNotEquals(unresolved, exports(child), "resolved base content must enter the fingerprint"); + } + + @Test + public void unresolvedBasesAreDistinguishedByTheirProxyUri() throws Exception { + FormatConfiguration child = parse(CHILD_NAME, "formatter for example.Child with example.Base\n"); + child.setExtendedFormatConfiguration(null); + final List absent = exports(child); + FormatConfiguration proxy = FormatFactory.eINSTANCE.createFormatConfiguration(); + ((InternalEObject) proxy).eSetProxyURI(URI.createURI("synthetic:/" + BASE_NAME + ".format#/0")); + child.setExtendedFormatConfiguration(proxy); + List unresolved = exports(child); + assertNotEquals(absent, unresolved, "an unresolved base must differ from an absent base"); + FormatConfiguration otherProxy = FormatFactory.eINSTANCE.createFormatConfiguration(); + ((InternalEObject) otherProxy).eSetProxyURI(URI.createURI("synthetic:/Other.format#/0")); + child.setExtendedFormatConfiguration(otherProxy); + assertNotEquals(unresolved, exports(child), "different unresolved bases must differ"); + } + + @Test + public void baseSelectionAndSuperclassArePartOfTheConfiguration() throws Exception { + String source = "formatter for example.Child with example.First\n"; + assertNotEquals(exports(parse(CHILD_NAME, source)), exports(parse(CHILD_NAME, source.replace("First", "Second")))); + assertNotEquals(exports(parse(CHILD_NAME, "formatter for example.Child\n")), + exports(parse(CHILD_NAME, "formatter for example.Child extends example.CustomFormatter\n"))); + } + + private FormatConfiguration parse(final String name, final String source) { + return assertDoesNotThrow(() -> parser.parse(source, URI.createURI("synthetic:/" + name + ".format"), resourceSets.get())); + } + + private List exports(final FormatConfiguration format) { + List descriptions = new ArrayList<>(); + strategy.createEObjectDescriptions(format, descriptions::add); + for (EObject child : format.eContents()) { + strategy.createEObjectDescriptions(child, descriptions::add); + } + return descriptions.stream().map(d -> d.getName().toString() + "@" + d.getEObjectURI()).sorted().toList(); + } +} diff --git a/com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/test/format/FormatTestSuite.java b/com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/test/format/FormatTestSuite.java index b15b42d688..d969fde5e5 100644 --- a/com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/test/format/FormatTestSuite.java +++ b/com.avaloq.tools.ddk.xtext.format.test/src/com/avaloq/tools/ddk/xtext/test/format/FormatTestSuite.java @@ -16,8 +16,10 @@ import com.avaloq.tools.ddk.xtext.format.FormatLineSeparatorBindingTest; import com.avaloq.tools.ddk.xtext.format.FormatParsingTest; import com.avaloq.tools.ddk.xtext.format.builder.FormatBuilderParticipantTest; +import com.avaloq.tools.ddk.xtext.format.builder.FormatIncrementalBuildTest; import com.avaloq.tools.ddk.xtext.format.formatting.FormatFormattingTest; import com.avaloq.tools.ddk.xtext.format.jvmmodel.FormatJvmModelInferrerTest; +import com.avaloq.tools.ddk.xtext.format.resource.FormatResourceDescriptionStrategyTest; import com.avaloq.tools.ddk.xtext.format.scoping.FormatScopingTest; import com.avaloq.tools.ddk.xtext.format.validation.FormatValidationTest; @@ -26,7 +28,7 @@ * Empty class serving only as holder for JUnit5 annotations. */ @Suite -@SelectClasses({FormatParsingTest.class, FormatLineSeparatorBindingTest.class, FormatFormattingTest.class, FormatValidationTest.class, FormatScopingTest.class, FormatBuilderParticipantTest.class, FormatJvmModelInferrerTest.class}) +@SelectClasses({FormatIncrementalBuildTest.class, FormatResourceDescriptionStrategyTest.class, FormatParsingTest.class, FormatLineSeparatorBindingTest.class, FormatFormattingTest.class, FormatValidationTest.class, FormatScopingTest.class, FormatBuilderParticipantTest.class, FormatJvmModelInferrerTest.class}) public class FormatTestSuite { } diff --git a/com.avaloq.tools.ddk.xtext.format/META-INF/MANIFEST.MF b/com.avaloq.tools.ddk.xtext.format/META-INF/MANIFEST.MF index 7246ff1206..cca41acbef 100644 --- a/com.avaloq.tools.ddk.xtext.format/META-INF/MANIFEST.MF +++ b/com.avaloq.tools.ddk.xtext.format/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: com.avaloq.tools.ddk.xtext.format Bundle-SymbolicName: com.avaloq.tools.ddk.xtext.format;singleton:=true -Bundle-Version: 17.3.3.qualifier +Bundle-Version: 17.3.4.qualifier Bundle-Vendor: Avaloq Group AG Bundle-RequiredExecutionEnvironment: JavaSE-21 Bundle-ActivationPolicy: lazy diff --git a/com.avaloq.tools.ddk.xtext.format/pom.xml b/com.avaloq.tools.ddk.xtext.format/pom.xml index 3b5667f9e7..d21c482257 100644 --- a/com.avaloq.tools.ddk.xtext.format/pom.xml +++ b/com.avaloq.tools.ddk.xtext.format/pom.xml @@ -6,7 +6,7 @@ 18.0.1-SNAPSHOT ../ddk-parent - 17.3.3-SNAPSHOT + 17.3.4-SNAPSHOT com.avaloq.tools.ddk com.avaloq.tools.ddk.xtext.format eclipse-plugin diff --git a/com.avaloq.tools.ddk.xtext.format/src/com/avaloq/tools/ddk/xtext/format/resource/FormatResourceDescriptionStrategy.java b/com.avaloq.tools.ddk.xtext.format/src/com/avaloq/tools/ddk/xtext/format/resource/FormatResourceDescriptionStrategy.java index 140b9f1b81..baa766ebcd 100644 --- a/com.avaloq.tools.ddk.xtext.format/src/com/avaloq/tools/ddk/xtext/format/resource/FormatResourceDescriptionStrategy.java +++ b/com.avaloq.tools.ddk.xtext.format/src/com/avaloq/tools/ddk/xtext/format/resource/FormatResourceDescriptionStrategy.java @@ -10,7 +10,12 @@ *******************************************************************************/ package com.avaloq.tools.ddk.xtext.format.resource; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.InternalEObject; import org.eclipse.xtext.AbstractRule; import org.eclipse.xtext.EcoreUtil2; import org.eclipse.xtext.Grammar; @@ -35,26 +40,58 @@ * (not its name) would not trigger a cascade propagation of this change from the base format specification (.format file) to all dependent (child) format * specifications. Therefore, besides a standard export, all the objects are exported under their fingerprints. Therefore when the content (text, value) of the * object changes this would cause a proper invalidations of the dependent formats. + *

+ * Invariant: the fingerprint of a {@link FormatConfiguration} is a function of the resource URI and source text of the configuration and of its resolved + * ancestors, and of nothing else. Object identity does not participate, so reloading unchanged sources yields identical exports, while any change to an + * inherited source (including comments and line endings, which move generated source locations) propagates to every dependent configuration. Renaming or + * moving an inherited source propagates as well, because the generated code records inherited source locations by file name. */ @SuppressWarnings("nls") public class FormatResourceDescriptionStrategy extends DefaultResourceDescriptionStrategy { + /** Initial profile capacity; whole source texts of the inheritance chain are appended. */ + private static final int PROFILE_CAPACITY = 8192; + /** - * A fingerprint computer that computes the hash using the content (text) and the parent container of the given {@link EObject}. + * A fingerprint computer that computes the hash using the source text of the given {@link EObject}; a {@link FormatConfiguration} is hashed together with + * the source text of its whole inheritance chain. */ private final IFingerprintComputer fingerprintComputer = new AbstractFingerprintComputer() { @Override protected ExportItem fingerprint(final EObject obj) { - final StringBuilder profile = new StringBuilder(); - if (obj != null) { - if (obj.eContainer() != null) { - addProfile(profile, obj.eContainer().toString()); - } + final StringBuilder profile = new StringBuilder(PROFILE_CAPACITY); + if (obj instanceof FormatConfiguration configuration) { + addInheritanceChain(profile, configuration); + } else if (obj != null) { addProfile(profile, NodeModelUtils.getTokenText(NodeModelUtils.getNode(obj))); } return new ExportItem(profile); } + + /** + * Adds the resource URI and root node text of the given configuration and of each resolved ancestor; a proxy ancestor contributes its proxy URI and ends + * the chain. + * + * @param profile + * the string builder building the fingerprint + * @param configuration + * the configuration whose inheritance chain is walked + */ + private void addInheritanceChain(final StringBuilder profile, final FormatConfiguration configuration) { + final Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + FormatConfiguration current = configuration; + while (current != null && visited.add(current)) { + if (current.eIsProxy()) { + addProfile(profile, ((InternalEObject) current).eProxyURI().toString()); + return; + } + addProfile(profile, current.eResource() == null ? "" : current.eResource().getURI().toString()); + final INode node = NodeModelUtils.getNode(current); + addProfile(profile, node == null ? "" : node.getRootNode().getText()); + current = current.getExtendedFormatConfiguration(); + } + } }; @Override @@ -65,13 +102,8 @@ public boolean createEObjectDescriptions(final EObject eObject, final IAcceptor< } boolean indexObject = false; - String objectFingerprint = null; - if (fingerprintComputer != null && eObject.eContainer() instanceof FormatConfiguration && NodeModelUtils.getNode(eObject) != null) { - objectFingerprint = fingerprintComputer.computeFingerprint(eObject); - } - - if (objectFingerprint != null && !"".equals(objectFingerprint) && eObject.eContainer() instanceof FormatConfiguration) { - acceptor.accept(EObjectDescription.create(objectFingerprint, eObject)); + if (eObject instanceof FormatConfiguration || (eObject.eContainer() instanceof FormatConfiguration && NodeModelUtils.getNode(eObject) != null)) { + acceptor.accept(EObjectDescription.create(fingerprintComputer.computeFingerprint(eObject), eObject)); indexObject = true; } boolean indexDefault = createDescriptionsForNonXbaseFormalParameters(eObject, acceptor); diff --git a/ddk-repository/category.xml b/ddk-repository/category.xml index c9846439c5..5eb5878ed3 100644 --- a/ddk-repository/category.xml +++ b/ddk-repository/category.xml @@ -1,6 +1,6 @@ - +