diff --git a/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java new file mode 100644 index 000000000000..e08f96e8cdca --- /dev/null +++ b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.spi; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import org.apache.maven.api.Artifact; +import org.apache.maven.api.annotations.Consumer; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.di.Named; + +/** + * SPI for IDE and tool integrators to provide workspace artifact resolution. + * + *

Implementations are discovered via DI and are automatically bridged into the + * resolver's workspace reader chain, replacing the need for the internal + * {@code @Named("ide")} resolver {@code WorkspaceReader} mechanism. + * + *

Implementations should be annotated with {@link Named} and registered as + * Maven core extensions (via {@code .mvn/extensions.xml}). + * + *

Unlike the legacy {@code org.eclipse.aether.repository.WorkspaceReader}, this SPI + * uses Maven 4 API types only (no maven-resolver-api dependency required). + * + * @since 4.1.0 + */ +@Experimental +@Consumer +@Named +public interface WorkspaceReader extends SpiService { + + /** + * Finds the path on disk for the given artifact in the workspace, if present. + * + * @param artifact the artifact to look up, never {@code null} + * @return the path to the artifact file, or empty if not found in workspace + */ + Optional findArtifact(Artifact artifact); + + /** + * Returns the list of available versions for the given artifact in the workspace. + * + * @param artifact the artifact to look up (version is ignored), never {@code null} + * @return list of available versions, may be empty + */ + List findVersions(Artifact artifact); +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java index ba5ea30d1db0..5d444a149c1f 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java +++ b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java @@ -71,6 +71,7 @@ import org.apache.maven.project.MavenProject; import org.apache.maven.resolver.MavenChainedWorkspaceReader; import org.apache.maven.resolver.RepositorySystemSessionFactory; +import org.apache.maven.resolver.SpiWorkspaceReaderAdapter; import org.apache.maven.session.scope.internal.SessionScope; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.RepositorySystemSession.CloseableSession; @@ -213,6 +214,14 @@ private MavenExecutionResult doExecute(MavenExecutionRequest request) { try { MavenChainedWorkspaceReader chainedWorkspaceReader = new MavenChainedWorkspaceReader(request.getWorkspaceReader(), ideWorkspaceReader); + // Add SPI workspace readers to the chain — looked up dynamically so that + // implementations discovered from core extensions are included (extensions + // are loaded after the container is bootstrapped, so constructor injection + // would miss them). + for (org.apache.maven.api.spi.WorkspaceReader spiReader : + lookup.lookupList(org.apache.maven.api.spi.WorkspaceReader.class)) { + chainedWorkspaceReader.addReader(new SpiWorkspaceReaderAdapter(spiReader)); + } try (CloseableSession closeableSession = newCloseableSession(request, chainedWorkspaceReader)) { MavenSession session = new MavenSession(closeableSession, request, result); session.setSession(defaultSessionFactory.newSession(session)); diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultPluginRealmCache.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultPluginRealmCache.java index 1e822a2ccb0c..cf3407022718 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultPluginRealmCache.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultPluginRealmCache.java @@ -200,6 +200,25 @@ public void flush() { cache.clear(); } + @Override + public void invalidate(org.apache.maven.api.Artifact artifact) { + cache.entrySet().removeIf(entry -> { + boolean matches = entry.getValue().getArtifacts().stream() + .anyMatch(a -> a.getGroupId().equals(artifact.getGroupId()) + && a.getArtifactId().equals(artifact.getArtifactId()) + && a.getVersion().equals(artifact.getVersion().toString())); + if (matches) { + ClassRealm realm = entry.getValue().getRealm(); + try { + realm.getWorld().disposeRealm(realm.getId()); + } catch (NoSuchRealmException e) { + // ignore + } + } + return matches; + }); + } + protected static int pluginHashCode(Plugin plugin) { return CacheUtils.pluginHashCode(plugin); } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginRealmCache.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginRealmCache.java index 7fcbd87949a6..9fc68b145d21 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginRealmCache.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginRealmCache.java @@ -94,6 +94,26 @@ default CacheRecord get(Key key, PluginRealmSupplier supplier) void flush(); + /** + * Invalidates all cache entries whose resolved artifacts include the given artifact. + * + *

IDE integrators and other workspace-aware tools can call this method when a workspace + * artifact changes on disk, so that subsequent builds will re-resolve the affected plugin + * realms from the updated sources rather than using a stale cached classloader. + * + *

Implementations may choose to match on {@code groupId:artifactId:version} only, ignoring + * classifier and extension, to maximize the chance of invalidating related entries. + * + *

The default implementation is a no-op (safe for existing implementations that do not + * track artifact-to-entry mappings). + * + * @param artifact the workspace artifact that has changed, never {@code null} + * @since 4.1.0 + */ + default void invalidate(org.apache.maven.api.Artifact artifact) { + // no-op by default + } + /** * Registers the specified cache record for usage with the given project. Integrators can use the information * collected from this method in combination with a custom cache implementation to dispose unused records from the diff --git a/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java b/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java new file mode 100644 index 000000000000..74ccf6b04aee --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.resolver; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import org.apache.maven.api.ArtifactCoordinates; +import org.apache.maven.api.Version; +import org.apache.maven.api.annotations.Nonnull; +import org.eclipse.aether.artifact.Artifact; +import org.eclipse.aether.repository.WorkspaceReader; +import org.eclipse.aether.repository.WorkspaceRepository; + +/** + * Bridge adapter that wraps an {@link org.apache.maven.api.spi.WorkspaceReader SPI WorkspaceReader} + * into a resolver {@link WorkspaceReader}. + * + *

This adapter translates between resolver {@link Artifact} and Maven API + * {@link org.apache.maven.api.Artifact} types, allowing SPI implementations to work + * with Maven 4 API types exclusively while being integrated into the resolver's + * workspace reader chain. + * + * @since 4.1.0 + */ +public class SpiWorkspaceReaderAdapter implements WorkspaceReader { + + private final org.apache.maven.api.spi.WorkspaceReader delegate; + private final WorkspaceRepository repository; + + public SpiWorkspaceReaderAdapter(org.apache.maven.api.spi.WorkspaceReader delegate) { + this.delegate = delegate; + this.repository = new WorkspaceRepository("spi-" + delegate.getClass().getSimpleName()); + } + + @Override + public WorkspaceRepository getRepository() { + return repository; + } + + @Override + public File findArtifact(Artifact artifact) { + Optional result = delegate.findArtifact(toApiArtifact(artifact)); + return result.map(Path::toFile).orElse(null); + } + + @Override + public List findVersions(Artifact artifact) { + return delegate.findVersions(toApiArtifact(artifact)); + } + + /** + * Returns the underlying SPI workspace reader. + */ + public org.apache.maven.api.spi.WorkspaceReader getDelegate() { + return delegate; + } + + /** + * Creates a lightweight Maven API {@link org.apache.maven.api.Artifact} from a resolver artifact + * without requiring an active session. + */ + private static org.apache.maven.api.Artifact toApiArtifact(Artifact artifact) { + return new LightweightApiArtifact(artifact); + } + + /** + * A lightweight implementation of {@link org.apache.maven.api.Artifact} that wraps a resolver artifact + * for the purpose of passing artifact coordinates to SPI workspace readers. + */ + private static class LightweightApiArtifact implements org.apache.maven.api.Artifact { + private final Artifact artifact; + private final String key; + + LightweightApiArtifact(Artifact artifact) { + this.artifact = artifact; + this.key = getGroupId() + + ':' + + getArtifactId() + + ':' + + getExtension() + + (getClassifier().isEmpty() ? "" : ":" + getClassifier()) + + ':' + + artifact.getVersion(); + } + + @Override + public String key() { + return key; + } + + @Nonnull + @Override + public String getGroupId() { + return artifact.getGroupId(); + } + + @Nonnull + @Override + public String getArtifactId() { + return artifact.getArtifactId(); + } + + @Nonnull + @Override + public Version getVersion() { + return new StringVersion(artifact.getVersion()); + } + + @Nonnull + @Override + public Version getBaseVersion() { + return new StringVersion(artifact.getBaseVersion()); + } + + @Nonnull + @Override + public String getExtension() { + return artifact.getExtension(); + } + + @Nonnull + @Override + public String getClassifier() { + return artifact.getClassifier(); + } + + @Override + public boolean isSnapshot() { + return artifact.isSnapshot(); + } + + @Nonnull + @Override + public ArtifactCoordinates toCoordinates() { + throw new UnsupportedOperationException("Lightweight artifact wrapper does not support toCoordinates(); " + + "use Session.createArtifactCoordinates() instead"); + } + + @Override + public boolean equals(Object o) { + return o instanceof org.apache.maven.api.Artifact a && key.equals(a.key()); + } + + @Override + public int hashCode() { + return key.hashCode(); + } + + @Override + public String toString() { + return key; + } + } + + /** + * Simple {@link Version} implementation that wraps a version string. + */ + private record StringVersion(String version) implements Version { + @Override + public int compareTo(Version o) { + return version.compareTo(o.toString()); + } + + @Override + public String toString() { + return version; + } + } +} diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java new file mode 100644 index 000000000000..6b5739259ab5 --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.it; + +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test for the {@code WorkspaceReader} SPI in maven-api-spi (MNG-8766). + * + *

Verifies that SPI workspace readers are: + *

    + *
  1. Discovered via DI and active during the build session
  2. + *
  3. Consulted for regular artifact resolution (dependency resolution, model building, etc.)
  4. + *
+ * + *

The {@code PluginRealmCache.invalidate(Artifact)} SPI allows IDE integrators to purge + * stale plugin realms when workspace artifacts are rebuilt, rather than opting out of plugin + * resolution entirely. + * + * @since 4.1.0 + */ +class MavenITmng8766SpiWorkspaceReaderTest extends AbstractMavenIntegrationTestCase { + + @Test + void testSpiWorkspaceReaderDiscoveredAndConsulted() throws Exception { + Path testDir = extractResources("mng-8766-spi-workspace-reader"); + + // First, install the extension + Verifier verifier = newVerifier(testDir.resolve("extension")); + verifier.addCliArgument("install"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Run the project that uses the extension — process-resources triggers artifact resolution + verifier = newVerifier(testDir.resolve("project")); + verifier.addCliArgument("process-resources"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Verify the SPI workspace reader was created (proves DI discovery works) + verifier.verifyTextInLog("[SPI-WR] created"); + + // Verify the SPI workspace reader was consulted during artifact resolution + // (findArtifact is called for dependencies, model resolution, etc.) + List logLines = verifier.loadLogLines(); + boolean hasFindArtifactCalls = + logLines.stream().anyMatch(line -> line.contains("[SPI-WR] findArtifact(")); + assertTrue( + hasFindArtifactCalls, + "SPI workspace reader should be consulted during artifact resolution"); + } +} diff --git a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/pom.xml b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/pom.xml new file mode 100644 index 000000000000..310e24ae9303 --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/pom.xml @@ -0,0 +1,57 @@ + + + + + org.apache.maven.its.mng8766 + spi-workspace-reader + 0.1 + jar + + Maven Integration Test :: spi-workspace-reader + SPI WorkspaceReader extension for IDE workspace artifact resolution + + + + org.apache.maven + maven-api-spi + 4.1.0-SNAPSHOT + provided + + + org.slf4j + slf4j-api + 2.0.16 + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + full + + + + + diff --git a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/src/main/java/org/apache/maven/its/extensions/TestSpiWorkspaceReader.java b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/src/main/java/org/apache/maven/its/extensions/TestSpiWorkspaceReader.java new file mode 100644 index 000000000000..db766070f182 --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/src/main/java/org/apache/maven/its/extensions/TestSpiWorkspaceReader.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.its.extensions; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.apache.maven.api.Artifact; +import org.apache.maven.api.di.Named; +import org.apache.maven.api.spi.WorkspaceReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Named("test-ide") +public class TestSpiWorkspaceReader implements WorkspaceReader { + + private static final Logger log = LoggerFactory.getLogger(TestSpiWorkspaceReader.class); + + public TestSpiWorkspaceReader() { + log.info("[SPI-WR] created"); + } + + @Override + public Optional findArtifact(Artifact artifact) { + log.info("[SPI-WR] findArtifact({})", artifact.key()); + return Optional.empty(); + } + + @Override + public List findVersions(Artifact artifact) { + log.info("[SPI-WR] findVersions({})", artifact.key()); + return Collections.emptyList(); + } +} diff --git a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/.mvn/extensions.xml b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/.mvn/extensions.xml new file mode 100644 index 000000000000..0b71d00e7a4c --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/.mvn/extensions.xml @@ -0,0 +1,28 @@ + + + + + + + org.apache.maven.its.mng8766 + spi-workspace-reader + 0.1 + + diff --git a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/pom.xml b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/pom.xml new file mode 100644 index 000000000000..7fd6dabec155 --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/pom.xml @@ -0,0 +1,39 @@ + + + + + org.apache.maven.its.mng8766 + test + 0.1 + jar + + Maven Integration Test :: mng-8766 + Verify that SPI WorkspaceReader is discovered and consulted for artifact resolution. + + + + junit + junit + 4.13.2 + test + + + +