• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright (C) 2024 The Dagger Authors.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Skylark rules to make publishing Maven artifacts simpler.
15"""
16
17load("@rules_java//java:defs.bzl", "java_library")
18load("@bazel_skylib//lib:unittest.bzl", "asserts", "unittest")
19
20MavenInfo = provider(
21    fields = {
22        "maven_artifacts": """
23        The Maven coordinates for the artifacts that are exported by this target: i.e. the target
24        itself and its transitively exported targets.
25        """,
26        "maven_dependencies": """
27        The Maven coordinates of the direct dependencies, and the transitively exported targets, of
28        this target.
29        """,
30    },
31)
32
33_EMPTY_MAVEN_INFO = MavenInfo(
34    maven_artifacts = depset(),
35    maven_dependencies = depset(),
36)
37
38_MAVEN_COORDINATES_PREFIX = "maven_coordinates="
39
40def _maven_artifacts(targets):
41    return [target[MavenInfo].maven_artifacts for target in targets if MavenInfo in target]
42
43def _collect_maven_info_impl(_target, ctx):
44    tags = getattr(ctx.rule.attr, "tags", [])
45    deps = getattr(ctx.rule.attr, "deps", [])
46    exports = getattr(ctx.rule.attr, "exports", [])
47    runtime_deps = getattr(ctx.rule.attr, "runtime_deps", [])
48
49    maven_artifacts = []
50    for tag in tags:
51        if tag in ("maven:compile_only", "maven:shaded"):
52            return [_EMPTY_MAVEN_INFO]
53        if tag.startswith(_MAVEN_COORDINATES_PREFIX):
54            maven_artifacts.append(tag[len(_MAVEN_COORDINATES_PREFIX):])
55
56    return [MavenInfo(
57        maven_artifacts = depset(maven_artifacts, transitive = _maven_artifacts(exports)),
58        maven_dependencies = depset(
59            [],
60            transitive = _maven_artifacts(deps + exports + runtime_deps),
61        ),
62    )]
63
64_collect_maven_info = aspect(
65    attr_aspects = [
66        "deps",
67        "exports",
68        "runtime_deps",
69    ],
70    doc = """
71    Collects the Maven information for targets, their dependencies, and their transitive exports.
72    """,
73    implementation = _collect_maven_info_impl,
74)
75
76def _prefix_index_of(item, prefixes):
77    """Returns the index of the first value in `prefixes` that is a prefix of `item`.
78
79    If none of the prefixes match, return the size of `prefixes`.
80
81    Args:
82      item: the item to match
83      prefixes: prefixes to match against
84
85    Returns:
86      an integer representing the index of the match described above.
87    """
88    for index, prefix in enumerate(prefixes):
89        if item.startswith(prefix):
90            return index
91    return len(prefixes)
92
93def _sort_artifacts(artifacts, prefixes):
94    """Sorts `artifacts`, preferring group ids that appear earlier in `prefixes`.
95
96    Values in `prefixes` do not need to be complete group ids. For example, passing `prefixes =
97    ['io.bazel']` will match `io.bazel.rules:rules-artifact:1.0`. If multiple prefixes match an
98    artifact, the first one in `prefixes` will be used.
99
100    _Implementation note_: Skylark does not support passing a comparator function to the `sorted()`
101    builtin, so this constructs a list of tuples with elements:
102      - `[0]` = an integer corresponding to the index in `prefixes` that matches the artifact (see
103        `_prefix_index_of`)
104      - `[1]` = parts of the complete artifact, split on `:`. This is used as a tiebreaker when
105        multilple artifacts have the same index referenced in `[0]`. The individual parts are used so
106        that individual artifacts in the same group are sorted correctly - if just the string is used,
107        the colon that separates the artifact name from the version will sort lower than a longer
108        name. For example:
109        -  `com.example.project:base:1
110        -  `com.example.project:extension:1
111        "base" sorts lower than "exension".
112      - `[2]` = the complete artifact. this is a convenience so that after sorting, the artifact can
113      be returned.
114
115    The `sorted` builtin will first compare the index element and if it needs a tiebreaker, will
116    recursively compare the contents of the second element.
117
118    Args:
119      artifacts: artifacts to be sorted
120      prefixes: the preferred group ids used to sort `artifacts`
121
122    Returns:
123      A new, sorted list containing the contents of `artifacts`.
124    """
125    indexed = []
126    for artifact in artifacts:
127        parts = artifact.split(":")
128        indexed.append((_prefix_index_of(parts[0], prefixes), parts, artifact))
129
130    return [x[-1] for x in sorted(indexed)]
131
132DEP_BLOCK = """
133<dependency>
134  <groupId>{0}</groupId>
135  <artifactId>{1}</artifactId>
136  <version>{2}</version>
137</dependency>
138""".strip()
139
140CLASSIFIER_DEP_BLOCK = """
141<dependency>
142  <groupId>{0}</groupId>
143  <artifactId>{1}</artifactId>
144  <version>{2}</version>
145  <type>{3}</type>
146  <classifier>{4}</classifier>
147</dependency>
148""".strip()
149
150DEP_PKG_BLOCK = """
151<dependency>
152  <groupId>{0}</groupId>
153  <artifactId>{1}</artifactId>
154  <packaging>{2}</packaging>
155  <version>{3}</version>
156</dependency>
157""".strip()
158
159def _pom_file(ctx):
160    mvn_deps = depset(
161        [],
162        transitive = [target[MavenInfo].maven_dependencies for target in ctx.attr.targets],
163    )
164
165    formatted_deps = []
166    for dep in _sort_artifacts(mvn_deps.to_list(), ctx.attr.preferred_group_ids):
167        parts = dep.split(":")
168        if ":".join(parts[0:2]) in ctx.attr.excluded_artifacts:
169            continue
170        if len(parts) == 3:
171            template = DEP_BLOCK
172        elif len(parts) == 4:
173            template = DEP_PKG_BLOCK
174        elif len(parts) == 5:
175            template = CLASSIFIER_DEP_BLOCK
176        else:
177            fail("Unknown dependency format: %s" % dep)
178
179        formatted_deps.append(template.format(*parts))
180
181    substitutions = {}
182    substitutions.update(ctx.attr.substitutions)
183    substitutions.update({
184        "{generated_bzl_deps}": "\n".join(formatted_deps),
185        "{pom_version}": ctx.var.get("pom_version", "LOCAL-SNAPSHOT"),
186    })
187
188    ctx.actions.expand_template(
189        template = ctx.file.template_file,
190        output = ctx.outputs.pom_file,
191        substitutions = substitutions,
192    )
193
194pom_file = rule(
195    attrs = {
196        "template_file": attr.label(
197            allow_single_file = True,
198        ),
199        "substitutions": attr.string_dict(
200            allow_empty = True,
201            mandatory = False,
202        ),
203        "targets": attr.label_list(
204            mandatory = True,
205            aspects = [_collect_maven_info],
206        ),
207        "preferred_group_ids": attr.string_list(),
208        "excluded_artifacts": attr.string_list(),
209    },
210    doc = """
211    Creates a Maven POM file for `targets`.
212
213    This rule scans the deps, runtime_deps, and exports of `targets` and their transitive exports,
214    checking each for tags of the form `maven_coordinates=<coords>`. These tags are used to build
215    the list of Maven dependencies for the generated POM.
216
217    Users should call this rule with a `template_file` that contains a `{generated_bzl_deps}`
218    placeholder. The rule will replace this with the appropriate XML for all dependencies.
219    Additional placeholders to replace can be passed via the `substitutions` argument.
220
221    The dependencies included will be sorted alphabetically by groupId, then by artifactId. The
222    `preferred_group_ids` can be used to specify groupIds (or groupId-prefixes) that should be
223    sorted ahead of other artifacts. Artifacts in the same group will be sorted alphabetically.
224
225    Args:
226      name: the name of target. The generated POM file will use this name, with `.xml` appended
227      targets: a list of build target(s) that represent this POM file
228      template_file: a pom.xml file that will be used as a template for the generated POM
229      substitutions: an optional mapping of placeholders to replacement values that will be applied
230        to the `template_file` (e.g. `{'GROUP_ID': 'com.example.group'}`). `{pom_version}` is
231        implicitly included in this mapping and can be configured by passing `bazel build
232        --define=pom_version=<version>`.
233      preferred_group_ids: an optional list of maven groupIds that will be used to sort the
234        generated deps.
235      excluded_artifacts: an optional list of maven artifacts in the format "groupId:artifactId"
236        that should be excluded from the generated pom file.
237    """,
238    outputs = {"pom_file": "%{name}.xml"},
239    implementation = _pom_file,
240)
241
242def _fake_java_library(name, deps = None, exports = None, runtime_deps = None):
243    src_file = ["%s.java" % name]
244    native.genrule(
245        name = "%s_source_file" % name,
246        outs = src_file,
247        cmd = "echo 'class %s {}' > $@" % name,
248    )
249    java_library(
250        name = name,
251        srcs = src_file,
252        tags = ["maven_coordinates=%s:_:_" % name],
253        javacopts = ["-Xep:DefaultPackage:OFF"],
254        deps = deps or [],
255        exports = exports or [],
256        runtime_deps = runtime_deps or [],
257    )
258
259def _maven_info_test_impl(ctx):
260    env = unittest.begin(ctx)
261    asserts.equals(
262        env,
263        expected = sorted(ctx.attr.maven_artifacts),
264        actual = sorted(ctx.attr.target[MavenInfo].maven_artifacts.to_list()),
265        msg = "MavenInfo.maven_artifacts",
266    )
267    asserts.equals(
268        env,
269        expected = sorted(ctx.attr.maven_dependencies),
270        actual = sorted(ctx.attr.target[MavenInfo].maven_dependencies.to_list()),
271        msg = "MavenInfo.maven_dependencies",
272    )
273    return unittest.end(env)
274
275_maven_info_test = unittest.make(
276    _maven_info_test_impl,
277    attrs = {
278        "target": attr.label(aspects = [_collect_maven_info]),
279        "maven_artifacts": attr.string_list(),
280        "maven_dependencies": attr.string_list(),
281    },
282)
283
284def pom_file_tests():
285    """Tests for `pom_file` and `MavenInfo`.
286    """
287    _fake_java_library(name = "FileA")
288    _maven_info_test(
289        name = "FileA_test",
290        target = ":FileA",
291        maven_artifacts = ["FileA:_:_"],
292        maven_dependencies = [],
293    )
294
295    _fake_java_library(
296        name = "DepOnFileA",
297        deps = [":FileA"],
298    )
299    _maven_info_test(
300        name = "DepOnFileA_test",
301        target = ":DepOnFileA",
302        maven_artifacts = ["DepOnFileA:_:_"],
303        maven_dependencies = ["FileA:_:_"],
304    )
305
306    _fake_java_library(
307        name = "RuntimeDepOnFileA",
308        runtime_deps = [":FileA"],
309    )
310    _maven_info_test(
311        name = "RuntimeDepOnFileA_test",
312        target = ":RuntimeDepOnFileA",
313        maven_artifacts = ["RuntimeDepOnFileA:_:_"],
314        maven_dependencies = ["FileA:_:_"],
315    )
316
317    _fake_java_library(
318        name = "ExportsFileA",
319        exports = [":FileA"],
320    )
321    _maven_info_test(
322        name = "ExportsFileA_test",
323        target = ":ExportsFileA",
324        maven_artifacts = [
325            "ExportsFileA:_:_",
326            "FileA:_:_",
327        ],
328        maven_dependencies = ["FileA:_:_"],
329    )
330
331    _fake_java_library(
332        name = "TransitiveExportsFileA",
333        exports = [":ExportsFileA"],
334    )
335    _maven_info_test(
336        name = "TransitiveExportsFileA_test",
337        target = ":TransitiveExportsFileA",
338        maven_artifacts = [
339            "TransitiveExportsFileA:_:_",
340            "ExportsFileA:_:_",
341            "FileA:_:_",
342        ],
343        maven_dependencies = [
344            "ExportsFileA:_:_",
345            "FileA:_:_",
346        ],
347    )
348
349    _fake_java_library(
350        name = "TransitiveDepsFileA",
351        deps = [":ExportsFileA"],
352    )
353    _maven_info_test(
354        name = "TransitiveDepsFileA_test",
355        target = ":TransitiveDepsFileA",
356        maven_artifacts = ["TransitiveDepsFileA:_:_"],
357        maven_dependencies = [
358            "ExportsFileA:_:_",
359            "FileA:_:_",
360        ],
361    )
362