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 @@ matrix: .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 @@ bazel_dep(name = "rules_license", version = "0.0.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 @@ local_path_override( 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 @@ s = shell.quote(p) * [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 @@ load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies") 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 @@ -58,6 +58,24 @@ stardoc_with_diff_test( out_label = "//docs:diff_test_doc.md", ) +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", 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 @@ + + +Skylib module containing rules to create metadata about directories. + + + +## directory + +
+directory(name, srcs) ++ + + +**ATTRIBUTES** + + +| Name | Description | Type | Mandatory | Default | +| :------------- | :------------- | :------------- | :------------- | :------------- | +| name | A unique name for this target. | Name | required | | +| srcs | - | List of labels | 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 @@ + + +Skylib module containing providers for directories. + + + +## DirectoryInfo + +
+DirectoryInfo(entries, transitive_files, path, human_readable, get_path, get_file, get_subdirectory) ++ +Information about a directory + +**FIELDS** + + +| Name | Description | +| :------------- | :------------- | +| 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. | + + + + +## create_directory_info + +
+create_directory_info(kwargs) ++ + + +**PARAMETERS** + + +| Name | Description | Default Value | +| :------------- | :------------- | :------------- | +| kwargs |
-
| 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 @@ + + +Skylib module containing rules to create metadata about subdirectories. + + + +## subdirectory + ++subdirectory(name, parent, path) ++ + + +**ATTRIBUTES** + + +| Name | Description | Type | Mandatory | Default | +| :------------- | :------------- | :------------- | :------------- | :------------- | +| name | A unique name for this target. | Name | required | | +| parent | A label corresponding to the parent directory (or subdirectory). | Label | required | | +| 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 @@ + + +Skylib module containing utility functions related to directories. + + + +## get_child + +
+get_child(directory, name, require_dir, require_file) ++ +Gets the direct child of a directory. + +**PARAMETERS** + + +| Name | Description | Default Value | +| :------------- | :------------- | :------------- | +| directory | (DirectoryInfo) The directory to look within. | none | +| name | (string) The name of the directory/file to look for. | none | +| require_dir | (bool) If true, throws an error if the value is not a directory. | `False` | +| require_file | (bool) If true, throws an error if the value is not a file. | `False` | + +**RETURNS** + +(File|DirectoryInfo) The content contained within. + + + + +## get_relative + +
+get_relative(directory, path, require_dir, require_file) ++ +Gets a subdirectory contained within a tree of another directory. + +**PARAMETERS** + + +| Name | Description | Default Value | +| :------------- | :------------- | :------------- | +| directory | (DirectoryInfo) The directory to look within. | none | +| path | (string) The path of the directory to look for within it. | none | +| require_dir | (bool) If true, throws an error if the value is not a directory. | `False` | +| 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 diff --git a/tests/directory/testdata/subdir/f2 b/tests/directory/testdata/subdir/f2 new file mode 100644 index 0000000..e69de29 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 @@ def _all_test(env): "copy_directory", "copy_file", "diff_test", + "directory", "expand_template", "select_file", "write_file", @@ -44,6 +45,7 @@ def _all_test(env): "common_settings", "copy_directory", "copy_file", + "directory", "expand_template", "select_file", "write_file",