diff --git a/lib/private/BUILD b/lib/private/BUILD index 3074b76..7e0235f 100644 --- a/lib/private/BUILD +++ b/lib/private/BUILD @@ -63,6 +63,7 @@ bzl_library( ":int_subject_bzl", ":matching_bzl", ":truth_common_bzl", + ":util_bzl", ], ) diff --git a/lib/private/collection_subject.bzl b/lib/private/collection_subject.bzl index 23f7334..6d72efe 100644 --- a/lib/private/collection_subject.bzl +++ b/lib/private/collection_subject.bzl @@ -35,6 +35,14 @@ load( load(":int_subject.bzl", "IntSubject") load(":matching.bzl", "matching") load(":truth_common.bzl", "to_list") +load(":util.bzl", "get_function_name") + +def _identity(v): + return v + +def _always_true(v): + _ = v # @unused + return True def _collection_subject_new( values, @@ -75,6 +83,7 @@ def _collection_subject_new( not_contains = lambda *a, **k: _collection_subject_not_contains(self, *a, **k), not_contains_predicate = lambda *a, **k: _collection_subject_not_contains_predicate(self, *a, **k), offset = lambda *a, **k: _collection_subject_offset(self, *a, **k), + transform = lambda *a, **k: _collection_subject_transform(self, *a, **k), # keep sorted end ) self = struct( @@ -354,6 +363,87 @@ def _collection_subject_offset(self, offset, factory): meta = self.meta.derive("offset({})".format(offset)), ) +def _collection_subject_transform( + self, + desc = None, + *, + map_each = None, + loop = None, + filter = None): + """Transforms a collections's value and returns another CollectionSubject. + + This is equivalent to applying a list comprehension over the collection values, + but takes care of propagating context information and wrapping the value + in a `CollectionSubject`. + + `transform(map_each=M, loop=L, filter=F)` is equivalent to + `[M(v) for v in L(collection) if F(v)]`. + + Args: + self: implicitly added. + desc: (optional [`str`]) a human-friendly description of the transform + for use in error messages. Required when a description can't be + inferred from the other args. The description can be inferred if the + filter arg is a named function (non-lambda) or Matcher object. + map_each: (optional [`callable`]) function to transform an element in + the collection. It takes one positional arg, the loop's + current iteration value, and its return value will be the element's + new value. If not specified, the values from the loop iteration are + returned unchanged. + loop: (optional [`callable`]) function to produce values from the + original collection and whose values are iterated over. It takes one + positional arg, which is the original collection. If not specified, + the original collection values are iterated over. + filter: (optional [`callable`]) function that decides what values are + passed onto `map_each` for inclusion in the final result. It takes + one positional arg, the value to match (which is the current + iteration value before `map_each` is applied), and returns a bool + (True if the value should be included in the result, False if it + should be skipped). + + Returns: + [`CollectionSubject`] of the transformed values. + """ + if not desc: + if map_each or loop: + fail("description required when map_each or loop used") + + if matching.is_matcher(filter): + desc = "filter=" + filter.desc + else: + func_name = get_function_name(filter) + if func_name == "lambda": + fail("description required: description cannot be " + + "inferred from lambdas. Explicitly specify the " + + "description, use a named function for the filter, " + + "or use a Matcher for the filter.") + else: + desc = "filter={}(...)".format(func_name) + + map_each = map_each or _identity + loop = loop or _identity + + if filter: + if matching.is_matcher(filter): + filter_func = filter.match + else: + filter_func = filter + else: + filter_func = _always_true + + new_values = [map_each(v) for v in loop(self.actual) if filter_func(v)] + + return _collection_subject_new( + new_values, + meta = self.meta.derive( + "transform()", + details = ["transform: {}".format(desc)], + ), + container_name = self.container_name, + sortable = self.sortable, + element_plural_name = self.element_plural_name, + ) + # We use this name so it shows up nice in docs. # buildifier: disable=name-conventions CollectionSubject = struct( @@ -369,5 +459,6 @@ CollectionSubject = struct( new = _collection_subject_new, not_contains_predicate = _collection_subject_not_contains_predicate, offset = _collection_subject_offset, + transform = _collection_subject_transform, # keep sorted end ) diff --git a/lib/private/expect.bzl b/lib/private/expect.bzl index e568a54..0f0ef5a 100644 --- a/lib/private/expect.bzl +++ b/lib/private/expect.bzl @@ -120,18 +120,19 @@ def _expect_that_bool(self, value, expr = "boolean"): meta = self.meta.derive(expr = expr), ) -def _expect_that_collection(self, collection, expr = "collection"): +def _expect_that_collection(self, collection, expr = "collection", **kwargs): """Creates a subject for asserting collections. Args: self: implicitly added. collection: The collection (list or depset) to assert. expr: ([`str`]) the starting "value of" expression to report in errors. + **kwargs: Additional kwargs to pass onto CollectionSubject.new Returns: [`CollectionSubject`] object. """ - return CollectionSubject.new(collection, self.meta.derive(expr)) + return CollectionSubject.new(collection, self.meta.derive(expr), **kwargs) def _expect_that_depset_of_files(self, depset_files): """Creates a subject for asserting a depset of files. diff --git a/lib/private/expect_meta.bzl b/lib/private/expect_meta.bzl index d3b0a05..249da1e 100644 --- a/lib/private/expect_meta.bzl +++ b/lib/private/expect_meta.bzl @@ -234,7 +234,7 @@ def _expect_meta_add_failure(self, problem, actual): if detail ]) if details: - details = "where...\n" + details + details = "where... (most recent context last)\n" + details msg = """\ in test: {test} value of: {expr} diff --git a/lib/private/matching.bzl b/lib/private/matching.bzl index fb83e95..9bd6610 100644 --- a/lib/private/matching.bzl +++ b/lib/private/matching.bzl @@ -214,6 +214,9 @@ def _match_parts_in_order(string, parts): return False return True +def _is_matcher(obj): + return hasattr(obj, "desc") and hasattr(obj, "match") + # For the definition of a `Matcher` object, see `_match_custom`. matching = struct( # keep sorted start @@ -229,5 +232,6 @@ matching = struct( str_endswith = _match_str_endswith, str_matches = _match_str_matches, str_startswith = _match_str_startswith, + is_matcher = _is_matcher, # keep sorted end ) diff --git a/lib/private/util.bzl b/lib/private/util.bzl index d1b39dd..fc003f9 100644 --- a/lib/private/util.bzl +++ b/lib/private/util.bzl @@ -31,3 +31,5 @@ def get_test_name_from_function(func): # have private names. This better allows unused tests to be flagged by # buildifier (indicating a bug or code to delete) return func_name.strip("_") + +get_function_name = get_test_name_from_function diff --git a/tests/truth_tests.bzl b/tests/truth_tests.bzl index 83980dc..d209978 100644 --- a/tests/truth_tests.bzl +++ b/tests/truth_tests.bzl @@ -832,6 +832,81 @@ def _collection_offset_test(env, _target): _suite.append(collection_offset_test) +def _collection_transform_test(name): + analysis_test(name, impl = _collection_transform_test_impl, target = "truth_tests_helper") + +def _collection_transform_test_impl(env, target): + _ = target # @unused + fake_env = _fake_env(env) + starter = truth.expect(fake_env).that_collection(["alan", "bert", "cari"]) + + actual = starter.transform( + "values that contain a", + filter = lambda v: "a" in v, + ) + actual.contains("not-present") + _assert_failure( + fake_env, + [ + "transform()", + "0: alan", + "1: cari", + "transform: values that contain a", + ], + env = env, + msg = "transform with lambda filter", + ) + + actual = starter.transform(filter = matching.contains("b")) + actual.contains("not-present") + _assert_failure( + fake_env, + [ + "0: bert", + "transform: filter=", + ], + env = env, + msg = "transform with matcher filter", + ) + + def contains_c(v): + return "c" in v + + actual = starter.transform(filter = contains_c) + actual.contains("not-present") + _assert_failure( + fake_env, + [ + "0: cari", + "transform: filter=contains_c(...)", + ], + env = env, + msg = "transform with named function filter", + ) + + actual = starter.transform( + "v.upper(); match even offsets", + map_each = lambda v: "{}-{}".format(v[0], v[1].upper()), + loop = enumerate, + ) + actual.contains("not-present") + _assert_failure( + fake_env, + [ + "transform()", + "0: 0-ALAN", + "1: 1-BERT", + "2: 2-CARI", + "transform: v.upper(); match even offsets", + ], + env = env, + msg = "transform with all args", + ) + + _end(env, fake_env) + +_suite.append(_collection_transform_test) + def execution_info_test(name): analysis_test(name, impl = _execution_info_test, target = "truth_tests_helper")