feat(sphinxdocs): make SphinxDocsLibraryInfo provider public (#4041)

Custom rules that produce doc files for sphinx_docs currently must
depend on the private sphinx_docs_library rule implementation, since
SphinxDocsLibraryInfo lives under sphinxdocs/private. Expose it via
//sphinxdocs:sphinx_docs_library_info.bzl so custom rules can supply
docs without that dependency.

Add a custom_docs_library test rule and output test verifying a
non-sphinx_docs_library rule can supply docs to sphinx_docs through the
public provider.

---------

Co-authored-by: Richard Levasseur <richardlev@gmail.com>
diff --git a/sphinxdocs/docs/BUILD.bazel b/sphinxdocs/docs/BUILD.bazel
index 58c9e7f..b6c176b 100644
--- a/sphinxdocs/docs/BUILD.bazel
+++ b/sphinxdocs/docs/BUILD.bazel
@@ -51,6 +51,7 @@
         "//sphinxdocs:readthedocs",
         "//sphinxdocs:sphinx",
         "//sphinxdocs:sphinx_docs_library",
+        "//sphinxdocs:sphinx_docs_library_info",
         "//sphinxdocs:sphinx_stardoc",
         "//sphinxdocs/private:sphinx_docs_library",
     ],
diff --git a/sphinxdocs/sphinxdocs/BUILD.bazel b/sphinxdocs/sphinxdocs/BUILD.bazel
index 9a871f5..148d127 100644
--- a/sphinxdocs/sphinxdocs/BUILD.bazel
+++ b/sphinxdocs/sphinxdocs/BUILD.bazel
@@ -64,6 +64,13 @@
 )
 
 bzl_library(
+    name = "sphinx_docs_library_info",
+    srcs = ["sphinx_docs_library_info.bzl"],
+    visibility = ["//visibility:public"],
+    deps = ["//sphinxdocs/private:sphinx_docs_library_info"],
+)
+
+bzl_library(
     name = "sphinx_stardoc",
     srcs = ["sphinx_stardoc.bzl"],
     visibility = ["//visibility:public"],
diff --git a/sphinxdocs/sphinxdocs/private/sphinx_docs_library.bzl b/sphinxdocs/sphinxdocs/private/sphinx_docs_library.bzl
index 076ed72..4ad36dd 100644
--- a/sphinxdocs/sphinxdocs/private/sphinx_docs_library.bzl
+++ b/sphinxdocs/sphinxdocs/private/sphinx_docs_library.bzl
@@ -1,27 +1,18 @@
 """Implementation of sphinx_docs_library."""
 
-load(":sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo")
+load(
+    ":sphinx_docs_library_info.bzl",
+    "SphinxDocsLibraryInfo",
+    "create_sphinx_docs_library_info",
+)
 
 def _sphinx_docs_library_impl(ctx):
-    strip_prefix = ctx.attr.strip_prefix or (ctx.label.package + "/")
-    direct_entries = []
-    if ctx.files.srcs:
-        entry = struct(
-            strip_prefix = strip_prefix,
-            prefix = ctx.attr.prefix,
-            files = ctx.files.srcs,
-        )
-        direct_entries.append(entry)
-
     return [
-        SphinxDocsLibraryInfo(
-            strip_prefix = strip_prefix,
-            prefix = ctx.attr.prefix,
+        create_sphinx_docs_library_info(
             files = ctx.files.srcs,
-            transitive = depset(
-                direct = direct_entries,
-                transitive = [t[SphinxDocsLibraryInfo].transitive for t in ctx.attr.deps],
-            ),
+            prefix = ctx.attr.prefix,
+            strip_prefix = ctx.attr.strip_prefix or (ctx.label.package + "/"),
+            deps = ctx.attr.deps,
         ),
         DefaultInfo(
             files = depset(ctx.files.srcs),
diff --git a/sphinxdocs/sphinxdocs/private/sphinx_docs_library_info.bzl b/sphinxdocs/sphinxdocs/private/sphinx_docs_library_info.bzl
index de40d8d..a441c80 100644
--- a/sphinxdocs/sphinxdocs/private/sphinx_docs_library_info.bzl
+++ b/sphinxdocs/sphinxdocs/private/sphinx_docs_library_info.bzl
@@ -1,30 +1,114 @@
 """Provider for collecting doc files as libraries."""
 
-SphinxDocsLibraryInfo = provider(
-    doc = "Information about a collection of doc files.",
+# NOTE: A provider is used for memory efficiency because providers perform key
+# sharing.
+# buildifier: disable=name-conventions
+SphinxDocsFileset = provider(
+    doc = "A set of doc files sharing the same path manipulation.",
     fields = {
         "files": """
-:type: depset[File]
+:type: tuple[File]
 
-The documentation files for the library.
+The documentation files. A tuple because depset elements must be immutable.
 """,
         "prefix": """
 :type: str
 
-Prefix to prepend to file paths in `files`. It is added after `strip_prefix`
-is removed.
+Prefix to prepend to file paths in `files`. Added after `strip_prefix` is
+removed.
 """,
         "strip_prefix": """
 :type: str
 
-Prefix to remove from file paths in `files`. It is removed before `prefix`
-is prepended.
-""",
-        "transitive": """
-:type: depset[struct]
-
-Depset of transitive library information. Each entry in the depset is a struct
-with fields matching the fields of this provider.
+Prefix to remove from file paths in `files`. Removed before `prefix` is
+prepended.
 """,
     },
 )
+
+SphinxDocsLibraryInfo = provider(
+    doc = "Information about a collection of doc files.",
+    fields = {
+        "files": """
+:type: list[File]
+
+The direct documentation files for the library.
+""",
+        "prefix": """
+:type: str
+
+Prefix to prepend to file paths in `files`. Added after `strip_prefix` is
+removed.
+""",
+        "strip_prefix": """
+:type: str
+
+Prefix to remove from file paths in `files`. Removed before `prefix` is
+prepended.
+""",
+        "transitive": """
+:type: depset[SphinxDocsFileset]
+
+This library's own files and those of its deps.
+
+A rule must include its own {obj}`SphinxDocsFileset` here or its files won't be
+propagated (and thus silently dropped). Use
+{obj}`create_sphinx_docs_library_info` to construct the provider correctly.
+""",
+    },
+)
+
+def create_sphinx_docs_library_info(
+        *,
+        files = [],
+        prefix = "",
+        strip_prefix = "",
+        deps = [],
+        transitives = []):
+    """Creates a {obj}`SphinxDocsLibraryInfo`, populating the `transitive` field.
+
+    Args:
+        files: {type}`list[File]` the direct doc files.
+        prefix: {type}`str` prefix to prepend to `files` paths. Not applied to
+            `deps`.
+        strip_prefix: {type}`str` prefix to remove from `files` paths. Not
+            applied to `deps`.
+        deps: {type}`list[Target]` targets whose {obj}`SphinxDocsLibraryInfo`
+            files are added as transitive (not direct) files. It is not
+            required that targets have the provider; targets without it are
+            ignored.
+        transitives: {type}`list[SphinxDocsFileset] | depset[SphinxDocsFileset]`
+            {obj}`SphinxDocsFileset` objects whose files are added as
+            transitive (not direct) files.
+
+    Returns:
+        {type}`SphinxDocsLibraryInfo`
+    """
+    direct = []
+    if files:
+        direct.append(SphinxDocsFileset(
+            files = tuple(files),
+            prefix = prefix,
+            strip_prefix = strip_prefix,
+        ))
+
+    transitive_depsets = [
+        d[SphinxDocsLibraryInfo].transitive
+        for d in deps
+        if SphinxDocsLibraryInfo in d
+    ]
+    if transitives:
+        if type(transitives) == "depset":
+            transitive_depsets.append(transitives)
+        else:
+            direct.extend(transitives)
+
+    return SphinxDocsLibraryInfo(
+        files = files,
+        prefix = prefix,
+        strip_prefix = strip_prefix,
+        transitive = depset(
+            direct = direct,
+            transitive = transitive_depsets,
+        ),
+    )
diff --git a/sphinxdocs/sphinxdocs/sphinx_docs_library_info.bzl b/sphinxdocs/sphinxdocs/sphinx_docs_library_info.bzl
new file mode 100644
index 0000000..236be66
--- /dev/null
+++ b/sphinxdocs/sphinxdocs/sphinx_docs_library_info.bzl
@@ -0,0 +1,34 @@
+"""Public entry point for SphinxDocsLibraryInfo.
+
+Lets custom rules supply doc files to `sphinx_docs` without depending on the
+`sphinx_docs_library` rule implementation:
+
+```starlark
+load(
+    "@sphinxdocs//sphinxdocs:sphinx_docs_library_info.bzl",
+    "create_sphinx_docs_library_info",
+)
+
+def _my_docs_impl(ctx):
+    return [create_sphinx_docs_library_info(
+        files = ctx.files.srcs,
+        prefix = "my_docs/",
+        strip_prefix = ctx.label.package + "/",
+        deps = ctx.attr.deps,
+    )]
+```
+"""
+
+load(
+    "//sphinxdocs/private:sphinx_docs_library_info.bzl",
+    _SphinxDocsFileset = "SphinxDocsFileset",
+    _SphinxDocsLibraryInfo = "SphinxDocsLibraryInfo",
+    _create_sphinx_docs_library_info = "create_sphinx_docs_library_info",
+)
+
+# buildifier: disable=name-conventions
+SphinxDocsFileset = _SphinxDocsFileset
+
+SphinxDocsLibraryInfo = _SphinxDocsLibraryInfo
+
+create_sphinx_docs_library_info = _create_sphinx_docs_library_info
diff --git a/sphinxdocs/tests/sphinx_docs/BUILD.bazel b/sphinxdocs/tests/sphinx_docs/BUILD.bazel
index 71bc1f3..99b84ce 100644
--- a/sphinxdocs/tests/sphinx_docs/BUILD.bazel
+++ b/sphinxdocs/tests/sphinx_docs/BUILD.bazel
@@ -1,7 +1,7 @@
 load("@bazel_skylib//rules:build_test.bzl", "build_test")
 load("@rules_python//python:py_test.bzl", "py_test")
 load("//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs")
-load(":defs.bzl", "gen_directory")
+load(":defs.bzl", "custom_docs_library", "gen_directory")
 
 # We only build for Linux and Mac because:
 # 1. The actual doc process only runs on Linux
@@ -28,6 +28,7 @@
     sphinx = ":sphinx-build",
     strip_prefix = package_name() + "/",
     target_compatible_with = _TARGET_COMPATIBLE_WITH,
+    deps = [":custom_docs"],
 )
 
 genrule(
@@ -40,6 +41,37 @@
     name = "generated_directory",
 )
 
+custom_docs_library(
+    name = "custom_docs",
+    page_name = "custom_page",
+    prefix = "custom/",
+    transitive_deps = [
+        ":custom_docs_transitive",
+    ],
+    deps = [
+        ":custom_docs_dep",
+        ":custom_docs_empty",
+        ":gen_binary_asset",
+    ],
+)
+
+# The parent's prefix must not be applied to a dep's files.
+custom_docs_library(
+    name = "custom_docs_dep",
+    page_name = "custom_dep_page",
+    prefix = "custom_dep/",
+)
+
+custom_docs_library(
+    name = "custom_docs_transitive",
+    page_name = "custom_transitive_page",
+    prefix = "custom_transitive/",
+)
+
+custom_docs_library(
+    name = "custom_docs_empty",
+)
+
 sphinx_build_binary(
     name = "sphinx-build",
     tags = ["manual"],  # Only needed as part of sphinx doc building
diff --git a/sphinxdocs/tests/sphinx_docs/defs.bzl b/sphinxdocs/tests/sphinx_docs/defs.bzl
index 36fd1db..40a7977 100644
--- a/sphinxdocs/tests/sphinx_docs/defs.bzl
+++ b/sphinxdocs/tests/sphinx_docs/defs.bzl
@@ -1,5 +1,76 @@
 """Supporting code for tests."""
 
+load(
+    "//sphinxdocs:sphinx_docs_library_info.bzl",
+    "SphinxDocsFileset",
+    "SphinxDocsLibraryInfo",
+    "create_sphinx_docs_library_info",
+)
+
+def _custom_docs_library_impl(ctx):
+    files = []
+    if ctx.attr.page_name:
+        out = ctx.actions.declare_file(ctx.attr.page_name + ".md")
+        ctx.actions.write(out, "# {}\n".format(ctx.attr.page_name))
+        files.append(out)
+
+    transitives = []
+    for d in ctx.attr.transitive_deps:
+        if SphinxDocsLibraryInfo in d:
+            transitives.append(d[SphinxDocsLibraryInfo].transitive)
+
+    info = create_sphinx_docs_library_info(
+        files = files,
+        prefix = ctx.attr.prefix,
+        strip_prefix = ctx.label.package + "/",
+        deps = ctx.attr.deps,
+        transitives = transitives[0] if len(transitives) == 1 else transitives,
+    )
+    if type(info.files) != "list":
+        fail("Expected SphinxDocsLibraryInfo.files to be a list, got: {}".format(
+            type(info.files),
+        ))
+
+    # Also test passing a list of SphinxDocsFileset objects to transitives:
+    test_fileset_info = create_sphinx_docs_library_info(
+        transitives = [
+            SphinxDocsFileset(
+                files = tuple(files),
+                prefix = "fileset/",
+                strip_prefix = "",
+            ),
+        ],
+    )
+    if type(test_fileset_info.files) != "list":
+        fail("Expected SphinxDocsLibraryInfo.files to be a list, got: {}".format(
+            type(test_fileset_info.files),
+        ))
+    if test_fileset_info.transitive.to_list():
+        first_fileset = test_fileset_info.transitive.to_list()[0]
+        if type(first_fileset.files) != "tuple":
+            fail("Expected SphinxDocsFileset.files to be a tuple, got: {}".format(
+                type(first_fileset.files),
+            ))
+
+    return [
+        info,
+        DefaultInfo(files = depset(files)),
+    ]
+
+# Verifies a rule that isn't sphinx_docs_library can supply doc files to
+# sphinx_docs using only the public SphinxDocsLibraryInfo entry point.
+custom_docs_library = rule(
+    implementation = _custom_docs_library_impl,
+    attrs = {
+        "deps": attr.label_list(),
+        # When unset, the rule produces no direct files, which exercises the
+        # empty-files path of create_sphinx_docs_library_info.
+        "page_name": attr.string(),
+        "prefix": attr.string(),
+        "transitive_deps": attr.label_list(),
+    },
+)
+
 def _gen_directory_impl(ctx):
     out = ctx.actions.declare_directory(ctx.label.name)
 
diff --git a/sphinxdocs/tests/sphinx_docs/sphinx_docs_output_test.py b/sphinxdocs/tests/sphinx_docs/sphinx_docs_output_test.py
index 5d00817..f4e9104 100644
--- a/sphinxdocs/tests/sphinx_docs/sphinx_docs_output_test.py
+++ b/sphinxdocs/tests/sphinx_docs/sphinx_docs_output_test.py
@@ -23,6 +23,25 @@
                     break
         self.assertEqual("dir_page2.html", actual)
 
+    def test_custom_sphinx_docs_library_info_provider(self):
+        page_path = importlib.resources.files(sphinx_docs).joinpath(
+            "docs/_build/html/custom/custom_page.html"
+        )
+        self.assertTrue(os.path.exists(str(page_path)), f"Not found at {page_path}")
+
+    def test_custom_sphinx_docs_library_info_deps(self):
+        # The dep's own prefix applies; the parent's prefix does not.
+        page_path = importlib.resources.files(sphinx_docs).joinpath(
+            "docs/_build/html/custom_dep/custom_dep_page.html"
+        )
+        self.assertTrue(os.path.exists(str(page_path)), f"Not found at {page_path}")
+
+    def test_custom_sphinx_docs_library_info_transitives(self):
+        page_path = importlib.resources.files(sphinx_docs).joinpath(
+            "docs/_build/html/custom_transitive/custom_transitive_page.html"
+        )
+        self.assertTrue(os.path.exists(str(page_path)), f"Not found at {page_path}")
+
 
 if __name__ == "__main__":
     absltest.main()