Repository: avrae/draconic

Issue:
Set Operations
**Feature Request:** 
The addition of Python's set operations

**Who would use it?** 
Working with sets, doing things like union, symmetric difference, difference, and intersection on sets

**How would it work?**
Implement Python's standard set operators, namely `|`, `^`, `-`, and `&` and their augmented assignment versions

**Why should this be added?**
Anyone working with unordered sets of data could then perform these operations as they would expect within normal Python

Discussion:

Patch:
diff --git a/README.md b/README.md
index 314a090..928391c 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,5 @@
 # draconic
- The Draconic scripting language.
+
+The Draconic scripting language.
+
+Requires Python 3.8+
diff --git a/draconic/exceptions.py b/draconic/exceptions.py
index a40c460..cbfd07a 100644
--- a/draconic/exceptions.py
+++ b/draconic/exceptions.py
@@ -1,3 +1,5 @@
+from .versions import PY_310
+
 __all__ = (
     "DraconicException", "DraconicSyntaxError",
     "InvalidExpression", "NotDefined", "FeatureNotAvailable", "DraconicValueError",
@@ -18,8 +20,14 @@ class DraconicSyntaxError(DraconicException):
         super().__init__(original.msg)
         self.lineno = original.lineno
         self.offset = original.offset
+        self.end_lineno = None
+        self.end_offset = None
         self.text = original.text
 
+        if PY_310:
+            self.end_lineno = original.end_lineno
+            self.end_offset = original.end_offset
+
 
 class InvalidExpression(DraconicException):
     """Base exception for all exceptions during run-time."""
diff --git a/draconic/helpers.py b/draconic/helpers.py
index 27e3efc..7d81cd4 100644
--- a/draconic/helpers.py
+++ b/draconic/helpers.py
@@ -1,10 +1,11 @@
 import ast
 import operator as op
+from typing import Sequence
 
 from .exceptions import *
 from .types import *
 
-__all__ = ("DraconicConfig", "OperatorMixin")
+__all__ = ("DraconicConfig", "OperatorMixin", "zip_star")
 
 # ===== config =====
 DISALLOW_PREFIXES = ['_', 'func_']
@@ -14,9 +15,11 @@ DISALLOW_METHODS = ['format', 'format_map', 'mro']
 class DraconicConfig:
     """A configuration object to pass into the Draconic interpreter."""
 
-    def __init__(self, max_const_len=200000, max_loops=10000, max_statements=100000, max_power_base=1000000,
-                 max_power=1000, disallow_prefixes=None, disallow_methods=None,
-                 default_names=None, builtins_extend_default=True, max_int_size=64):
+    def __init__(
+        self, max_const_len=200000, max_loops=10000, max_statements=100000, max_power_base=1000000,
+        max_power=1000, disallow_prefixes=None, disallow_methods=None,
+        default_names=None, builtins_extend_default=True, max_int_size=64
+    ):
         """
         Configuration object for the Draconic interpreter.
 
@@ -186,3 +189,28 @@ class OperatorMixin:
             _raise_in_context(NumberTooHigh, "This number is too large")
         if isinstance(b, int) and (b < self._config.min_int or b > self._config.max_int):
             _raise_in_context(NumberTooHigh, "This number is too large")
+
+
+# ==== other utils ====
+def zip_star(a: Sequence, b: Sequence, star_index: int):
+    """
+    Like zip(a, b), but zips the element at ``a[star_index]`` with a list of 0..len(b) elements such that every other
+    element of ``a`` maps to exactly one element of ``b``.
+
+    >>> zip_star(['a', 'b', 'c'], [1, 2, 3, 4], star_index=1)  # like a, *b, c = [1, 2, 3, 4]
+    [('a', 1), ('b', [2, 3]), ('c', 4)]
+    >>> zip_star(['a', 'b', 'c'], [1, 2], star_index=1)  # like a, *b, c = [1, 2]
+    [('a', 1), ('b', []), ('c', 2)]
+
+    Requires ``len(b) >= len(a) - 1`` and ``star_index < len(a)``.
+    """
+    if not 0 <= star_index < len(a):
+        raise IndexError("'star_index' must be a valid index of 'a'")
+    if not len(b) >= len(a) - 1:
+        raise ValueError("'b' must be no more than 1 shorter than 'a'")
+
+    length_difference = len(b) - (len(a) - 1)
+
+    yield from zip(a[:star_index], b[:star_index])
+    yield a[star_index], b[star_index:star_index + length_difference]
+    yield from zip(a[star_index + 1:], b[star_index + length_difference:])
diff --git a/draconic/interpreter.py b/draconic/interpreter.py
index 7356f1a..6854519 100644
--- a/draconic/interpreter.py
+++ b/draconic/interpreter.py
@@ -1,8 +1,10 @@
 import ast
+from collections.abc import Mapping, Sequence
 
 from .exceptions import *
-from .helpers import DraconicConfig, OperatorMixin
+from .helpers import DraconicConfig, OperatorMixin, zip_star
 from .string import check_format_spec
+from .versions import PY_310
 
 __all__ = ("SimpleInterpreter", "DraconicInterpreter")
 
@@ -46,8 +48,8 @@ class SimpleInterpreter(OperatorMixin):
             ast.keyword: self._eval_keyword,  # foo(x=y), kwargs (not supported)
             # container[key]:
             ast.Subscript: self._eval_subscript,
-            ast.Index: self._eval_index,
-            ast.Slice: self._eval_slice,
+            ast.Index: self._eval_index,  # deprecated in py3.9 (bpo-34822)
+            ast.Slice: self._eval_slice,  # deprecated in py3.9 (bpo-34822)
             # container.key:
             ast.Attribute: self._eval_attribute,
         }
@@ -88,8 +90,10 @@ class SimpleInterpreter(OperatorMixin):
         try:
             handler = self.nodes[type(node)]
         except KeyError:
-            raise FeatureNotAvailable("Sorry, {0} is not available in this "
-                                      "evaluator".format(type(node).__name__), node)
+            raise FeatureNotAvailable(
+                "Sorry, {0} is not available in this "
+                "evaluator".format(type(node).__name__), node
+            )
 
         try:
             return handler(node)
@@ -123,13 +127,15 @@ class SimpleInterpreter(OperatorMixin):
     def _eval_str(self, node):
         if len(node.s) > self._config.max_const_len:
             raise IterableTooLong(
-                f"String literal in statement is too long ({len(node.s)} > {self._config.max_const_len})", node)
+                f"String literal in statement is too long ({len(node.s)} > {self._config.max_const_len})", node
+            )
         return self._str(node.s)
 
     def _eval_constant(self, node):
         if hasattr(node.value, '__len__') and len(node.value) > self._config.max_const_len:
             raise IterableTooLong(
-                f"Literal in statement is too long ({len(node.value)} > {self._config.max_const_len})", node)
+                f"Literal in statement is too long ({len(node.value)} > {self._config.max_const_len})", node
+            )
         if isinstance(node.value, bytes):
             raise FeatureNotAvailable("Creation of bytes literals is not allowed", node)
         return node.value
@@ -138,8 +144,10 @@ class SimpleInterpreter(OperatorMixin):
         return self.operators[type(node.op)](self._eval(node.operand))
 
     def _eval_binop(self, node):
-        return self.operators[type(node.op)](self._eval(node.left),
-                                             self._eval(node.right))
+        return self.operators[type(node.op)](
+            self._eval(node.left),
+            self._eval(node.right)
+        )
 
     def _eval_boolop(self, node):
         vout = False
@@ -238,8 +246,10 @@ class SimpleInterpreter(OperatorMixin):
             val = str(self._eval(n))
             length += len(val)
             if length > self._config.max_const_len:
-                raise IterableTooLong(f"f-string in statement is too long ({length} > {self._config.max_const_len})",
-                                      node)
+                raise IterableTooLong(
+                    f"f-string in statement is too long ({length} > {self._config.max_const_len})",
+                    node
+                )
             evaluated_values.append(val)
         return ''.join(evaluated_values)
 
@@ -252,60 +262,52 @@ class SimpleInterpreter(OperatorMixin):
 
 
 # ===== multiple-line execution, assignment, compound types =====
-class DraconicInterpreter(SimpleInterpreter):
-    """The Draconic interpreter. Capable of running Draconic code."""
+_break_sentinel = object()
+_continue_sentinel = object()
 
-    class _FinalValue:
-        def __init__(self, value):
-            self.value = value
 
-    class _Return(BaseException):
-        """We propogate a ``return`` up by using a custom exception."""
+class _Return:
+    __slots__ = ("value",)
 
-        def __init__(self, retval):
-            self.value = retval
+    def __init__(self, retval):
+        self.value = retval
 
-    class _Break(BaseException):
-        pass
 
-    class _Continue(BaseException):
-        pass
+class DraconicInterpreter(SimpleInterpreter):
+    """The Draconic interpreter. Capable of running Draconic code."""
 
     def __init__(self, builtins=None, config=None, initial_names=None):
-        super(DraconicInterpreter, self).__init__(builtins, config)
+        super().__init__(builtins, config)
 
         if initial_names is None:
             initial_names = {}
 
-        self.nodes.update({
-            # compound types:
-            ast.Dict: self._eval_dict,
-            ast.Tuple: self._eval_tuple,
-            ast.List: self._eval_list,
-            ast.Set: self._eval_set,
-            # comprehensions:
-            ast.ListComp: self._eval_listcomp,
-            ast.SetComp: self._eval_setcomp,
-            ast.DictComp: self._eval_dictcomp,
-            ast.GeneratorExp: self._eval_generatorexp,
-            # assignments:
-            ast.Assign: self._eval_assign,
-            ast.AugAssign: self._eval_augassign,
-            self._FinalValue: lambda v: v.value,
-            # control:
-            ast.Return: self._exec_return,
-            ast.If: self._exec_if,
-            ast.For: self._exec_for,
-            ast.While: self._exec_while,
-            ast.Break: self._exec_break,
-            ast.Continue: self._exec_continue,
-            ast.Pass: lambda node: None
-        })
-
-        if hasattr(ast, 'NamedExpr'):
-            self.nodes.update({
-                ast.NamedExpr: self._eval_namedexpr
-            })
+        self.nodes.update(
+            {
+                # compound types:
+                ast.Dict: self._eval_dict,
+                ast.Tuple: self._eval_tuple,
+                ast.List: self._eval_list,
+                ast.Set: self._eval_set,
+                # comprehensions:
+                ast.ListComp: self._eval_listcomp,
+                ast.SetComp: self._eval_setcomp,
+                ast.DictComp: self._eval_dictcomp,
+                ast.GeneratorExp: self._eval_generatorexp,
+                # assignments:
+                ast.Assign: self._eval_assign,
+                ast.AugAssign: self._eval_augassign,
+                ast.NamedExpr: self._eval_namedexpr,
+                # control:
+                ast.Return: self._exec_return,
+                ast.If: self._exec_if,
+                ast.For: self._exec_for,
+                ast.While: self._exec_while,
+                ast.Break: lambda node: _break_sentinel,
+                ast.Continue: lambda node: _continue_sentinel,
+                ast.Pass: lambda node: None
+            }
+        )
 
         self.assign_nodes = {
             ast.Name: self._assign_name,
@@ -315,6 +317,29 @@ class DraconicInterpreter(SimpleInterpreter):
             # no assigning to attributes
         }
 
+        self.patma_nodes = {}
+
+        if PY_310:
+            self.nodes.update(
+                {
+                    ast.Match: self._exec_match,
+                }
+            )
+
+            self.patma_nodes.update(
+                {
+                    ast.MatchValue: self._patma_match_value,
+                    ast.MatchSingleton: self._patma_match_singleton,
+                    ast.MatchSequence: self._patma_match_sequence,
+                    ast.MatchMapping: self._patma_match_mapping,
+                    ast.MatchStar: self._patma_match_star,
+                    # no MatchClass
+
+                    ast.MatchAs: self._patma_match_as,
+                    ast.MatchOr: self._patma_match_or
+                }
+            )
+
         # compound type helpers
         self._list = self._config.list
         self._set = self._config.set
@@ -325,13 +350,17 @@ class DraconicInterpreter(SimpleInterpreter):
         self._names = initial_names
 
     def eval(self, expr):
-        try:
-            return super().eval(expr)
-        except self._Return as r:
-            return r.value
-        except (self._Break, self._Continue):
-            raise DraconicSyntaxError(SyntaxError("Loop control outside loop",
-                                                  ("<string>", 1, 1, expr)))
+        retval = super().eval(expr)
+        if isinstance(retval, _Return):
+            return retval.value
+        elif retval is _break_sentinel or retval is _continue_sentinel:
+            raise DraconicSyntaxError(
+                SyntaxError(
+                    "Loop control outside loop",
+                    ("<string>", 1, 1, expr)
+                )
+            )
+        return retval
 
     def execute(self, expr):
         """
@@ -343,13 +372,16 @@ class DraconicInterpreter(SimpleInterpreter):
             expr = self.parse(expr)
 
         self._preflight()
-        try:
-            self._exec(expr)
-        except self._Return as r:
-            return r.value
-        except (self._Break, self._Continue):
-            raise DraconicSyntaxError(SyntaxError("Loop control outside loop",
-                                                  ("<string>", 1, 1, expr)))
+        retval = self._exec(expr)
+        if retval is _break_sentinel or retval is _continue_sentinel:
+            raise DraconicSyntaxError(
+                SyntaxError(
+                    "Loop control outside loop",
+                    ("<string>", 1, 1, expr)
+                )
+            )
+        if isinstance(retval, _Return):
+            return retval.value
 
     def _preflight(self):
         self._num_stmts = 0
@@ -377,7 +409,9 @@ class DraconicInterpreter(SimpleInterpreter):
 
     def _exec(self, body):
         for expression in body:
-            self._eval(expression)
+            retval = self._eval(expression)
+            if isinstance(retval, _Return) or retval is _break_sentinel or retval is _continue_sentinel:
+                return retval
 
     @property
     def names(self):
@@ -475,16 +509,23 @@ class DraconicInterpreter(SimpleInterpreter):
 
     # ===== assignments =====
     def _eval_assign(self, node):
+        value = self._eval(node.value)
         for target in node.targets:  # a = b = 1
-            self._assign(target, node.value)
+            self._assign(target, value)
 
     def _eval_augassign(self, node):
-        self._aug_assign(node.target, node.op, node.value)
+        target = node.target
+        # transform a += 1 to a = a + 1, then we can use assign and eval
+        new_value = ast.BinOp(left=target, op=node.op, right=node.value)
+        ast.copy_location(new_value, target)
+        self._assign(target, self._eval_binop(new_value))
 
     def _eval_namedexpr(self, node):
-        self._assign(node.target, node.value)
-        return self._eval_name(node.target)
+        value = self._eval(node.value)
+        self._assign(node.target, value)
+        return value
 
+    # ---- primary assign branch ----
     def _assign(self, names, values):
         try:
             handler = self.assign_nodes[type(names)]
@@ -492,52 +533,41 @@ class DraconicInterpreter(SimpleInterpreter):
             raise FeatureNotAvailable("Assignment to {} is not allowed".format(type(names).__name__), names)
         return handler(names, values)
 
-    def _aug_assign(self, target, oper, value):
-        # transform a += 1 to a = a + 1, then we can use assign and eval
-        new_value = ast.BinOp(left=target, op=oper, right=value)
-        ast.copy_location(new_value, target)
-        self._assign(target, new_value)
-
     def _assign_name(self, name, value):
         if name.id in self.builtins:
             raise DraconicValueError(f"{name.id} is already builtin (no shadow assignments).", name)
-        value = self._eval(value)
         self._names[name.id] = value
 
     def _assign_subscript(self, name, value):
         container = self._eval(name.value)
         key = self._eval(name.slice)
-        value = self._eval(value)
         container[key] = value  # no further evaluation needed, if container is in names it will update
 
     def _assign_unpack(self, names, values):
-        def do_assign(target, value):
-            if not isinstance(target, (ast.Tuple, ast.List)):
-                self._assign(target, self._FinalValue(value=value))
-            else:
-                try:
-                    value = list(iter(value))
-                except TypeError:
-                    raise DraconicValueError("Cannot unpack non-iterable {} object".format(type(value).__name__), names)
-                if not len(target.elts) == len(value):
-                    raise DraconicValueError(
-                        "Unequal unpack: {} names, {} values".format(len(target.elts), len(value)), names)
-                for t, v in zip(target.elts, value):
-                    do_assign(t, v)
-
-        values = self._eval(values)
-        do_assign(names, values)
+        if not isinstance(names, (ast.Tuple, ast.List)):
+            self._assign(names, values)
+        else:
+            try:
+                values = list(iter(values))
+            except TypeError:
+                raise DraconicValueError("Cannot unpack non-iterable {} object".format(type(values).__name__), names)
+            if not len(names.elts) == len(values):
+                raise DraconicValueError(
+                    "Unequal unpack: {} names, {} values".format(len(names.elts), len(values)), names
+                )
+            for t, v in zip(names.elts, values):
+                self._assign_unpack(t, v)
 
     # ===== execution =====
     def _exec_return(self, node):
-        raise self._Return(self._eval(node.value))
+        return _Return(self._eval(node.value))
 
     def _exec_if(self, node):
         test = self._eval(node.test)
         if test:
-            self._exec(node.body)
+            return self._exec(node.body)
         else:
-            self._exec(node.orelse)
+            return self._exec(node.orelse)
 
     def _exec_for(self, node):
         for item in self._eval(node.iter):
@@ -545,15 +575,16 @@ class DraconicInterpreter(SimpleInterpreter):
             if self._loops > self._config.max_loops:
                 raise TooManyStatements('Too many loops (in for block)', node)
 
-            self._assign(node.target, self._FinalValue(value=item))
-            try:
-                self._exec(node.body)
-            except self._Break:
+            self._assign(node.target, item)
+            retval = self._exec(node.body)
+            if isinstance(retval, _Return):
+                return retval
+            elif retval is _break_sentinel:
                 break
-            except self._Continue:
+            elif retval is _continue_sentinel:
                 continue
         else:
-            self._exec(node.orelse)
+            return self._exec(node.orelse)
 
     def _exec_while(self, node):
         while self._eval(node.test):
@@ -561,17 +592,162 @@ class DraconicInterpreter(SimpleInterpreter):
             if self._loops > self._config.max_loops:
                 raise TooManyStatements('Too many loops (in while block)', node)
 
-            try:
-                self._exec(node.body)
-            except self._Break:
+            retval = self._exec(node.body)
+            if isinstance(retval, _Return):
+                return retval
+            elif retval is _break_sentinel:
                 break
-            except self._Continue:
+            elif retval is _continue_sentinel:
                 continue
         else:
-            self._exec(node.orelse)
+            return self._exec(node.orelse)
+
+    # ===== patma =====
+    # impl inspired by GVR's impl at https://github.com/gvanrossum/patma/blob/master/patma.py
+    # note: we do duplicate binding checks at runtime instead of preflight, which means that
+    # some code could run before the duplicate binding is detected, and certain exprs illegal in Python are legal here
+    # this is OK for our use case but differs from Python's impl
+    def _exec_match(self, node):
+        subject = self._eval(node.subject)
+        for match_case in node.cases:
+            if (bindings := self._patma(match_case.pattern, subject)) is not None:
+                self._names.update(bindings)  # In python patma, values are bound before the guard executes
+                if match_case.guard is not None and not self._eval(match_case.guard):
+                    continue
+                return self._exec(match_case.body)
+
+    def _patma(self, pattern, subject):
+        """
+        Execute matching logic for a given match_case and subject.
+        If the subject matches the case, return the dict of bindings for this case.
+        Otherwise, return None.
+        """
+        try:
+            handler = self.patma_nodes[type(pattern)]
+        except KeyError:
+            raise FeatureNotAvailable(f"Matching on {type(pattern).__name__} is not allowed", pattern)
+        self._num_stmts += 1
+        return handler(pattern, subject)
 
-    def _exec_break(self, node):
-        raise self._Break
+    def _patma_match_value(self, node, subject):
+        if subject == self._eval(node.value):
+            return {}
+        return None
+
+    @staticmethod
+    def _patma_match_singleton(node, subject):
+        if subject is node.value:
+            return {}
+        return None
 
-    def _exec_continue(self, node):
-        raise self._Continue
+    def _patma_match_sequence(self, node, subject):
+        if not isinstance(subject, Sequence) or isinstance(subject, (str, bytes)):
+            return None
+
+        match_star_idxs = [
+            idx
+            for idx, pattern in enumerate(node.patterns)
+            if isinstance(pattern, ast.MatchStar)
+        ]
+        if len(match_star_idxs) > 1:
+            # multiple starred names
+            raise DraconicValueError(
+                f"multiple starred names in sequence pattern",
+                node
+            )
+        elif match_star_idxs:
+            # one starred name
+            if not len(node.patterns) <= len(subject) + 1:
+                return None
+            pattern_iterator = zip_star(node.patterns, subject, star_index=match_star_idxs[0])
+        else:
+            # no starred names
+            if len(node.patterns) != len(subject):
+                return None
+            pattern_iterator = zip(node.patterns, subject)
+
+        # do iteration over patterns and values
+        bindings = {}
+        bound_names = set()
+        for pattern, item in pattern_iterator:
+            # recursive check
+            # noinspection DuplicatedCode
+            match = self._patma(pattern, item)
+            if match is None:
+                return None
+
+            # duplicate bindings check
+            if bound_names.intersection(match):
+                raise DraconicValueError(
+                    f"multiple assignment to names {sorted(bound_names.intersection(match))} in sequence pattern",
+                    node
+                )
+            bindings.update(match)
+            bound_names.update(match)
+
+        return bindings
+
+    def _patma_match_mapping(self, node, subject):
+        if not isinstance(subject, Mapping):
+            return None
+
+        bindings = {}
+        bound_names = set()
+        bound_keys = set()
+        for key, pattern in zip(node.keys, node.patterns):
+            # recursive check
+            key = self._eval(key)
+            try:
+                value = subject[key]
+            except KeyError:
+                return None
+            # noinspection DuplicatedCode
+            match = self._patma(pattern, value)
+            if match is None:
+                return None
+
+            # duplicate bindings check
+            if bound_names.intersection(match):
+                raise DraconicValueError(
+                    f"multiple assignment to names {sorted(bound_names.intersection(match))} in mapping pattern",
+                    node
+                )
+            bindings.update(match)
+            bound_names.update(match)
+            bound_keys.add(key)
+
+        if node.rest is not None:
+            if node.rest in bound_names:
+                raise DraconicValueError(
+                    f"multiple assignment to name {node.rest!r} in mapping pattern",
+                    node
+                )
+            bindings[node.rest] = {k: v for k, v in subject.items() if k not in bound_keys}
+
+        return bindings
+
+    @staticmethod
+    def _patma_match_star(node, subject):
+        if node.name is None:
+            return {}
+        return {node.name: subject}
+
+    def _patma_match_as(self, node, subject):
+        if node.name is None:  # this is the wildcard pattern, always matches
+            return {}
+        if node.pattern is None:  # bare name capture pattern, always matches
+            return {node.name: subject}
+        # otherwise if the inner match matches, we just add an additional binding to it
+        inner_match = self._patma(node.pattern, subject)
+        if inner_match is None:
+            return None
+        return {**inner_match, node.name: subject}
+
+    def _patma_match_or(self, node, subject):
+        # since we don't know the subpattern's bindings until it executes, we can't enforce both sides having the
+        # same bindings like in Python
+        for pattern in node.patterns:
+            match = self._patma(pattern, subject)
+            if match is not None:
+                return match
+        return None
diff --git a/draconic/string.py b/draconic/string.py
index bb8c64a..0c2b156 100644
--- a/draconic/string.py
+++ b/draconic/string.py
@@ -4,7 +4,7 @@ import re
 from .exceptions import IterableTooLong, _raise_in_context
 
 __all__ = (
-    'FORMAT_SPEC_RE', 'PRINTF_TEMPLATE_RE', 'JoinProxy', 'TranslateTableProxy'
+    'check_format_spec', 'FORMAT_SPEC_RE', 'PRINTF_TEMPLATE_RE', 'JoinProxy', 'TranslateTableProxy'
 )
 
 # ==== format spec ====
@@ -17,14 +17,16 @@ _WIDTH = r"\d+"
 _GROUPING_OPTION = r"[_,]"
 _PRECISION = r"\d+"
 _TYPE = r"[bcdeEfFgGnosxX%]"
-FORMAT_SPEC_RE = re.compile(rf"(?:(?P<fill>{_FILL})?(?P<align>{_ALIGN}))?"
-                            rf"(?P<sign>{_SIGN})?"
-                            rf"(?P<alt_form>#)?"
-                            rf"(?P<zero_pad>0)?"
-                            rf"(?P<width>{_WIDTH})?"
-                            rf"(?P<grouping_option>{_GROUPING_OPTION})?"
-                            rf"(?:\.(?P<precision>{_PRECISION}))?"
-                            rf"(?P<type>{_TYPE})?")
+FORMAT_SPEC_RE = re.compile(
+    rf"(?:(?P<fill>{_FILL})?(?P<align>{_ALIGN}))?"
+    rf"(?P<sign>{_SIGN})?"
+    rf"(?P<alt_form>#)?"
+    rf"(?P<zero_pad>0)?"
+    rf"(?P<width>{_WIDTH})?"
+    rf"(?P<grouping_option>{_GROUPING_OPTION})?"
+    rf"(?:\.(?P<precision>{_PRECISION}))?"
+    rf"(?P<type>{_TYPE})?"
+)
 
 
 def check_format_spec(config, format_spec):
@@ -52,13 +54,15 @@ _PF_WIDTH = r"\*|\d+"
 _PF_PRECISION = r"\*|\d+"
 _PF_LENGTH_MODIFIER = r"[hlL]"
 _PF_TYPE = r"[diouxXeEfFgGcrsa%]"
-PRINTF_TEMPLATE_RE = re.compile(rf"%"
-                                rf"(?:\((?P<mapping_key>{_PF_MAPPING_KEY})\))?"
-                                rf"(?P<conversion_flags>{_PF_CONVERSION_FLAGS})*"
-                                rf"(?P<width>{_PF_WIDTH})?"
-                                rf"(?:\.(?P<precision>{_PF_PRECISION}))?"
-                                rf"(?P<length_modifier>{_PF_LENGTH_MODIFIER})?"
-                                rf"(?P<type>{_PF_TYPE})")
+PRINTF_TEMPLATE_RE = re.compile(
+    rf"%"
+    rf"(?:\((?P<mapping_key>{_PF_MAPPING_KEY})\))?"
+    rf"(?P<conversion_flags>{_PF_CONVERSION_FLAGS})*"
+    rf"(?P<width>{_PF_WIDTH})?"
+    rf"(?:\.(?P<precision>{_PF_PRECISION}))?"
+    rf"(?P<length_modifier>{_PF_LENGTH_MODIFIER})?"
+    rf"(?P<type>{_PF_TYPE})"
+)
 
 
 # ==== helpers ====
diff --git a/draconic/types.py b/draconic/types.py
index 58ade08..812bc44 100644
--- a/draconic/types.py
+++ b/draconic/types.py
@@ -4,6 +4,7 @@ from collections import UserList, UserString
 
 from .exceptions import *
 from .string import JoinProxy, PRINTF_TEMPLATE_RE, TranslateTableProxy
+from .versions import PY_39
 
 __all__ = (
     'safe_list', 'safe_dict', 'safe_set', 'safe_str', 'approx_len_of'
@@ -11,6 +12,7 @@ __all__ = (
 
 _sentinel = object()
 
+
 # ---- size helper ----
 def approx_len_of(obj, visited=None):
     """Gets the approximate size of an object (including recursive objects)."""
@@ -97,6 +99,32 @@ def safe_set(config):
             super().__init__(*args, **kwargs)
             self.__approx_len__ = approx_len_of(self)
 
+        def union(self, *s):
+            if approx_len_of(self) + sum(approx_len_of(other) for other in s) > config.max_const_len:
+                _raise_in_context(IterableTooLong, "This set is too large")
+            return SafeSet(super().union(*s))
+
+        def __or__(self, other):
+            return self.union(other)
+
+        def intersection(self, *s):
+            if any(approx_len_of(other) > config.max_const_len for other in s):
+                _raise_in_context(IterableTooLong, "This set is too large")
+            return SafeSet(super().intersection(*s))
+
+        def __and__(self, other):
+            return self.intersection(other)
+
+        def symmetric_difference(self, *s):
+            if approx_len_of(self) + sum(approx_len_of(other) for other in s) > config.max_const_len:
+                _raise_in_context(IterableTooLong, "This set is too large")
+            return SafeSet(super().symmetric_difference(*s))
+
+        def __xor__(self, other):
+            return self.symmetric_difference(other)
+
+        # difference not reimplemented as it cannot grow the set and has no cheap approximation for len
+
         def update(self, *s):
             other_lens = sum(approx_len_of(other) for other in s)
             if approx_len_of(self) + other_lens > config.max_const_len:
@@ -104,17 +132,27 @@ def safe_set(config):
             super().update(*s)
             self.__approx_len__ += other_lens
 
+        def intersection_update(self, *s):
+            if any(approx_len_of(other) > config.max_const_len for other in s):
+                _raise_in_context(IterableTooLong, "This set is too large")
+            super().intersection_update(*s)
+            self.__approx_len__ = min(self.__approx_len__, *(approx_len_of(other) for other in s))
+
+        def symmetric_difference_update(self, s):
+            total_approx = approx_len_of(self) + approx_len_of(s)
+            if total_approx > config.max_const_len:
+                _raise_in_context(IterableTooLong, "This set is too large")
+            super().symmetric_difference_update(s)
+            self.__approx_len__ = total_approx
+
+        # difference_update not reimplemented as it cannot grow the set and has no cheap approximation for len
+
         def add(self, element):
             if approx_len_of(self) + 1 > config.max_const_len:
                 _raise_in_context(IterableTooLong, "This set is too large")
             super().add(element)
             self.__approx_len__ += 1
 
-        def union(self, *s):
-            if approx_len_of(self) + sum(approx_len_of(other) for other in s) > config.max_const_len:
-                _raise_in_context(IterableTooLong, "This set is too large")
-            return SafeSet(super().union(*s))
-
         def pop(self):
             retval = super().pop()
             self.__approx_len__ -= 1
@@ -124,9 +162,7 @@ def safe_set(config):
             super().remove(element)
             self.__approx_len__ -= 1
 
-        def discard(self, element):
-            super().discard(element)
-            self.__approx_len__ -= 1
+        # discard not reimplemented as the discarded element may not be a member
 
         def clear(self):
             super().clear()
@@ -171,6 +207,13 @@ def safe_dict(config):
             super().__delitem__(key)
             self.__approx_len__ -= 1
 
+        if PY_39:
+            def __or__(self, other):
+                if approx_len_of(self) + approx_len_of(other) > config.max_const_len:
+                    _raise_in_context(IterableTooLong, "This dict is too large")
+
+                return SafeDict(super().__or__(other))
+
     return SafeDict
 
 
diff --git a/draconic/versions.py b/draconic/versions.py
new file mode 100644
index 0000000..f6793a7
--- /dev/null
+++ b/draconic/versions.py
@@ -0,0 +1,4 @@
+import sys
+
+PY_310 = sys.version_info >= (3, 10)
+PY_39 = sys.version_info >= (3, 9)
diff --git a/setup.py b/setup.py
index dad6267..6821b5a 100644
--- a/setup.py
+++ b/setup.py
@@ -18,5 +18,5 @@ setuptools.setup(
         "License :: OSI Approved :: MIT License",
         "Operating System :: OS Independent",
     ],
-    python_requires='>=3.6',
+    python_requires='>=3.8',
 )


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 3f8f628..9636071 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -1,6 +1,6 @@
 name: Test Package
 
-on: [push, pull_request]
+on: [ push, pull_request ]
 
 jobs:
   build:
@@ -9,7 +9,7 @@ jobs:
     strategy:
       max-parallel: 4
       matrix:
-        python-version: [3.6, 3.7, 3.8]
+        python-version: [ "3.8", "3.9", "3.10" ]
 
     steps:
       - uses: actions/checkout@v1
@@ -17,9 +17,11 @@ jobs:
         uses: actions/setup-python@v1
         with:
           python-version: ${{ matrix.python-version }}
+
       - name: Install pip
         run: |
           python -m pip install --upgrade pip
+
       - name: Lint with flake8
         run: |
           pip install flake8
@@ -27,10 +29,12 @@ jobs:
           flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
           # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
           flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+
       - name: Test with pytest
         run: |
           pip install -r tests/requirements.txt
           pytest --cov=./ --cov-report=xml --cov-config=.coveragerc
+
       - name: Upload coverage to Codecov
         uses: codecov/codecov-action@v1
         with:
diff --git a/tests/test_executors.py b/tests/test_executors.py
index 94a2778..b8bd1b1 100644
--- a/tests/test_executors.py
+++ b/tests/test_executors.py
@@ -1,5 +1,4 @@
 import pytest
-import sys
 
 from draconic.exceptions import *
 
@@ -85,8 +84,6 @@ def test_keywords(i, ex):
 
 
 def test_namedexpr_if(i, ex):
-    if sys.version_info < (3, 8, 0): return
-
     expr = """
     if (a := 'true'):
         print(a)
@@ -99,8 +96,6 @@ def test_namedexpr_if(i, ex):
 
 
 def test_namedexpr_for(i, ex):
-    if sys.version_info < (3, 8, 0): return
-
     expr = """
     for i in (l := [1, 2, 3]):
         print(l.index(i))
@@ -111,8 +106,6 @@ def test_namedexpr_for(i, ex):
 
 
 def test_namedexpr_while(i, ex):
-    if sys.version_info < (3, 8, 0): return
-
     expr = """
     i = 1
     while (a := i % 5) != 0:
@@ -141,7 +134,8 @@ def test_infinite_loops(ex):
         ex(expr)
 
 
-def test_break(ex):
+# while
+def test_break_while(ex):
     expr = """
     while 1:
         break
@@ -160,7 +154,19 @@ def test_break(ex):
     assert ex(expr) == 5
 
 
-def test_return(ex):
+def test_continue_while(ex):
+    expr = """
+    i = 0
+    while 1:
+        i += 1
+        if i < 5:
+            continue
+        return i
+    """
+    assert ex(expr) == 5
+
+
+def test_return_while(ex):
     expr = """
     while 1:
         return 1
@@ -175,3 +181,79 @@ def test_return(ex):
             return i
     """
     assert ex(expr) == 5
+
+
+# for
+def test_break_for(ex):
+    expr = """
+    for _ in range(500):
+        break
+    return 1
+    """
+    assert ex(expr) == 1
+
+    expr = """
+    i = 0
+    for _ in range(500):
+        i += 1
+        if i == 5:
+            break
+    return i
+    """
+    assert ex(expr) == 5
+
+
+def test_continue_for(ex):
+    expr = """
+    i = 0
+    for _ in range(500):
+        i += 1
+        if i < 5:
+            continue
+        return i
+    """
+    assert ex(expr) == 5
+
+
+def test_return_for(ex):
+    expr = """
+    for i in range(500):
+        return i
+    """
+    assert ex(expr) == 0
+
+    expr = """
+    i = 0
+    for _ in range(500):
+        i += 1
+        if i == 5:
+            return i
+    """
+    assert ex(expr) == 5
+
+
+# outside
+def test_break_outside(e, ex):
+    with pytest.raises(DraconicSyntaxError):
+        ex("break")
+
+    with pytest.raises(DraconicSyntaxError):
+        e("break")
+
+
+def test_continue_outside(e, ex):
+    with pytest.raises(DraconicSyntaxError):
+        ex("continue")
+
+    with pytest.raises(DraconicSyntaxError):
+        e("continue")
+
+
+def test_return_outside(e, ex):
+    expr = """
+    return 1
+    return 2
+    return 3
+    """
+    assert ex(expr) == 1
+    assert e("return 1") == 1
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
new file mode 100644
index 0000000..232f0da
--- /dev/null
+++ b/tests/test_helpers.py
@@ -0,0 +1,53 @@
+import pytest
+
+from draconic.helpers import zip_star
+
+
+def test_zip_star_start():
+    # star at start
+    result = zip_star(['a', 'b', 'c'], [1, 2, 3, 4], star_index=0)
+    assert list(result) == [('a', [1, 2]), ('b', 3), ('c', 4)]
+
+    result = zip_star(['a', 'b', 'c'], [1, 2, 3], star_index=0)
+    assert list(result) == [('a', [1]), ('b', 2), ('c', 3)]
+
+    result = zip_star(['a', 'b', 'c'], [1, 2], star_index=0)
+    assert list(result) == [('a', []), ('b', 1), ('c', 2)]
+
+
+def test_zip_star_middle():
+    # star in middle
+    result = zip_star(['a', 'b', 'c'], [1, 2, 3, 4], star_index=1)
+    assert list(result) == [('a', 1), ('b', [2, 3]), ('c', 4)]
+
+    result = zip_star(['a', 'b', 'c'], [1, 2, 3], star_index=1)
+    assert list(result) == [('a', 1), ('b', [2]), ('c', 3)]
+
+    result = zip_star(['a', 'b', 'c'], [1, 2], star_index=1)
+    assert list(result) == [('a', 1), ('b', []), ('c', 2)]
+
+
+def test_zip_star_end():
+    # star at end
+    result = zip_star(['a', 'b', 'c'], [1, 2, 3, 4], star_index=2)
+    assert list(result) == [('a', 1), ('b', 2), ('c', [3, 4])]
+
+    result = zip_star(['a', 'b', 'c'], [1, 2, 3], star_index=2)
+    assert list(result) == [('a', 1), ('b', 2), ('c', [3])]
+
+    result = zip_star(['a', 'b', 'c'], [1, 2], star_index=2)
+    assert list(result) == [('a', 1), ('b', 2), ('c', [])]
+
+
+def test_zip_star_only():
+    # star only
+    result = zip_star(['a'], [1, 2, 3], star_index=0)
+    assert list(result) == [('a', [1, 2, 3])]
+
+
+def test_zip_star_invalid():
+    with pytest.raises(IndexError):
+        next(zip_star(['a', 'b', 'c'], [1, 2, 3], star_index=3))
+
+    with pytest.raises(ValueError):
+        next(zip_star(['a', 'b', 'c'], [1], star_index=0))
diff --git a/tests/test_language.py b/tests/test_language.py
index cba18c2..b3799bf 100644
--- a/tests/test_language.py
+++ b/tests/test_language.py
@@ -244,8 +244,6 @@ class TestCompoundAssignments:
 
 class TestNamedExpressions:
     def test_names(self, e):
-        if sys.version_info < (3, 8, 0): return
-
         assert e('(a := 1)') == 1
         assert e('a') == 1
 
@@ -260,8 +258,6 @@ class TestNamedExpressions:
             e("(d[0] := 0)")
 
     def test_assigning_expressions(self, e):
-        if sys.version_info < (3, 8, 0): return
-
         e('a = 1')
         e('b = 2')
         e('c = "foo"')
diff --git a/tests/test_limits.py b/tests/test_limits.py
index d6fccf8..b8ae420 100644
--- a/tests/test_limits.py
+++ b/tests/test_limits.py
@@ -3,6 +3,8 @@ import pytest
 from draconic import DraconicInterpreter
 from draconic.exceptions import *
 from draconic.helpers import DraconicConfig
+from draconic.versions import PY_39
+from tests.utils import temp_limits
 
 
 @pytest.fixture()
@@ -28,6 +30,26 @@ def test_creating(i, e):
         e(f"[*long, *long]")
 
 
+def test_f_string(i, e):
+    really_long_str = 'foo' * 1000
+    not_quite_as_long = 'f' * 999
+    i._names['long'] = really_long_str
+    i._names['lesslong'] = not_quite_as_long
+
+    assert e("lesslong") == not_quite_as_long
+    assert e("f'{lesslong}'") == not_quite_as_long
+    assert e("f'{lesslong}a'") == not_quite_as_long + 'a'
+
+    with pytest.raises(IterableTooLong):
+        e("f'{long}'")
+
+    with pytest.raises(IterableTooLong):
+        e("f'{lesslong}{lesslong}'")
+
+    with pytest.raises(IterableTooLong):
+        e("f'{lesslong}aaaaaa'")
+
+
 def test_list(i, e):
     e("long = [1] * 1000")
 
@@ -53,6 +75,7 @@ def test_set(i, e):
     i.builtins['range'] = range
     e("long = set(range(1000))")
     e("long2 = set(range(1000, 2000))")
+    e("longer = set(range(1001))")
 
     with pytest.raises(IterableTooLong):
         e("long.add(1000)")
@@ -60,18 +83,47 @@ def test_set(i, e):
     with pytest.raises(IterableTooLong):
         e("long.update(long2)")
 
+    with pytest.raises(IterableTooLong):
+        e("long.update({1000})")
+
     with pytest.raises(IterableTooLong):
         e("long.union(long2)")
 
     with pytest.raises(IterableTooLong):
-        e("long.update({1000})")
+        e("long | long2")
+
+    with pytest.raises(IterableTooLong):
+        e("longer.intersection(longer)")
+
+    with pytest.raises(IterableTooLong):
+        e("longer & longer")
+
+    with pytest.raises(IterableTooLong):
+        e("long.symmetric_difference(long2)")
+
+    with pytest.raises(IterableTooLong):
+        e("long ^ long2")
+
+    with pytest.raises(IterableTooLong):
+        e("long |= long2")
+
+    with pytest.raises(IterableTooLong):
+        e("longer &= longer")
+
+    with pytest.raises(IterableTooLong):
+        e("longer.intersection_update(longer)")
+
+    with pytest.raises(IterableTooLong):
+        e("long ^= long2")
+
+    with pytest.raises(IterableTooLong):
+        e("long.symmetric_difference_update(long2)")
 
     # we should always be operating using safe sets
     i.builtins['realset'] = {1, 2, 3}
     e("my_set = {3, 4, 5}")
-    # these operations don't work because sets use bitwise ops and we don't allow those
-    # assert isinstance(e("my_set | realset"), i._set)
-    # assert isinstance(e("realset | my_set"), i._set)
+    assert isinstance(e("my_set | realset"), i._set)
+    assert isinstance(e("realset | my_set"), i._set)
     e("my_set.update(realset)")
     assert isinstance(e("my_set"), i._set)
     assert isinstance(e("my_set.union(realset)"), i._set)
@@ -81,6 +133,7 @@ def test_dict(i, e):
     i.builtins['range'] = range
     e("long = dict((i, i) for i in range(1000))")
     e("long2 = {i: i for i in range(1000, 2000)}")
+    e("long_copy = long.copy()")
 
     with pytest.raises(IterableTooLong):
         e("long.update(long2)")
@@ -88,6 +141,16 @@ def test_dict(i, e):
     with pytest.raises(IterableTooLong):
         e("long.update({'foo': 'bar'})")
 
+    with pytest.raises(IterableTooLong):
+        e("long['foo'] = 'bar'")
+
+    if PY_39:
+        with pytest.raises(IterableTooLong):
+            e("long | {'foo': 'bar'}")
+
+        with pytest.raises(IterableTooLong):
+            e("long_copy |= long2")
+
 
 def test_that_it_still_works_right(i, e):
     e("l = [1, 2]")
@@ -279,12 +342,38 @@ def test_int_limits_not_floats(e):
 
 
 @pytest.mark.timeout(3)  # list mult should be fast, even if we do it a lot
-def test_list_mult_speed():
-    config = DraconicConfig(max_loops=10000, max_const_len=10000)
-    i = DraconicInterpreter(config=config)
+def test_list_mult_speed(i):
     expr = """
     while True:
         a = [0] * 10000
     """.strip()
-    with pytest.raises(TooManyStatements):
-        i.execute(expr)
+    with temp_limits(i, max_loops=10000, max_const_len=10000):
+        with pytest.raises(TooManyStatements):
+            i.execute(expr)
+
+
+def test_loop_limit(i):
+    expr = """
+    while True:
+        pass
+    """.strip()
+
+    expr2 = """
+    for _ in [0] * 101:
+        pass
+    """.strip()
+    with temp_limits(i, max_loops=100):
+        with pytest.raises(TooManyStatements):
+            i.execute(expr)
+
+        with pytest.raises(TooManyStatements):
+            i.execute(expr2)
+
+
+def test_stmt_limit(i):
+    expr = """
+    a = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1
+    """.strip()
+    with temp_limits(i, max_statements=10):
+        with pytest.raises(TooManyStatements):
+            i.execute(expr)
diff --git a/tests/test_patma.py b/tests/test_patma.py
new file mode 100644
index 0000000..7420d1e
--- /dev/null
+++ b/tests/test_patma.py
@@ -0,0 +1,405 @@
+import pytest
+
+from draconic import DraconicValueError, FeatureNotAvailable
+from draconic.versions import PY_310
+
+pytestmark = pytest.mark.skipif(not PY_310, reason="requires python 3.10")
+
+
+# many test cases taken from https://docs.python.org/3/whatsnew/3.10.html#pep-634-structural-pattern-matching
+
+def test_match_literal(i, ex):
+    expr = """
+    for status in (400, 418, 403, "foo", []):
+        match status:
+            case 400:
+                print("Bad request")
+            case 404:
+                print("Not found")
+            case 418:
+                print("I'm a teapot")
+            case _:
+                print("Something's wrong with the internet")
+    """
+    ex(expr)
+    assert i.out__ == [
+        "Bad request",
+        "I'm a teapot",
+        "Something's wrong with the internet",
+        "Something's wrong with the internet",
+        "Something's wrong with the internet"
+    ]
+
+    expr = """
+    for status in (400, 418, 403, "foo", []):
+        match status:
+            case 400:
+                print("Bad request")
+            case 404:
+                print("Not found")
+            case 418:
+                print("I'm a teapot")
+            case 401 | 403 | 404:
+                print("Not allowed")
+            case _:
+                print("Something's wrong with the internet")
+    """
+    ex(expr)
+    assert i.out__ == [
+        "Bad request",
+        "I'm a teapot",
+        "Not allowed",
+        "Something's wrong with the internet",
+        "Something's wrong with the internet"
+    ]
+
+    expr = """
+    for status in (400, 418, 403, "foo", []):
+        match status:
+            case 400:
+                print("Bad request")
+            case 404:
+                print("Not found")
+            case 418:
+                print("I'm a teapot")
+    """
+    ex(expr)
+    assert i.out__ == [
+        "Bad request",
+        "I'm a teapot"
+    ]
+
+
+def test_literal_variable(i, ex):
+    expr = """
+    for point in [(0, 0), (0, 1), (1, 0), (1, 1), 123]:
+        match point:
+            case (0, 0):
+                print("Origin")
+            case (0, y):
+                print(f"Y={y}")
+            case (x, 0):
+                print(f"X={x}")
+            case (x, y):
+                print(f"X={x}, Y={y}")
+            case _:
+                print("Not a point")
+    """
+    ex(expr)
+    assert i.out__ == [
+        "Origin",
+        "Y=1",
+        "X=1",
+        "X=1, Y=1",
+        "Not a point"
+    ]
+
+
+def test_nested_patterns(i, ex):
+    expr = """
+    for points in [
+        [],
+        [(0, 0)],
+        [(0, 0), (0, 1)],
+        [(1, 1)],
+        [(0, 0), (1, 1)]
+    ]:
+        match points:
+            case []:
+                print("No points in the list.")
+            case [(0, 0)]:
+                print("The origin is the only point in the list.")
+            case [(x, y)]:
+                print(f"A single point {x}, {y} is in the list.")
+            case [(0, y1), (0, y2)]:
+                print(f"Two points on the Y axis at {y1}, {y2} are in the list.")
+            case _:
+                print("Something else is found in the list.")
+    """
+    ex(expr)
+    assert i.out__ == [
+        "No points in the list.",
+        "The origin is the only point in the list.",
+        "Two points on the Y axis at 0, 1 are in the list.",
+        "A single point 1, 1 is in the list.",
+        "Something else is found in the list."
+    ]
+
+
+def test_complex_wildcard(i, ex):
+    expr = """
+    for test_variable in [
+        ('foo', 'bar', 'bletch'),
+        ('warning', 100, 40),
+        ('error', 404, 'foobar'),
+        ('warning', 100, 100)
+    ]:
+        match test_variable:
+            case ('warning', code, 40):
+                print("A warning has been received.")
+            case ('error', code, _):
+                print(f"An error {code} occurred.")
+    """
+    ex(expr)
+    assert i.out__ == [
+        "A warning has been received.",
+        "An error 404 occurred."
+    ]
+
+
+def test_guard(i, ex):
+    expr = """
+    for point in [(0, 0), (-1, 1), (-1, -1)]:
+        match point:
+            case (x, y) if x == y:
+                print(f"The point is located on the diagonal Y=X at {x}.")
+            case (x, y):
+                print(f"Point is not on the diagonal.")
+    """
+    ex(expr)
+    assert i.out__ == [
+        "The point is located on the diagonal Y=X at 0.",
+        "Point is not on the diagonal.",
+        "The point is located on the diagonal Y=X at -1."
+    ]
+
+
+def test_sequence_pattern(i, ex):
+    expr = """
+    for nums in [
+        [0, 0],
+        [0, 1, 0],
+        [0, 1, 2, 0],
+        [0, 1],
+        [1, 0],
+        [1, 1],
+        []
+    ]:
+        match nums:
+            case [0, *middle, 0]:
+                print(f"starts and ends at 0, middle={middle}")
+            case [0, *middle]:
+                print(f"starts at 0, middle={middle}")
+            case [*middle, 0]:
+                print(f"ends at 0, middle={middle}")
+            case [1, *_]:
+                print("starts at 1, we don't care about the rest")
+    """
+    ex(expr)
+    assert i.out__ == [
+        "starts and ends at 0, middle=[]",
+        "starts and ends at 0, middle=[1]",
+        "starts and ends at 0, middle=[1, 2]",
+        "starts at 0, middle=[1]",
+        "ends at 0, middle=[1]",
+        "starts at 1, we don't care about the rest"
+    ]
+
+
+def test_sequence_muliple_starred(ex):
+    # multiple starred names
+    expr = """
+    match [1, 2, 3]:
+        case [*a, *a]:
+            pass
+    """
+    with pytest.raises(DraconicValueError, match="multiple starred names"):
+        ex(expr)
+
+
+def test_sequence_multiple_assignment(ex):
+    # multiple assignments in same pattern
+    expr = """
+    a = [1, 2, 3]
+    match a:
+        case [x, x, x]:
+            print(x)
+    """
+    with pytest.raises(DraconicValueError, match="multiple assignment"):
+        ex(expr)
+
+    # recursive too
+    expr = """
+    a = [1, [2]]
+    match a:
+        case [x, [x]]:
+            print(x)
+    """
+    with pytest.raises(DraconicValueError, match="multiple assignment"):
+        ex(expr)
+
+
+def test_mapping_pattern(i, ex):
+    expr = """
+    for action in [
+        {"color": "blue", "text": "hello world"},
+        {"text": "ignore me!", "sleep": 2},
+        {"sound": "beep", "format": "ogg"},
+        {"sound": "boop", "format": "wav"},
+        {"log": "thing", "a": 1, "foo": ["bar"]}
+    ]:
+        match action:
+            case {"text": message, "color": c}:
+                print(f"{c}: {message}")
+            case {"sleep": duration}:
+                print("z" * duration)
+            case {"sound": url, "format": "ogg"}:
+                print(f"playing {url}.ogg")
+            case {"sound": _, "format": _}:
+                print("Unsupported audio format")
+            case {"log": logger, **rest}:
+                print(f"LOG[{logger}]: {rest}")
+    """
+    ex(expr)
+    assert i.out__ == [
+        "blue: hello world",
+        "zz",
+        "playing beep.ogg",
+        "Unsupported audio format",
+        "LOG[thing]: {'a': 1, 'foo': ['bar']}"
+    ]
+
+
+def test_mapping_multiple_assignment(ex):
+    expr = """
+    match {1: 1, 2: 2}:
+        case {1: a, 2: a}:
+            pass
+    """
+    with pytest.raises(DraconicValueError, match="multiple assignment"):
+        ex(expr)
+
+    expr = """
+    match {1: 1, 2: 2}:
+        case {1: a, **a}:
+            pass
+    """
+    with pytest.raises(DraconicValueError, match="multiple assignment"):
+        ex(expr)
+
+
+def test_match_singleton(i, ex):
+    expr = """
+    for value in [
+        ("success", True),
+        ("failure", False)
+    ]:
+        match value:
+            case (val, True):
+                print(f"success: {val}")
+            case (val, False):
+                print(f"failure: {val}")
+    """
+    ex(expr)
+    assert i.out__ == [
+        "success: success",
+        "failure: failure"
+    ]
+
+
+def test_match_not_allowed(ex):
+    expr = """
+    match "foo":
+        case str(val):
+            print(val)
+    """
+    with pytest.raises(FeatureNotAvailable):
+        ex(expr)
+
+
+def test_match_as(i, ex):
+    expr = """
+    for command in [
+        "go north",
+        "go south",
+        "go up"
+    ]:
+        match command.split():
+            case ["go", ("north" | "south" | "east" | "west") as direction]:
+                print(f"Going {direction}!")
+            case ["go", _]:
+                print("I don't know how to go that way.")
+    """
+    ex(expr)
+    assert i.out__ == [
+        "Going north!",
+        "Going south!",
+        "I don't know how to go that way."
+    ]
+
+
+def test_binding_leaks(i, ex):
+    # stops executing on first match, name leaks to outer scope
+    expr = """
+    a = [1, 2, 3]
+    match a:
+        case [1, 2, x]:
+            print(x)
+        case x:
+            print(x)
+    print(x)
+    """
+    ex(expr)
+    assert i.out__ == [3, 3]
+
+    expr = """
+    a = [1, 2, 3]
+    match a:
+        case [x, *_]:
+            print(x)
+        case x:
+            print(x)
+    print(x)
+    """
+    ex(expr)
+    assert i.out__ == [1, 1]
+
+    expr = """
+    a = [1, 2, 3]
+    match a:
+        case [x, 1, 1]:
+            print(x)
+        case x:
+            print(x)
+    print(x)
+    """
+    ex(expr)
+    assert i.out__ == [[1, 2, 3], [1, 2, 3]]
+
+
+def test_binding_before_guard(i, ex):
+    # binding happens before guard execution
+    expr = """
+    a = [1, 2, 3]
+    match a:
+        case [x, 2, 3] if x == 2:
+            print(x)
+        case y:
+            print(y)
+    print(x)
+    """
+    ex(expr)
+    assert i.out__ == [[1, 2, 3], 1]
+
+
+def test_binding_from_aliases(i, ex):
+    # name aliases generate an *additional* binding
+    expr = """
+    a = [1, 2, 3]
+    match a:
+        case [x as y, 2, 3]:
+            pass
+    print(x)
+    print(y)
+    """
+    ex(expr)
+    assert i.out__ == [1, 1]
+
+    expr = """
+    a = [1, 2, 3]
+    match a:
+        case [((b as c) as d) as e, 2, 3]:
+            pass
+    return b, c, d, e
+    """
+    assert ex(expr) == (1, 1, 1, 1)
diff --git a/tests/test_strings.py b/tests/test_strings.py
index c26f8cd..a98bba7 100644
--- a/tests/test_strings.py
+++ b/tests/test_strings.py
@@ -62,6 +62,7 @@ def test_ljust(e):
 
 def test_replace(e):
     assert e("'foo'.replace('o', 'a')") == 'faa'
+    assert e("'foo'.replace('o', 'a', 1)") == 'fao'
     assert e("'foo'.replace('z', 'a'*999)") == 'foo'
     assert e("'foo'.replace('f', 'a'*998)") == 'foo'.replace('f', 'a' * 998)
     with pytest.raises(IterableTooLong):
diff --git a/tests/test_types.py b/tests/test_types.py
new file mode 100644
index 0000000..bac99de
--- /dev/null
+++ b/tests/test_types.py
@@ -0,0 +1,140 @@
+import pytest
+
+from draconic.exceptions import AnnotatedException
+from draconic.versions import PY_39
+
+
+class TestList:
+    def test_type(self, i, e):
+        e("a = [1, 2, 3]")
+        e("b = list('123')")
+        assert type(i.names['a']) is type(i.names['b']) is i._list
+
+    def test_pop(self, e):
+        e("a = [1, 2, 3]")
+        assert e("a.pop()") == 3
+        assert e("a") == [1, 2]
+
+        assert e("a.pop(0)") == 1
+        assert e("a") == [2]
+
+        with pytest.raises(AnnotatedException, match='pop index out of range'):
+            e("a.pop(1)")
+
+    def test_remove(self, e):
+        e("a = [1, 2, 3]")
+        e("a.remove(1)")
+        assert e("a") == [2, 3]
+
+        with pytest.raises(AnnotatedException, match='not in list'):
+            e("a.remove(1)")
+
+    def test_clear(self, e):
+        e("a = [1, 2, 3]")
+        e("a.clear()")
+        assert e("a") == []
+
+
+class TestSet:
+    def test_type(self, i, e):
+        e("a = {1, 2, 3}")
+        e("b = set('123')")
+        assert type(i.names['a']) is type(i.names['b']) is i._set
+
+    def test_intersection_update(self, e):
+        e("a = {1, 2, 3}")
+        e("b = {3, 4, 5}")
+
+        e("a.intersection_update(b)")
+        assert e("a") == {3}
+
+        # idempotent
+        e("a.intersection_update(b)")
+        assert e("a") == {3}
+
+        e("a.intersection_update(set())")
+        assert e("a") == set()
+
+        e("b.intersection_update({3, 4}, {4, 5})")
+        assert e("b") == {4}
+
+    def test_symmetric_difference_update(self, e):
+        e("a = {1, 2, 3}")
+        e("b = {3, 4, 5}")
+
+        e("a.symmetric_difference_update(b)")
+        assert e("a") == {1, 2, 4, 5}
+
+        e("a.symmetric_difference_update(b)")
+        assert e("a") == {1, 2, 3}
+
+        e("a.symmetric_difference_update({})")
+        assert e("a") == {1, 2, 3}
+
+    def test_pop(self, e):
+        e("a = {1}")
+        assert e("a.pop()") == 1
+        assert e("a") == set()
+
+        with pytest.raises(AnnotatedException, match='pop from an empty set'):
+            e("a.pop()")
+
+    def test_remove(self, e):
+        e("a = {1, 2, 3}")
+        e("a.remove(1)")
+        assert e("a") == {2, 3}
+
+        with pytest.raises(AnnotatedException, match='1'):  # KeyError
+            e("a.remove(1)")
+
+    def test_discard(self, e):
+        e("a = {1, 2, 3}")
+        e("a.discard(1)")
+        assert e("a") == {2, 3}
+
+        e("a.discard(1)")
+        assert e("a") == {2, 3}
+
+    def test_clear(self, e):
+        e("a = {1, 2, 3}")
+        e("a.clear()")
+        assert e("a") == set()
+
+    def test_ops(self, e):
+        e('a = {1, 2, 3}')
+        e('b = {3, 4, 5}')
+
+        assert e('a | b') == {1, 2, 3, 4, 5}
+
+        assert e('a & b') == {3}
+
+        assert e('a - b') == {1, 2}
+
+        assert e('a ^ b') == {1, 2, 4, 5}
+
+
+class TestDict:
+    def test_type(self, i, e):
+        e("a = {1: 1, 2: 2}")
+        e("b = dict(((1, 1), (2, 2)))")
+        assert type(i.names['a']) is type(i.names['b']) is i._dict
+
+    def test_update(self, e):
+        e("a = {1: 1, 2: 2}")
+
+        e("a.update({3: 3})")
+        assert e("a") == {1: 1, 2: 2, 3: 3}
+
+        e("a.update(a='foo')")
+        assert e("a") == {1: 1, 2: 2, 3: 3, 'a': 'foo'}
+
+    if PY_39:
+        def test_union_op(self, e):
+            e('a = {"a": 1, "b": 2}')
+            e('b = {"b": 3, "c": 4}')
+            e('c = a.copy()')
+
+            assert e('a | b') == {"a": 1, "b": 3, "c": 4}
+
+            e('c |= b')
+            assert e('c') == {"a": 1, "b": 3, "c": 4}
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..2c6b2e1
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,13 @@
+import contextlib
+
+from draconic import DraconicConfig
+from draconic.helpers import OperatorMixin
+
+
+# noinspection PyProtectedMember
+@contextlib.contextmanager
+def temp_limits(interpreter: OperatorMixin, **limits):
+    old_config = interpreter._config
+    interpreter._config = DraconicConfig(**limits)
+    yield
+    interpreter._config = old_config