Skip to content

Commit c2cddf5

Browse files
joaodinissfclaude
andcommitted
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 focused description regressions 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 <noreply@anthropic.com>
1 parent bd137db commit c2cddf5

10 files changed

Lines changed: 505 additions & 19 deletions

File tree

com.avaloq.tools.ddk.feature/feature.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<feature
33
id="com.avaloq.tools.ddk.feature"
44
label="com.avaloq.tools.ddk.feature"
5-
version="19.2.0.qualifier"
5+
version="19.2.1.qualifier"
66
provider-name="Avaloq Group AG">
77

88
<description>

com.avaloq.tools.ddk.feature/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<version>18.0.1-SNAPSHOT</version>
88
<relativePath>../ddk-parent</relativePath>
99
</parent>
10-
<version>19.2.0-SNAPSHOT</version>
10+
<version>19.2.1-SNAPSHOT</version>
1111

1212
<artifactId>com.avaloq.tools.ddk.feature</artifactId>
1313
<packaging>eclipse-feature</packaging>
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2026 Avaloq Group AG and others.
3+
* All rights reserved. This program and the accompanying materials
4+
* are made available under the terms of the Eclipse Public License v1.0
5+
* which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v10.html
7+
*
8+
* Contributors:
9+
* Avaloq Group AG - initial API and implementation
10+
*******************************************************************************/
11+
package com.avaloq.tools.ddk.xtext.format.builder;
12+
13+
import static org.junit.jupiter.api.Assertions.assertEquals;
14+
import static org.junit.jupiter.api.Assertions.assertFalse;
15+
import static org.junit.jupiter.api.Assertions.assertTrue;
16+
17+
import java.io.ByteArrayInputStream;
18+
import java.io.InputStream;
19+
import java.nio.charset.StandardCharsets;
20+
import java.util.ArrayList;
21+
import java.util.HexFormat;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.TreeMap;
25+
26+
import org.eclipse.core.resources.IFile;
27+
import org.eclipse.core.resources.IFolder;
28+
import org.eclipse.core.resources.IMarker;
29+
import org.eclipse.core.resources.IProject;
30+
import org.eclipse.core.resources.IResource;
31+
import org.eclipse.core.resources.IWorkspace;
32+
import org.eclipse.core.resources.IWorkspaceDescription;
33+
import org.eclipse.core.resources.IncrementalProjectBuilder;
34+
import org.eclipse.core.resources.ResourcesPlugin;
35+
import org.eclipse.core.runtime.CoreException;
36+
import org.eclipse.core.runtime.NullProgressMonitor;
37+
import org.eclipse.core.runtime.jobs.Job;
38+
import org.eclipse.jdt.core.JavaCore;
39+
import org.eclipse.xtext.builder.builderState.IBuilderState;
40+
import org.eclipse.xtext.resource.IResourceDescription;
41+
import org.eclipse.xtext.resource.IResourceDescription.Event.Listener;
42+
import org.eclipse.xtext.ui.XtextProjectHelper;
43+
import org.eclipse.xtext.ui.shared.Access;
44+
import org.eclipse.xtext.ui.testing.util.JavaProjectSetupUtil;
45+
import org.eclipse.xtext.ui.util.PluginProjectFactory;
46+
import org.junit.jupiter.api.Test;
47+
48+
import com.avaloq.tools.ddk.xtext.format.FormatConstants;
49+
import com.avaloq.tools.ddk.xtext.format.ui.internal.FormatActivator;
50+
import com.google.inject.Injector;
51+
52+
/** Exercises the real index, linking and builder with a three-level Format inheritance chain. */
53+
@SuppressWarnings("nls")
54+
public class FormatIncrementalBuildTest {
55+
56+
private static final String PROJECT = "format.fingerprint.regression";
57+
private static final String BASE = "formatter for org.eclipse.xtext.xbase.Xtype\nconst int SPACING = 1;\nID { rule : no_space around; }\n";
58+
private static final String CHILD = "formatter for org.eclipse.xtext.xbase.Xbase with org.eclipse.xtext.xbase.Xtype\n";
59+
private static final String GRANDCHILD = "formatter for org.eclipse.xtext.xbase.annotations.XbaseWithAnnotations with org.eclipse.xtext.xbase.Xbase\nconst int LOCAL = 3;\n";
60+
61+
private static final String BASE_PATH = "src/repro/Xtype.format";
62+
63+
private static final String CHILD_FILE = "Xbase.format";
64+
65+
private static final String GRANDCHILD_FILE = "XbaseWithAnnotations.format";
66+
67+
private static final String LF = "\n";
68+
69+
private static final String CRLF = "\r\n";
70+
71+
@Test
72+
public void incrementalOutputMatchesFullBuildAndIdenticalSavesStayLocal() throws Exception {
73+
final boolean autoBuild = setAutoBuilding(false);
74+
IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(PROJECT);
75+
List<String> indexed = new ArrayList<>();
76+
Listener listener = event -> {
77+
for (IResourceDescription.Delta delta : event.getDeltas()) {
78+
if (delta.getUri().isPlatformResource() && PROJECT.equals(delta.getUri().segment(1)) && "format".equals(delta.getUri().fileExtension())) {
79+
indexed.add(delta.getUri().lastSegment());
80+
}
81+
}
82+
};
83+
IBuilderState index = Access.getIBuilderState().get();
84+
index.addListener(listener);
85+
try {
86+
createFixture(project);
87+
88+
indexed.clear();
89+
write(project, BASE_PATH, BASE);
90+
build(IncrementalProjectBuilder.INCREMENTAL_BUILD);
91+
assertFalse(indexed.contains(CHILD_FILE) || indexed.contains(GRANDCHILD_FILE),
92+
"identical save must not rebuild descendants: " + indexed);
93+
assertMatchesFullBuild(project);
94+
95+
for (String source : List.of(BASE.replace("SPACING = 1", "SPACING = 2"),
96+
"// shifted source location\n" + BASE, BASE.replace("no_space around", "linewrap before"),
97+
BASE.replace(LF, CRLF), BASE.replace("ID {", "const int ADDED = 4;\nID {"), BASE.replace("SPACING", "RENAMED"), BASE)) {
98+
write(project, BASE_PATH, source);
99+
build(IncrementalProjectBuilder.INCREMENTAL_BUILD);
100+
assertMatchesFullBuild(project);
101+
}
102+
String crlf = BASE.replace(LF, CRLF);
103+
write(project, BASE_PATH, crlf);
104+
build(IncrementalProjectBuilder.INCREMENTAL_BUILD);
105+
assertMatchesFullBuild(project);
106+
indexed.clear();
107+
final Map<String, String> unchanged = outputs(project);
108+
write(project, BASE_PATH, crlf);
109+
build(IncrementalProjectBuilder.INCREMENTAL_BUILD);
110+
assertFalse(indexed.contains(CHILD_FILE) || indexed.contains(GRANDCHILD_FILE), "identical CRLF save must stay local");
111+
assertEquals(unchanged, outputs(project), "identical save must preserve all generated bytes");
112+
} finally {
113+
index.removeListener(listener);
114+
if (project.exists()) {
115+
project.delete(true, true, null);
116+
}
117+
setAutoBuilding(autoBuild);
118+
}
119+
}
120+
121+
@Test
122+
public void renamedBaseWithUnchangedContentMatchesFullBuild() throws Exception {
123+
final boolean autoBuild = setAutoBuilding(false);
124+
IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(PROJECT);
125+
try {
126+
createFixture(project);
127+
// The with clause names the grammar, not the file, so the dependents still resolve after the rename.
128+
IFile renamed = project.getFile("src/repro/XtypeRenamed.format");
129+
project.getFile(BASE_PATH).move(renamed.getFullPath(), IResource.FORCE, null);
130+
assertTrue(renamed.exists() && !project.getFile(BASE_PATH).exists(), "rename must move the base file");
131+
build(IncrementalProjectBuilder.INCREMENTAL_BUILD);
132+
assertMatchesFullBuild(project);
133+
} finally {
134+
if (project.exists()) {
135+
project.delete(true, true, null);
136+
}
137+
setAutoBuilding(autoBuild);
138+
}
139+
}
140+
141+
private boolean setAutoBuilding(final boolean enabled) throws CoreException, InterruptedException {
142+
IWorkspace workspace = ResourcesPlugin.getWorkspace();
143+
IWorkspaceDescription description = workspace.getDescription();
144+
final boolean previous = description.isAutoBuilding();
145+
description.setAutoBuilding(enabled);
146+
workspace.setDescription(description);
147+
Job.getJobManager().join(ResourcesPlugin.FAMILY_AUTO_BUILD, null);
148+
return previous;
149+
}
150+
151+
private void createFixture(final IProject project) throws CoreException {
152+
if (project.exists()) {
153+
project.delete(true, true, null);
154+
}
155+
createProject();
156+
write(project, BASE_PATH, BASE);
157+
write(project, "src/repro/Xbase.format", CHILD);
158+
write(project, "src/repro/XbaseWithAnnotations.format", GRANDCHILD);
159+
for (String language : List.of("Xtype", "Xbase", "annotations.XbaseWithAnnotations")) {
160+
String simple = language.substring(language.lastIndexOf('.') + 1);
161+
String pkg = "org.eclipse.xtext.xbase." + (language.startsWith("annotations.") ? "annotations." : "") + "formatting";
162+
write(project, "src/" + pkg.replace('.', '/') + "/" + simple + "Formatter.java",
163+
"package " + pkg + ";\npublic abstract class " + simple + "Formatter extends Abstract" + simple + "Formatter {}\n");
164+
}
165+
build(IncrementalProjectBuilder.FULL_BUILD);
166+
build(IncrementalProjectBuilder.INCREMENTAL_BUILD);
167+
assertNoErrors(project);
168+
assertFalse(outputs(project).isEmpty(), "fixture must produce Java and traces");
169+
assertTrue(outputs(project).keySet().stream().anyMatch(n -> n.endsWith("._trace")), "trace comparison must not be vacuous");
170+
}
171+
172+
private void createProject() throws CoreException {
173+
Injector injector = FormatActivator.getInstance().getInjector(FormatConstants.GRAMMAR);
174+
PluginProjectFactory factory = injector.getInstance(PluginProjectFactory.class);
175+
factory.setProjectName(PROJECT);
176+
factory.addFolders(List.of("src", "src-gen"));
177+
factory.addBuilderIds(JavaCore.BUILDER_ID, "org.eclipse.pde.ManifestBuilder", "org.eclipse.pde.SchemaBuilder", XtextProjectHelper.BUILDER_ID);
178+
factory.addProjectNatures(JavaCore.NATURE_ID, "org.eclipse.pde.PluginNature", XtextProjectHelper.NATURE_ID);
179+
factory.addRequiredBundles(List.of("org.eclipse.xtext", "org.eclipse.xtext.xbase", "org.eclipse.xtext.xbase.lib", "org.eclipse.emf.ecore",
180+
"com.avaloq.tools.ddk.xtext", "com.avaloq.tools.ddk.xtext.format", "org.eclipse.core.runtime"));
181+
IProject project = factory.createProject(new NullProgressMonitor(), null);
182+
JavaProjectSetupUtil.addJreClasspathEntry(JavaCore.create(project));
183+
}
184+
185+
private void assertMatchesFullBuild(final IProject project) throws CoreException {
186+
assertNoErrors(project);
187+
Map<String, String> incremental = outputs(project);
188+
build(IncrementalProjectBuilder.FULL_BUILD);
189+
assertNoErrors(project);
190+
assertEquals(outputs(project), incremental, "incremental Java and trace bytes must match full-build output");
191+
}
192+
193+
private void assertNoErrors(final IProject project) throws CoreException {
194+
List<String> errors = new ArrayList<>();
195+
for (IMarker marker : project.findMarkers(IMarker.PROBLEM, true, IResource.DEPTH_INFINITE)) {
196+
if (marker.getAttribute(IMarker.SEVERITY, 0) == IMarker.SEVERITY_ERROR) {
197+
errors.add(marker.getResource().getProjectRelativePath() + ": " + marker.getAttribute(IMarker.MESSAGE, ""));
198+
}
199+
}
200+
assertTrue(errors.isEmpty(), "fixture must build without errors: " + errors);
201+
}
202+
203+
private Map<String, String> outputs(final IProject project) throws CoreException {
204+
Map<String, String> result = new TreeMap<>();
205+
project.getFolder("src-gen").accept(resource -> {
206+
if (resource instanceof IFile file) {
207+
try (InputStream in = file.getContents()) {
208+
result.put(file.getProjectRelativePath().toString(), HexFormat.of().formatHex(in.readAllBytes()));
209+
} catch (java.io.IOException exception) {
210+
throw new java.io.UncheckedIOException(exception);
211+
}
212+
}
213+
return true;
214+
});
215+
return result;
216+
}
217+
218+
private void build(final int kind) throws CoreException {
219+
ResourcesPlugin.getWorkspace().build(kind, new NullProgressMonitor());
220+
}
221+
222+
private void write(final IProject project, final String path, final String content) throws CoreException {
223+
IFile file = project.getFile(path);
224+
createFolder(file.getParent());
225+
try (InputStream in = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
226+
if (file.exists()) {
227+
file.setContents(in, IResource.FORCE, null);
228+
} else {
229+
file.create(in, true, null);
230+
}
231+
} catch (java.io.IOException exception) {
232+
throw new java.io.UncheckedIOException(exception);
233+
}
234+
}
235+
236+
private void createFolder(final org.eclipse.core.resources.IContainer container) throws CoreException {
237+
if (container instanceof IFolder folder && !folder.exists()) {
238+
createFolder(folder.getParent());
239+
folder.create(true, true, null);
240+
}
241+
}
242+
}

0 commit comments

Comments
 (0)