001/*
002 * JDrupes Builder
003 * Copyright (C) 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.bnd;
020
021import aQute.bnd.osgi.Analyzer;
022import aQute.bnd.version.Version;
023import com.google.common.flogger.FluentLogger;
024import static com.google.common.flogger.LazyArgs.lazy;
025import io.vavr.control.Try;
026import java.io.File;
027import java.nio.file.Path;
028import java.util.Map;
029import java.util.Optional;
030import java.util.jar.Attributes;
031import java.util.jar.Manifest;
032import java.util.stream.Collectors;
033import java.util.stream.Stream;
034import org.jdrupes.builder.api.BuildException;
035import org.jdrupes.builder.api.Generator;
036import static org.jdrupes.builder.api.Intent.Consume;
037import static org.jdrupes.builder.api.Intent.Expose;
038import static org.jdrupes.builder.api.Intent.Reveal;
039import static org.jdrupes.builder.api.Intent.Supply;
040import org.jdrupes.builder.api.Project;
041import org.jdrupes.builder.api.Resource;
042import org.jdrupes.builder.api.ResourceProvider;
043import org.jdrupes.builder.api.ResourceRequest;
044import org.jdrupes.builder.api.ResourceType;
045import org.jdrupes.builder.api.Resources;
046import org.jdrupes.builder.api.RootProject;
047import org.jdrupes.builder.java.ClassTree;
048import org.jdrupes.builder.java.JavaCompiler;
049import static org.jdrupes.builder.java.JavaTypes.*;
050import org.jdrupes.builder.java.LibraryJarFile;
051import org.jdrupes.builder.java.ManifestAttributes;
052
053/// A [Generator] that computes OSGi metadata in response to requests for
054/// [ManifestAttributes].
055///
056/// This implementation uses the `bndlib` library from the
057/// [bnd](https://github.com/bndtools/bnd) project to analyze bundle
058/// contents and compute manifest attributes.
059///
060/// When invoked, the analyzer first obtains resources of type [ClassTree]
061/// supplied to the project (typically by a [JavaCompiler]). These class
062/// trees are treated as the content of the bundle.
063///
064/// It then obtains resources of type [LibraryJarFile] from the project's
065/// dependencies with intents `Consume`, `Reveal` and `Expose` (the same
066/// intents as used by the [JavaCompiler] when assembling the compilation
067/// classpath). These library resources are registered as bundle
068/// dependencies.
069///
070/// The collected class tree and library resources are analyzed by `bndlib`
071/// to produce the manifest attributes requested.
072///
073/// Contrary to most [ResourceProvider]s, the [BndAnalyzer] needs project
074/// specific informations (supplied as instructions). This can be handled
075/// in multiple ways. One approach is to add the [BndAnalyzer] with the
076/// instructions in the project’s constructor rather than in 
077/// [RootProject#prepareProject]. Alternatively, put project-specific
078/// instructions in a `bnd.bnd` file in the project's directory, then
079/// register the analyzer in [RootProject#prepareProject] and add the
080/// instructions via [#instructions(Path)], where `Path` refers to the
081/// `bnd.bnd` file.
082///
083@SuppressWarnings("PMD.TooManyStaticImports")
084public class BndAnalyzer extends AbstractBndGenerator {
085
086    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
087
088    /// Initializes a new osgi analyzer.
089    ///
090    /// @param project the project
091    ///
092    public BndAnalyzer(Project project) {
093        super(project);
094    }
095
096    /// Add the instruction specified by key and value.
097    ///
098    /// @param key the key
099    /// @param value the value
100    /// @return the bnd analyzer
101    ///
102    @Override
103    public BndAnalyzer instruction(String key, String value) {
104        super.instruction(key, value);
105        return this;
106    }
107
108    /// Add the given instructions for the analyzer.
109    ///
110    /// @param instructions the instructions
111    /// @return the bnd analyzer
112    ///
113    @Override
114    public BndAnalyzer instructions(Map<String, String> instructions) {
115        super.instructions(instructions);
116        return this;
117    }
118
119    /// Add the instructions from the given bnd (properties) file.
120    ///
121    /// @param bndFile the bnd file
122    /// @return the bnd analyzer
123    ///
124    @Override
125    public BndAnalyzer instructions(Path bndFile) {
126        super.instructions(bndFile);
127        return this;
128    }
129
130    @Override
131    @SuppressWarnings("PMD.AvoidCatchingGenericException")
132    protected <T extends Resource> Stream<T>
133            doProvide(ResourceRequest<T> requested) {
134        if (!requested.accepts(ManifestAttributesType)) {
135            return Stream.empty();
136        }
137        try (var analyzer = new Analyzer();
138                var jar = new aQute.bnd.osgi.Jar("dot")) {
139            // Assemble bundle content
140            var content = newResource(ClassTreesType).addAll(project()
141                .providers().resources(of(ClassTree.class).using(Supply)));
142            // A bnd ("better never document") Jar can actually be a
143            // classfile tree, and several such "Jar"s can be merged.
144            // IOException will be throw (.get()) and handled in the outer try
145            vavrStream(content).find(_ -> true).peek(t -> Try.of(() -> jar
146                .addAll(new aQute.bnd.osgi.Jar(t.root().toFile()))).get());
147            analyzer.setJar(jar);
148            applyInstructions(analyzer);
149
150            // Add classpath dependencies
151            var bundleDeps = newResource(
152                new ResourceType<Resources<LibraryJarFile>>() {}).addAll(
153                    project().providers(Consume, Reveal, Expose)
154                        .resources(project().of(LibraryJarFileType)));
155            logger.atFiner().log("BndAnalyzer in"
156                + " %s uses dependencies %s", project(),
157                lazy(() -> bundleDeps.stream().map(e -> e.path().toString())
158                    .collect(Collectors.joining(File.pathSeparator))));
159            // IOException will be throw (.get()) and handled in the outer try
160            vavrStream(bundleDeps).forEach(dep -> Try
161                .run(() -> analyzer.addClasspath(dep.path().toFile())).get());
162
163            // Evaluate and convert to result type
164            var manifest = analyzer.calcManifest();
165            verifyManifest(manifest);
166            var asResource = newResource(ManifestAttributesType);
167            asResource.putAll(manifest.getMainAttributes());
168            @SuppressWarnings("unchecked")
169            var result = (T) asResource;
170            return Stream.of(result);
171        } catch (Exception e) {
172            throw new BuildException("Failed to generate OSGi metadata: %s", e)
173                .from(this).cause(e);
174        }
175    }
176
177    private void verifyManifest(Manifest manifest) {
178        Optional.ofNullable((String) manifest.getMainAttributes()
179            .get(new Attributes.Name("Bundle-Version"))).ifPresent(v -> {
180                try {
181                    new Version(v);
182                } catch (IllegalArgumentException e) {
183                    throw new BuildException("Attempt to specify invalid"
184                        + " OSGi version %s", v).from(this).cause(e);
185                }
186            });
187
188    }
189}