Copy rules_directory to bazel-skylib. (#510)

Original implementation is at https://github.com/matts1/rules_directory
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index 9ccffc2..176e998 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -16,6 +16,7 @@
 .reusable_targets: &reusable_targets
   ? "--"
   ? "//..."
+  ? "@external_directory_tests//..."
   ? "@bazel_skylib_gazelle_plugin//..."
 
 .reusable_config: &reusable_config
diff --git a/MODULE.bazel b/MODULE.bazel
index 13cd955..3f29192 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -18,6 +18,7 @@
 # Build-only / test-only dependencies
 bazel_dep(name = "stardoc", version = "0.6.2", dev_dependency = True, repo_name = "io_bazel_stardoc")
 bazel_dep(name = "rules_pkg", version = "0.9.1", dev_dependency = True)
+bazel_dep(name = "rules_testing", version = "0.6.0", dev_dependency = True)
 
 # Needed for bazelci and for building distribution tarballs.
 # If using an unreleased version of bazel_skylib via git_override, apply
@@ -28,8 +29,11 @@
     path = "gazelle",
 )
 
-as_extension_test_ext = use_extension("//tests:modules_test.bzl", "as_extension_test_ext")
+external_directory_tests_ext = use_extension("//tests/directory:external_directory_tests.bzl", "external_directory_tests_ext", dev_dependency = True)
+use_repo(external_directory_tests_ext, "external_directory_tests")
+
+as_extension_test_ext = use_extension("//tests:modules_test.bzl", "as_extension_test_ext", dev_dependency = True)
 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_all_repos_test_ext = use_extension("//tests:modules_test.bzl", "use_all_repos_test_ext", dev_dependency = True)
 use_repo(use_all_repos_test_ext, "baz", "qux")
diff --git a/README.md b/README.md
index 2c22da7..66224b1 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,9 @@
 * [analysis_test](docs/analysis_test_doc.md)
 * [build_test](docs/build_test_doc.md)
 * [common_settings](docs/common_settings_doc.md)
+* [directories](docs/copy_directory_doc.md)
+    * [directory](docs/directory_doc.md)
+    * [subdirectory](docs/subdirectory_doc.md)
 * [copy_directory](docs/copy_directory_doc.md)
 * [copy_file](docs/copy_file_doc.md)
 * [diff_test](docs/diff_test_doc.md)
diff --git a/WORKSPACE b/WORKSPACE
index 5746f25..3a4d21f 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -68,6 +68,16 @@
 
 rules_pkg_dependencies()
 
+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",
+)
+
 load("//lib:unittest.bzl", "register_unittest_toolchains")
+load("//tests/directory:external_directory_tests.bzl", "external_directory_tests")
+
+external_directory_tests(name = "external_directory_tests")
 
 register_unittest_toolchains()
diff --git a/docs/BUILD b/docs/BUILD
index 8569278..283029e 100644
--- a/docs/BUILD
+++ b/docs/BUILD
@@ -59,6 +59,24 @@
 )
 
 stardoc_with_diff_test(
+    name = "directory",
+    bzl_library_target = "//rules/directory:directory",
+    out_label = "//docs:directory_doc.md",
+)
+
+stardoc_with_diff_test(
+    name = "directory_providers",
+    bzl_library_target = "//rules/directory:providers",
+    out_label = "//docs:directory_providers_doc.md",
+)
+
+stardoc_with_diff_test(
+    name = "directory_subdirectory",
+    bzl_library_target = "//rules/directory:subdirectory",
+    out_label = "//docs:directory_subdirectory_doc.md",
+)
+
+stardoc_with_diff_test(
     name = "expand_template",
     bzl_library_target = "//rules:expand_template",
     out_label = "//docs:expand_template_doc.md",
diff --git a/docs/directory_doc.md b/docs/directory_doc.md
new file mode 100644
index 0000000..41c0931
--- /dev/null
+++ b/docs/directory_doc.md
@@ -0,0 +1,23 @@
+<!-- Generated with Stardoc: http://skydoc.bazel.build -->
+
+Skylib module containing rules to create metadata about directories.
+
+<a id="directory"></a>
+
+## directory
+
+<pre>
+directory(<a href="#directory-name">name</a>, <a href="#directory-srcs">srcs</a>)
+</pre>
+
+
+
+**ATTRIBUTES**
+
+
+| Name  | Description | Type | Mandatory | Default |
+| :------------- | :------------- | :------------- | :------------- | :------------- |
+| <a id="directory-name"></a>name |  A unique name for this target.   | <a href="https://bazel.build/concepts/labels#target-names">Name</a> | required |  |
+| <a id="directory-srcs"></a>srcs |  -   | <a href="https://bazel.build/concepts/labels">List of labels</a> | optional |  `[]`  |
+
+
diff --git a/docs/directory_providers_doc.md b/docs/directory_providers_doc.md
new file mode 100644
index 0000000..ddf2250
--- /dev/null
+++ b/docs/directory_providers_doc.md
@@ -0,0 +1,46 @@
+<!-- Generated with Stardoc: http://skydoc.bazel.build -->
+
+Skylib module containing providers for directories.
+
+<a id="DirectoryInfo"></a>
+
+## DirectoryInfo
+
+<pre>
+DirectoryInfo(<a href="#DirectoryInfo-entries">entries</a>, <a href="#DirectoryInfo-transitive_files">transitive_files</a>, <a href="#DirectoryInfo-path">path</a>, <a href="#DirectoryInfo-human_readable">human_readable</a>, <a href="#DirectoryInfo-get_path">get_path</a>, <a href="#DirectoryInfo-get_file">get_file</a>, <a href="#DirectoryInfo-get_subdirectory">get_subdirectory</a>)
+</pre>
+
+Information about a directory
+
+**FIELDS**
+
+
+| Name  | Description |
+| :------------- | :------------- |
+| <a id="DirectoryInfo-entries"></a>entries |  (Dict[str, Either[File, DirectoryInfo]]) The entries contained directly within. Ordered by filename    |
+| <a id="DirectoryInfo-transitive_files"></a>transitive_files |  (depset[File]) All files transitively contained within this directory.    |
+| <a id="DirectoryInfo-path"></a>path |  (string) Path to all files contained within this directory.    |
+| <a id="DirectoryInfo-human_readable"></a>human_readable |  (string) A human readable identifier for a directory. Useful for providing error messages to a user.    |
+| <a id="DirectoryInfo-get_path"></a>get_path |  (Function(str) -> DirectoryInfo\|File) A function to return the entry corresponding to the joined path.    |
+| <a id="DirectoryInfo-get_file"></a>get_file |  (Function(str) -> File) A function to return the entry corresponding to the joined path.    |
+| <a id="DirectoryInfo-get_subdirectory"></a>get_subdirectory |  (Function(str) -> DirectoryInfo) A function to return the entry corresponding to the joined path.    |
+
+
+<a id="create_directory_info"></a>
+
+## create_directory_info
+
+<pre>
+create_directory_info(<a href="#create_directory_info-kwargs">kwargs</a>)
+</pre>
+
+
+
+**PARAMETERS**
+
+
+| Name  | Description | Default Value |
+| :------------- | :------------- | :------------- |
+| <a id="create_directory_info-kwargs"></a>kwargs |  <p align="center"> - </p>   |  none |
+
+
diff --git a/docs/directory_subdirectory_doc.md b/docs/directory_subdirectory_doc.md
new file mode 100644
index 0000000..0737feb
--- /dev/null
+++ b/docs/directory_subdirectory_doc.md
@@ -0,0 +1,24 @@
+<!-- Generated with Stardoc: http://skydoc.bazel.build -->
+
+Skylib module containing rules to create metadata about subdirectories.
+
+<a id="subdirectory"></a>
+
+## subdirectory
+
+<pre>
+subdirectory(<a href="#subdirectory-name">name</a>, <a href="#subdirectory-parent">parent</a>, <a href="#subdirectory-path">path</a>)
+</pre>
+
+
+
+**ATTRIBUTES**
+
+
+| Name  | Description | Type | Mandatory | Default |
+| :------------- | :------------- | :------------- | :------------- | :------------- |
+| <a id="subdirectory-name"></a>name |  A unique name for this target.   | <a href="https://bazel.build/concepts/labels#target-names">Name</a> | required |  |
+| <a id="subdirectory-parent"></a>parent |  A label corresponding to the parent directory (or subdirectory).   | <a href="https://bazel.build/concepts/labels">Label</a> | required |  |
+| <a id="subdirectory-path"></a>path |  A path within the parent directory (eg. "path/to/subdir")   | String | required |  |
+
+
diff --git a/docs/directory_utils_doc.md b/docs/directory_utils_doc.md
new file mode 100644
index 0000000..62f1925
--- /dev/null
+++ b/docs/directory_utils_doc.md
@@ -0,0 +1,54 @@
+<!-- Generated with Stardoc: http://skydoc.bazel.build -->
+
+Skylib module containing utility functions related to directories.
+
+<a id="get_child"></a>
+
+## get_child
+
+<pre>
+get_child(<a href="#get_child-directory">directory</a>, <a href="#get_child-name">name</a>, <a href="#get_child-require_dir">require_dir</a>, <a href="#get_child-require_file">require_file</a>)
+</pre>
+
+Gets the direct child of a directory.
+
+**PARAMETERS**
+
+
+| Name  | Description | Default Value |
+| :------------- | :------------- | :------------- |
+| <a id="get_child-directory"></a>directory |  (DirectoryInfo) The directory to look within.   |  none |
+| <a id="get_child-name"></a>name |  (string) The name of the directory/file to look for.   |  none |
+| <a id="get_child-require_dir"></a>require_dir |  (bool) If true, throws an error if the value is not a directory.   |  `False` |
+| <a id="get_child-require_file"></a>require_file |  (bool) If true, throws an error if the value is not a file.   |  `False` |
+
+**RETURNS**
+
+(File|DirectoryInfo) The content contained within.
+
+
+<a id="get_relative"></a>
+
+## get_relative
+
+<pre>
+get_relative(<a href="#get_relative-directory">directory</a>, <a href="#get_relative-path">path</a>, <a href="#get_relative-require_dir">require_dir</a>, <a href="#get_relative-require_file">require_file</a>)
+</pre>
+
+Gets a subdirectory contained within a tree of another directory.
+
+**PARAMETERS**
+
+
+| Name  | Description | Default Value |
+| :------------- | :------------- | :------------- |
+| <a id="get_relative-directory"></a>directory |  (DirectoryInfo) The directory to look within.   |  none |
+| <a id="get_relative-path"></a>path |  (string) The path of the directory to look for within it.   |  none |
+| <a id="get_relative-require_dir"></a>require_dir |  (bool) If true, throws an error if the value is not a directory.   |  `False` |
+| <a id="get_relative-require_file"></a>require_file |  (bool) If true, throws an error if the value is not a file.   |  `False` |
+
+**RETURNS**
+
+(File|DirectoryInfo) The directory contained within.
+
+
diff --git a/rules/directory/BUILD b/rules/directory/BUILD
new file mode 100644
index 0000000..11c5dbb
--- /dev/null
+++ b/rules/directory/BUILD
@@ -0,0 +1,37 @@
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+
+licenses(["notice"])
+
+# export bzl files for the documentation
+exports_files(
+    glob(["*.bzl"]),
+    visibility = ["//:__subpackages__"],
+)
+
+bzl_library(
+    name = "directory",
+    srcs = ["directory.bzl"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":providers",
+        "//lib:paths",
+    ],
+)
+
+bzl_library(
+    name = "providers",
+    srcs = ["providers.bzl"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//rules/directory/private:paths",
+    ],
+)
+
+bzl_library(
+    name = "subdirectory",
+    srcs = ["subdirectory.bzl"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":providers",
+    ],
+)
diff --git a/rules/directory/directory.bzl b/rules/directory/directory.bzl
new file mode 100644
index 0000000..8b2b541
--- /dev/null
+++ b/rules/directory/directory.bzl
@@ -0,0 +1,140 @@
+# Copyright 2024 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 rules to create metadata about directories."""
+
+load("//lib:paths.bzl", "paths")
+load(":providers.bzl", "DirectoryInfo", "create_directory_info")
+
+def _prefix_match(f, prefixes):
+    for prefix in prefixes:
+        if f.path.startswith(prefix):
+            return prefix
+    fail("Expected {path} to start with one of {prefixes}".format(path = f.path, prefixes = list(prefixes)))
+
+def _choose_path(prefixes):
+    filtered = {prefix: example for prefix, example in prefixes.items() if example}
+    if len(filtered) > 1:
+        examples = list(filtered.values())
+        fail(
+            "Your sources contain {} and {}.\n\n".format(
+                examples[0],
+                examples[1],
+            ) +
+            "Having both source and generated files in a single directory is " +
+            "unsupported, since they will appear in two different " +
+            "directories in the bazel execroot. You may want to consider " +
+            "splitting your directory into one for source files and one for " +
+            "generated files.",
+        )
+
+    # If there's no entries, use the source path (it's always first in the dict)
+    return list(filtered if filtered else prefixes)[0][:-1]
+
+def _directory_impl(ctx):
+    # Declare a generated file so that we can get the path to generated files.
+    f = ctx.actions.declare_file("_directory_rule_" + ctx.label.name)
+    ctx.actions.write(f, "")
+
+    source_prefix = ctx.label.package + "/"
+    if ctx.label.workspace_root:
+        source_prefix = ctx.label.workspace_root + "/" + source_prefix
+
+    # Mapping of a prefix to an arbitrary (but deterministic) file matching that path.
+    # The arbitrary file is used to present error messages if we have both generated files and source files.
+    prefixes = {
+        source_prefix: None,
+        f.dirname + "/": None,
+    }
+
+    root_metadata = struct(
+        directories = {},
+        files = [],
+        relative = "",
+        human_readable = str(ctx.label),
+    )
+
+    topological = [root_metadata]
+    for src in ctx.files.srcs:
+        prefix = _prefix_match(src, prefixes)
+        prefixes[prefix] = src
+        relative = src.path[len(prefix):].split("/")
+        current_path = root_metadata
+        for dirname in relative[:-1]:
+            if dirname not in current_path.directories:
+                dir_metadata = struct(
+                    directories = {},
+                    files = [],
+                    relative = paths.join(current_path.relative, dirname),
+                    human_readable = paths.join(current_path.human_readable, dirname),
+                )
+                current_path.directories[dirname] = dir_metadata
+                topological.append(dir_metadata)
+
+            current_path = current_path.directories[dirname]
+
+        current_path.files.append(src)
+
+    # The output DirectoryInfos. Key them by something arbitrary but unique.
+    # In this case, we choose relative.
+    out = {}
+
+    root_path = _choose_path(prefixes)
+
+    # By doing it in reversed topological order, we ensure that a child is
+    # created before its parents. This means that when we create a provider,
+    # we can always guarantee that a depset of its children will work.
+    for dir_metadata in reversed(topological):
+        directories = {
+            dirname: out[subdir_metadata.relative]
+            for dirname, subdir_metadata in sorted(dir_metadata.directories.items())
+        }
+        entries = {
+            file.basename: file
+            for file in dir_metadata.files
+        }
+        entries.update(directories)
+
+        transitive_files = depset(
+            direct = sorted(dir_metadata.files, key = lambda f: f.basename),
+            transitive = [
+                d.transitive_files
+                for d in directories.values()
+            ],
+            order = "preorder",
+        )
+        directory = create_directory_info(
+            entries = {k: v for k, v in sorted(entries.items())},
+            transitive_files = transitive_files,
+            path = paths.join(root_path, dir_metadata.relative) if dir_metadata.relative else root_path,
+            human_readable = dir_metadata.human_readable,
+        )
+        out[dir_metadata.relative] = directory
+
+    root_directory = out[root_metadata.relative]
+
+    return [
+        root_directory,
+        DefaultInfo(files = root_directory.transitive_files),
+    ]
+
+directory = rule(
+    implementation = _directory_impl,
+    attrs = {
+        "srcs": attr.label_list(
+            allow_files = True,
+        ),
+    },
+    provides = [DirectoryInfo],
+)
diff --git a/rules/directory/private/BUILD b/rules/directory/private/BUILD
new file mode 100644
index 0000000..cb745ce
--- /dev/null
+++ b/rules/directory/private/BUILD
@@ -0,0 +1,18 @@
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+
+licenses(["notice"])
+
+# export bzl files for the documentation
+exports_files(
+    glob(["*.bzl"]),
+    visibility = ["//:__subpackages__"],
+)
+
+bzl_library(
+    name = "paths",
+    srcs = ["paths.bzl"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//lib:paths",
+    ],
+)
diff --git a/rules/directory/private/paths.bzl b/rules/directory/private/paths.bzl
new file mode 100644
index 0000000..ad48dc1
--- /dev/null
+++ b/rules/directory/private/paths.bzl
@@ -0,0 +1,94 @@
+# Copyright 2024 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 path operations on directories."""
+
+load("//lib:paths.bzl", "paths")
+
+_NOT_FOUND = """{directory} does not contain an entry named {name}.
+Instead, it contains the following entries:
+{children}
+
+"""
+_WRONG_TYPE = "Expected {dir}/{name} to have type {want}, but got {got}"
+
+# These correspond to an "enum".
+FILE = "file"
+DIRECTORY = "directory"
+
+def _check_path_relative(path):
+    if paths.is_absolute(path):
+        fail("Path must be relative. Got {path}".format(path = path))
+
+def _get_direct_child(directory, name, require_type = None):
+    """Gets the direct child of a directory.
+
+    Args:
+        directory: (DirectoryInfo) The directory to look within.
+        name: (string) The name of the directory/file to look for.
+        require_type: (Optional[DIRECTORY|FILE]) If provided, must return
+          either a the corresponding type.
+
+    Returns:
+        (File|DirectoryInfo) The content contained within.
+    """
+    entry = directory.entries.get(name, None)
+    if entry == None:
+        fail(_NOT_FOUND.format(
+            directory = directory.human_readable,
+            name = repr(name),
+            children = "\n".join(directory.entries.keys()),
+        ))
+    if require_type == DIRECTORY and type(entry) == "File":
+        fail(_WRONG_TYPE.format(
+            dir = directory.human_readable,
+            name = name,
+            want = "Directory",
+            got = "File",
+        ))
+
+    if require_type == FILE and type(entry) != "File":
+        fail(_WRONG_TYPE.format(
+            dir = directory.human_readable,
+            name = name,
+            want = "File",
+            got = "Directory",
+        ))
+    return entry
+
+def get_path(directory, path, require_type = None):
+    """Gets a subdirectory or file contained within a directory.
+
+    Example: `get_path(directory, "a/b", require_type=FILE)`
+      -> the file  corresponding to `directory.path + "/a/b"`
+
+    Args:
+        directory: (DirectoryInfo) The directory to look within.
+        path: (string) The path of the directory to look for within it.
+        require_type: (Optional[DIRECTORY|FILE]) If provided, must return
+          either a the corresponding type.
+
+    Returns:
+        (File|DirectoryInfo) The directory contained within.
+    """
+    _check_path_relative(path)
+
+    chunks = path.split("/")
+    for dirname in chunks[:-1]:
+        directory = _get_direct_child(directory, dirname, require_type = DIRECTORY)
+    return _get_direct_child(
+        directory,
+        chunks[-1],
+        require_type = require_type,
+    )
diff --git a/rules/directory/providers.bzl b/rules/directory/providers.bzl
new file mode 100644
index 0000000..2d6735c
--- /dev/null
+++ b/rules/directory/providers.bzl
@@ -0,0 +1,46 @@
+# Copyright 2024 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 providers for directories."""
+
+load("//rules/directory/private:paths.bzl", "DIRECTORY", "FILE", "get_path")
+
+def _init_directory_info(**kwargs):
+    self = struct(**kwargs)
+    kwargs.update(
+        get_path = lambda path: get_path(self, path, require_type = None),
+        get_file = lambda path: get_path(self, path, require_type = FILE),
+        get_subdirectory = lambda path: get_path(self, path, require_type = DIRECTORY),
+    )
+    return kwargs
+
+# TODO: Once bazel 5 no longer needs to be supported, remove this function, and add
+# init = _init_directory_info to the provider below
+# buildifier: disable=function-docstring
+def create_directory_info(**kwargs):
+    return DirectoryInfo(**_init_directory_info(**kwargs))
+
+DirectoryInfo = provider(
+    doc = "Information about a directory",
+    # @unsorted-dict-items
+    fields = {
+        "entries": "(Dict[str, Either[File, DirectoryInfo]]) The entries contained directly within. Ordered by filename",
+        "transitive_files": "(depset[File]) All files transitively contained within this directory.",
+        "path": "(string) Path to all files contained within this directory.",
+        "human_readable": "(string) A human readable identifier for a directory. Useful for providing error messages to a user.",
+        "get_path": "(Function(str) -> DirectoryInfo|File) A function to return the entry corresponding to the joined path.",
+        "get_file": "(Function(str) -> File) A function to return the entry corresponding to the joined path.",
+        "get_subdirectory": "(Function(str) -> DirectoryInfo) A function to return the entry corresponding to the joined path.",
+    },
+)
diff --git a/rules/directory/subdirectory.bzl b/rules/directory/subdirectory.bzl
new file mode 100644
index 0000000..f10578c
--- /dev/null
+++ b/rules/directory/subdirectory.bzl
@@ -0,0 +1,40 @@
+# Copyright 2024 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 rules to create metadata about subdirectories."""
+
+load(":providers.bzl", "DirectoryInfo")
+
+def _subdirectory_impl(ctx):
+    dir = ctx.attr.parent[DirectoryInfo].get_subdirectory(ctx.attr.path)
+    return [
+        dir,
+        DefaultInfo(files = dir.transitive_files),
+    ]
+
+subdirectory = rule(
+    implementation = _subdirectory_impl,
+    attrs = {
+        "parent": attr.label(
+            providers = [DirectoryInfo],
+            mandatory = True,
+            doc = "A label corresponding to the parent directory (or subdirectory).",
+        ),
+        "path": attr.string(
+            mandatory = True,
+            doc = "A path within the parent directory (eg. \"path/to/subdir\")",
+        ),
+    },
+    provides = [DirectoryInfo],
+)
diff --git a/tests/directory/BUILD b/tests/directory/BUILD
new file mode 100644
index 0000000..5cfe168
--- /dev/null
+++ b/tests/directory/BUILD
@@ -0,0 +1,33 @@
+load("@bazel_skylib//rules:copy_file.bzl", "copy_file")
+load("@bazel_skylib//rules/directory:directory.bzl", "directory")
+load(":directory_test.bzl", "directory_test_suite")
+load(":subdirectory_test.bzl", "subdirectory_test_suite")
+
+directory(
+    name = "root",
+    srcs = glob(["testdata/**"]),
+)
+
+filegroup(
+    name = "f1_filegroup",
+    srcs = ["testdata/f1"],
+)
+
+filegroup(
+    name = "f2_filegroup",
+    srcs = ["testdata/subdir/f2"],
+)
+
+copy_file(
+    name = "generated_file",
+    src = "testdata/f1",
+    out = "dir/generated",
+)
+
+directory_test_suite(
+    name = "directory_tests",
+)
+
+subdirectory_test_suite(
+    name = "subdirectory_tests",
+)
diff --git a/tests/directory/directory_test.bzl b/tests/directory/directory_test.bzl
new file mode 100644
index 0000000..5a6695a
--- /dev/null
+++ b/tests/directory/directory_test.bzl
@@ -0,0 +1,158 @@
+# Copyright 2024 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.
+
+"""Unit tests for the directory rule."""
+
+load("@bazel_skylib//rules/directory:directory.bzl", "directory")
+load("@bazel_skylib//rules/directory:providers.bzl", "DirectoryInfo")
+load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite")
+load("@rules_testing//lib:truth.bzl", "matching")
+load(":utils.bzl", "directory_subject", "failure_matching", "failure_test")
+
+def _source_root_test(name):
+    analysis_test(
+        name = name,
+        impl = _source_root_test_impl,
+        targets = {
+            "root": ":root",
+            "f1": ":f1_filegroup",
+            "f2": ":f2_filegroup",
+        },
+    )
+
+def _source_root_test_impl(env, targets):
+    f1 = targets.f1.files.to_list()[0]
+    f2 = targets.f2.files.to_list()[0]
+
+    env.expect.that_collection(targets.root.files.to_list()).contains_exactly(
+        [f1, f2],
+    )
+
+    human_readable = str(targets.root.label)
+
+    root = directory_subject(env, targets.root[DirectoryInfo])
+    root.entries().keys().contains_exactly(["testdata"])
+    root.transitive_files().contains_exactly([f1, f2]).in_order()
+    root.human_readable().equals(human_readable)
+    env.expect.that_str(root.actual.path + "/testdata/f1").equals(f1.path)
+
+    testdata = directory_subject(env, root.actual.entries["testdata"])
+    testdata.entries().keys().contains_exactly(["f1", "subdir"])
+    testdata.human_readable().equals(human_readable + "/testdata")
+
+    subdir = directory_subject(env, testdata.actual.entries["subdir"])
+    subdir.entries().contains_exactly({"f2": f2})
+    subdir.transitive_files().contains_exactly([f2])
+    env.expect.that_str(subdir.actual.path + "/f2").equals(f2.path)
+
+def _generated_root_test(name):
+    subject_name = "_%s_subject" % name
+    directory(
+        name = subject_name,
+        srcs = [":generated_file"],
+    )
+
+    analysis_test(
+        name = name,
+        impl = _generated_root_test_impl,
+        targets = {
+            "root": subject_name,
+            "generated": ":generated_file",
+        },
+    )
+
+def _generated_root_test_impl(env, targets):
+    generated = targets.generated.files.to_list()[0]
+
+    env.expect.that_collection(targets.root.files.to_list()).contains_exactly(
+        [generated],
+    )
+
+    human_readable = str(targets.root.label)
+
+    root = directory_subject(env, targets.root[DirectoryInfo])
+    root.entries().keys().contains_exactly(["dir"])
+    root.transitive_files().contains_exactly([generated]).in_order()
+    root.human_readable().equals(human_readable)
+    env.expect.that_str(root.actual.path + "/dir/generated").equals(generated.path)
+
+    dir = directory_subject(env, root.actual.entries["dir"])
+    dir.human_readable().equals(human_readable + "/dir")
+    dir.entries().contains_exactly({"generated": generated})
+    dir.transitive_files().contains_exactly([generated])
+    env.expect.that_str(dir.actual.path + "/generated").equals(generated.path)
+
+def _no_srcs_test(name):
+    subject_name = "_%s_subject" % name
+    directory(
+        name = subject_name,
+    )
+
+    analysis_test(
+        name = name,
+        impl = _no_srcs_test_impl,
+        targets = {
+            "root": subject_name,
+            "f1": ":f1_filegroup",
+        },
+    )
+
+def _no_srcs_test_impl(env, targets):
+    f1 = targets.f1.files.to_list()[0]
+
+    env.expect.that_collection(targets.root.files.to_list()).contains_exactly([])
+
+    d = directory_subject(env, targets.root[DirectoryInfo])
+    d.entries().contains_exactly({})
+    env.expect.that_str(d.actual.path + "/testdata/f1").equals(f1.path)
+
+def _directory_with_self_srcs_test(name):
+    failure_test(
+        name = name,
+        impl = failure_matching(matching.contains("tests/directory to start with")),
+        rule = directory,
+        srcs = ["."],
+    )
+
+def _outside_testdata_test(name):
+    failure_test(
+        name = name,
+        impl = failure_matching(matching.contains("lib/paths.bzl to start with")),
+        rule = directory,
+        srcs = ["@bazel_skylib//lib:paths"],
+    )
+
+def _source_and_generated_root_test(name):
+    failure_test(
+        name = name,
+        impl = failure_matching(matching.contains(
+            "Having both source and generated files in a single directory is unsupported",
+        )),
+        rule = directory,
+        srcs = ["f1", ":generated_file"],
+    )
+
+# buildifier: disable=function-docstring
+def directory_test_suite(name):
+    test_suite(
+        name = name,
+        tests = [
+            _source_root_test,
+            _generated_root_test,
+            _no_srcs_test,
+            _directory_with_self_srcs_test,
+            _outside_testdata_test,
+            _source_and_generated_root_test,
+        ],
+    )
diff --git a/tests/directory/external_directory_tests.bzl b/tests/directory/external_directory_tests.bzl
new file mode 100644
index 0000000..baf8510
--- /dev/null
+++ b/tests/directory/external_directory_tests.bzl
@@ -0,0 +1,44 @@
+# Copyright 2024 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.
+
+"""Generates tests for the directory rules from outside the repository."""
+
+def _external_directory_tests_impl(repo_ctx):
+    for f in repo_ctx.attr.files:
+        repo_ctx.symlink(repo_ctx.path(f), f.package + "/" + f.name)
+
+# Directory paths work differently while inside and outside the repository.
+# To properly test this, we copy all our test code to an external
+# repository.
+external_directory_tests = repository_rule(
+    implementation = _external_directory_tests_impl,
+    attrs = {
+        "files": attr.label_list(default = [
+            "//tests/directory:BUILD",
+            "//tests/directory:directory_test.bzl",
+            "//tests/directory:subdirectory_test.bzl",
+            "//tests/directory:testdata/f1",
+            "//tests/directory:testdata/subdir/f2",
+            "//tests/directory:utils.bzl",
+        ]),
+    },
+)
+
+def _external_directory_tests_ext_impl(_module_ctx):
+    external_directory_tests(name = "external_directory_tests")
+
+# use_repo_rule would be preferred, but it isn't supported in bazel 6.
+external_directory_tests_ext = module_extension(
+    implementation = _external_directory_tests_ext_impl,
+)
diff --git a/tests/directory/subdirectory_test.bzl b/tests/directory/subdirectory_test.bzl
new file mode 100644
index 0000000..0546c39
--- /dev/null
+++ b/tests/directory/subdirectory_test.bzl
@@ -0,0 +1,113 @@
+# Copyright 2024 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.
+
+"""Unit tests for subdirectory rules."""
+
+load("@bazel_skylib//rules/directory:providers.bzl", "DirectoryInfo")
+load("@bazel_skylib//rules/directory:subdirectory.bzl", "subdirectory")
+load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite")
+load("@rules_testing//lib:truth.bzl", "matching")
+load(":utils.bzl", "failure_matching", "failure_test")
+
+_NONEXISTENT_SUBDIRECTORY_ERR = """directory:root/testdata does not contain an entry named "nonexistent".
+Instead, it contains the following entries:
+f1
+subdir
+"""
+
+def _subdirectory_test(name):
+    testdata_name = "_%s_dir" % name
+    subdir_name = "_%s_subdir" % name
+
+    subdirectory(
+        name = testdata_name,
+        parent = ":root",
+        path = "testdata",
+    )
+
+    subdirectory(
+        name = subdir_name,
+        parent = ":root",
+        path = "testdata/subdir",
+    )
+
+    analysis_test(
+        name = name,
+        impl = _subdirectory_test_impl,
+        targets = {
+            "root": ":root",
+            "testdata": testdata_name,
+            "subdir": subdir_name,
+            "f1": ":f1_filegroup",
+            "f2": ":f2_filegroup",
+        },
+    )
+
+def _subdirectory_test_impl(env, targets):
+    f1 = targets.f1.files.to_list()[0]
+    f2 = targets.f2.files.to_list()[0]
+
+    root = targets.root[DirectoryInfo]
+    want_dir = root.entries["testdata"]
+    want_subdir = want_dir.entries["subdir"]
+
+    # Use that_str because it supports equality checks. They're not strings.
+    env.expect.that_str(targets.testdata[DirectoryInfo]).equals(want_dir)
+    env.expect.that_str(targets.subdir[DirectoryInfo]).equals(want_subdir)
+
+    env.expect.that_collection(
+        targets.testdata.files.to_list(),
+    ).contains_exactly([f1, f2])
+    env.expect.that_collection(
+        targets.subdir.files.to_list(),
+    ).contains_exactly([f2])
+
+def _nonexistent_subdirectory_test(name):
+    failure_test(
+        name = name,
+        impl = failure_matching(matching.contains(_NONEXISTENT_SUBDIRECTORY_ERR)),
+        rule = subdirectory,
+        parent = ":root",
+        path = "testdata/nonexistent",
+    )
+
+def _subdirectory_of_file_test(name):
+    failure_test(
+        name = name,
+        impl = failure_matching(matching.contains("testdata/f1 to have type Directory, but got File")),
+        rule = subdirectory,
+        parent = ":root",
+        path = "testdata/f1/foo",
+    )
+
+def _subdirectory_as_file_test(name):
+    failure_test(
+        name = name,
+        impl = failure_matching(matching.contains("testdata/f1 to have type Directory, but got File")),
+        rule = subdirectory,
+        parent = ":root",
+        path = "testdata/f1",
+    )
+
+# buildifier: disable=function-docstring
+def subdirectory_test_suite(name):
+    test_suite(
+        name = name,
+        tests = [
+            _subdirectory_test,
+            _nonexistent_subdirectory_test,
+            _subdirectory_as_file_test,
+            _subdirectory_of_file_test,
+        ],
+    )
diff --git a/tests/directory/testdata/f1 b/tests/directory/testdata/f1
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/directory/testdata/f1
diff --git a/tests/directory/testdata/subdir/f2 b/tests/directory/testdata/subdir/f2
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/directory/testdata/subdir/f2
diff --git a/tests/directory/utils.bzl b/tests/directory/utils.bzl
new file mode 100644
index 0000000..dccc419
--- /dev/null
+++ b/tests/directory/utils.bzl
@@ -0,0 +1,65 @@
+# Copyright 2024 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.
+
+"""Helper functions for testing directory rules."""
+
+load("@rules_testing//lib:analysis_test.bzl", "analysis_test")
+load("@rules_testing//lib:truth.bzl", "subjects")
+load("@rules_testing//lib:util.bzl", "util")
+
+_depset_as_list_subject = lambda value, *, meta: subjects.collection(
+    value.to_list(),
+    meta = meta,
+)
+
+directory_info_subject = lambda value, *, meta: subjects.struct(
+    value,
+    meta = meta,
+    attrs = dict(
+        entries = subjects.dict,
+        transitive_files = _depset_as_list_subject,
+        path = subjects.str,
+        human_readable = subjects.str,
+    ),
+)
+
+def failure_matching(matcher):
+    def test(env, target):
+        env.expect.that_target(target).failures().contains_exactly_predicates([
+            matcher,
+        ])
+
+    return test
+
+def directory_subject(env, directory_info):
+    return env.expect.that_value(
+        value = directory_info,
+        expr = "DirectoryInfo(%r)" % directory_info.path,
+        factory = directory_info_subject,
+    )
+
+def failure_test(*, name, impl, rule, **kwargs):
+    subject_name = "_%s_subject" % name
+    util.helper_target(
+        rule,
+        name = subject_name,
+        **kwargs
+    )
+
+    analysis_test(
+        name = name,
+        expect_failure = True,
+        impl = impl,
+        target = subject_name,
+    )
diff --git a/tests/subpackages_tests.bzl b/tests/subpackages_tests.bzl
index 885d472..3336b93 100644
--- a/tests/subpackages_tests.bzl
+++ b/tests/subpackages_tests.bzl
@@ -26,6 +26,7 @@
         "copy_directory",
         "copy_file",
         "diff_test",
+        "directory",
         "expand_template",
         "select_file",
         "write_file",
@@ -44,6 +45,7 @@
         "common_settings",
         "copy_directory",
         "copy_file",
+        "directory",
         "expand_template",
         "select_file",
         "write_file",