001/*
002 * JDrupes Builder
003 * Copyright (C) 2025, 2026 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.builder.mvnrepo;
020
021import com.google.common.flogger.FluentLogger;
022import static com.google.common.flogger.LazyArgs.lazy;
023import java.net.URI;
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.Collection;
027import java.util.Collections;
028import java.util.List;
029import java.util.Objects;
030import java.util.stream.Collectors;
031import java.util.stream.Stream;
032import org.apache.maven.model.DependencyManagement;
033import org.apache.maven.model.Model;
034import org.apache.maven.model.building.DefaultModelBuilderFactory;
035import org.apache.maven.model.building.DefaultModelBuildingRequest;
036import org.apache.maven.model.building.ModelBuildingException;
037import org.apache.maven.model.building.ModelBuildingRequest;
038import org.eclipse.aether.RepositorySystem;
039import org.eclipse.aether.RepositorySystemSession;
040import org.eclipse.aether.artifact.Artifact;
041import org.eclipse.aether.collection.CollectRequest;
042import org.eclipse.aether.graph.Dependency;
043import org.eclipse.aether.graph.DependencyNode;
044import org.eclipse.aether.repository.RemoteRepository;
045import org.eclipse.aether.resolution.ArtifactRequest;
046import org.eclipse.aether.resolution.ArtifactResolutionException;
047import org.eclipse.aether.resolution.DependencyRequest;
048import org.eclipse.aether.resolution.DependencyResolutionException;
049import org.eclipse.aether.util.artifact.SubArtifact;
050import org.eclipse.aether.util.graph.visitor.PreorderDependencyNodeConsumerVisitor;
051import org.jdrupes.builder.api.BuildException;
052import org.jdrupes.builder.api.Resource;
053import org.jdrupes.builder.api.ResourceRequest;
054import org.jdrupes.builder.core.AbstractProvider;
055import static org.jdrupes.builder.mvnrepo.MvnRepoTypes.*;
056
057/// Depending on the request, this provider provides two types of resources.
058/// 
059///  1. The artifacts to be resolved as resources of type [MvnRepoDependency].
060///     The artifacts to be resolved are those added with [resolve].
061///     Note that the result also includes the [MvnRepoBom]s added with
062///     [bom].
063///
064///  2. The resources of type [MvnRepoLibraryJarFile] that result from
065///     resolving the artifacts to be resolved.
066///
067/// The repositories used are those added with [addRepositories]. If
068/// no repositories are configured, the Maven Central repository
069/// is added automatically.
070/// 
071/// Resolving is performed using Maven Resolver (formerly Eclipse Aether)
072/// version 2.x. Dependencies are collected from the specified artifacts after
073/// evaluating their effective Maven models, including any imported
074/// BOMs. Version conflicts are resolved using a "highest wins"
075/// strategy, i.e. the highest version of a dependency encountered in the
076/// dependency graph is selected. Note that this differs from Maven's
077/// default behavior which is "nearest wins".
078/// 
079/// Results of the dependency resolution are written to the log with
080/// log level FINE.
081/// 
082@SuppressWarnings("PMD.CouplingBetweenObjects")
083public class MvnRepoLookup extends AbstractProvider {
084
085    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
086    private final List<RemoteRepository> addedRepos = new ArrayList<>();
087    private final List<String> coordinates = new ArrayList<>();
088    private final List<String> boms = new ArrayList<>();
089    private boolean downloadSources = true;
090    private boolean downloadJavadoc = true;
091    private boolean probeMode;
092
093    /// Initializes a new Maven repository lookup.
094    ///
095    public MvnRepoLookup() {
096        // Make javadoc happy.
097    }
098
099    /// Add repositories to be used for the lookup.
100    ///
101    /// @param repositories the repositories
102    /// @return the mvn repo lookup
103    ///
104    public MvnRepoLookup addRepositories(RemoteRepository... repositories) {
105        for (var repo : Objects.requireNonNull(repositories)) {
106            Objects.requireNonNull(repo);
107        }
108        addedRepos.addAll(Arrays.asList(repositories));
109        return this;
110
111    }
112
113    /// Uses [MavenContext#createRepository] to create a repository
114    /// and adds it to the lookup.
115    ///
116    /// @param id the repository id
117    /// @param uri the repository uri
118    /// @param supported the supported version types
119    /// @return the mvn repo lookup
120    ///
121    public MvnRepoLookup addRepository(
122            String id, URI uri, MvnVersionType... supported) {
123        addedRepos.add(MavenContext.createRepository(id, uri, supported));
124        return this;
125    }
126
127    /// Add a bill of materials. The coordinates are resolved as 
128    /// a dependency with scope `import` which is added to the
129    /// `dependencyManagement` section when evaluating the effective
130    /// model.
131    ///
132    /// @param coordinates the coordinates in the form
133    /// groupId:artifactId:version
134    /// @return the mvn repo lookup
135    ///
136    public MvnRepoLookup bom(String... coordinates) {
137        boms.addAll(Arrays.asList(coordinates));
138        return this;
139    }
140
141    /// Add artifacts, specified by their coordinates
142    /// (`groupId:artifactId:version`) as resources.
143    ///
144    /// @param coordinates the coordinates in the form
145    /// groupId:artifactId:version
146    /// @return the mvn repo lookup
147    ///
148    public MvnRepoLookup resolve(String... coordinates) {
149        this.coordinates.addAll(Arrays.asList(coordinates));
150        return this;
151    }
152
153    /// Add artifacts. The method handles [MvnRepoBom]s correctly.
154    ///
155    /// @param resources the resources
156    /// @return the mvn repo lookup
157    ///
158    public MvnRepoLookup resolve(Stream<? extends MvnRepoResource> resources) {
159        resources.forEach(r -> {
160            if (r instanceof MvnRepoBom) {
161                bom(r.coordinates());
162            } else {
163                resolve(r.coordinates());
164            }
165        });
166        return this;
167    }
168
169    /// Failing to resolve the dependencies normally results in a
170    /// [BuildException], because the requested artifacts are assumed
171    /// to be required for the build.
172    /// 
173    /// By invoking this method the provider enters probe mode
174    /// and returns an empty result stream instead of throwing an
175    /// exception if the resolution fails.
176    ///
177    /// @return the mvn repo lookup
178    ///
179    public MvnRepoLookup probe() {
180        probeMode = true;
181        logger.atFine().log("Probe mode enabled for %s", this);
182        return this;
183    }
184
185    /// Whether to also download the sources. Defaults to `true`.
186    ///
187    /// @param enable the enable
188    /// @return the mvn repo lookup
189    ///
190    public MvnRepoLookup downloadSources(boolean enable) {
191        this.downloadSources = enable;
192        return this;
193    }
194
195    /// Whether to also download the javadoc. Defaults to `true`.
196    ///
197    /// @param enable the enable
198    /// @return the mvn repo lookup
199    ///
200    public MvnRepoLookup downloadJavadoc(boolean enable) {
201        this.downloadJavadoc = enable;
202        return this;
203    }
204
205    @Override
206    protected <T extends Resource> Collection<T>
207            doProvide(ResourceRequest<T> request) {
208        // Check if provided and evaluate for most special type
209        if (!request.accepts(MvnRepoLibraryJarFileType,
210            MvnRepoDependencyType)) {
211            return Collections.emptyList();
212        }
213        if (request.accepts(MvnRepoDependencyType)
214            && !request.isFor(MvnRepoDependencyType)) {
215            @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" })
216            var result = (Collection<T>) context()
217                .resources(this, of(MvnRepoDependencyType)).toList();
218            return result;
219        }
220        if (request.accepts(MvnRepoLibraryJarFileType)
221            && !request.isFor(MvnRepoLibraryJarFileType)) {
222            @SuppressWarnings({ "unchecked" })
223            var result = (Collection<T>) context()
224                .resources(this, of(MvnRepoLibraryJarFileType)).toList();
225            return result;
226        }
227        
228        // Handle request for maven dependencies
229        if (request.accepts(MvnRepoDependencyType)) {
230            return provideMvnDeps();
231        }
232
233        // Handle request for libraries
234        try {
235            return provideJars();
236        } catch (ModelBuildingException e) {
237            throw new BuildException().from(this).cause(e);
238        } catch (DependencyResolutionException e) {
239            if (probeMode) {
240                return Collections.emptyList();
241            }
242            logger.atSevere().withCause(e).log("%s failed to resolve", this);
243            Throwable cause = e;
244            while (cause.getCause() != null) {
245                cause = cause.getCause();
246            }
247            throw new BuildException().from(this).cause(cause);
248        }
249
250    }
251
252    private <T extends Resource> Collection<T> provideMvnDeps() {
253        @SuppressWarnings("unchecked")
254        var boms = (Stream<T>) this.boms.stream()
255            .map(MvnRepoBom::of);
256        @SuppressWarnings("unchecked")
257        var deps = (Stream<T>) coordinates.stream()
258            .map(MvnRepoDependency::of);
259        return Stream.concat(boms, deps).toList();
260    }
261
262    @SuppressWarnings("PMD.AvoidSynchronizedStatement")
263    private <T extends Resource> Collection<T> provideJars()
264            throws DependencyResolutionException, ModelBuildingException {
265        @SuppressWarnings("PMD.CloseResource")
266        var repoSystem = MavenContext.repositorySystem();
267        var repoSession = MavenContext.repositorySession();
268
269        // Create one synthetic CollectRequest
270        var repos = new ArrayList<>(addedRepos);
271        if (repos.isEmpty()) {
272            repos.add(MavenContext.mavenCentral());
273        }
274        CollectRequest collectRequest
275            = new CollectRequest().setRepositories(repos);
276
277        // Add dependencies via their effective model
278        coordinates.stream().parallel().map(c -> depsFromEffectiveModel(
279            c, repoSystem, repoSession, repos)).forEach(deps -> {
280                // collectRequest::addDependency is not thread safe
281                synchronized (collectRequest) {
282                    deps.forEach(collectRequest::addDependency);
283                }
284            });
285
286        // Resolve dependencies - Resolver performs mediation
287        logger.atFine().log("Resolving dependencies: %s",
288            lazy(() -> collectRequest.getDependencies().stream()
289                .map(Dependency::toString).collect(Collectors.joining(", "))));
290        DependencyRequest dependencyRequest
291            = new DependencyRequest(collectRequest, null);
292        DependencyNode rootNode = repoSystem.resolveDependencies(repoSession,
293            dependencyRequest).getRoot();
294        logger.atFine().log("Dependency tree for %s:\n%s", name(),
295            lazy(() -> buildTreeString(rootNode, 0, "", true)));
296        List<DependencyNode> dependencyNodes = new ArrayList<>();
297        rootNode.accept(new PreorderDependencyNodeConsumerVisitor(
298            dependencyNodes::add));
299        @SuppressWarnings("unchecked")
300        var result = (Collection<T>) dependencyNodes.stream()
301            .filter(d -> d.getArtifact() != null)
302            .map(d -> {
303                var artifact = extraDownloads(
304                    repoSystem, repoSession, repos, d.getArtifact());
305                return MvnRepoLibraryJarFile.of(d.getRepositories(),
306                    artifact.toString(), artifact.getPath());
307            }).toList();
308        return result;
309    }
310
311    private Stream<Dependency> depsFromEffectiveModel(
312            String coordinates, RepositorySystem repoSystem,
313            RepositorySystemSession repoSession,
314            List<RemoteRepository> repos) {
315        // First build raw model
316        Model model = new Model();
317        model.setModelVersion("4.0.0");
318        model.setGroupId("model.group");
319        model.setArtifactId("model.artifact");
320        model.setVersion("0.0.0");
321        model.setDescription(name());
322        var depMgmt = new DependencyManagement();
323        model.setDependencyManagement(depMgmt);
324
325        // Build raw model from boms and coordinate
326        for (String bom : boms) {
327            var dep = DependencyConverter
328                .convert(MvnRepoDependency.of(bom), "import");
329            dep.setType("pom");
330            depMgmt.addDependency(dep);
331        }
332        model.addDependency(DependencyConverter.convert(
333            MvnRepoDependency.of(coordinates), "compile"));
334
335        // Now build (derive) effective model and add its dependencies
336        var buildingRequest = new DefaultModelBuildingRequest()
337            .setRawModel(model).setProcessPlugins(false)
338            .setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL)
339            .setModelResolver(
340                new MvnModelResolver(repoSystem, repoSession, repos));
341        try {
342            var effectiveModel = new DefaultModelBuilderFactory()
343                .newInstance().build(buildingRequest).getEffectiveModel();
344            return effectiveModel.getDependencies().stream()
345                .map(DependencyConverter::convert);
346        } catch (ModelBuildingException e) {
347            throw new BuildException().from(this).cause(e);
348        }
349    }
350
351    private Artifact extraDownloads(
352            RepositorySystem repoSystem, RepositorySystemSession repoSession,
353            List<RemoteRepository> repos, Artifact artifact) {
354        if (downloadSources) {
355            downloadSourceJar(repoSystem, repoSession, repos, artifact);
356        }
357        if (downloadJavadoc) {
358            downloadJavadocJar(repoSystem, repoSession, repos, artifact);
359        }
360        return artifact;
361    }
362
363    private void downloadSourceJar(RepositorySystem repoSystem,
364            RepositorySystemSession repoSession,
365            List<RemoteRepository> repos, Artifact jarArtifact) {
366        Artifact sourcesArtifact
367            = new SubArtifact(jarArtifact, "sources", "jar");
368        ArtifactRequest sourcesRequest = new ArtifactRequest();
369        sourcesRequest.setArtifact(sourcesArtifact);
370        sourcesRequest.setRepositories(repos);
371        try {
372            repoSystem.resolveArtifact(repoSession, sourcesRequest);
373        } catch (ArtifactResolutionException e) { // NOPMD
374            // Ignore, sources are optional
375        }
376    }
377
378    private void downloadJavadocJar(RepositorySystem repoSystem,
379            RepositorySystemSession repoSession,
380            List<RemoteRepository> repos, Artifact jarArtifact) {
381        Artifact javadocArtifact
382            = new SubArtifact(jarArtifact, "javadoc", "jar");
383        ArtifactRequest sourcesRequest = new ArtifactRequest();
384        sourcesRequest.setArtifact(javadocArtifact);
385        sourcesRequest.setRepositories(repos);
386        try {
387            repoSystem.resolveArtifact(repoSession, sourcesRequest);
388        } catch (ArtifactResolutionException e) { // NOPMD
389            // Ignore, javadoc is optional
390        }
391    }
392
393    private String buildTreeString(DependencyNode node, int indent,
394            String prefix, boolean isLast) {
395        @SuppressWarnings("PMD.ShortVariable")
396        StringBuilder sb = new StringBuilder();
397        var artifact = node.getArtifact();
398
399        if (indent == 0) {
400            sb.append("root\n");
401        } else {
402            sb.append(prefix).append(isLast ? "`-- " : "|-- ")
403                .append(artifact != null ? artifact.toString() : "node")
404                .append('\n');
405        }
406
407        var children = node.getChildren();
408        String childPrefix
409            = prefix + (indent == 0 ? "  " : isLast ? "    " : "|  ");
410
411        for (int i = 0; i < children.size(); i++) {
412            sb.append(buildTreeString(children.get(i), indent + 1, childPrefix,
413                i == children.size() - 1));
414        }
415        return sb.toString();
416    }
417}