From 3fc9d3d4808773a9ef7b23837c2b859ff3757102 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Thu, 18 Jul 2024 16:32:41 -0500 Subject: [PATCH] Add :recursive selector function Closes #2228 --- docs/source-2.0/spec/selectors.rst | 30 ++++ .../model/selector/RecursiveSelector.java | 67 ++++++++ .../smithy/model/selector/SelectorParser.java | 7 + .../selector/cases/recursive-function.smithy | 160 ++++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 smithy-model/src/main/java/software/amazon/smithy/model/selector/RecursiveSelector.java create mode 100644 smithy-model/src/test/resources/software/amazon/smithy/model/selector/cases/recursive-function.smithy diff --git a/docs/source-2.0/spec/selectors.rst b/docs/source-2.0/spec/selectors.rst index 1fa9f99a0d5..9a454cc6cfd 100644 --- a/docs/source-2.0/spec/selectors.rst +++ b/docs/source-2.0/spec/selectors.rst @@ -1471,6 +1471,36 @@ Implementations MAY choose to evaluate ``:root`` expressions eagerly or lazily, though they MUST evaluate ``:root`` expressions no more than once. +``:recursive`` +-------------- + +The ``:recursive`` function applies a selector to the current shape, and for +every shape yielded that was not previously yielded, applies the selector to +that shape. This happens recursively until all matching shapes have been +traversed. Shapes that match the selector are yielded by the function up until +the point that a downstream selector tells the recursive selector to stop. + +The following example finds all shapes that have a specific mixin: + +.. code-block:: none + + :recursive(-[mixin]->) [id=smithy.example#Foo] + +The following selector finds all shapes contained within the resource +hierarchy of a specific resource. + +.. code-block:: none + + resource :test(:recursive(<-[resource]-) [id=smithy.example#Baz]) + +The following selector finds all shapes that directly or transitively target +a specific shape, essentially the inverse of ``~>``. + +.. code-block:: none + + [id=smithy.example#MyShape] :recursive(<) + + ``:topdown`` ------------ diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/selector/RecursiveSelector.java b/smithy-model/src/main/java/software/amazon/smithy/model/selector/RecursiveSelector.java new file mode 100644 index 00000000000..0c4e4c568c4 --- /dev/null +++ b/smithy-model/src/main/java/software/amazon/smithy/model/selector/RecursiveSelector.java @@ -0,0 +1,67 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.model.selector; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashSet; +import java.util.Set; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; + +final class RecursiveSelector implements InternalSelector { + + private final InternalSelector selector; + + RecursiveSelector(InternalSelector selector) { + this.selector = selector; + } + + @Override + public Response push(Context context, Shape shape, Receiver next) { + // This queue contains the shapes that have yet to have the selector applied to them. + QueueReceiver queueReceiver = new QueueReceiver(next); + queueReceiver.queue.add(shape); + + while (!queueReceiver.queue.isEmpty()) { + Shape match = queueReceiver.queue.pop(); + // Apply the selector to the queue, it will send results downstream immediately, and can ask to stop early. + if (selector.push(context, match, queueReceiver) == Response.STOP) { + return Response.STOP; + } + } + + return Response.CONTINUE; + } + + private static final class QueueReceiver implements Receiver { + + final Deque queue = new ArrayDeque<>(); + private final Set visited = new HashSet<>(); + private final Receiver next; + + QueueReceiver(Receiver next) { + this.next = next; + } + + @Override + public Response apply(Context context, Shape matchedShapeFromSelector) { + // This method receives each shape matched by the selector of RecursiveSelector. + if (visited.add(matchedShapeFromSelector.getId())) { + // Send the match downstream right away to do as little work as possible. + // For example, in `:recursive(-[mixin]->) :test([id=foo#Bar])`, when a match is found, the recursive + // function can stop finding more mixins. + if (next.apply(context, matchedShapeFromSelector) == Response.STOP) { + return Response.STOP; + } + // Enqueue the shape so that the outer loop can send this match back into the selector. + queue.add(matchedShapeFromSelector); + } + + return Response.CONTINUE; + } + } +} diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/selector/SelectorParser.java b/smithy-model/src/main/java/software/amazon/smithy/model/selector/SelectorParser.java index 789f97a73a5..13b72698748 100644 --- a/smithy-model/src/main/java/software/amazon/smithy/model/selector/SelectorParser.java +++ b/smithy-model/src/main/java/software/amazon/smithy/model/selector/SelectorParser.java @@ -262,6 +262,13 @@ private InternalSelector parseSelectorFunction() { input().toString(), functionPosition, line(), column()); } return new TopDownSelector(selectors); + case "recursive": + if (selectors.size() != 1) { + throw new SelectorSyntaxException( + "The :recursive function requires a single selector argument", + input().toString(), functionPosition, line(), column()); + } + return new RecursiveSelector(selectors.get(0)); case "each": LOGGER.warning("The `:each` selector function has been renamed to `:is`: " + input()); return IsSelector.of(selectors); diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/selector/cases/recursive-function.smithy b/smithy-model/src/test/resources/software/amazon/smithy/model/selector/cases/recursive-function.smithy new file mode 100644 index 00000000000..42d80c73310 --- /dev/null +++ b/smithy-model/src/test/resources/software/amazon/smithy/model/selector/cases/recursive-function.smithy @@ -0,0 +1,160 @@ +$version: "2.0" + +metadata selectorTests = [ + { + // Get the resource hierarchy of a shape. + selector: "[id=smithy.example#C] :recursive(<-[resource]-)" + matches: [ + smithy.example#A + smithy.example#B + ] + } + + { + // Check if a shape is in a specific heirarchy. + selector: ":test(:recursive(<-[resource]-) [id=smithy.example#A])" + matches: [ + smithy.example#B + smithy.example#C + ] + } + + { + // Get all mixins of a shape. + selector: "[id=smithy.example#Indirect2] :recursive(-[mixin]->)" + matches: [ + smithy.example#Indirect + smithy.example#Direct + smithy.example#MyMixin + ] + } + + { + // Get all shapes that have a specific mixin. + // This will also short-circuit the recursive function once the fist match is found by the attribute. + selector: ":test(:recursive(-[mixin]->) [id=smithy.example#MyMixin])" + matches: [ + smithy.example#Direct + smithy.example#Indirect + smithy.example#Indirect2 + ] + } + { + // This is the same as the previous selector, but uses and unnecessary :test in the :test. + // This will also short-circuit the recursive function once the fist match is found by the test. + selector: ":test(:recursive(-[mixin]->) :test([id=smithy.example#MyMixin]))" + matches: [ + smithy.example#Direct + smithy.example#Indirect + smithy.example#Indirect2 + ] + } + + { + // An inefficient way to check if a mixin is applied to any shape as a mixin. + // The more efficient approach is: [trait|mixin] :test(<-[mixin]-) + selector: ":recursive(-[mixin]->) [trait|mixin]" + matches: [ + smithy.example#MyMixin + smithy.example#Direct + smithy.example#Indirect + ] + } + { + // Proof of the more efficient way to check if a mixin is applied to any shape as a mixin. + selector: "[trait|mixin] :test(<-[mixin]-)" + matches: [ + smithy.example#MyMixin + smithy.example#Direct + smithy.example#Indirect + ] + } + + + { + // A slightly less efficient form of "~>". + selector: "[id=smithy.example#Direct] :recursive(>)" + matches: [ + smithy.example#MyMixin + smithy.api#String + smithy.example#Direct$foo + ] + } + { + // This is the same result, but slightly more efficient. + selector: "[id=smithy.example#Direct] ~>" + matches: [ + smithy.example#MyMixin + smithy.api#String + smithy.example#Direct$foo + ] + } + + { + // Find the closure of shapes that ultimately target a specific shape. + selector: "[id=smithy.example#Direct] :recursive(<)" + matches: [ + smithy.example#Indirect + smithy.example#Indirect2 + ] + } + + { + // Make a pathological selector to ensure we don't inifinitely recurse. + // This just matches shapes in the smithy.example namespace that are targeted by another shape. + // Note: This isn't a useful selector. + selector: ":recursive(:recursive(:recursive(:recursive(:recursive(~>))))) [id|namespace=smithy.example]" + matches: [ + smithy.example#C + smithy.example#Direct + smithy.example#MyMixin + smithy.example#Indirect + smithy.example#B + smithy.example#Direct$foo + smithy.example#Indirect$foo + smithy.example#Indirect2$foo + ] + } + { + // Make another pathological selector to ensure we don't inifinitely recurse. + // This matches shapes in the smithy.example namespace that are targeted by another shape that are targeted + // by another shape. Note: This isn't a useful selector. + selector: "~> :recursive(:recursive(:recursive(:recursive(:recursive(~>))))) [id|namespace=smithy.example]" + matches: [ + smithy.example#C + smithy.example#Direct + smithy.example#MyMixin + smithy.example#Direct$foo + smithy.example#Indirect$foo + ] + } +] + +namespace smithy.example + +resource A { + resources: [B] +} + +@internal +resource B { + resources: [C] +} + +resource C {} + +@mixin +structure UnusedMixin {} + +@mixin +structure MyMixin {} + +@mixin +structure Direct with [MyMixin] { + foo: String +} + +@mixin +structure Indirect with [Direct] {} + +structure Indirect2 with [Indirect] {}