Make our rules output `PackageInfo` where possible (#1232)

diff --git a/BUILD b/BUILD
index 8ed0a3c..9bb85b6 100644
--- a/BUILD
+++ b/BUILD
@@ -12,6 +12,7 @@
     srcs = [
         ":defs.bzl",
         ":specs.bzl",
+        "@rules_license//:docs_deps",
     ],
     visibility = [
         # This library is only visible to allow others who depend on
diff --git a/MODULE.bazel b/MODULE.bazel
index 79499cb..03e14fe 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -5,6 +5,10 @@
 )
 
 bazel_dep(
+    name = "rules_android",
+    version = "0.1.1",
+)
+bazel_dep(
     name = "bazel_features",
     version = "1.15.0",
 )
@@ -17,6 +21,10 @@
     version = "0.0.10",
 )
 bazel_dep(
+    name = "rules_license",
+    version = "1.0.0",
+)
+bazel_dep(
     name = "rules_java",
     version = "7.10.0",
 )
@@ -25,11 +33,6 @@
     version = "1.9.6",
 )
 bazel_dep(
-    name = "rules_android",
-    version = "0.1.1",
-)
-
-bazel_dep(
     name = "stardoc",
     version = "0.7.0",
     dev_dependency = True,
@@ -309,7 +312,12 @@
     name = "jvm_import_test",
     artifacts = [
         "com.google.code.findbugs:jsr305:3.0.2",
+        "com.android.support:appcompat-v7:aar:28.0.0",
     ],
+    repositories = [
+        "https://repo1.maven.org/maven2",
+        "https://maven.google.com",
+    ]
 )
 dev_maven.install(
     name = "m2local_testing",
diff --git a/WORKSPACE b/WORKSPACE
index a49e228..395db37 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -52,6 +52,13 @@
 
 stardoc_repositories()
 
+http_archive(
+    name = "rules_testing",
+    sha256 = "02c62574631876a4e3b02a1820cb51167bb9cdcdea2381b2fa9d9b8b11c407c4",
+    strip_prefix = "rules_testing-0.6.0",
+    url = "https://github.com/bazelbuild/rules_testing/releases/download/v0.6.0/rules_testing-v0.6.0.tar.gz",
+)
+
 # Stardoc also depends on skydoc_repositories, rules_sass, rules_nodejs, but our
 # usage of Stardoc (scripts/generate_docs) doesn't require any of these
 # dependencies. So, we omit them to keep the WORKSPACE file simpler.
diff --git a/private/dependency_tree_parser.bzl b/private/dependency_tree_parser.bzl
index b7de59a..d778bab 100644
--- a/private/dependency_tree_parser.bzl
+++ b/private/dependency_tree_parser.bzl
@@ -28,6 +28,7 @@
     "strip_packaging_and_classifier",
     "strip_packaging_and_classifier_and_version",
 )
+load("//private/lib:coordinates.bzl", "unpack_coordinates")
 
 def _genrule_copy_artifact_from_http_file(artifact, visibilities):
     http_file_repository = escape(artifact["coordinates"])
@@ -121,7 +122,7 @@
     #
     is_dylib = False
     if packaging == "jar":
-        target_import_string.append("\tjars = [\"%s\"]," % artifact_path)
+        target_import_string.append("\tjar = \"%s\"," % artifact_path)
         if srcjar_paths != None and target_label in srcjar_paths:
             target_import_string.append("\tsrcjar = \"%s\"," % srcjar_paths[target_label])
     elif packaging == "aar":
@@ -220,6 +221,30 @@
         target_import_string.append("\t\t\"maven_sha256=%s\"," % artifact["sha256"])
     target_import_string.append("\t],")
 
+    if packaging == "jar":
+        target_import_string.append("\tmaven_coordinates = \"%s\"," % coordinates)
+        if len(artifact["urls"]):
+            target_import_string.append("\tmaven_url = \"%s\"," % artifact["urls"][0])
+    else:
+        unpacked = unpack_coordinates(coordinates)
+        url = artifact["urls"][0] if len(artifact["urls"]) else None
+
+        package_info_name = "%s_package_info" % target_label
+        target_import_string.append("\tapplicable_licenses = [\":%s\"]," % package_info_name)
+        to_return.append("""
+package_info(
+    name = {name},
+    package_name = {coordinates},
+    package_url = {url},
+    package_version = {version},
+)
+""".format(
+            coordinates = repr(coordinates),
+            name = repr(package_info_name),
+            url = repr(url),
+            version = repr(unpacked.version),
+        ))
+
     # 6. If `neverlink` is True in the artifact spec, add the neverlink attribute to make this artifact
     #    available only as a compile time dependency.
     #
diff --git a/private/lib/coordinates.bzl b/private/lib/coordinates.bzl
new file mode 100644
index 0000000..f9772bd
--- /dev/null
+++ b/private/lib/coordinates.bzl
@@ -0,0 +1,133 @@
+def unpack_coordinates(coords):
+    """Takes a maven coordinate and unpacks it into a struct with fields
+    `groupId`, `artifactId`, `version`, `type`, `scope`
+    where type and scope are optional.
+
+    Assumes `coords` is in one of the following syntaxes:
+     * groupId:artifactId[:type[:scope]]:version
+     * groupId:artifactId[:version][@classifier][:type]
+    """
+    if not coords:
+        return None
+
+    parts = coords.split(":")
+    nparts = len(parts)
+
+    if nparts < 2:
+        fail("Unparsed: %s" % coords)
+
+    # Both formats look the same for just `group:artifact`
+    if nparts == 2:
+        return struct(
+            groupId = parts[0],
+            artifactId = parts[1],
+            type = None,
+            scope = None,
+            version = None,
+            classifier = None,
+        )
+
+    # From here, we can be sure we have at least three `parts`
+    if _is_version_number(parts[2]):
+        return _unpack_gradle_format(coords)
+
+    return _unpack_rje_format(coords, parts)
+
+def _is_version_number(part):
+    return part[0].isdigit()
+
+def _unpack_rje_format(coords, parts):
+    nparts = len(parts)
+
+    if nparts < 3 or nparts > 5:
+        fail("Unparsed: %s" % coords)
+
+    version = parts[-1]
+    parts = dict(enumerate(parts[:-1]))
+    return struct(
+        groupId = parts.get(0),
+        artifactId = parts.get(1),
+        type = parts.get(2),
+        scope = parts.get(3),
+        classifier = None,
+        version = version,
+    )
+
+def _unpack_gradle_format(coords):
+    idx = coords.find("@")
+    type = None
+    if idx != -1:
+        type = coords[idx + 1:]
+        coords = coords[0:idx]
+
+    parts = coords.split(":")
+    nparts = len(parts)
+
+    if nparts < 3 or nparts > 4:
+        fail("Unparsed: %s" % coords)
+
+    parts = dict(enumerate(parts))
+
+    return struct(
+        groupId = parts.get(0),
+        artifactId = parts.get(1),
+        version = parts.get(2),
+        classifier = parts.get(3),
+        type = type,
+        scope = None,
+    )
+
+def to_external_form(coords):
+    """Formats `coords` as a string suitable for use by tools such as Gradle.
+
+    The returned format matches Gradle's "external dependency" short-form
+    syntax: `group:name:version:classifier@type`
+    """
+
+    if type(coords) == "string":
+        unpacked = unpack_coordinates(coords)
+    else:
+        unpacked = coords
+
+    to_return = "%s:%s:%s" % (unpacked.groupId, unpacked.artifactId, unpacked.version)
+
+    if hasattr(unpacked, "classifier"):
+        if unpacked.classifier and unpacked.classifier != "jar":
+            to_return += ":" + unpacked.classifier
+
+    if hasattr(unpacked, "type"):
+        if unpacked.type and unpacked.type != "jar":
+            to_return += "@" + unpacked.type
+
+    return to_return
+
+_DEFAULT_PURL_REPOS = [
+    "https://repo.maven.apache.org/maven2",
+    "https://repo.maven.apache.org/maven2/",
+    "https://repo1.maven.org",
+    "https://repo1.maven.org/",
+]
+
+def to_purl(coords, repository):
+    to_return = "pkg:maven/"
+
+    unpacked = unpack_coordinates(coords)
+    to_return += "{group}:{artifact}@{version}".format(
+        artifact = unpacked.artifactId,
+        group = unpacked.groupId,
+        version = unpacked.version,
+    )
+
+    suffix = []
+    if unpacked.classifier:
+        suffix.append("classifier=" + unpacked.classifier)
+    if unpacked.type:
+        suffix.append("type=" + unpacked.type)
+    if repository and repository not in _DEFAULT_PURL_REPOS:
+        # Default repository name is pulled from https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst
+        suffix.append("repository=" + repository)
+
+    if len(suffix):
+        to_return += "?" + "&".join(suffix)
+
+    return to_return
diff --git a/private/lib/urls.bzl b/private/lib/urls.bzl
index 9200027..5f6aa7e 100644
--- a/private/lib/urls.bzl
+++ b/private/lib/urls.bzl
@@ -17,6 +17,20 @@
     url_parts = url_without_protocol.split("/")
     return protocol, url_parts
 
+_REMOTE_SCHEMES = [
+    "ftp",
+    "http",
+    "https",
+]
+
+def scheme_and_host(url):
+    if not url:
+        return None
+
+    new_url = remove_auth_from_url(url)
+    (protocol, url_parts) = split_url(new_url)
+    return protocol + "://" + url_parts[0]
+
 def remove_auth_from_url(url):
     """Returns url without `user:pass@` or `user@`."""
     if "@" not in url:
diff --git a/private/rules/coursier.bzl b/private/rules/coursier.bzl
index 6ef641f..d5fec29 100644
--- a/private/rules/coursier.bzl
+++ b/private/rules/coursier.bzl
@@ -37,6 +37,7 @@
 # package(default_visibility = [{visibilities}])  # https://github.com/bazelbuild/bazel/issues/13681
 
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("@rules_license//rules:package_info.bzl", "package_info")
 load("@rules_jvm_external//private/rules:pin_dependencies.bzl", "pin_dependencies")
 load("@rules_jvm_external//private/rules:jvm_import.bzl", "jvm_import")
 {aar_import_statement}
diff --git a/private/rules/java_export.bzl b/private/rules/java_export.bzl
index 09283f1..80ef633 100644
--- a/private/rules/java_export.bzl
+++ b/private/rules/java_export.bzl
@@ -216,6 +216,7 @@
     maven_project_jar(
         name = "%s-project" % name,
         target = ":%s" % lib_name,
+        maven_coordinates = maven_coordinates,
         manifest_entries = manifest_entries,
         deploy_env = deploy_env,
         excluded_workspaces = excluded_workspaces.keys(),
diff --git a/private/rules/jvm_import.bzl b/private/rules/jvm_import.bzl
index 2fc1eee..0fbbd10 100644
--- a/private/rules/jvm_import.bzl
+++ b/private/rules/jvm_import.bzl
@@ -8,8 +8,10 @@
 # [1]: https://github.com/bazelbuild/bazel/issues/4549
 
 load("@rules_java//java:defs.bzl", "JavaInfo")
+load("@rules_license//rules:providers.bzl", "PackageInfo")
+load("//private/lib:coordinates.bzl", "to_external_form", "to_purl", "unpack_coordinates")
+load("//private/lib:urls.bzl", "scheme_and_host")
 load("//settings:stamp_manifest.bzl", "StampManifestProvider")
-load(":maven_utils.bzl", "unpack_coordinates")
 
 def _jvm_import_impl(ctx):
     if not ctx.attr.jar and not ctx.attr.jars:
@@ -66,6 +68,21 @@
         progress_message = "Creating compile jar for %s" % ctx.label,
     )
 
+    additional_providers = []
+    if ctx.attr.maven_coordinates:
+        unpacked = unpack_coordinates(ctx.attr.maven_coordinates)
+
+        additional_providers.append(
+            PackageInfo(
+                type = "jvm_import",
+                label = ctx.label,
+                package_url = ctx.attr.maven_url,
+                package_version = unpacked.version,
+                package_name = to_external_form(ctx.attr.maven_coordinates),
+                purl = to_purl(ctx.attr.maven_coordinates, scheme_and_host(ctx.attr.maven_url)),
+            ),
+        )
+
     return [
         DefaultInfo(
             files = depset([outjar]),
@@ -81,7 +98,7 @@
             ],
             neverlink = ctx.attr.neverlink,
         ),
-    ]
+    ] + additional_providers
 
 jvm_import = rule(
     attrs = {
@@ -105,6 +122,12 @@
         "neverlink": attr.bool(
             default = False,
         ),
+        "maven_coordinates": attr.string(
+            doc = "The maven coordinates that the `jar` can be downloaded from.",
+        ),
+        "maven_url": attr.string(
+            doc = "URL from where `jar` will be downloaded from.",
+        ),
         "_add_jar_manifest_entry": attr.label(
             executable = True,
             cfg = "exec",
diff --git a/private/rules/maven_bom.bzl b/private/rules/maven_bom.bzl
index a6ae9b8..906c68c 100644
--- a/private/rules/maven_bom.bzl
+++ b/private/rules/maven_bom.bzl
@@ -1,6 +1,7 @@
+load("//private/lib:coordinates.bzl", "unpack_coordinates")
 load(":maven_bom_fragment.bzl", "MavenBomFragmentInfo")
 load(":maven_publish.bzl", "maven_publish")
-load(":maven_utils.bzl", "generate_pom", "unpack_coordinates")
+load(":maven_utils.bzl", "generate_pom")
 
 _NON_EXISTENT_LABEL = Label("//:thisdoesnotexistinrulesjvmexternal")
 
diff --git a/private/rules/maven_project_jar.bzl b/private/rules/maven_project_jar.bzl
index 9753283..8637156 100644
--- a/private/rules/maven_project_jar.bzl
+++ b/private/rules/maven_project_jar.bzl
@@ -1,5 +1,7 @@
 load("@rules_java//java:defs.bzl", "JavaInfo", "java_common")
+load("@rules_license//rules:providers.bzl", "PackageInfo")
 load("//private/lib:bzlmod.bzl", "get_module_name_of_owner_of_repo")
+load("//private/lib:coordinates.bzl", "to_external_form", "to_purl", "unpack_coordinates")
 load(":has_maven_deps.bzl", "MavenInfo", "calculate_artifact_jars", "calculate_artifact_source_jars", "has_maven_deps")
 load(":maven_utils.bzl", "determine_additional_dependencies")
 
@@ -148,6 +150,21 @@
         exports = exported_infos,
     )
 
+    package_info = []
+    if ctx.attr.maven_coordinates:
+        unpacked = unpack_coordinates(ctx.attr.maven_coordinates)
+
+        package_info.append(
+            PackageInfo(
+                type = "java_export",
+                label = ctx.label,
+                package_url = None,
+                package_name = to_external_form(ctx.attr.maven_coordinates),
+                package_version = unpacked.version,
+                purl = to_purl(ctx.attr.maven_coordinates, None),
+            ),
+        )
+
     return [
         DefaultInfo(
             files = depset([bin_jar]),
@@ -163,7 +180,7 @@
             _source_jars = [src_jar],
         ),
         java_info,
-    ]
+    ] + package_info
 
 maven_project_jar = rule(
     _maven_project_jar_impl,
@@ -186,6 +203,9 @@
                 has_maven_deps,
             ],
         ),
+        "maven_coordinates": attr.string(
+            doc = "Coordinates that this artifact will be published from",
+        ),
         "manifest_entries": attr.string_dict(
             doc = "A dict of `String: String` containing additional manifest entry attributes and values.",
         ),
diff --git a/private/rules/maven_utils.bzl b/private/rules/maven_utils.bzl
index ac46e81..08bdb54 100644
--- a/private/rules/maven_utils.bzl
+++ b/private/rules/maven_utils.bzl
@@ -1,40 +1,9 @@
 load("//private/lib:bzlmod.bzl", "get_module_name_of_owner_of_repo")
+load("//private/lib:coordinates.bzl", _unpack_coordinates = "unpack_coordinates")
 
 def unpack_coordinates(coords):
-    """Takes a maven coordinate and unpacks it into a struct with fields
-    `groupId`, `artifactId`, `version`, `type`, `scope`
-    where type and scope are optional.
-
-    Assumes following maven coordinate syntax:
-    groupId:artifactId[:type[:scope]]:version
-    """
-    if not coords:
-        return None
-
-    parts = coords.split(":")
-    nparts = len(parts)
-
-    if nparts == 2:
-        return struct(
-            groupId = parts[0],
-            artifactId = parts[1],
-            type = None,
-            scope = None,
-            version = None,
-        )
-
-    if nparts < 3 or nparts > 5:
-        fail("Unparsed: %s" % coords)
-
-    version = parts[-1]
-    parts = dict(enumerate(parts[:-1]))
-    return struct(
-        groupId = parts.get(0),
-        artifactId = parts.get(1),
-        type = parts.get(2),
-        scope = parts.get(3),
-        version = version,
-    )
+    print("Please load `unpack_coordinates` from `@rules_jvm_external//private/lib:coordinates.bzl`.")
+    return _unpack_coordinates(coords)
 
 def _whitespace(indent):
     whitespace = ""
@@ -89,7 +58,7 @@
         unversioned_dep_coordinates = [],
         runtime_deps = [],
         indent = 8):
-    unpacked_coordinates = unpack_coordinates(coordinates)
+    unpacked_coordinates = _unpack_coordinates(coordinates)
     substitutions = {
         "{groupId}": unpacked_coordinates.groupId,
         "{artifactId}": unpacked_coordinates.artifactId,
@@ -100,7 +69,7 @@
 
     if parent:
         # We only want the groupId, artifactID, and version
-        unpacked_parent = unpack_coordinates(parent)
+        unpacked_parent = _unpack_coordinates(parent)
 
         whitespace = _whitespace(indent - 4)
         parts = [
@@ -116,7 +85,7 @@
     deps = []
     for dep in sorted(versioned_dep_coordinates) + sorted(unversioned_dep_coordinates):
         include_version = dep in versioned_dep_coordinates
-        unpacked = unpack_coordinates(dep)
+        unpacked = _unpack_coordinates(dep)
         new_scope = "runtime" if dep in runtime_deps else unpacked.scope
         unpacked = struct(
             groupId = unpacked.groupId,
diff --git a/providers.bzl b/providers.bzl
new file mode 100644
index 0000000..21d7944
--- /dev/null
+++ b/providers.bzl
@@ -0,0 +1,6 @@
+load("//private/rules:has_maven_deps.bzl", _MavenHintInfo = "MavenHintInfo", _MavenInfo = "MavenInfo")
+load("//private/rules:maven_publish.bzl", _MavenPublishInfo = "MavenPublishInfo")
+
+MavenHintInfo = _MavenHintInfo
+MavenInfo = _MavenInfo
+MavenPublishInfo = _MavenPublishInfo
diff --git a/repositories.bzl b/repositories.bzl
index 77c304f..a923f60 100644
--- a/repositories.bzl
+++ b/repositories.bzl
@@ -47,6 +47,16 @@
             sha256 = "eb5447f019734b0c4284eaa5f8248415084da5445ba8201c935a211ab8af43a0",
         )
 
+    maybe(
+        http_archive,
+        name = "rules_license",
+        urls = [
+            "https://mirror.bazel.build/github.com/bazelbuild/rules_license/releases/download/1.0.0/rules_license-1.0.0.tar.gz",
+            "https://github.com/bazelbuild/rules_license/releases/download/1.0.0/rules_license-1.0.0.tar.gz",
+        ],
+        sha256 = "26d4021f6898e23b82ef953078389dd49ac2b5618ac564ade4ef87cced147b38",
+    )
+
     maven_install(
         name = "rules_jvm_external_deps",
         artifacts = [
diff --git a/tests/unit/jvm_import/jvm_import_test.bzl b/tests/unit/jvm_import/jvm_import_test.bzl
index bc0199a..4e6316a 100644
--- a/tests/unit/jvm_import/jvm_import_test.bzl
+++ b/tests/unit/jvm_import/jvm_import_test.bzl
@@ -17,6 +17,9 @@
 """
 
 load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts")
+load("@rules_license//rules:providers.bzl", "PackageInfo")
+load("@rules_license//rules:gather_metadata.bzl", "gather_metadata_info")
+load("@rules_license//rules_gathering:gathering_providers.bzl", "TransitiveMetadataInfo")
 
 TagsInfo = provider(
     doc = "Provider to propagate jvm_import's tags for testing purposes",
@@ -59,12 +62,71 @@
     },
 )
 
+def _does_jvm_import_export_a_package_provider_impl(ctx):
+    env = analysistest.begin(ctx)
+
+    asserts.true(env, PackageInfo in ctx.attr.src)
+    package_info = ctx.attr.src[PackageInfo]
+    asserts.equals(env, "pkg:maven/com.google.code.findbugs:jsr305@3.0.2", package_info.purl)
+
+    # The metadata is applied directly to the target in this case, so there should
+    # not be any transitive metadata. Apparently.
+# TODO: restore once https://github.com/bazelbuild/rules_license/issues/154 is resolved
+#    metadata_info = ctx.attr.src[TransitiveMetadataInfo]
+#    asserts.equals(env, depset(), metadata_info.package_info)
+
+    return analysistest.end(env)
+
+does_jvm_import_export_a_package_provider_test = analysistest.make(
+    _does_jvm_import_export_a_package_provider_impl,
+    attrs = {
+        "src": attr.label(
+            doc = "Target to traverse for providers",
+            aspects = [gather_metadata_info],
+            mandatory = True,
+        )
+    }
+)
+
+def _does_non_jvm_import_target_carry_metadata(ctx):
+    env = analysistest.begin(ctx)
+
+    asserts.false(env, PackageInfo in ctx.attr.src)
+
+    metadata_info = ctx.attr.src[TransitiveMetadataInfo]
+    infos = metadata_info.package_info.to_list()
+    asserts.equals(env, 1, len(infos))
+
+    return analysistest.end(env)
+
+does_non_jvm_import_target_carry_metadata_test = analysistest.make(
+    _does_non_jvm_import_target_carry_metadata,
+    attrs = {
+        "src": attr.label(
+            doc = "Target to traverse for providers",
+            aspects = [gather_metadata_info],
+            mandatory = True,
+        )
+    }
+)
+
 def jvm_import_test_suite(name):
     does_jvm_import_have_tags_test(
         name = "does_jvm_import_have_tags_test",
         target_under_test = "@jvm_import_test//:com_google_code_findbugs_jsr305_3_0_2",
         src = "@jvm_import_test//:com_google_code_findbugs_jsr305_3_0_2",
     )
+    does_jvm_import_export_a_package_provider_test(
+        name = "does_jvm_import_export_a_package_provider",
+        target_under_test = "@jvm_import_test//:com_google_code_findbugs_jsr305",
+        src = "@jvm_import_test//:com_google_code_findbugs_jsr305",
+    )
+# TODO: restore once https://github.com/bazelbuild/rules_license/issues/154 is resolved
+#    does_non_jvm_import_target_carry_metadata_test(
+#        name = "does_non_jvm_import_target_carry_metadata",
+#        target_under_test = "@jvm_import_test//:com_android_support_appcompat_v7",
+#        src = "@jvm_import_test//:com_android_support_appcompat_v7",
+#    )
     native.test_suite(
         name = name,
         tests = [