Repository: Sceptre/sceptre

Issue:
Question: Join Resolver output with additional strings possible?
I'm wondering how you can use resolver outputs with some additional strings - e.g. use the stack_output and use some additional strings to attach / join them with. Can Jinja2 be use for this?

Usecase for example is to build a connection string (host:hostname:port?option=A), e.g.

parameters:
  paramA: `host:!stack_output <stack_name>::output:1234?option=A`

Dose the new stack_attr help here, e.g.

host_endpoint: !stack_output <stack_name>::output

parameters:
  paramA: `host:{{ stack_attr.host_endpoint }}:1234?option=A`

Discussion:

Patch:
diff --git a/docs/_source/docs/hooks.rst b/docs/_source/docs/hooks.rst
index 3212bd3..9adb2fb 100644
--- a/docs/_source/docs/hooks.rst
+++ b/docs/_source/docs/hooks.rst
@@ -207,7 +207,9 @@ This hook can be used in a Stack config file with the following syntax:
 
 hook arguments
 ^^^^^^^^^^^^^^
-Hook arguments can be a simple string or a complex data structure.
+Hook arguments can be a simple string or a complex data structure. You can even use resolvers in
+hook arguments, so long as they're nested in a list or a dict.
+
 Assume a Sceptre `copy` hook that calls the `cp command`_:
 
 .. code-block:: yaml
@@ -224,7 +226,7 @@ Assume a Sceptre `copy` hook that calls the `cp command`_:
        - !copy
            options: "-r"
            source: "from_dir"
-           destination: "to_dir"
+           destination: !stack_output my/other/stack::CopyDestination
 
 .. _Custom Hooks: #custom-hooks
 .. _subprocess documentation: https://docs.python.org/3/library/subprocess.html
diff --git a/docs/_source/docs/resolvers.rst b/docs/_source/docs/resolvers.rst
index 8c93b1f..9708012 100644
--- a/docs/_source/docs/resolvers.rst
+++ b/docs/_source/docs/resolvers.rst
@@ -5,6 +5,9 @@ Sceptre implements resolvers, which can be used to resolve a value of a
 CloudFormation ``parameter`` or ``sceptre_user_data`` value at runtime. This is
 most commonly used to chain the outputs of one Stack to the inputs of another.
 
+You can use resolvers with any resolvable property on a StackConfig, as well as in the arguments
+of hooks and other resolvers.
+
 If required, users can create their own resolvers, as described in the section
 on `Custom Resolvers`_.
 
@@ -52,6 +55,27 @@ file_contents
 
 **deprecated**: Consider using the `file`_ resolver instead.
 
+join
+~~~~
+
+This resolver allows you to join multiple strings together to form a single string. This is great
+for combining the outputs of multiple resolvers. This resolver works just like CloudFormation's
+``!Join`` intrinsic function.
+
+The argument for this resolver should be a list with two elements: (1) A string to join the elements
+on and (2) a list of items to join.
+
+Example:
+
+.. code-block:: yaml
+
+   parameters:
+     BaseUrl: !join
+       - ":"
+       - - !stack_output my/app/stack.yaml::HostName
+         - !stack_output my/other/stack.yaml::Port
+
+
 no_value
 ~~~~~~~~
 
@@ -79,6 +103,48 @@ A resolver to execute any shell command.
 
 Refer to `sceptre-resolver-cmd <https://github.com/Sceptre/sceptre-resolver-cmd/>`_ for documentation.
 
+select
+~~~~~~
+
+This resolver allows you to select a specific index of a list of items. This is great for combining
+with the ``!split`` resolver to obtain part of a string. This function works almost the same as
+CloudFormation's ``!Select`` intrinsic function, **except you can use this with negative indices to
+select from the end of a list**.
+
+The argument for this resolver should be a list with two elements: (1) A numerical index and (2) a
+list of items to select out of. If the index is negative, it will select from the end of the list.
+For example, "-1" would select the last element and "-2" would select the second-to-last element.
+
+Example:
+
+.. code-block:: yaml
+
+   sceptre_user_data:
+     # This selects the last element after you split the connection string on "/"
+     DatabaseName: !select
+       - -1
+       - !split ["/", !stack_output my/database/stack.yaml::ConnectionString]
+
+split
+~~~~~
+
+This resolver will split a value on a given delimiter string. This is great when combining with the
+``!select`` resolver. This function works the same as CloudFormation's ``!Split`` intrinsic function.
+
+Note: The return value of this resolver is a *list*, not a string. This will not work to set Stack
+configurations that expect strings, but it WILL work to set Stack configurations that expect lists.
+
+The argument for this resolver should be a list with two elements: (1) The delimiter to split on and
+(2) a string to split.
+
+Example:
+
+.. code-block:: yaml
+
+   notifications: !split
+     - ";"
+     - !stack_output my/sns/topics.yaml::SemicolonDelimitedArns
+
 .. _stack_attr_resolver:
 
 stack_attr
@@ -181,6 +247,67 @@ Example:
    parameters:
      VpcIdParameter: !stack_output_external prj-network-vpc::VpcIdOutput prod
 
+
+sub
+~~~
+
+This resolver allows you to create a string using Python string format syntax. This functions as a
+great way to combine together a number of resolver outputs into a single string. This functions
+similarly to Cloudformation's ``!Sub`` intrinsic function.
+
+It should be noted that Jinja2 syntax is far more capable of interpolating values than this resolver,
+so you should use Jinja2 if all you need is to interpolate raw values from environment variables,
+variables from stack group configs, var files, and ``--var`` arguments. **The one thing that Jinja2
+interpolation can't do is interpolate resolver arguments into a string.** And that's what ``!sub``
+can do. For more information on why Jinja2 can't reference resolvers directly, see
+:ref:`resolution_order`.
+
+The argument to this resolver should be a two-element list: (1) Is the format string, using
+curly-brace templates to indicate variables, and (2) a dictionary where the keys are the format
+string's variable names and the values are the variable values.
+
+Example:
+
+.. code-block:: yaml
+
+   parameters:
+     ConnectionString: !sub
+       - "postgres://{username}:{password}@{hostname}:{port}/{database}"
+       # Notice how we're interpolating a username and database via Jinja2? Technically it's not
+       # necessary to pass them this way. They could be interpolated directly. But it might be
+       # easier to read this way if you pass them explicitly like this. See example below for the
+       # other way this can be done.
+       - username: {{ var.username }}
+         password: !ssm /my/ssm/password
+         hostname: !stack_output my/database/stack.yaml::HostName
+         port: !stack_output my/database/stack.yaml::Port
+         database: {{var.database}}
+
+
+It's relevant to note that this functions similarly to the *more verbose* form of CloudFormation's
+``!Sub`` intrinsic function, where you use a list argument and supply the interpolated values as a
+second list item in a dictionary. **Important**: Sceptre's ``!sub`` resolver will not work without
+a list argument. It does **not** directly reference variables without you directly passing them
+in the second list item in its argument.
+
+You *can* combine Jinja2 syntax with this resolver if you want to interpolate in other variables
+that Jinja2 has access to.
+
+Example:
+
+.. code-block:: yaml
+
+   parameters:
+     ConnectionString: !sub
+       # Notice the double-curly braces. That's Jinja2 syntax. Jinja2 will render the username into
+       # the string even before the yaml is loaded. If you use Jinja2 to interpolate the value, then
+       # it's not a template string variable you need to pass in the second list item passed to
+       # !sub.
+       - "postgres://{{ var.username }}:{password}@{hostname}:{port}/{{ stack_group_config.database }}"
+       - password: !ssm /my/ssm/password
+         hostname: !stack_output my/database/stack.yaml::HostName
+         port: !stack_output my/database/stack.yaml::Port
+
 Custom Resolvers
 ----------------
 
@@ -306,18 +433,22 @@ For details on calling AWS services or invoking AWS-related third party tools in
 
 Resolver arguments
 ^^^^^^^^^^^^^^^^^^
-Resolver arguments can be a simple string or a complex data structure.
+Resolver arguments can be a simple string or a complex data structure. You can even use
+other resolvers in the arguments to resolvers! (Note: Other resolvers can only be passed in
+arguments when they're passed in lists and dicts.)
 
 .. code-block:: yaml
 
    template:
      path: <...>
      type: <...>
-    parameters:
-      Param1: !ssm "/dev/DbPassword"
-      Param2: !ssm {"name": "/dev/DbPassword"}
-      Param3: !ssm
-        name: "/dev/DbPassword"
+   parameters:
+     Param1: !ssm "/dev/DbPassword"
+     Param2: !ssm {"name": "/dev/DbPassword"}
+     Param3: !ssm
+       name: "/dev/DbPassword"
+     Param4: !ssm
+       name: !stack_output my/other/stack.yaml::MySsmParameterName
 
 .. _Custom Resolvers: #custom-resolvers
 .. _this is great place to start: https://docs.python.org/3/distributing/
diff --git a/docs/_source/docs/stack_config.rst b/docs/_source/docs/stack_config.rst
index ae34010..52ff8db 100644
--- a/docs/_source/docs/stack_config.rst
+++ b/docs/_source/docs/stack_config.rst
@@ -99,7 +99,7 @@ and that Stack need not be added as an explicit dependency.
 
 hooks
 ~~~~~
-* Resolvable: No
+* Resolvable: No (but you can use resolvers _in_ hook arguments!)
 * Can be inherited from StackGroup: Yes
 * Inheritance strategy: Overrides parent if set
 
@@ -556,7 +556,8 @@ order:
 A common point of confusion tends to be around the distinction between **"render time"** (phase 3, when
 Jinja logic is applied) and **"resolve time"** (phase 6, when resolvers are resolved). You cannot use
 a resolver via Jinja during "render time", since the resolver won't exist or be ready to use yet. You can,
-however, use Jinja logic to indicate *whether*, *which*, or *how* a resolver is configured.
+however, use Jinja logic to indicate *whether*, *which*, or *how* a resolver is configured. You can
+also use resolvers like ``!sub`` to interpolate resolved values when Jinja isn't available.
 
 For example, you **can** do something like this:
 
@@ -566,6 +567,11 @@ For example, you **can** do something like this:
      {% if var.use_my_parameter %}
        my_parameter: !stack_output {{ var.stack_name }}::{{ var.output_name }}
      {% endif %}
+       # !sub will let you combine outputs of multiple resolvers into a single string
+       my_combined_parameter: !sub
+         - "{fist_part} - {second_part}"
+         - first_part: !stack_output my/stack/name.yaml::Output
+         - second_part: {{ var.second_part }}
 
 Accessing resolved values in other fields
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/sceptre/config/reader.py b/sceptre/config/reader.py
index 5f65aef..f618d87 100644
--- a/sceptre/config/reader.py
+++ b/sceptre/config/reader.py
@@ -182,7 +182,7 @@ class ConfigReader(object):
             :rtype: func
             """
 
-            # This function signture is required by PyYAML
+            # This function signature is required by PyYAML
             def class_constructor(loader, node):
                 return node_class(
                     loader.construct_object(self.resolve_node_tag(loader, node))
diff --git a/sceptre/exceptions.py b/sceptre/exceptions.py
index 50478ce..a0ebce5 100644
--- a/sceptre/exceptions.py
+++ b/sceptre/exceptions.py
@@ -219,3 +219,9 @@ class CannotPruneStackError(SceptreException):
     Error raised when an obsolete stack cannot be pruned because another stack depends on it that is
     not itself obsolete.
     """
+
+
+class InvalidResolverArgumentError(SceptreException):
+    """
+    Indicates a resolver argument is invalid in some way.
+    """
diff --git a/sceptre/helpers.py b/sceptre/helpers.py
index 266d6ac..66b8c6a 100644
--- a/sceptre/helpers.py
+++ b/sceptre/helpers.py
@@ -2,7 +2,7 @@
 from contextlib import contextmanager
 from datetime import datetime
 from os import sep
-from typing import Optional, Any, List
+from typing import Optional, Any, List, Tuple, Union
 
 import dateutil.parser
 import deprecation
@@ -68,6 +68,28 @@ def _call_func_on_values(func, attr, cls):
     return attr
 
 
+Container = Union[list, dict]
+Key = Union[str, int]
+
+
+def delete_keys_from_containers(keys_to_delete: List[Tuple[Container, Key]]):
+    """Removes the indicated keys/indexes from their paired containers."""
+    list_items_to_delete = []
+    for container, key in keys_to_delete:
+        if isinstance(container, list):
+            # If it's a list, we want to gather up the items to remove from the list.
+            # We don't want to modify the list length yet, since removals will change all the other
+            # list indexes. Instead, we'll get the actual items at those indexes to remove later.
+            list_items_to_delete.append((container, container[key]))
+        else:
+            del container[key]
+
+    # Finally, now that we have all the items we want to remove the lists, we'll remove those
+    # items specifically from the lists.
+    for containing_list, item in list_items_to_delete:
+        containing_list.remove(item)
+
+
 def normalise_path(path):
     """
     Converts a path to use correct path separator.
diff --git a/sceptre/hooks/__init__.py b/sceptre/hooks/__init__.py
index 3d2e19f..56b4df6 100644
--- a/sceptre/hooks/__init__.py
+++ b/sceptre/hooks/__init__.py
@@ -1,39 +1,21 @@
 import abc
 import logging
 from functools import wraps
+from typing import TYPE_CHECKING
 
 from sceptre.helpers import _call_func_on_values
-from sceptre.logging import StackLoggerAdapter
-
-from typing import TYPE_CHECKING, Any
+from sceptre.resolvers import CustomYamlTagBase
 
 if TYPE_CHECKING:
     from sceptre.stack import Stack
 
 
-class Hook(abc.ABC):
+class Hook(CustomYamlTagBase, metaclass=abc.ABCMeta):
     """
-    Hook is an abstract base class that should be inherited by all hooks.
-
-    :param argument: The argument of the hook.
-    :param stack: The associated stack of the hook.
+    Hook is an abstract base class that should be subclassed by all hooks.
     """
 
-    def __init__(self, argument: Any = None, stack: "Stack" = None):
-        self.logger = logging.getLogger(__name__)
-
-        if stack is not None:
-            self.logger = StackLoggerAdapter(self.logger, stack.name)
-
-        self.argument = argument
-        self.stack = stack
-
-    def setup(self):
-        """
-        setup is a method that may be overwritten by inheriting classes. Allows
-        hooks to run so initalisation steps when config is first read.
-        """
-        pass  # pragma: no cover
+    logger = logging.getLogger(__name__)
 
     @abc.abstractmethod
     def run(self):
@@ -43,15 +25,6 @@ class Hook(abc.ABC):
         """
         pass  # pragma: no cover
 
-    def clone(self, stack: "Stack") -> "Hook":
-        """
-        Produces a "fresh" copy of the Hook, with the specified stack.
-
-        :param stack: The stack to set on the cloned resolver
-        """
-        clone = type(self)(self.argument, stack)
-        return clone
-
 
 class HookProperty(object):
     """
@@ -84,7 +57,7 @@ class HookProperty(object):
         """
 
         def setup(attr, key, value: Hook):
-            attr[key] = clone = value.clone(instance)
+            attr[key] = clone = value.clone_for_stack(instance)
             clone.setup()
 
         _call_func_on_values(setup, value, Hook)
diff --git a/sceptre/resolvers/__init__.py b/sceptre/resolvers/__init__.py
index e46be4e..06fd34f 100644
--- a/sceptre/resolvers/__init__.py
+++ b/sceptre/resolvers/__init__.py
@@ -5,7 +5,8 @@ from contextlib import contextmanager
 from threading import RLock
 from typing import Any, TYPE_CHECKING, Type, Union, TypeVar
 
-from sceptre.helpers import _call_func_on_values
+from sceptre.exceptions import InvalidResolverArgumentError
+from sceptre.helpers import _call_func_on_values, delete_keys_from_containers
 from sceptre.logging import StackLoggerAdapter
 from sceptre.resolvers.placeholders import (
     create_placeholder_value,
@@ -17,37 +18,170 @@ if TYPE_CHECKING:
     from sceptre import stack
 
 T_Container = TypeVar("T_Container", bound=Union[dict, list])
+Self = TypeVar("Self")
 
 
 class RecursiveResolve(Exception):
     pass
 
 
-class Resolver(abc.ABC):
-    """
-    Resolver is an abstract base class that should be inherited by all
-    Resolvers.
+class CustomYamlTagBase:
+    """A base class for custom Yaml Elements (i.e. hooks and resolvers).
+
+    This base class takes care of common functionality needed by subclasses:
+    | * logging setup (associated with stacks)
+    | * Creating unique clones associated with individual stacks (applied recursively down to all
+    |   resolvers in the argument)
+    | * On-stack-connect setup that might be needed once connected to a Stack (applied recursively down to
+    |   all resolvers in the argument)
+    | * Automatically resolving resolvers in the argument when accessing self.argument
 
-    :param argument: The argument of the resolver.
-    :param stack: The associated stack of the resolver.
     """
 
+    logger = logging.getLogger(__name__)
+
     def __init__(self, argument: Any = None, stack: "stack.Stack" = None):
-        self.logger = logging.getLogger(__name__)
+        """Initializes a custom yaml tag object.
+
+        :param argument: The argument passed to the yaml tag. This could be a string, number, list,
+            or dict. Resolvers are supported in the argument, but they must be in either list or dict
+            arguments.
+        :param stack: The stack object associated with this instance. NOTE: When first instantiated
+            from loading the YAML, there will be no Stack. A Stack instance will only be passed into
+            the instance when "clone_for_stack" is invoked when the tag is associated with a specific
+            stack.
+        """
         if stack is not None:
             self.logger = StackLoggerAdapter(self.logger, stack.name)
 
-        self.argument = argument
         self.stack = stack
 
+        self._argument = argument
+        self._argument_is_resolved = False
+
+    @property
+    def argument(self) -> Any:
+        """This is the resolver or hook's argument.
+
+        This property will resolve all nested resolvers inside the argument, but only if this
+        instance has been associated with a Stack.
+
+        Resolving nested resolvers will result in their values being replaced in the dict/list they
+        were in with their resolved value, so we won't have to resolve them again.
+
+        Any resolvers that "resolve to nothing" (i.e. return None) will be removed from the dict/list
+        they were in.
+
+        If this property is accessed BEFORE the instance has a stack, it will return
+        the raw argument value. This is to safeguard any __init__() behaviors from triggering
+        resolution prematurely.
+        """
+        if self.stack is not None and not self._argument_is_resolved:
+            # Since resolving the argument updates the argument list or dict so that there aren't
+            # resolvers in it any more, we only need to do this resolution once per instance.
+            self._resolve_argument()
+
+        return self._argument
+
+    @argument.setter
+    def argument(self, value):
+        self._argument = value
+
+    def _resolve_argument(self):
+        """Resolves all argument resolvers recursively."""
+
+        keys_to_delete = []
+
+        def resolve(containing_list_or_dict, key, obj: Resolver):
+            result = obj.resolve()
+            # If the resolver "resolves to nothing", then it should get deleted out of its container.
+            if result is None:
+                keys_to_delete.append((containing_list_or_dict, key))
+            else:
+                containing_list_or_dict[key] = result
+
+        _call_func_on_values(resolve, self._argument, Resolver)
+        delete_keys_from_containers(keys_to_delete)
+
+        self._argument_is_resolved = True
+
+    def _recursively_setup(self):
+        """Ensures all nested resolvers in this resolver's argument are also setup when this
+        instance's setup method is called.
+        """
+        self.setup()
+
+        def setup_nested(containing_list_or_dict, key, obj: Resolver):
+            obj._recursively_setup()
+
+        _call_func_on_values(setup_nested, self._argument, Resolver)
+
+    def _recursively_clone(self: Self, stack: "stack.Stack") -> Self:
+        """Recursively clones the instance and its arguments.
+
+        The returned instance will have an identical argument that is a different memory reference,
+        so that instances inherited from a stack group and applied across multiple stacks are
+        independent of each other.
+
+        Furthermore, all nested resolvers in this resolver's argument will also be cloned to ensure
+        they themselves are also independent and fully configured for the current stack.
+        """
+
+        def recursively_clone_arguments(obj):
+            if isinstance(obj, Resolver):
+                return obj._recursively_clone(stack)
+            if isinstance(obj, list):
+                return [recursively_clone_arguments(item) for item in obj]
+            elif isinstance(obj, dict):
+                return {
+                    key: recursively_clone_arguments(val) for key, val in obj.items()
+                }
+            return obj
+
+        argument = recursively_clone_arguments(self._argument)
+        clone = type(self)(argument, stack)
+        return clone
+
+    def clone_for_stack(self: Self, stack: "stack.Stack") -> Self:
+        """
+        Obtains a clone of the current object, setup and ready for use for a given Stack instance.
+        """
+        clone = self._recursively_clone(stack)
+        clone._recursively_setup()
+        return clone
+
     def setup(self):
         """
-        This method is called at during stack initialisation.
+        This method is called when the object is connected to a Stack instance.
         Implementation of this method in subclasses can be used to do any
         initial setup of the object.
         """
         pass  # pragma: no cover
 
+    def __repr__(self) -> str:
+        """Returns a string representation of the resolver.
+
+        This is mostly used for resolver placeholders. In cases where we cannot resolve a resolver
+        YET, such as when we're generating a template or diff and there's a dependency on a stack
+        output of a stack that hasn't been deployed yet, placeholders need to render the resolver
+        in a way that is useful.
+
+        We use self._argument instead of self.argument because if there are resolvers nested in this
+        resolver's argument and one of those cannot be resolved, we'll need those resolvers to also
+        be converted to useful placeholder values.
+        """
+        as_str = f"!{self.__class__.__name__}"
+        if self._argument is not None:
+            as_str += f"({self._argument})"
+
+        return as_str
+
+
+class Resolver(CustomYamlTagBase, metaclass=abc.ABCMeta):
+    """
+    Resolver is an abstract base class that should be subclassed by all Resolvers.
+    """
+
     @abc.abstractmethod
     def resolve(self):
         """
@@ -58,13 +192,11 @@ class Resolver(abc.ABC):
         """
         pass  # pragma: no cover
 
-    def clone(self, stack: "stack.Stack") -> "Resolver":
-        """
-        Produces a "fresh" copy of the Resolver, with the specified stack.
-
-        :param stack: The stack to set on the cloned resolver
-        """
-        return type(self)(self.argument, stack)
+    def raise_invalid_argument_error(self, message, from_: Exception = None):
+        error_message = f"{self.stack.name} - {message}"
+        if from_:
+            raise InvalidResolverArgumentError(error_message) from from_
+        raise InvalidResolverArgumentError(error_message)
 
 
 class ResolvableProperty(abc.ABC):
@@ -127,23 +259,6 @@ class ResolvableProperty(abc.ABC):
         finally:
             setattr(stack, get_status_name, False)
 
-    def get_setup_resolver_for_stack(
-        self, stack: "stack.Stack", resolver: Resolver
-    ) -> Resolver:
-        """Obtains a clone of the resolver with the stack set on it and the setup method having
-        been called on it.
-
-        :param stack: The stack to set on the Resolver
-        :param resolver: The Resolver to clone and set up
-        :return: The cloned resolver.
-        """
-        # We clone the resolver when we assign the value so that every stack gets its own resolver
-        # rather than potentially having one resolver instance shared in memory across multiple
-        # stacks.
-        clone = resolver.clone(stack)
-        clone.setup()
-        return clone
-
     @abc.abstractmethod
     def get_resolved_value(
         self, stack: "stack.Stack", stack_class: Type["stack.Stack"]
@@ -254,20 +369,7 @@ class ResolvableContainerProperty(ResolvableProperty):
 
         container = getattr(stack, self.name)
         _call_func_on_values(resolve, container, Resolver)
-        # Remove keys and indexes from their containers that had resolvers resolve to None.
-        list_items_to_delete = []
-        for attr, key in keys_to_delete:
-            if isinstance(attr, list):
-                # If it's a list, we want to gather up the items to remove from the list.
-                # We don't want to modify the list length yet.
-                # Since removals will change all the other list indexes,
-                # we don't wan't to modify lists yet.
-                list_items_to_delete.append((attr, attr[key]))
-            else:
-                del attr[key]
-
-        for containing_list, item in list_items_to_delete:
-            containing_list.remove(item)
+        delete_keys_from_containers(keys_to_delete)
 
         return container
 
@@ -294,7 +396,7 @@ class ResolvableContainerProperty(ResolvableProperty):
 
         def recurse(obj):
             if isinstance(obj, Resolver):
-                return self.get_setup_resolver_for_stack(stack, obj)
+                return obj.clone_for_stack(stack)
             if isinstance(obj, list):
                 return [recurse(item) for item in obj]
             elif isinstance(obj, dict):
@@ -389,5 +491,5 @@ class ResolvableValueProperty(ResolvableProperty):
         :param value: The value to set
         """
         if isinstance(value, Resolver):
-            value = self.get_setup_resolver_for_stack(stack, value)
+            value = value.clone_for_stack(stack)
         setattr(stack, self.name, value)
diff --git a/sceptre/resolvers/join.py b/sceptre/resolvers/join.py
new file mode 100644
index 0000000..3910d33
--- /dev/null
+++ b/sceptre/resolvers/join.py
@@ -0,0 +1,36 @@
+from sceptre.resolvers import Resolver
+
+
+class Join(Resolver):
+    """This resolver allows you to join multiple strings together to form a single string. This is
+    great for combining the outputs of multiple resolvers. This resolver works just like
+    CloudFormation's ``!Join`` intrinsic function.
+
+    The argument for this resolver should be a list with two elements: (1) A string to join the
+    elements on and (2) a list of items to join.
+
+    Example:
+
+       parameters:
+         BaseUrl: !join
+           - ":"
+           - - !stack_output my/app/stack.yaml::HostName
+             - !stack_output my/other/stack.yaml::Port
+
+    """
+
+    def resolve(self):
+        error_message = (
+            "The argument to !join must be a 2-element list, where the first element is the join "
+            "string and the second is a list of items to join."
+        )
+        if not isinstance(self.argument, list) or len(self.argument) != 2:
+            self.raise_invalid_argument_error(error_message)
+
+        delimiter, items_list = self.argument
+        if not isinstance(delimiter, str) or not isinstance(items_list, list):
+            self.raise_invalid_argument_error(error_message)
+
+        string_items = map(str, items_list)
+        joined = delimiter.join(string_items)
+        return joined
diff --git a/sceptre/resolvers/placeholders.py b/sceptre/resolvers/placeholders.py
index f93afa3..8a08399 100644
--- a/sceptre/resolvers/placeholders.py
+++ b/sceptre/resolvers/placeholders.py
@@ -65,10 +65,7 @@ def _create_explicit_resolver_placeholder(resolver: "resolvers.Resolver") -> str
     :param resolver: The resolver to create a placeholder for
     :return: The placeholder value
     """
-    base = f"!{type(resolver).__name__}"
-    suffix = f"({resolver.argument})" if resolver.argument is not None else ""
-    # double-braces in an f-string is just an escaped single brace
-    return f"{{ {base}{suffix} }}"
+    return f"{{ {resolver} }}"
 
 
 def _create_alphanumeric_placeholder(resolver: "resolvers.Resolver") -> str:
diff --git a/sceptre/resolvers/select.py b/sceptre/resolvers/select.py
new file mode 100644
index 0000000..92e5f94
--- /dev/null
+++ b/sceptre/resolvers/select.py
@@ -0,0 +1,42 @@
+from sceptre.resolvers import Resolver
+
+
+class Select(Resolver):
+    """This resolver allows you to select a specific index from a list of items or a specific key
+    from a dict.. This is great for combining with the ``!split`` resolver to obtain part of a
+    string. This function works almost the same as CloudFormation's ``!Select`` intrinsic function,
+    **except (1) you can use this with negative indexes to select with a reverse index** and (2)
+    you can select keys from a dict.
+
+    The argument for this resolver should be a list with two elements: (1) A numerical index or
+    string key and (2) a list or dict of items to select out of. If the index is negative,
+    it will select from the end of the list. For example, "-1" would select the last element and
+    "-2" would select the second-to-last element.
+
+    Example:
+
+       parameters:
+         # This selects the last element after you split the connection string on "/"
+         DatabaseName: !select
+           - -1
+           - !split ["/", !stack_output my/database/stack.yaml::ConnectionString]
+    """
+
+    def resolve(self):
+        error_message = (
+            "The argument to !select must be a two-element list, where the first element is the "
+            "index or key to select with and the second element is the list or dict to select from."
+        )
+        if not isinstance(self.argument, list) or len(self.argument) != 2:
+            self.raise_invalid_argument_error(error_message)
+
+        index, items = self.argument
+        if not isinstance(items, (dict, list)):
+            self.raise_invalid_argument_error(error_message)
+
+        try:
+            return items[index]
+        except (TypeError, KeyError, IndexError) as e:
+            self.raise_invalid_argument_error(
+                f"Could not select with index/key {index}: {e}", e
+            )
diff --git a/sceptre/resolvers/split.py b/sceptre/resolvers/split.py
new file mode 100644
index 0000000..e88a0e3
--- /dev/null
+++ b/sceptre/resolvers/split.py
@@ -0,0 +1,33 @@
+from sceptre.resolvers import Resolver
+
+
+class Split(Resolver):
+    """This resolver will split a value on a given delimiter string. This is great when combining with the
+    ``!select`` resolver. This function works the same as CloudFormation's ``!Split`` intrinsic function.
+
+    Note: The return value of this resolver is a *list*, not a string. This will not work to set Stack
+    configurations that expect strings, but it WILL work to set Stack configurations that expect lists.
+
+    The argument for this resolver should be a list with two elements: (1) The delimiter to split on and
+    (2) a string to split.
+
+    Example:
+       notifications: !split
+         - ";"
+         - !stack_output my/sns/topics.yaml::SemicolonDelimitedArns
+    """
+
+    def resolve(self):
+        error_message = (
+            "The argument to !split must be a two-element list, where the first element is the "
+            "string to split on and the second element string to split."
+        )
+        if (
+            not isinstance(self.argument, list)
+            or len(self.argument) != 2
+            or not all(isinstance(a, str) for a in self.argument)
+        ):
+            self.raise_invalid_argument_error(error_message)
+
+        split_on, split_string = self.argument
+        return split_string.split(split_on)
diff --git a/sceptre/resolvers/sub.py b/sceptre/resolvers/sub.py
new file mode 100644
index 0000000..93c29e9
--- /dev/null
+++ b/sceptre/resolvers/sub.py
@@ -0,0 +1,42 @@
+from sceptre.resolvers import Resolver
+
+
+class Sub(Resolver):
+    """This resolver allows you to create a string using Python string format syntax. This is a
+    great way to combine together a number of resolver outputs into a single string. This functions
+    very similarly to Cloudformation's ``!Sub`` intrinsic function.
+
+    The argument to this resolver should be a two-element list: (1) Is the format string, using
+    curly-brace templates to indicate variables, and (2) a dictionary where the keys are the format
+    string's variable names and the values are the variable values.
+
+    Example:
+
+       parameters:
+         ConnectionString: !sub
+           - "postgres://{username}:{password}@{hostname}:{port}/{database}"
+           - username: {{ var.username }}
+             password: !ssm /my/ssm/password
+             hostname: !stack_output my/database/stack.yaml::HostName
+             port: !stack_output my/database/stack.yaml::Port
+             database: {{var.database}}
+    """
+
+    def resolve(self):
+        error_message = (
+            "The argument to !sub must be a two-element list, where the first element is the "
+            "a format string and the second element is a dict of values to interpolate into it."
+        )
+        if not isinstance(self.argument, list) or len(self.argument) != 2:
+            self.raise_invalid_argument_error(error_message)
+
+        template, variables = self.argument
+        if not isinstance(template, str) or not isinstance(variables, dict):
+            self.raise_invalid_argument_error(error_message)
+
+        try:
+            return template.format(**variables)
+        except KeyError as e:
+            self.raise_invalid_argument_error(
+                f"Could not find !sub argument for {e}", e
+            )
diff --git a/setup.py b/setup.py
index 5c23b07..3a96a24 100755
--- a/setup.py
+++ b/setup.py
@@ -71,7 +71,11 @@ setup(
             "stack_output_external ="
             "sceptre.resolvers.stack_output:StackOutputExternal",
             "no_value = sceptre.resolvers.no_value:NoValue",
+            "select = sceptre.resolvers.select:Select",
             "stack_attr = sceptre.resolvers.stack_attr:StackAttr",
+            "sub = sceptre.resolvers.sub:Sub",
+            "split = sceptre.resolvers.split:Split",
+            "join = sceptre.resolvers.join:Join",
         ],
         "sceptre.template_handlers": [
             "file = sceptre.template_handlers.file:File",


Test patch:
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 4d69c1e..ba52792 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -8,7 +8,11 @@ from os.path import join, sep
 from datetime import datetime, timezone, timedelta
 
 from sceptre.exceptions import PathConversionError
-from sceptre.helpers import get_external_stack_name, create_deprecated_alias_property
+from sceptre.helpers import (
+    get_external_stack_name,
+    create_deprecated_alias_property,
+    delete_keys_from_containers,
+)
 from sceptre.helpers import normalise_path
 from sceptre.helpers import sceptreise_path
 from sceptre.helpers import extract_datetime_from_aws_response_headers, gen_repr
@@ -171,3 +175,22 @@ class TestHelpers(object):
 
         assert len(messages) == 1
         assert messages[0].category == deprecation.DeprecatedWarning
+
+    def test_delete_keys_from_containers__removes_keys_from_dicts(self):
+        a = {"keep": "me", "kill": "me"}
+        b = {"keep": "me", "take": "me out"}
+        c = {"keep": "me", "destroy": "me"}
+
+        arg = [(a, "kill"), (b, "take"), (c, "destroy")]
+        delete_keys_from_containers(arg)
+        expected = {"keep": "me"}
+        assert a == b == c == expected
+
+    def test_delete_keys_from_containers__removes_indexes_from_lists(self):
+        a = ["keep me", "kill me", "keep me", "destroy me"]
+        b = ["take me out", "keep me", "send me the true death", "keep me"]
+
+        arg = [(a, 1), (a, 3), (b, 0), (b, 2)]
+        delete_keys_from_containers(arg)
+        expected = ["keep me", "keep me"]
+        assert a == b == expected
diff --git a/tests/test_hooks/test_hooks.py b/tests/test_hooks/test_hooks.py
index 0e9047a..47194b6 100644
--- a/tests/test_hooks/test_hooks.py
+++ b/tests/test_hooks/test_hooks.py
@@ -3,14 +3,12 @@ from unittest import TestCase
 from unittest.mock import MagicMock, Mock
 
 from sceptre.hooks import Hook, HookProperty, add_stack_hooks, execute_hooks
+from sceptre.resolvers import Resolver
 from sceptre.stack import Stack
 import logging
 
 
 class MockHook(Hook):
-    def __init__(self, *args, **kwargs):
-        super(MockHook, self).__init__(*args, **kwargs)
-
     def run(self):
         pass
 
@@ -63,6 +61,11 @@ class TestHooksFunctions(object):
         hook_2.run.called_once_with()
 
 
+class MyResolver(Resolver):
+    def resolve(self):
+        return self.argument
+
+
 class TestHook(TestCase):
     def setUp(self):
         self.stack = Mock(Stack)
@@ -75,6 +78,11 @@ class TestHook(TestCase):
 
         assert handler.records[0].message == f"{self.stack.name} - Bonjour"
 
+    def test_argument__supports_resolvers_in_arguments(self):
+        arg = [MyResolver("hello")]
+        hook = MockHook(arg, self.stack)
+        self.assertEqual(["hello"], hook.argument)
+
 
 class MockClass(object):
     hook_property = HookProperty("hook_property")
@@ -90,7 +98,9 @@ class TestHookPropertyDescriptor(object):
         mock_hook = MagicMock(spec=MockHook)
 
         self.mock_object.hook_property = [mock_hook]
-        assert self.mock_object._hook_property == [mock_hook.clone.return_value]
+        assert self.mock_object._hook_property == [
+            mock_hook.clone_for_stack.return_value
+        ]
 
     def test_getting_hook_property(self):
         self.mock_object._hook_property = self.mock_object
diff --git a/tests/test_resolvers/test_join.py b/tests/test_resolvers/test_join.py
new file mode 100644
index 0000000..0c8e1e1
--- /dev/null
+++ b/tests/test_resolvers/test_join.py
@@ -0,0 +1,73 @@
+from unittest.mock import Mock
+
+import pytest
+
+from sceptre.exceptions import InvalidResolverArgumentError
+from sceptre.resolvers import Resolver
+from sceptre.resolvers.join import Join
+
+
+class ArgResolver(Resolver):
+    def resolve(self):
+        return self.argument
+
+
+class TestJoin:
+    def test_resolve__joins_resolver_values_into_single_string(self):
+        argument = [
+            "/",
+            [
+                ArgResolver("first"),
+                "middle",
+                ArgResolver("last"),
+            ],
+        ]
+        join = Join(argument, Mock())
+        resolved = join.resolve()
+        expected = "first/middle/last"
+        assert expected == resolved
+
+    def test_resolve__argument_returns_non_string__casts_it_to_string(self):
+        argument = [
+            "/",
+            [
+                ArgResolver(123),
+                "other",
+            ],
+        ]
+        join = Join(argument, Mock())
+        resolved = join.resolve()
+        expected = "123/other"
+        assert expected == resolved
+
+    def test_resolve__argument_returns_none__not_included_in_string(self):
+        argument = [
+            "/",
+            [
+                ArgResolver("first"),
+                ArgResolver(None),
+                ArgResolver("last"),
+            ],
+        ]
+        join = Join(argument, Mock())
+        resolved = join.resolve()
+        expected = "first/last"
+        assert expected == resolved
+
+    @pytest.mark.parametrize(
+        "bad_argument",
+        [
+            pytest.param("just a string", id="just a string"),
+            pytest.param([123, ["something"]], id="non-string delimiter"),
+            pytest.param(
+                ["join", "not list to join"], id="second argument is not list"
+            ),
+            pytest.param(["first", ["second"], "third"], id="too many items"),
+        ],
+    )
+    def test_resolve__invalid_arguments_passed__raises_invalid_resolver_argument_error(
+        self, bad_argument
+    ):
+        join = Join(bad_argument, Mock())
+        with pytest.raises(InvalidResolverArgumentError):
+            join.resolve()
diff --git a/tests/test_resolvers/test_placeholders.py b/tests/test_resolvers/test_placeholders.py
index fa2377f..236a19e 100644
--- a/tests/test_resolvers/test_placeholders.py
+++ b/tests/test_resolvers/test_placeholders.py
@@ -8,6 +8,11 @@ from sceptre.resolvers.placeholders import (
 )
 
 
+class MyResolver(Resolver):
+    def resolve(self):
+        return self.argument
+
+
 class TestPlaceholders:
     def test_are_placeholders_enabled__returns_false(self):
         assert are_placeholders_enabled() is False
@@ -51,6 +56,12 @@ class TestPlaceholders:
                 "{ !MyResolver({'key': 'value'}) }",
                 id="explicit dict argument",
             ),
+            pytest.param(
+                PlaceholderType.explicit,
+                {"some_key": MyResolver("some value")},
+                "{ !MyResolver({'some_key': !MyResolver(some value)}) }",
+                id="explicit with nested resolvers",
+            ),
             pytest.param(
                 PlaceholderType.alphanum, None, "MyResolver", id="alphanum no argument"
             ),
@@ -66,14 +77,18 @@ class TestPlaceholders:
                 "MyResolverkeyvalue",
                 id="alphanum dict argument",
             ),
-            pytest.param(PlaceholderType.none, "something", None),
+            pytest.param(
+                PlaceholderType.alphanum,
+                {"some_key": MyResolver("some value")},
+                "MyResolversomekeyMyResolversomevalue",
+                id="alphanum with nested resolvers",
+            ),
+            pytest.param(
+                PlaceholderType.none, "something", None, id="none type placeholder type"
+            ),
         ],
     )
     def test_create_placeholder_value(self, placeholder_type, argument, expected):
-        class MyResolver(Resolver):
-            def resolve(self):
-                pass
-
         resolver = MyResolver(argument)
 
         with use_resolver_placeholders_on_error():
diff --git a/tests/test_resolvers/test_resolver.py b/tests/test_resolvers/test_resolver.py
index 5711dc0..111661e 100644
--- a/tests/test_resolvers/test_resolver.py
+++ b/tests/test_resolvers/test_resolver.py
@@ -1,11 +1,11 @@
 # -*- coding: utf-8 -*-
 import logging
 from unittest import TestCase
-from unittest.mock import call, Mock
+from unittest.mock import call, Mock, sentinel, MagicMock
 
 import pytest
-from unittest.mock import sentinel, MagicMock
 
+from sceptre.exceptions import InvalidResolverArgumentError
 from sceptre.resolvers import (
     Resolver,
     ResolvableContainerProperty,
@@ -26,9 +26,26 @@ class MockResolver(Resolver):
     Resolver, which is not otherwise instantiable.
     """
 
+    setup_has_been_called = False
+
     def resolve(self):
         pass
 
+    def setup(self):
+        self.setup_has_been_called = True
+
+
+class NestedResolver(Resolver):
+    setup_has_been_called = False
+    resolve_count = 0
+
+    def setup(self):
+        self.setup_has_been_called = True
+
+    def resolve(self):
+        self.resolve_count += 1
+        return self.argument
+
 
 class MockClass(object):
     resolvable_container_property = ResolvableContainerProperty(
@@ -62,6 +79,146 @@ class TestResolver(TestCase):
 
         assert handler.records[0].message == f"{sentinel.stack.name} - Bonjour"
 
+    def test_invalid_argument_error__raises_invalid_argument_error_with_stack_name_prefix(
+        self,
+    ):
+        with pytest.raises(
+            InvalidResolverArgumentError, match=f"{sentinel.stack.name} - danger"
+        ):
+            self.mock_resolver.raise_invalid_argument_error("danger")
+
+
+class TestCustomYamlTagBase(TestCase):
+    def setUp(self):
+        self.stack = Mock()
+        self.stack.name = "My Stack"
+
+    def test_clone_for_stack__dict_argument_is_cloned(self):
+        arg = {"greeting": "hello"}
+        resolver = MockResolver(arg)
+        clone = resolver.clone_for_stack(self.stack)
+        self.assertIsNot(clone.argument, arg)
+
+    def test_clone_for_stack__list_argument_is_cloned(self):
+        arg = ["hello"]
+        resolver = MockResolver(arg)
+        clone = resolver.clone_for_stack(self.stack)
+        self.assertIsNot(clone.argument, arg)
+
+    def test_clone_for_stack__nested_dict_argument_is_cloned(self):
+        arg = {"greetings": {"French": "bonjour"}}
+        resolver = MockResolver(arg)
+        clone = resolver.clone_for_stack(None)
+        self.assertIsNot(clone.argument["greetings"], arg["greetings"])
+
+    def test_clone_for_stack__nested_list_argument_is_cloned(self):
+        arg = [{"French": "bonjour"}]
+        resolver = MockResolver(arg)
+        clone = resolver.clone_for_stack(None)
+        self.assertIsNot(clone.argument[0], arg[0])
+
+    def test_clone_for_stack__nested_resolvers_are_cloned(self):
+        arg = {"greetings": {"French": NestedResolver("bonjour")}}
+        resolver = MockResolver(arg)
+        clone = resolver.clone_for_stack(None)
+        self.assertIsNot(
+            clone.argument["greetings"]["French"], arg["greetings"]["French"]
+        )
+
+    def test_clone_for_stack__calls_setup(self):
+        arg = {"greetings": {"French": NestedResolver("bonjour")}}
+        resolver = MockResolver(arg)
+        clone = resolver.clone_for_stack(self.stack)
+        self.assertTrue(clone.setup_has_been_called)
+
+    def test_clone_for_stack__nested_resolvers_are_setup(self):
+        arg = {"greetings": {"French": NestedResolver("bonjour")}}
+        resolver = MockResolver(arg)
+        # Passing None here will mean that the argument won't actually be resolved, so it lets us
+        # access the actual resolver instance.
+        clone = resolver.clone_for_stack(None)
+
+        self.assertTrue(clone.argument["greetings"]["French"].setup_has_been_called)
+
+    def test_clone_for_stack__resolvers_inside_resolvers_are_cloned(self):
+        arg = {
+            "greetings": {
+                "French": NestedResolver({"informal": NestedResolver("salut")})
+            }
+        }
+        resolver = MockResolver(arg)
+        # Passing None here will mean that the argument won't actually be resolved, so it lets us
+        # access the actual resolver instance.
+        clone = resolver.clone_for_stack(None)
+        self.assertIsNot(
+            clone.argument["greetings"]["French"].argument["informal"],
+            arg["greetings"]["French"].argument["informal"],
+        )
+
+    def test_clone_for_stack__resolvers_inside_resolvers_are_setup(self):
+        arg = {
+            "greetings": {
+                "French": NestedResolver({"informal": NestedResolver("salut")})
+            }
+        }
+        resolver = MockResolver(arg)
+        # Passing None here will mean that the argument won't actually be resolved, so it lets us
+        # access the actual resolver instance.
+        clone = resolver.clone_for_stack(None)
+        self.assertTrue(
+            clone.argument["greetings"]["French"]
+            .argument["informal"]
+            .setup_has_been_called
+        )
+
+    def test_argument__has_stack__arg_is_string__resolves_to_string(self):
+        arg = "hello"
+        resolver = MockResolver(arg)
+        clone = resolver.clone_for_stack(self.stack)
+
+        self.assertEqual("hello", clone.argument)
+
+    def test_argument__cloned_for_stack__resolves_arg_resolver(self):
+        arg = {"greetings": {"French": NestedResolver("bonjour")}}
+        resolver = MockResolver(arg).clone_for_stack(self.stack)
+        expected = {"greetings": {"French": "bonjour"}}
+        self.assertEqual(expected, resolver.argument)
+
+    def test_argument__cloned_for_stack__nested_argument_in_nested_argument__results_all_resolvers(
+        self,
+    ):
+        arg = {
+            "greetings": {
+                "French": NestedResolver({"informal": NestedResolver("salut")})
+            }
+        }
+        resolver = MockResolver(arg).clone_for_stack(self.stack)
+        expected = {"greetings": {"French": {"informal": "salut"}}}
+        self.assertEqual(expected, resolver.argument)
+
+    def test_argument__cloned_for_stack__nested_argument_in_dict_resolves_to_nothing__removes_it_from_argument(
+        self,
+    ):
+        arg = {"greetings": {"French": NestedResolver(None)}}
+        resolver = MockResolver(arg).clone_for_stack(self.stack)
+        expected = {"greetings": {}}
+        self.assertEqual(expected, resolver.argument)
+
+    def test_argument__nested_argument_in_list_resolves_to_nothing__removes_it_from_argument(
+        self,
+    ):
+        arg = {
+            "greetings": [
+                NestedResolver(None),
+                "Hello",
+                NestedResolver(None),
+                "Bonjour",
+            ]
+        }
+        resolver = MockResolver(arg).clone_for_stack(self.stack)
+        expected = {"greetings": ["Hello", "Bonjour"]}
+        self.assertEqual(expected, resolver.argument)
+
 
 class TestResolvableContainerPropertyDescriptor:
     def setup_method(self, test_method):
@@ -86,13 +243,13 @@ class TestResolvableContainerPropertyDescriptor:
 
         cloned_data_structure = [
             "String",
-            mock_resolver.clone.return_value,
+            mock_resolver.clone_for_stack.return_value,
             [
-                mock_resolver.clone.return_value,
+                mock_resolver.clone_for_stack.return_value,
                 "String",
                 [
-                    [mock_resolver.clone.return_value, "String", None],
-                    mock_resolver.clone.return_value,
+                    [mock_resolver.clone_for_stack.return_value, "String", None],
+                    mock_resolver.clone_for_stack.return_value,
                     "String",
                 ],
             ],
@@ -100,8 +257,8 @@ class TestResolvableContainerPropertyDescriptor:
 
         self.mock_object.resolvable_container_property = complex_data_structure
         assert self.mock_object._resolvable_container_property == cloned_data_structure
-        expected_calls = [call(self.mock_object), call().setup()] * 4
-        mock_resolver.clone.assert_has_calls(expected_calls)
+        expected_calls = [call(self.mock_object)] * 4
+        mock_resolver.clone_for_stack.assert_has_calls(expected_calls)
 
     def test_getting_resolvable_property_with_none(self):
         self.mock_object._resolvable_container_property = None
@@ -466,14 +623,10 @@ class TestResolvableValueProperty:
         resolver = Mock(spec=MockResolver)
         self.mock_object.resolvable_value_property = resolver
         assert (
-            self.mock_object._resolvable_value_property == resolver.clone.return_value
+            self.mock_object._resolvable_value_property
+            == resolver.clone_for_stack.return_value
         )
 
-    def test_set__resolver__sets_up_cloned_resolver(self):
-        resolver = Mock(spec=MockResolver)
-        self.mock_object.resolvable_value_property = resolver
-        resolver.clone.return_value.setup.assert_any_call()
-
     @pytest.mark.parametrize("value", ["string", True, 123, 1.23, None])
     def test_get__non_resolver__returns_value(self, value):
         self.mock_object._resolvable_value_property = value
diff --git a/tests/test_resolvers/test_select.py b/tests/test_resolvers/test_select.py
new file mode 100644
index 0000000..c30fb83
--- /dev/null
+++ b/tests/test_resolvers/test_select.py
@@ -0,0 +1,68 @@
+from unittest.mock import Mock
+
+import pytest
+
+from sceptre.exceptions import InvalidResolverArgumentError
+from sceptre.resolvers import Resolver
+from sceptre.resolvers.select import Select
+
+
+class MyListResolver(Resolver):
+    def resolve(self):
+        return ["first", "second", "third"]
+
+
+class ItemResolver(Resolver):
+    def resolve(self):
+        return self.argument
+
+
+class TestSelect:
+    def test_resolve__second_arg_is_list_resolver__selects_item_at_list_index(self):
+        argument = [1, MyListResolver()]
+        select = Select(argument, Mock())
+        resolved = select.resolve()
+        expected = "second"
+        assert expected == resolved
+
+    def test_resolve__second_arg_is_list_of_resolvers__selects_item_at_list_index(self):
+        argument = [
+            1,
+            [ItemResolver("first"), ItemResolver("second"), ItemResolver("third")],
+        ]
+        select = Select(argument, Mock())
+        resolved = select.resolve()
+        expected = "second"
+        assert expected == resolved
+
+    def test_resolve__negative_index__selects_in_reverse(self):
+        argument = [-1, MyListResolver()]
+        select = Select(argument, Mock())
+        resolved = select.resolve()
+        expected = "third"
+        assert expected == resolved
+
+    def test_resolve__can_select_key_from_dict(self):
+        argument = ["something", ItemResolver({"something": 123})]
+        select = Select(argument, Mock())
+        resolved = select.resolve()
+        expected = 123
+        assert expected == resolved
+
+    @pytest.mark.parametrize(
+        "bad_argument",
+        [
+            pytest.param("just a string", id="just a string"),
+            pytest.param([123, "something"], id="second item is not list or dict"),
+            pytest.param([99, [1, 2]], id="index out of bounds"),
+            pytest.param(["hello", [1, 2]], id="string index on list"),
+            pytest.param(["hello", {"something": "else"}], id="key not present"),
+            pytest.param(["first", ["second"], "third"], id="too many items"),
+        ],
+    )
+    def test_resolve__invalid_arguments__raises_invalid_resolver_argument_error(
+        self, bad_argument
+    ):
+        select = Select(bad_argument, Mock())
+        with pytest.raises(InvalidResolverArgumentError):
+            select.resolve()
diff --git a/tests/test_resolvers/test_split.py b/tests/test_resolvers/test_split.py
new file mode 100644
index 0000000..d00ef47
--- /dev/null
+++ b/tests/test_resolvers/test_split.py
@@ -0,0 +1,36 @@
+from unittest.mock import Mock
+
+import pytest
+
+from sceptre.exceptions import InvalidResolverArgumentError
+from sceptre.resolvers import Resolver
+from sceptre.resolvers.split import Split
+
+
+class MyResolver(Resolver):
+    def resolve(self):
+        return "first,second,third"
+
+
+class TestSplit:
+    def test_resolve__splits_resolver_value_into_list(self):
+        argument = [",", MyResolver()]
+        split = Split(argument, Mock())
+        resolved = split.resolve()
+        expected = ["first", "second", "third"]
+        assert expected == resolved
+
+    @pytest.mark.parametrize(
+        "bad_argument",
+        [
+            pytest.param("just a string", id="just a string"),
+            pytest.param([123, "something"], id="first item is not string"),
+            pytest.param(["something", 123], id="second item is not string"),
+        ],
+    )
+    def test_resolve__invalid_arguments__raises_invalid_resolver_argument_error(
+        self, bad_argument
+    ):
+        split = Split(bad_argument, Mock())
+        with pytest.raises(InvalidResolverArgumentError):
+            split.resolve()
diff --git a/tests/test_resolvers/test_sub.py b/tests/test_resolvers/test_sub.py
new file mode 100644
index 0000000..7151f6e
--- /dev/null
+++ b/tests/test_resolvers/test_sub.py
@@ -0,0 +1,53 @@
+from unittest.mock import Mock
+
+import pytest
+
+from sceptre.exceptions import InvalidResolverArgumentError
+from sceptre.resolvers import Resolver
+from sceptre.resolvers.sub import Sub
+
+
+class FirstResolver(Resolver):
+    def resolve(self):
+        return "first"
+
+
+class SecondResolver(Resolver):
+    def resolve(self):
+        return "second"
+
+
+class TestSub:
+    def test_resolve__combines_resolvers_into_single_string(self):
+        argument = [
+            "{first} is {first_value}; {second} is {second_value}",
+            {
+                "first": FirstResolver(),
+                "second": SecondResolver(),
+                "first_value": 123,
+                "second_value": 456,
+            },
+        ]
+        sub = Sub(argument, Mock())
+        resolved = sub.resolve()
+        expected = "first is 123; second is 456"
+        assert expected == resolved
+
+    @pytest.mark.parametrize(
+        "bad_argument",
+        [
+            pytest.param("just a string", id="just a string"),
+            pytest.param([123, {"something": "else"}], id="first item is not string"),
+            pytest.param(["123", "hello"], id="second item is not a dict"),
+            pytest.param(
+                ["{this}", {"that": "hi"}], id="format string requires key not in dict"
+            ),
+            pytest.param(["first", ["second"], "third"], id="too many items"),
+        ],
+    )
+    def test_resolve__invalid_arguments__raises_invalid_resolver_argument_error(
+        self, bad_argument
+    ):
+        sub = Sub(bad_argument, Mock())
+        with pytest.raises(InvalidResolverArgumentError):
+            sub.resolve()