diff --git a/resources/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF b/resources/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF index 2ffa5128ccf..48f9109e6db 100644 --- a/resources/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF +++ b/resources/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: %pluginName Bundle-SymbolicName: org.eclipse.core.resources; singleton:=true -Bundle-Version: 3.24.100.qualifier +Bundle-Version: 3.25.100.qualifier Bundle-Activator: org.eclipse.core.resources.ResourcesPlugin Bundle-Vendor: %providerName Bundle-Localization: plugin diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/BuildManager.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/BuildManager.java index 170f4d33de5..31517c21ee6 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/BuildManager.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/BuildManager.java @@ -49,6 +49,7 @@ import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IProjectDescription; import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.IResourceChangeEvent; import org.eclipse.core.resources.IResourceDelta; import org.eclipse.core.resources.IResourceStatus; import org.eclipse.core.resources.IncrementalProjectBuilder; @@ -262,6 +263,11 @@ private void basicBuild(int trigger, IncrementalProjectBuilder builder, Map= 0) { - getWorkManager().endUnprotected(depth); - } - if (rule != null) { - try { - Job.getJobManager().endRule(rule); - } catch (IllegalArgumentException e) { - throw handleRuleConflict(false, currentBuilder, e); + // Re-acquire the WS lock and release the rule so POST_PROJECT_BUILD + // sees the same locking state as PRE_PROJECT_BUILD did. + try { + if (depth >= 0) { + getWorkManager().endUnprotected(depth); } - } - // Be sure to clean up after ourselves. - if (clean || currentBuilder.wasForgetStateRequested()) { - currentBuilder.setLastBuiltTree(null); - } else if (currentBuilder.wasRememberStateRequested()) { - // If remember last build state, and FULL_BUILD - // last tree must be set to => null for next build - if (trigger == IncrementalProjectBuilder.FULL_BUILD) { - currentBuilder.setLastBuiltTree(null); + if (rule != null) { + try { + Job.getJobManager().endRule(rule); + } catch (IllegalArgumentException e) { + throw handleRuleConflict(false, currentBuilder, e); + } + } + } finally { + // Guarantee the matching POST_PROJECT_BUILD even if the handoff above threw. + try { + workspace.broadcastProjectBuildEvent(builderProject, builderName, + IResourceChangeEvent.POST_PROJECT_BUILD, trigger); + } finally { + // Be sure to clean up after ourselves. + if (clean || currentBuilder.wasForgetStateRequested()) { + currentBuilder.setLastBuiltTree(null); + } else if (currentBuilder.wasRememberStateRequested()) { + // If remember last build state, and FULL_BUILD + // last tree must be set to => null for next build + if (trigger == IncrementalProjectBuilder.FULL_BUILD) { + currentBuilder.setLastBuiltTree(null); + } + // else don't modify the last built tree + } else { + // remember the current state as the last built state. + ElementTree lastTree = workspace.getElementTree(); + lastTree.immutable(); + currentBuilder.setLastBuiltTree(lastTree); + } + hookEndBuild(builder); } - // else don't modify the last built tree - } else { - // remember the current state as the last built state. - ElementTree lastTree = workspace.getElementTree(); - lastTree.immutable(); - currentBuilder.setLastBuiltTree(lastTree); } - hookEndBuild(builder); } } finally { currentBuilders.remove(currentBuilder); diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/NotificationManager.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/NotificationManager.java index 017afaec0c3..784ad5768cd 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/NotificationManager.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/NotificationManager.java @@ -214,6 +214,20 @@ private void cleanUp(ElementTree lastState, int type) { } } + /** + * Dispatches a per-builder build event (PRE_PROJECT_BUILD / POST_PROJECT_BUILD). + * Unlike {@link #broadcastChanges}, this computes no resource delta and does not + * touch {@code lastPostBuildTree}; the events are pure timing/metadata signals + * nested inside the enclosing workspace build. + */ + public void broadcastProjectBuildEvent(IProject project, String builderName, int type, int buildKind) { + if (!listeners.hasListenerFor(type)) { + return; + } + ResourceChangeEvent event = new ResourceChangeEvent(project, type, buildKind, project, builderName); + notify(getListeners(), event, false); + } + /** * Helper method for the save participant lifecycle computation. */ public void broadcastChanges(IResourceChangeListener listener, int type, IResourceDelta delta) { diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeEvent.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeEvent.java index 96ea502d275..07158852431 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeEvent.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeEvent.java @@ -12,6 +12,7 @@ * IBM Corporation - initial API and implementation * James Blackburn (Broadcom Corp.) - ongoing development * Lars Vogel - Bug 473427 + * Vogella GmbH - per-builder build events *******************************************************************************/ package org.eclipse.core.internal.events; @@ -32,6 +33,11 @@ public class ResourceChangeEvent extends EventObject implements IResourceChangeE private int trigger = 0; int type; + /** + * The builder id for PRE_PROJECT_BUILD / POST_PROJECT_BUILD events, or null. + */ + private String builderName; + protected ResourceChangeEvent(Object source, int type, IResource resource) { super(source); this.resource = resource; @@ -45,6 +51,12 @@ public ResourceChangeEvent(Object source, int type, int buildKind, IResourceDelt this.type = type; } + public ResourceChangeEvent(Object source, int type, int buildKind, IResource resource, String builderName) { + this(source, type, resource); + this.trigger = buildKind; + this.builderName = builderName; + } + /** * @see IResourceChangeEvent#findMarkerDeltas(String, boolean) */ @@ -107,6 +119,14 @@ public int getType() { return type; } + /** + * @see IResourceChangeEvent#getBuilderName() + */ + @Override + public String getBuilderName() { + return builderName; + } + public void setDelta(IResourceDelta value) { delta = value; } @@ -133,6 +153,12 @@ public String toDebugString() { case PRE_REFRESH : output.append("PRE_REFRESH"); //$NON-NLS-1$ break; + case PRE_PROJECT_BUILD : + output.append("PRE_PROJECT_BUILD"); //$NON-NLS-1$ + break; + case POST_PROJECT_BUILD : + output.append("POST_PROJECT_BUILD"); //$NON-NLS-1$ + break; default : output.append("?"); //$NON-NLS-1$ break; @@ -154,6 +180,9 @@ public String toDebugString() { break; } output.append("\nResource: " + (resource == null ? "null" : resource)); //$NON-NLS-1$ //$NON-NLS-2$ + if (builderName != null) { + output.append("\nBuilder: " + builderName); //$NON-NLS-1$ + } output.append("\nDelta:" + (delta == null ? " null" : ((ResourceDelta) delta).toDeepDebugString())); //$NON-NLS-1$ //$NON-NLS-2$ return output.toString(); } diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeListenerList.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeListenerList.java index 3fc2de8496e..7c32a72a0d0 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeListenerList.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/events/ResourceChangeListenerList.java @@ -60,6 +60,8 @@ public String toString() { private volatile int count8 = 0; private volatile int count16 = 0; private volatile int count32 = 0; + private volatile int count64 = 0; + private volatile int count128 = 0; /** * The list of listeners. @@ -114,6 +116,12 @@ private void adding(int mask) { if ((mask & 32) != 0) { count32++; } + if ((mask & 64) != 0) { + count64++; + } + if ((mask & 128) != 0) { + count128++; + } } /** @@ -138,6 +146,10 @@ public boolean hasListenerFor(int event) { return count16 > 0; case 32: return count32 > 0; + case 64: + return count64 > 0; + case 128: + return count128 > 0; default: return false; } @@ -170,6 +182,8 @@ public synchronized void clear() { count8 = 0; count16 = 0; count32 = 0; + count64 = 0; + count128 = 0; } private void removing(int mask) { @@ -191,6 +205,12 @@ private void removing(int mask) { if ((mask & 32) != 0) { count32--; } + if ((mask & 64) != 0) { + count64--; + } + if ((mask & 128) != 0) { + count128--; + } } @Override diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Workspace.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Workspace.java index 690cb8fe72e..c6cbc2e0443 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Workspace.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/Workspace.java @@ -458,6 +458,15 @@ public void broadcastBuildEvent(Object source, int type, int buildTrigger) { notificationManager.broadcastChanges(tree, event, false); } + /** + * Broadcasts a per-builder build event. The {@code type} must be either + * {@link IResourceChangeEvent#PRE_PROJECT_BUILD} or + * {@link IResourceChangeEvent#POST_PROJECT_BUILD}. + */ + public void broadcastProjectBuildEvent(IProject project, String builderName, int type, int buildTrigger) { + notificationManager.broadcastProjectBuildEvent(project, builderName, type, buildTrigger); + } + /** * Broadcasts an internal workspace lifecycle event to interested * internal listeners. diff --git a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/resources/IResourceChangeEvent.java b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/resources/IResourceChangeEvent.java index 2d4ad4be70f..b7cb48e56d7 100644 --- a/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/resources/IResourceChangeEvent.java +++ b/resources/bundles/org.eclipse.core.resources/src/org/eclipse/core/resources/IResourceChangeEvent.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2000, 2014 IBM Corporation and others. + * Copyright (c) 2000, 2026 IBM Corporation and others. * * This program and the accompanying materials * are made available under the terms of the Eclipse Public License 2.0 @@ -10,6 +10,7 @@ * * Contributors: * IBM Corporation - initial API and implementation + * Vogella GmbH - per-builder build events *******************************************************************************/ package org.eclipse.core.resources; @@ -86,6 +87,20 @@ * method returns the project being refreshed. * The workspace is closed for changes during notification of these events. * + *
  • + * Before-the-fact report that a single builder is about to run on a single + * project. Event type is PRE_PROJECT_BUILD; getSource + * returns the {@link IProject}, getBuilderName the builder id, + * and getBuildKind the kind of build. Fired once per builder, + * nested inside the enclosing PRE_BUILD/POST_BUILD + * pair, with no resource delta. + *
  • + *
  • + * After-the-fact report that a single builder has run on a single project. + * Event type is POST_PROJECT_BUILD; the accessors behave as for + * PRE_PROJECT_BUILD. Under parallel builds, events for different + * projects may be delivered concurrently on different threads. + *
  • * *

    * In order to handle additional event types that may be introduced @@ -179,6 +194,26 @@ public interface IResourceChangeEvent { */ int PRE_REFRESH = 32; + /** + * Event type constant (bit mask) indicating that a single builder is about to + * run on a single project. getSource returns the {@link IProject}, + * getBuilderName the builder id, and getBuildKind the + * kind of build; no resource delta is provided. + * + * @see #getBuilderName() + * @since 3.25 + */ + int PRE_PROJECT_BUILD = 64; + + /** + * Event type constant (bit mask) indicating that a single builder has run on a + * single project. Accessors behave as for {@link #PRE_PROJECT_BUILD}. + * + * @see #getBuilderName() + * @since 3.25 + */ + int POST_PROJECT_BUILD = 128; + /** * Returns all marker deltas of the specified type that are associated * with resource deltas for this event. If includeSubtypes @@ -262,6 +297,19 @@ public interface IResourceChangeEvent { * @see #PRE_CLOSE * @see #PRE_DELETE * @see #PRE_REFRESH + * @see #PRE_PROJECT_BUILD + * @see #POST_PROJECT_BUILD */ int getType(); + + /** + * Returns the id of the builder this event relates to (as declared in the + * project's build spec) for {@link #PRE_PROJECT_BUILD} and + * {@link #POST_PROJECT_BUILD} events, or null for other events. + * + * @since 3.25 + */ + default String getBuilderName() { + return null; + } } diff --git a/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/builders/ProjectBuildEventTest.java b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/builders/ProjectBuildEventTest.java new file mode 100644 index 00000000000..8ec50040079 --- /dev/null +++ b/resources/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/internal/builders/ProjectBuildEventTest.java @@ -0,0 +1,182 @@ +/******************************************************************************* + * Copyright (c) 2026 Vogella GmbH and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ +package org.eclipse.core.tests.internal.builders; + +import static org.eclipse.core.resources.ResourcesPlugin.getWorkspace; +import static org.eclipse.core.tests.resources.ResourceTestUtil.createTestMonitor; +import static org.eclipse.core.tests.resources.ResourceTestUtil.setAutoBuilding; +import static org.eclipse.core.tests.resources.ResourceTestUtil.updateProjectDescription; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResourceChangeEvent; +import org.eclipse.core.resources.IResourceChangeListener; +import org.eclipse.core.resources.IncrementalProjectBuilder; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.tests.resources.util.WorkspaceResetExtension; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Tests the PRE_PROJECT_BUILD and POST_PROJECT_BUILD events. In particular it + * asserts that events fire even when a builder is short-circuited by the + * needsBuild check (e.g. an incremental build after Project > Clean where no + * source changed). + */ +@ExtendWith(WorkspaceResetExtension.class) +public class ProjectBuildEventTest { + + private record Record(int type, Object source, String builderName, int buildKind) { + } + + private final List events = new CopyOnWriteArrayList<>(); + private final IResourceChangeListener listener = event -> events.add( + new Record(event.getType(), event.getSource(), event.getBuilderName(), event.getBuildKind())); + + @BeforeEach + public void setUp() { + int mask = IResourceChangeEvent.PRE_BUILD | IResourceChangeEvent.POST_BUILD + | IResourceChangeEvent.PRE_PROJECT_BUILD | IResourceChangeEvent.POST_PROJECT_BUILD; + getWorkspace().addResourceChangeListener(listener, mask); + } + + @AfterEach + public void tearDown() { + getWorkspace().removeResourceChangeListener(listener); + } + + @Test + public void testPerBuilderEventsFireOnFullBuild() throws CoreException { + IProject project = createProjectWithBuilder("FULL"); + + events.clear(); + getWorkspace().build(IncrementalProjectBuilder.FULL_BUILD, createTestMonitor()); + + List perBuilder = filter(IResourceChangeEvent.PRE_PROJECT_BUILD, + IResourceChangeEvent.POST_PROJECT_BUILD); + assertEquals(2, perBuilder.size(), "expected one PRE/POST_PROJECT_BUILD pair, got events: " + events); + + Record pre = perBuilder.get(0); + Record post = perBuilder.get(1); + assertEquals(IResourceChangeEvent.PRE_PROJECT_BUILD, pre.type()); + assertEquals(IResourceChangeEvent.POST_PROJECT_BUILD, post.type()); + assertEquals(project, pre.source()); + assertEquals(project, post.source()); + assertEquals(DeltaVerifierBuilder.BUILDER_NAME, pre.builderName()); + assertEquals(DeltaVerifierBuilder.BUILDER_NAME, post.builderName()); + assertEquals(IncrementalProjectBuilder.FULL_BUILD, pre.buildKind()); + assertEquals(IncrementalProjectBuilder.FULL_BUILD, post.buildKind()); + } + + @Test + public void testPerBuilderEventsFireOnCleanBuild() throws CoreException { + createProjectWithBuilder("CLEAN"); + getWorkspace().build(IncrementalProjectBuilder.FULL_BUILD, createTestMonitor()); + + events.clear(); + getWorkspace().build(IncrementalProjectBuilder.CLEAN_BUILD, createTestMonitor()); + List perBuilder = filter(IResourceChangeEvent.PRE_PROJECT_BUILD, + IResourceChangeEvent.POST_PROJECT_BUILD); + assertEquals(2, perBuilder.size(), "CLEAN should fire one PRE/POST_PROJECT_BUILD pair, got: " + events); + assertEquals(IncrementalProjectBuilder.CLEAN_BUILD, perBuilder.get(0).buildKind()); + assertEquals(IncrementalProjectBuilder.CLEAN_BUILD, perBuilder.get(1).buildKind()); + } + + @Test + public void testPerBuilderEventsFireOnIncrementalBuildWithNoDelta() throws CoreException { + createProjectWithBuilder("INC-NO-DELTA"); + // Prime: run a full build so the builder has a last-built tree. + getWorkspace().build(IncrementalProjectBuilder.FULL_BUILD, createTestMonitor()); + + // Second incremental build without any intervening source change. The + // builder should be considered but short-circuited by needsBuild. Events + // must still fire so the Build Monitor view can render the session. + events.clear(); + getWorkspace().build(IncrementalProjectBuilder.INCREMENTAL_BUILD, createTestMonitor()); + List perBuilder = filter(IResourceChangeEvent.PRE_PROJECT_BUILD, + IResourceChangeEvent.POST_PROJECT_BUILD); + assertEquals(2, perBuilder.size(), + "INCREMENTAL with no delta must still fire PRE/POST_PROJECT_BUILD, got: " + events); + } + + @Test + public void testPerBuilderEventsNestedInsideWorkspaceBuild() throws CoreException { + createProjectWithBuilder("NESTED"); + + events.clear(); + getWorkspace().build(IncrementalProjectBuilder.FULL_BUILD, createTestMonitor()); + + List types = new ArrayList<>(); + for (Record r : events) { + types.add(r.type()); + } + int preBuild = types.indexOf(IResourceChangeEvent.PRE_BUILD); + int postBuild = types.indexOf(IResourceChangeEvent.POST_BUILD); + int prePrj = types.indexOf(IResourceChangeEvent.PRE_PROJECT_BUILD); + int postPrj = types.indexOf(IResourceChangeEvent.POST_PROJECT_BUILD); + assertTrue(preBuild >= 0 && postBuild > preBuild, "workspace PRE/POST_BUILD must be present and ordered: " + events); + assertTrue(prePrj > preBuild && prePrj < postBuild, + "PRE_PROJECT_BUILD must be nested inside workspace build: " + events); + assertTrue(postPrj > prePrj && postPrj < postBuild, + "POST_PROJECT_BUILD must come after its PRE and before workspace POST_BUILD: " + events); + } + + @Test + public void testBuilderNameOnlyPopulatedForProjectBuildEvents() throws CoreException { + createProjectWithBuilder("NAME"); + events.clear(); + getWorkspace().build(IncrementalProjectBuilder.FULL_BUILD, createTestMonitor()); + + boolean sawProjectBuildWithName = false; + for (Record r : events) { + if (r.type() == IResourceChangeEvent.PRE_PROJECT_BUILD + || r.type() == IResourceChangeEvent.POST_PROJECT_BUILD) { + assertNotNull(r.builderName(), "per-builder event must carry builder name"); + sawProjectBuildWithName = true; + } else { + assertNull(r.builderName(), "workspace-level event must not carry builder name, got: " + r); + } + } + assertTrue(sawProjectBuildWithName, "expected at least one per-builder event: " + events); + } + + private IProject createProjectWithBuilder(String tag) throws CoreException { + IProject project = getWorkspace().getRoot().getProject("PROJECT-" + tag); + setAutoBuilding(false); + project.create(createTestMonitor()); + project.open(createTestMonitor()); + updateProjectDescription(project).addingCommand(DeltaVerifierBuilder.BUILDER_NAME) + .withTestBuilderId("Builder-" + tag).apply(); + return project; + } + + private List filter(int... types) { + List out = new ArrayList<>(); + for (Record r : events) { + for (int t : types) { + if (r.type() == t) { + out.add(r); + break; + } + } + } + return out; + } +}