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