Skip to content

Commit

Permalink
Add :recursive selector function
Browse files Browse the repository at this point in the history
Closes #2228
  • Loading branch information
mtdowling authored and kstich committed Aug 28, 2024
1 parent c74f14e commit 3fc9d3d
Show file tree
Hide file tree
Showing 4 changed files with 264 additions and 0 deletions.
30 changes: 30 additions & 0 deletions docs/source-2.0/spec/selectors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
------------

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Shape> queue = new ArrayDeque<>();
private final Set<ShapeId> 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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] {}

0 comments on commit 3fc9d3d

Please sign in to comment.