Add helper functions for module extensions as `modules` (#456)

Adds a new module `modules` with two helper functions for module
extensions:

* `use_all_repos` makes it easy to return an appropriate
  `extension_metadata` from a module extension (if supported) to
  indicate that all repositories generated by the extension should be
  imported via `use_repo`.
* `as_extension` turns a WORKSPACE macro into a module extension that
  uses `use_all_repos` to automate the generation of `use_repo` calls.
diff --git a/MODULE.bazel b/MODULE.bazel
index 1d8de47..54d01c2 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -26,3 +26,9 @@
     module_name = "bazel_skylib_gazelle_plugin",
     path = "gazelle",
 )
+
+as_extension_test_ext = use_extension("//tests:modules_test.bzl", "as_extension_test_ext")
+use_repo(as_extension_test_ext, "bar", "foo")
+
+use_all_repos_test_ext = use_extension("//tests:modules_test.bzl", "use_all_repos_test_ext")
+use_repo(use_all_repos_test_ext, "baz", "qux")
diff --git a/README.md b/README.md
index 0c13d32..2c22da7 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@
 * [paths](docs/paths_doc.md)
 * [selects](docs/selects_doc.md)
 * [sets](lib/sets.bzl) - _deprecated_, use `new_sets`
+* [modules](docs/modules_doc.md)
 * [new_sets](docs/new_sets_doc.md)
 * [shell](docs/shell_doc.md)
 * [structs](docs/structs_doc.md)
diff --git a/docs/BUILD b/docs/BUILD
index a809f2f..b25bcce 100644
--- a/docs/BUILD
+++ b/docs/BUILD
@@ -63,6 +63,12 @@
 )
 
 stardoc_with_diff_test(
+    name = "modules",
+    bzl_library_target = "//lib:modules",
+    out_label = "//docs:modules_doc.md",
+)
+
+stardoc_with_diff_test(
     name = "native_binary",
     bzl_library_target = "//rules:native_binary",
     out_label = "//docs:native_binary_doc.md",
diff --git a/docs/modules_doc.md b/docs/modules_doc.md
new file mode 100755
index 0000000..1b8a049
--- /dev/null
+++ b/docs/modules_doc.md
@@ -0,0 +1,76 @@
+<!-- Generated with Stardoc: http://skydoc.bazel.build -->
+
+Skylib module containing utilities for Bazel modules and module extensions.
+
+<a id="modules.as_extension"></a>
+
+## modules.as_extension
+
+<pre>
+modules.as_extension(<a href="#modules.as_extension-macro">macro</a>, <a href="#modules.as_extension-doc">doc</a>)
+</pre>
+
+Wraps a WORKSPACE dependency macro into a module extension.
+
+Example:
+```starlark
+def rules_foo_deps(optional_arg = True):
+    some_repo_rule(name = "foobar")
+    http_archive(name = "bazqux")
+
+rules_foo_deps_ext = modules.as_extension(rules_foo_deps)
+```
+
+
+**PARAMETERS**
+
+
+| Name  | Description | Default Value |
+| :------------- | :------------- | :------------- |
+| <a id="modules.as_extension-macro"></a>macro |  A [WORKSPACE dependency macro](https://bazel.build/rules/deploying#dependencies), i.e., a function with no required parameters that instantiates one or more repository rules.   |  none |
+| <a id="modules.as_extension-doc"></a>doc |  A description of the module extension that can be extracted by documentation generating tools.   |  <code>None</code> |
+
+**RETURNS**
+
+A module extension that generates the repositories instantiated by the given macro and also
+uses [`use_all_repos`](#use_all_repos) to indicate that all of those repositories should be
+imported via `use_repo`.
+
+
+<a id="modules.use_all_repos"></a>
+
+## modules.use_all_repos
+
+<pre>
+modules.use_all_repos(<a href="#modules.use_all_repos-module_ctx">module_ctx</a>)
+</pre>
+
+Return from a module extension that should have all its repositories imported via `use_repo`.
+
+Example:
+```starlark
+def _ext_impl(module_ctx):
+    some_repo_rule(name = "foobar")
+    http_archive(name = "bazqux")
+    return modules.use_all_repos(module_ctx)
+
+ext = module_extension(_ext_impl)
+```
+
+
+**PARAMETERS**
+
+
+| Name  | Description | Default Value |
+| :------------- | :------------- | :------------- |
+| <a id="modules.use_all_repos-module_ctx"></a>module_ctx |  The [<code>module_ctx</code>](https://bazel.build/rules/lib/builtins/module_ctx) object passed to the module extension's implementation function.   |  none |
+
+**RETURNS**
+
+An [`extension_metadata`](https://bazel.build/rules/lib/builtins/extension_metadata.html)
+object that, when returned from a module extension implementation function, specifies that all
+repositories generated by this extension should be imported via `use_repo`. If the current
+version of Bazel doesn't support `extension_metadata`, returns `None` instead, which can
+safely be returned from a module extension implementation function in all versions of Bazel.
+
+
diff --git a/lib/BUILD b/lib/BUILD
index 2328081..08a7173 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -21,6 +21,11 @@
 )
 
 bzl_library(
+    name = "modules",
+    srcs = ["modules.bzl"],
+)
+
+bzl_library(
     name = "partial",
     srcs = ["partial.bzl"],
 )
diff --git a/lib/modules.bzl b/lib/modules.bzl
new file mode 100644
index 0000000..21f3a1b
--- /dev/null
+++ b/lib/modules.bzl
@@ -0,0 +1,99 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Skylib module containing utilities for Bazel modules and module extensions."""
+
+def _as_extension(macro, doc = None):
+    """Wraps a WORKSPACE dependency macro into a module extension.
+
+    Example:
+    ```starlark
+    def rules_foo_deps(optional_arg = True):
+        some_repo_rule(name = "foobar")
+        http_archive(name = "bazqux")
+
+    rules_foo_deps_ext = modules.as_extension(rules_foo_deps)
+    ```
+
+    Args:
+      macro: A [WORKSPACE dependency macro](https://bazel.build/rules/deploying#dependencies), i.e.,
+          a function with no required parameters that instantiates one or more repository rules.
+      doc: A description of the module extension that can be extracted by documentation generating
+          tools.
+
+    Returns:
+      A module extension that generates the repositories instantiated by the given macro and also
+      uses [`use_all_repos`](#use_all_repos) to indicate that all of those repositories should be
+      imported via `use_repo`.
+    """
+
+    def _ext_impl(module_ctx):
+        macro()
+        return _use_all_repos(module_ctx)
+
+    return module_extension(
+        implementation = _ext_impl,
+        doc = doc,
+    )
+
+def _use_all_repos(module_ctx):
+    """Return from a module extension that should have all its repositories imported via `use_repo`.
+
+    Example:
+    ```starlark
+    def _ext_impl(module_ctx):
+        some_repo_rule(name = "foobar")
+        http_archive(name = "bazqux")
+        return modules.use_all_repos(module_ctx)
+
+    ext = module_extension(_ext_impl)
+    ```
+
+    Args:
+      module_ctx: The [`module_ctx`](https://bazel.build/rules/lib/builtins/module_ctx) object
+          passed to the module extension's implementation function.
+
+    Returns:
+      An [`extension_metadata`](https://bazel.build/rules/lib/builtins/extension_metadata.html)
+      object that, when returned from a module extension implementation function, specifies that all
+      repositories generated by this extension should be imported via `use_repo`. If the current
+      version of Bazel doesn't support `extension_metadata`, returns `None` instead, which can
+      safely be returned from a module extension implementation function in all versions of Bazel.
+    """
+
+    # module_ctx.extension_metadata is available in Bazel 6.2.0 and later.
+    # If not available, returning None from a module extension is equivalent to not returning
+    # anything.
+    extension_metadata = getattr(module_ctx, "extension_metadata", None)
+    if not extension_metadata:
+        return None
+
+    # module_ctx.root_module_has_non_dev_dependency is available in Bazel 6.3.0 and later.
+    root_module_has_non_dev_dependency = getattr(
+        module_ctx,
+        "root_module_has_non_dev_dependency",
+        None,
+    )
+    if root_module_has_non_dev_dependency == None:
+        return None
+
+    return extension_metadata(
+        root_module_direct_deps = "all" if root_module_has_non_dev_dependency else [],
+        root_module_direct_dev_deps = [] if root_module_has_non_dev_dependency else "all",
+    )
+
+modules = struct(
+    as_extension = _as_extension,
+    use_all_repos = _use_all_repos,
+)
diff --git a/tests/BUILD b/tests/BUILD
index 7f056d2..9cfe7d6 100644
--- a/tests/BUILD
+++ b/tests/BUILD
@@ -3,6 +3,7 @@
 load(":collections_tests.bzl", "collections_test_suite")
 load(":common_settings_tests.bzl", "common_settings_test_suite")
 load(":dicts_tests.bzl", "dicts_test_suite")
+load(":modules_test.bzl", "modules_test_suite")
 load(":new_sets_tests.bzl", "new_sets_test_suite")
 load(":partial_tests.bzl", "partial_test_suite")
 load(":paths_tests.bzl", "paths_test_suite")
@@ -29,6 +30,8 @@
 
 dicts_test_suite()
 
+modules_test_suite()
+
 new_sets_test_suite()
 
 partial_test_suite()
diff --git a/tests/modules_test.bzl b/tests/modules_test.bzl
new file mode 100644
index 0000000..0887463
--- /dev/null
+++ b/tests/modules_test.bzl
@@ -0,0 +1,70 @@
+# Copyright 2017 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Test usage of modules.bzl."""
+
+load("//lib:modules.bzl", "modules")
+load("//rules:build_test.bzl", "build_test")
+
+def _repo_rule_impl(repository_ctx):
+    repository_ctx.file("WORKSPACE")
+    repository_ctx.file("BUILD", """exports_files(["hello"])""")
+    repository_ctx.file("hello", "Hello, Bzlmod!")
+
+_repo_rule = repository_rule(_repo_rule_impl)
+
+def _workspace_macro(register_toolchains = False):
+    _repo_rule(name = "foo")
+    _repo_rule(name = "bar")
+    if register_toolchains:
+        native.register_toolchains()
+
+as_extension_test_ext = modules.as_extension(
+    _workspace_macro,
+    doc = "Only used for testing modules.as_extension().",
+)
+
+def _use_all_repos_ext_impl(module_ctx):
+    _repo_rule(name = "baz")
+    _repo_rule(name = "qux")
+    return modules.use_all_repos(module_ctx)
+
+use_all_repos_test_ext = module_extension(
+    _use_all_repos_ext_impl,
+    doc = "Only used for testing modules.use_all_repos().",
+)
+
+# buildifier: disable=unnamed-macro
+def modules_test_suite():
+    """Creates the tests for modules.bzl if Bzlmod is enabled."""
+
+    is_bzlmod_enabled = str(Label("//tests:module_tests.bzl")).startswith("@@")
+    if not is_bzlmod_enabled:
+        return
+
+    build_test(
+        name = "modules_as_extension_test",
+        targets = [
+            "@foo//:hello",
+            "@bar//:hello",
+        ],
+    )
+
+    build_test(
+        name = "modules_use_all_repos_test",
+        targets = [
+            "@baz//:hello",
+            "@qux//:hello",
+        ],
+    )