diff --git a/muppet/data/__init__.py b/muppet/data/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..92dcbc29fab8070201d07fe33dbdea83c32fc247
--- /dev/null
+++ b/muppet/data/__init__.py
@@ -0,0 +1,177 @@
+"""
+Data types representing a tagged document.
+
+Also contains the function signatures for rendering the document into a concrete
+representation (HTML, plain text, ...)
+
+Almost all of the datatypes have "bad" __repr__ implementations. This
+is to allow *much* easier ocular diffs when running pytest.
+"""
+
+from dataclasses import dataclass
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from typing import (
+    Any,
+    TypeAlias,
+    Union,
+)
+
+
+Markup: TypeAlias = Union[str,
+                          'Tag',
+                          'Link',
+                          'ID',
+                          'Documentation',
+                          'Indentation']
+
+
+@dataclass
+class Tag:
+    """An item with basic metadata."""
+
+    # item: Any  # str | 'Tag' | Sequence[str | 'Tag']
+    item: Markup | Sequence[Markup]
+    # item: Any
+    tags: Sequence[str]
+
+    def __eq__(self, other: Any) -> bool:
+        if not isinstance(other, Tag):
+            return False
+        return self.item == other.item and set(self.tags) == set(other.tags)
+
+    def __repr__(self) -> str:
+        return f'tag({repr(self.item)}, tags={self.tags})'
+
+
+@dataclass
+class Link:
+    """An item which should link somewhere."""
+
+    item: Markup
+    target: str
+
+    def __repr__(self) -> str:
+        return f'link({repr(self.item)})'
+
+
+@dataclass
+class ID:
+    """Item with an ID attached."""
+
+    item: Markup
+    id: str
+
+    def __repr__(self) -> str:
+        return f'id({repr(self.item)})'
+
+
+@dataclass
+class Documentation:
+    """Attach documentation to a given item."""
+
+    item: Markup
+    documentation: str
+
+    def __repr__(self) -> str:
+        return f'doc({repr(self.item)})'
+
+
+@dataclass
+class Indentation:
+    """Abstract indentation object."""
+
+    depth: int
+
+    def __str__(self) -> str:
+        return ' ' * self.depth * 2
+
+    def __repr__(self) -> str:
+        if self.depth == 0:
+            return '_'
+        else:
+            return 'i' * self.depth
+
+
+def tag(item: Markup | Sequence[Markup], *tags: str) -> Tag:
+    """Tag item with tags."""
+    return Tag(item, tags=tags)
+
+
+def link(item: Markup, target: str) -> Link:
+    """Create a new link element."""
+    return Link(item, target)
+
+
+def id(item: Markup, id: str) -> ID:
+    """Attach an id to an item."""
+    return ID(item, id)
+
+
+def doc(item: Markup, documentation: str) -> Documentation:
+    """Attach documentation to an item."""
+    return Documentation(item, documentation)
+
+
+class Renderer(ABC):
+    """
+    Group of functions to render a marked up document into an output format.
+
+    `render` could be a class method, but the base class wouldn't have any
+    members. Instead, have that as a standalone function, and give a renderer
+    "namespace" to it.
+    """
+
+    @abstractmethod
+    def render_tag(self, tag: Tag) -> str:
+        """Render a tag value into a string."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def render_link(self, link: Link) -> str:
+        """Render a link value into a string."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def render_id(self, id: ID) -> str:
+        """Render an object with a wrapping id tag into a string."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def render_doc(self, doc: Documentation) -> str:
+        """Render an item with attached documentation into a string."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def render_indent(self, i: Indentation) -> str:
+        """Render whitespace making out indentation."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def render_str(self, s: str) -> str:
+        """
+        Render plain strings into plain strings.
+
+        This is needed since some implementations might want to do
+        something with whitespace, prettify operators, or anything
+        else.
+        """
+        raise NotImplementedError
+
+
+def render(renderer: Renderer, doc: Markup) -> str:
+    """Render a given document, with the help of a given renderer."""
+    if isinstance(doc, Tag):
+        return renderer.render_tag(doc)
+    elif isinstance(doc, Link):
+        return renderer.render_link(doc)
+    elif isinstance(doc, ID):
+        return renderer.render_id(doc)
+    elif isinstance(doc, Documentation):
+        return renderer.render_doc(doc)
+    elif isinstance(doc, Indentation):
+        return renderer.render_indent(doc)
+    elif isinstance(doc, str):
+        return renderer.render_str(doc)
+    else:
+        raise ValueError('Value in tree of unknown value', doc)
diff --git a/muppet/data/html.py b/muppet/data/html.py
new file mode 100644
index 0000000000000000000000000000000000000000..e165dcfa9333210fc3340ef0899242142888271a
--- /dev/null
+++ b/muppet/data/html.py
@@ -0,0 +1,72 @@
+"""HTML Renderer."""
+
+from . import (
+    Tag,
+    Link,
+    ID,
+    Documentation,
+    Renderer,
+    Indentation,
+    render,
+)
+from collections.abc import Sequence
+
+
+class HTMLRenderer(Renderer):
+    """Render the document into HTML."""
+
+    def render_tag(self, tag: Tag) -> str:
+        """Attaches all tags as classes in a span."""
+        inner: str
+        # if isinstance(tag.item, str):
+        #     inner = tag.item
+        if isinstance(tag.item, Sequence):
+            inner = ''.join(render(self, i) for i in tag.item)
+        else:
+            inner = render(self, tag.item)
+
+        tags = ' '.join(tag.tags)
+        return f'<span class="{tags}">{inner}</span>'
+
+    def render_link(self, link: Link) -> str:
+        """Wrap the value in an anchor tag."""
+        return f'<a href="{link.target}">{render(self, link.item)}</a>'
+
+    def render_id(self, id: ID) -> str:
+        """Render an object with a wrapping id tag into a string."""
+        return f'<span id="{id.id}">{render(self, id.item)}</span>'
+
+    def render_doc(self, doc: Documentation) -> str:
+        """
+        Set up a documentation anchor span, with content.
+
+        The anchor will contain both the item, rendered as normally,
+        and a div with class documentation.
+
+        The suggested CSS for this is
+
+            .documentation-anchor {
+                display: relative;
+            }
+
+            .documentation-anchor .documentation {
+                display: none;
+            }
+
+            .documentation-anchor:hover .documentation {
+                display: block;
+            }
+        """
+        s = '<span class="documentation-anchor">'
+        s += render(self, doc.item)
+        s += f'<div class="documentation">{doc.documentation}</div>'
+        s += '</span>'
+        return s
+
+    def render_indent(self, ind: Indentation) -> str:
+        """Return indentation width * 2 as a string."""
+        return ' ' * ind.depth * 2
+
+    def render_str(self, s: str) -> str:
+        """Return the given string verbatim."""
+        return s
diff --git a/muppet/data/plain.py b/muppet/data/plain.py
new file mode 100644
index 0000000000000000000000000000000000000000..0ffdcf553e8e5986b65b8099f224b30089402980
--- /dev/null
+++ b/muppet/data/plain.py
@@ -0,0 +1,50 @@
+"""Plain text renderer."""
+
+from . import (
+    Tag,
+    Link,
+    ID,
+    Documentation,
+    Renderer,
+    Indentation,
+    render,
+)
+from collections.abc import Sequence
+
+
+class TextRenderer(Renderer):
+    """
+    Renders the document back into plain text.
+
+    On its own this is rather worthless, since we already started with
+    valid Puppet code (altough this being prettified, but without
+    comments). It however allows us to feed our output back into puppet lint,
+    which will work nicely as end-to-end tests.
+    """
+
+    def render_tag(self, tag: Tag) -> str:
+        """Only renders the content."""
+        if isinstance(tag.item, Sequence):
+            return ''.join(render(self, i) for i in tag.item)
+        else:
+            return render(self, tag.item)
+
+    def render_link(self, link: Link) -> str:
+        """Only renders the content."""
+        return render(self, link.item)
+
+    def render_id(self, id: ID) -> str:
+        """Only renders the content."""
+        return render(self, id.item)
+
+    def render_doc(self, doc: Documentation) -> str:
+        """Only renders the content."""
+        return render(self, doc.item)
+
+    def render_indent(self, ind: Indentation) -> str:
+        """Return indentation width * 2 as a string."""
+        return ' ' * ind.depth * 2
+
+    def render_str(self, s: str) -> str:
+        """Return the string verbatim."""
+        return s
diff --git a/muppet/format.py b/muppet/format.py
index b0e7e2a54f6fb26d2fe78b05811930cb1270aeaf..15db923b986bf22611b46a8e1285f586619a3bb5 100644
--- a/muppet/format.py
+++ b/muppet/format.py
@@ -17,11 +17,29 @@ from typing import (
     TypeAlias,
     Union,
 )
-from collections.abc import Sequence
-from dataclasses import dataclass
 
 from .puppet.parser import puppet_parser
 from .intersperse import intersperse
+from .data import (
+    Markup,
+    Indentation,
+    Tag,
+    Link,
+    doc,
+    id,
+    link,
+    tag,
+    render,
+)
+# from .data.html import (
+#     HTMLRenderer,
+# )
+from .data.plain import (
+    TextRenderer,
+)
+from pprint import PrettyPrinter
+
+pp = PrettyPrinter(indent=2, compact=True)
 
 parse_puppet = puppet_parser
 
@@ -34,96 +52,14 @@ Context: TypeAlias = list['str']
 param_doc: dict[str, str] = {}
 
 
-@dataclass
-class Tag:
-    """An item with basic metadata."""
-
-    # item: Any  # str | 'Tag' | Sequence[str | 'Tag']
-    # item: str | 'Tag' | Sequence[str | 'Tag']
-    item: Any
-    tags: Sequence[str]
-
-    def __str__(self) -> str:
-        inner: str
-        if isinstance(self.item, str):
-            inner = self.item
-        elif isinstance(self.item, Markup):
-            inner = str(self.item)
-        else:
-            inner = ''.join(str(i) for i in self.item)
-
-        tags = ' '.join(self.tags)
-        return f'<span class="{tags}">{inner}</span>'
-
-
-@dataclass
-class Link:
-    """An item which should link somewhere."""
-
-    item: Any
-    target: str
-
-    def __str__(self) -> str:
-        return f'<a href="{self.target}">{self.item}</a>'
-
-
-@dataclass
-class ID:
-    """Item with an ID attached."""
-
-    item: Any
-    id: str
-
-    def __str__(self) -> str:
-        return f'<span id="{self.id}">{self.item}</span>'
-
-
-@dataclass
-class Documentation:
-    """Attach documentation to a given item."""
-
-    item: Any
-    documentation: str
-
-    def __str__(self) -> str:
-        s = '<span class="documentation-anchor">'
-        s += str(self.item)
-        s += f'<div class="documentation">{self.documentation}</div>'
-        s += '</span>'
-        return s
-
-
-Markup: TypeAlias = Tag | Link | ID | Documentation
-
-
-all_tags: set[str] = set()
-
-
-def tag(item: str | Markup | Sequence[str | Markup], *tags: str) -> Tag:
-    """Tag item with tags."""
-    global all_tags
-    all_tags |= set(tags)
-    return Tag(item, tags=tags)
-
-
-def link(item: str | Markup, target: str) -> Link:
-    """Create a new link element."""
-    return Link(item, target)
+def ind(level: int) -> Indentation:
+    """Return a string for indentation."""
+    return Indentation(level)
 
 
-def id(item: str | Markup, id: str) -> ID:
-    """Attach an id to an item."""
-    return ID(item, id)
-
-
-def doc(item: str | Markup, documentation: str) -> Documentation:
-    """Attach documentation to an item."""
-    return Documentation(item, documentation)
-
-
-def ind(level: int) -> str:
-    """Returnu a string for indentation."""
-    return ' '*level*2
+def keyword(x: str) -> Tag:
+    """Return a keyword token for the given string."""
+    return tag(x, 'keyword', x)
 
 
 def print_hash(hash: list[HashEntry],
@@ -133,20 +69,14 @@ def print_hash(hash: list[HashEntry],
     if not hash:
         return tag('')
     # namelen = 0
-    items: list[str | Tag] = []
+    items: list[Markup] = []
     for item in hash:
         match item:
             case ['=>', key, value]:
                 items += [
                     ind(indent),
                     parse(key, indent, context),
-                    ' ', '⇒', ' ',
-                    parse(value, indent, context),
-                ]
-            case ['splat-hash', value]:
-                items += [
-                    ind(indent),
-                    '*', ' ', '⇒', ' ',
+                    ' ', '=>', ' ',
                     parse(value, indent, context),
                 ]
             case _:
@@ -172,7 +102,7 @@ def ops_namelen(ops: list[HashEntry]) -> int:
     return namelen
 
 
-def print_var(x: str, dollar: bool = True) -> Tag:
+def print_var(x: str, dollar: bool = True) -> Link:
     """
     Print the given variable.
 
@@ -198,24 +128,10 @@ def declare_var(x: str) -> Tag:
 # TODO strip leading colons when looking up documentation
 
 
-symbols: dict[str, str] = {
-    '=>': '⇒',
-    '!': '¬',
-    '!=': '≠',
-    '*': '×',
-    '>=': '≥',
-    '<=': '≤',
-    '~>': '⤳',
-    '->': '→',
-    '==': '≡',
-    '!~': '≁',
-}
-
-
 def handle_case_body(forms: list[dict[str, Any]],
                      indent: int, context: Context) -> Tag:
     """Handle case body when parsing AST."""
-    ret: list[Tag | str] = []
+    ret: list[Markup] = []
     for form in forms:
         when = form['when']
         then = form['then']
@@ -231,7 +147,7 @@ def handle_case_body(forms: list[dict[str, Any]],
 
         for item in then:
             ret += [ind(indent+2), parse(item, indent+2, context), '\n']
-        ret += [ind(indent+1), '},', '\n']
+        ret += [ind(indent+1), '}', '\n']
 
     return tag(ret)
 
@@ -251,6 +167,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
         How many levels deep in indentation the code is.
         Will get multiplied by the indentation width.
     """
+    items: list[Markup]
     # Sorted per `sort -V`
     match form:
         case None:
@@ -276,9 +193,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
         case ['and', a, b]:
             return tag([
                 parse(a, indent, context),
-                ' ',
-                tag('and', 'keyword', 'and'),
-                ' ',
+                ' ', keyword('and'), ' ',
                 parse(b, indent, context),
             ])
 
@@ -291,7 +206,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                 out += [
                     ind(indent+2),
                     parse(item, indent+1, context),
-                    ','
+                    ',',
                     '\n',
                 ]
             out += [ind(indent), ']']
@@ -326,7 +241,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
 
         case ['case', test, forms]:
             items = [
-                tag('case', 'keyword', 'case'),
+                keyword('case'),
                 ' ',
                 parse(test, indent, context),
                 ' ', '{', '\n',
@@ -338,12 +253,10 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
             return tag(items)
 
         case ['class', {'name': name,
-                        'body': body,
                         **rest}]:
             items = []
             items += [
-                ind(indent),
-                tag('class', 'keyword', 'class'),
+                keyword('class'),
                 ' ',
                 tag(name, 'name'),
                 ' ',
@@ -362,6 +275,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                     if 'value' in data:
                         items += [
                             ' ', '=', ' ',
+                            # TODO this is a declaration
                             parse(data.get('value'), indent+1, context),
                         ]
                     items += [',', '\n']
@@ -369,20 +283,23 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
             else:
                 items += ['{', '\n']
 
-            for entry in body:
-                items += [ind(indent+1),
-                          parse(entry, indent+1, context),
-                          '\n']
-            items += [ind(indent), '}' + '\n']
+            if 'body' in rest:
+                for entry in rest['body']:
+                    items += [ind(indent+1),
+                              parse(entry, indent+1, context),
+                              '\n']
+            items += [ind(indent), '}']
             return tag(items)
 
         case ['concat', *args]:
             items = ['"']
             for item in args:
                 match item:
+                    case ['str', ['var', x]]:
+                        items += [tag(['${', print_var(x, False), '}'], 'str-var')]
                     case ['str', thingy]:
                         content = parse(thingy, indent, ['str'] + context)
-                        items += [tag(f'${{{content}}}', 'str-var')]
+                        items += [tag(['${', content, '}'], 'str-var')]
                     case s:
                         items += [s
                                   .replace('"', '\\"')
@@ -397,13 +314,11 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                         parse(q, indent, context)])
 
         case ['default']:
-            return tag('default', 'keyword', 'default')
+            return keyword('default')
 
         case ['define', {'name': name,
-                         'body': body,
                          **rest}]:
-            items = [ind(indent),
-                     tag('define', 'keyword', 'define'),
+            items = [keyword('define'),
                      ' ',
                      tag(name, 'name'),
                      ' ']
@@ -420,20 +335,20 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                     items += [declare_var(name)]
                     if 'value' in data:
                         items += [
-                            ' = ',
+                            ' ', '=', ' ',
                             parse(data.get('value'), indent, context),
-                            ',',
                         ]
-                    items += ['\n']
+                    items += [',', '\n']
 
-                items += [ind(indent), ')']
+                items += [ind(indent), ')', ' ']
 
             items += ['{', '\n']
 
-            for entry in body:
-                items += [ind(indent+1),
-                          parse(entry, indent+1, context),
-                          '\n']
+            if 'body' in rest:
+                for entry in rest['body']:
+                    items += [ind(indent+1),
+                              parse(entry, indent+1, context),
+                              '\n']
 
             items += [ind(indent), '}']
 
@@ -448,12 +363,12 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                         ' ', '|>>'])
 
         case ['function', {'name': name,
-                           'body': body,
                            **rest}]:
             items = []
-            items += [tag('function', 'keyword', 'function'),
-                      ' ', name, ' ', '(', '\n']
+            items += [keyword('function'),
+                      ' ', name]
             if 'params' in rest:
+                items += [' ', '(', '\n']
                 for name, attributes in rest['params'].items():
                     items += [ind(indent+1)]
                     if 'type' in attributes:
@@ -466,22 +381,27 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                             parse(attributes['value'], indent, context),
                         ]
                     items += [',', '\n']
-            items += [')']
+                items += [ind(indent), ')']
+
             if 'returns' in rest:
                 items += [' ', '>>', ' ',
                           parse(rest['returns'], indent, context)]
-            items += [' ', '{', '\n']
-            for item in body:
-                items += [
-                    ind(indent+1),
-                    parse(item, indent+1, context),
-                    '\n',
-                ]
-            items += ['}', '\n']
+
+            items += [' ', '{']
+            if 'body' in rest:
+                items += ['\n']
+                for item in rest['body']:
+                    items += [
+                        ind(indent+1),
+                        parse(item, indent+1, context),
+                        '\n',
+                    ]
+                items += [ind(indent)]
+            items += ['}']
             return tag(items)
 
         case ['hash']:
-            return tag('{}')
+            return tag('{}', 'hash')
 
         case ['hash', *hash]:
             return tag([
@@ -489,29 +409,94 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                 print_hash(hash, indent+1, context),
                 ind(indent),
                 '}',
-            ])
+            ], 'hash')
+
+        # TODO a safe string to use?
+        # TODO extra options?
+        #      Are all these already removed by the parser, requiring
+        #      us to reverse parse the text?
+
+        # Parts can NEVER be empty, since that case wouldn't generate
+        # a concat element, but a "plain" text element
+        case ['heredoc', {'text': ['concat', *parts]}]:
+            items = ['@("EOF")']
+
+            LineFragment: TypeAlias = str | Tag
+            Line: TypeAlias = list[LineFragment]
+
+            lines: list[Line] = [[]]
+
+            for part in parts:
+                match part:
+                    case ['str', ['var', x]]:
+                        lines[-1] += [tag(['${', print_var(x, False), '}'])]
+                    case ['str', form]:
+                        lines[-1] += [tag(['${', parse(form, indent, context), '}'])]
+                    case s:
+                        if not isinstance(s, str):
+                            raise ValueError('Unexpected value in heredoc', s)
+
+                        first, *rest = s.split('\n')
+                        lines[-1] += [first]
+                        # lines += [[]]
+
+                        for item in rest:
+                            lines += [[item]]
+
+            for line in lines:
+                items += ['\n']
+                if line != ['']:
+                    items += [ind(indent)]
+                    for item in line:
+                        if item:
+                            items += [item]
+
+            match lines:
+                case [*_, ['']]:
+                    # We have a trailing newline
+                    items += [ind(indent), '|']
+                case _:
+                    # We don't have a trailing newline
+                    # Print the graphical one, but add the dash to the pipe
+                    items += ['\n', ind(indent), '|-']
+
+            items += [' ', 'EOF']
+            return tag(items, 'heredoc', 'literal')
+
+        case ['heredoc', {'text': ''}]:
+            return tag(['@(EOF)', '\n', ind(indent), '|', ' ', 'EOF'],
+                       'heredoc', 'literal')
 
         case ['heredoc', {'text': text}]:
-            # TODO Should variables be interploated?
-            # TODO a safe string to use?
-            # TODO extra options?
-            # Are all these already removed by the parser, requiring
-            # us to reverse parse the text?
-            #
-            # NOTE text can be a number of types
-            # It can be an explicit "concat",
-            # it can be an untagged string
-            tag(['@("EOF")', '\n',
-                 parse(text, indent + 1, ['heredoc'] + context),
-                 ind(indent+1),
-                 '|', ' ', 'EOF',
-                 ])
+            items = []
+            items += ['@(EOF)', '\n']
+            lines = text.split('\n')
+
+            no_eol: bool = True
+
+            if lines[-1] == '':
+                lines = lines[:-1]
+                no_eol = False
+
+            for line in lines:
+                if line:
+                    items += [ind(indent), line]
+                items += ['\n']
+            items += [ind(indent)]
+
+            if no_eol:
+                items += ['|-']
+            else:
+                items += ['|']
+            items += [' ', 'EOF']
+
+            return tag(items, 'heredoc', 'literal')
 
         case ['if', {'test': test,
                      **rest}]:
             items = []
             items += [
-                tag('if', 'keyword', 'if'),
+                keyword('if'),
                 ' ',
                 parse(test, indent, context),
                 ' ', '{', '\n',
@@ -523,16 +508,17 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                         parse(item, indent+1, context),
                         '\n',
                     ]
-            items += [ind(indent), '}', ' ']
+            items += [ind(indent), '}']
 
             if 'else' in rest:
+                items += [' ']
                 match rest['else']:
                     case [['if', *rest]]:
                         # TODO propper tagging
                         items += ['els',
                                   parse(['if', *rest], indent, context)]
                     case el:
-                        items += [tag('else', 'keyword', 'else'),
+                        items += [keyword('else'),
                                   ' ', '{', '\n']
                         for item in el:
                             items += [
@@ -549,7 +535,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
         case ['in', needle, stack]:
             return tag([
                 parse(needle, indent, context),
-                ' ', tag('in', 'keyword', 'in'), ' ',
+                ' ', keyword('in'), ' ',
                 parse(stack, indent, context),
             ])
 
@@ -562,7 +548,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
             if len(args) == 1:
                 items += [parse(args[0], indent+1, context)]
             else:
-                items += [args, '\n', '(']
+                items += ['(']
                 for sublist in intersperse([',', ' '],
                                            [[parse(arg, indent+1, context)]
                                             for arg in args]):
@@ -571,18 +557,17 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
             return tag(items, 'invoke')
 
         case ['nop']:
-            return tag('')
+            return tag('', 'nop')
 
         case ['lambda', {'params': params,
                          'body': body}]:
             items = []
-            args = [f'${x}' for x in params.keys()]
-            if args:
-                first, *rest = args
-                items += [first]
-                for arg in rest:
-                    items += [',', ' ', arg]
-            items += [' ', f'|{args}|', ' ', '{', '\n']
+            # TODO note these are declarations
+            items += ['|']
+            for sublist in intersperse([',', ' '],
+                                       [[f'${x}'] for x in params.keys()]):
+                items += sublist
+            items += ['|', ' ', '{', '\n']
             for entry in body:
                 items += [
                     ind(indent),
@@ -592,17 +577,10 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
             items += [ind(indent-1), '}']
             return tag(items, 'lambda')
 
-        case ['and', a, b]:
-            return tag([
-                parse(a, indent, context),
-                ' ', tag('and', 'keyword', 'and'), ' ',
-                parse(b, indent, context),
-            ])
-
         case ['or', a, b]:
             return tag([
                 parse(a, indent, context),
-                ' ', tag('or', 'keyword', 'or'), ' ',
+                ' ', keyword('or'), ' ',
                 parse(b, indent, context),
             ])
 
@@ -612,7 +590,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                 *(parse(form, indent+1, context)
                   for form in forms),
                 ')',
-            ])
+            ], 'paren')
 
         # Qualified name?
         case ['qn', x]:
@@ -645,7 +623,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                         items += [
                             ind(indent+1),
                             tag(key, 'parameter'),
-                            ' '*pad, ' ', '⇒', ' ',
+                            ' '*pad, ' ', '=>', ' ',
                             parse(value, indent+1, context),
                             ',', '\n',
                         ]
@@ -655,7 +633,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                                 ind(indent+1),
                                 tag('*', 'parameter', 'splat'),
                                 ' '*(namelen-1),
-                                ' ', '⇒', ' ',
+                                ' ', '=>', ' ',
                                 parse(value, indent+1, context),
                                 ',', '\n',
                         ]
@@ -696,7 +674,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                                 ind(indent+2),
                                 tag(key, 'parameter'),
                                 ' '*pad,
-                                ' ', '⇒', ' ',
+                                ' ', '=>', ' ',
                                 parse(value, indent+2, context),
                                 ',', '\n',
                             ]
@@ -706,7 +684,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                                     ind(indent+2),
                                     tag('*', 'parameter', 'splat'),
                                     ' '*(namelen - 1),
-                                    ' ', '⇒', ' ',
+                                    ' ', '=>', ' ',
                                     parse(value, indent+2, context),
                                     ',', '\n',
                             ]
@@ -733,7 +711,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                             ind(indent+1),
                             tag(key, 'parameter'),
                             ' '*pad,
-                            ' ', '⇒', ' ',
+                            ' ', '=>', ' ',
                             parse(value, indent+3, context),
                             ',', '\n',
                         ]
@@ -744,8 +722,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                             ind(indent+1),
                             tag('*', 'parameter', 'splat'),
                             ' '*pad,
-                            ' '*(namelen-1),
-                            ' ', '⇒', ' ',
+                            ' ', '=>', ' ',
                             parse(value, indent+2, context),
                             ',', '\n',
                         ]
@@ -774,7 +751,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                             ind(indent+1),
                             tag(key, 'parameter'),
                             ' '*pad,
-                            ' ', '⇒', ' ',
+                            ' ', '=>', ' ',
                             parse(value, indent+3, context),
                             ',', '\n',
                         ]
@@ -782,7 +759,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                     case ['+>', key, value]:
                         pad = namelen - len(key)
                         items += [
-                            ind(indent+2),
+                            ind(indent+1),
                             tag(key, 'parameter'),
                             ' '*pad,
                             ' ', '+>', ' ',
@@ -796,8 +773,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                             ind(indent+1),
                             tag('*', 'parameter', 'splat'),
                             ' '*pad,
-                            ' '*(namelen-1),
-                            ' ', '⇒', ' ',
+                            ' ', '=>', ' ',
                             parse(value, indent+2, context),
                             ',', '\n',
                         ]
@@ -814,32 +790,33 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
             return tag(items)
 
         case ['unless', {'test': test,
-                         'then': then}]:
+                         **rest}]:
             items = [
-                tag('unless', 'keyword', 'unless'),
+                keyword('unless'),
                 ' ',
                 parse(test, indent, context),
                 ' ', '{', '\n',
             ]
-            for item in then:
-                items += [
-                    ind(indent+1),
-                    parse(item, indent+1, context),
-                    '\n',
-                ]
+
+            if 'then' in rest:
+                for item in rest['then']:
+                    items += [
+                        ind(indent+1),
+                        parse(item, indent+1, context),
+                        '\n',
+                    ]
 
             items += [
                 ind(indent),
                 '}',
             ]
+            return tag(items)
 
         case ['var', x]:
-            # TODO how does this work with deeply nested expressions
-            # in strings?
             if context[0] == 'declaration':
                 return declare_var(x)
             else:
-                return print_var(x, context[0] != 'str')
+                return print_var(x, True)
 
         case ['virtual-query', q]:
             return tag([
@@ -851,18 +828,16 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
         case ['virtual-query']:
             return tag(['<|', ' ', '|>'])
 
-        # TODO unary splat
-
         case ['!', x]:
             return tag([
-                '¬', ' ',
+                '!', ' ',
                 parse(x, indent, context),
             ])
 
         case ['!=', a, b]:
             return tag([
                 parse(a, indent, context),
-                ' ', '≠', ' ',
+                ' ', '!=', ' ',
                 parse(b, indent, context),
             ])
 
@@ -883,13 +858,13 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
         case ['-', a]:
             return tag([
                 '-', ' ',
-                parse(a),
+                parse(a, indent, context),
             ])
 
         case ['*', a, b]:
             return tag([
                 parse(a, indent, context),
-                ' ', '×', ' ',
+                ' ', '*', ' ',
                 parse(b, indent, context),
             ])
 
@@ -917,14 +892,14 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
         case ['>=', a, b]:
             return tag([
                 parse(a, indent, context),
-                ' ', '≥', ' ',
+                ' ', '>=', ' ',
                 parse(b, indent, context),
             ])
 
         case ['<=', a, b]:
             return tag([
                 parse(a, indent, context),
-                ' ', '≤', ' ',
+                ' ', '<=', ' ',
                 parse(b, indent, context),
             ])
 
@@ -947,7 +922,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                 parse(left, indent, context),
                 '\n',
                 ind(indent),
-                '⤳', ' ',
+                '~>', ' ',
                 parse(right, indent, context)
             ])
 
@@ -956,7 +931,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                 parse(left, indent, context),
                 '\n',
                 ind(indent),
-                '→', ' ',
+                '->', ' ',
                 parse(right, indent, context),
             ])
 
@@ -986,7 +961,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
         case ['==', a, b]:
             return tag([
                 parse(a, indent, context),
-                ' ', '≡', ' ',
+                ' ', '==', ' ',
                 parse(b, indent, context),
             ])
 
@@ -995,12 +970,12 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                 parse(a, indent, context),
                 ' ', '=~', ' ',
                 parse(b, indent, context),
-            ], 'declaration')
+            ])
 
         case ['!~', a, b]:
             return tag([
                 parse(a, indent, context),
-                ' ', '≁', ' ',
+                ' ', '!~', ' ',
                 parse(b, indent, context),
             ])
 
@@ -1015,25 +990,26 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
 
         case form:
             if isinstance(form, str):
-                if context[0] == 'heredoc':
-                    lines: list[str]
-                    match form.split('\n'):
-                        case [*_lines, '']:
-                            lines = _lines
-                        case _lines:
-                            lines = _lines
-
-                    items = []
-                    for line in lines:
-                        items += [ind(indent), line, '\n']
-
-                    return tag(items, 'literal', 'string')
-
-                else:
-                    # TODO further escaping?
-                    s = form.replace('\n', r'\n')
-                    s = f"'{s}'"
-                    return tag(s, 'literal', 'string')
+                # TODO remove this case?
+                # if context[0] == 'heredoc':
+                #     lines: list[str]
+                #     match form.split('\n'):
+                #         case [*_lines, '']:
+                #             lines = _lines
+                #         case _lines:
+                #             lines = _lines
+
+                #     items = []
+                #     for line in lines:
+                #         items += [ind(indent), line, '\n']
+
+                #     return tag(items, 'literal', 'string')
+
+                # else:
+                # TODO further escaping?
+                s = form.replace('\n', r'\n')
+                s = f"'{s}'"
+                return tag(s, 'literal', 'string')
 
             elif isinstance(form, int) or isinstance(form, float):
                 return tag(str(form), 'literal', 'number')
@@ -1069,17 +1045,17 @@ def print_docstring(name: str, docstring: dict[str, Any]) -> str:
 
     out += f'<h2><code>{name}</code></h2>\n'
 
-    for tag in tags:
-        text = html.escape(tag.get('text') or '')
-        if tag['tag_name'] == 'summary':
+    for t in tags:
+        text = html.escape(t.get('text') or '')
+        if t['tag_name'] == 'summary':
             out += '<em class="summary">'
             out += text
             out += '</em>'
 
-    for tag in tags:
-        text = html.escape(tag.get('text') or '')
-        if tag['tag_name'] == 'example':
-            out += f'<h3>{tag["name"]}</h3>\n'
+    for t in tags:
+        text = html.escape(t.get('text') or '')
+        if t['tag_name'] == 'example':
+            out += f'<h3>{t["name"]}</h3>\n'
             out += f'<pre><code class="puppet">{text}</code></pre>\n'
 
     if 'text' in docstring:
@@ -1090,6 +1066,9 @@ def print_docstring(name: str, docstring: dict[str, Any]) -> str:
     return out
 
 
+renderer = TextRenderer()
+
+
 def format_class(d_type: dict[str, Any]) -> str:
     """Format Puppet class."""
     out = ''
@@ -1099,7 +1078,9 @@ def format_class(d_type: dict[str, Any]) -> str:
 
     out += '<pre><code class="puppet">'
     t = parse_puppet(d_type['source'])
-    out += str(parse(t, 0, ['root']))
+    data = parse(t, 0, ['root'])
+    pp.pprint(data)
+    out += render(renderer, data)
     out += '</code></pre>'
     return out
 
@@ -1118,7 +1099,9 @@ def format_type_alias(d_type: dict[str, Any]) -> str:
     out += '\n'
     out += '<pre><code class="puppet">'
     t = parse_puppet(d_type['alias_of'])
-    out += str(parse(t, 0, ['root']))
+    data = parse(t, 0, ['root'])
+    pp.pprint(data)
+    out += render(renderer, data)
     out += '</code></pre>\n'
     return out
 
@@ -1132,7 +1115,7 @@ def format_defined_type(d_type: dict[str, Any]) -> str:
 
     out += '<pre><code class="puppet">'
     t = parse_puppet(d_type['source'])
-    out += str(parse(t, 0, ['root']))
+    out += render(renderer, parse(t, 0, ['root']))
     out += '</code></pre>\n'
     return out
 
diff --git a/muppet/puppet/parser.py b/muppet/puppet/parser.py
index d4467433294183eafbd0d1a4d37fae0744a14520..5ab7e1b509230559834186d9b51a0b1ac85ba9f4 100644
--- a/muppet/puppet/parser.py
+++ b/muppet/puppet/parser.py
@@ -9,7 +9,7 @@ something managable.
 import subprocess
 import json
 
-from typing import Any
+from typing import Any, TypeAlias, Union
 from ..cache import Cache
 
 
@@ -74,11 +74,50 @@ def puppet_parser_raw(code: bytes) -> bytes:
     return cmd.stdout
 
 
-def puppet_parser(code: str) -> list:
+JsonPrimitive: TypeAlias = Union[str, int, float, bool, None]
+JSON: TypeAlias = JsonPrimitive | dict[str, 'JSON'] | list['JSON']
+
+
+def puppet_parser(code: str) -> JSON:
     """Parse the given puppet string, and reflow it."""
     data = traverse(json.loads(puppet_parser_raw(code.encode('UTF-8'))))
-    # TODO log output here?
-    if isinstance(data, list):
+
+    # The two compound cases technically needs to recursively check
+    # the type, but it should be fine.
+    #
+    # isinstance(data, JsonPrimitive) would be better here, and it
+    # works in runtime. But mypy fails on it...
+    if data is None:
+        return data
+    elif isinstance(data, str):
+        return data
+    elif isinstance(data, int):
+        return data
+    elif isinstance(data, float):
+        return data
+    elif isinstance(data, bool):
+        return data
+    elif isinstance(data, dict):
+        return data
+    elif isinstance(data, list):
         return data
     else:
         raise ValueError('Expected well formed tree, got %s', data)
+
+
+def __main() -> None:
+    import sys
+    match sys.argv:
+        case [_]:
+            inp = sys.stdin
+        case [_, file]:
+            inp = open(file)
+
+    json.dump(puppet_parser(inp.read()),
+              sys.stdout,
+              indent=2)
+    print()
+
+
+if __name__ == '__main__':
+    __main()
diff --git a/muppet/symbols.py b/muppet/symbols.py
new file mode 100644
index 0000000000000000000000000000000000000000..d8b0c5211e23a9d83d4403d1e56bf13121fa855f
--- /dev/null
+++ b/muppet/symbols.py
@@ -0,0 +1,23 @@
+"""
+Prettify symbols appearing in puppet code.
+
+For example, replace bangs ('!') with negation signs ('¬').
+"""
+
+symbols: dict[str, str] = {
+    '=>': '⇒',
+    '!': '¬',
+    '!=': '≠',
+    '*': '×',
+    '>=': '≥',
+    '<=': '≤',
+    '~>': '⤳',
+    '->': '→',
+    '==': '≡',
+    '!~': '≁',
+}
+
+
+def prettify(symb: str) -> str:
+    """Either turn the symbol into it's "pretty" variant, or return itself."""
+    return symbols.get(symb, symb)
diff --git a/tests/test_intersperse.py b/tests/test_intersperse.py
index 7df28325319396774611ea84872e9344bf97954f..e99fb4a91c322aa4ce3b2d6452fcc311768f2bb9 100644
--- a/tests/test_intersperse.py
+++ b/tests/test_intersperse.py
@@ -1,4 +1,4 @@
-from intersperse import intersperse
+from muppet.intersperse import intersperse
 
 def test_intersperse():
     assert list(intersperse(1, [2, 3, 4])) == [2, 1, 3, 1, 4]
diff --git a/tests/test_parse.py b/tests/test_parse.py
new file mode 100644
index 0000000000000000000000000000000000000000..c48d414b6e19de7e3b6b83a22bce529930657196
--- /dev/null
+++ b/tests/test_parse.py
@@ -0,0 +1,538 @@
+"""
+Tests for the "parser".
+
+TODO rename parser to "re-interpreter" (or similar), `puppet parse` is
+the parser, we just rewrite the tree "slightly".
+"""
+
+from muppet.format import parse, parse_puppet, ind, keyword
+from muppet.data import tag, link, id
+
+
+def run(s):
+    return parse(parse_puppet(s), 0, ['root'])
+
+
+def s(st):
+    """Bulid a string literal."""
+    return tag(f"'{st}'", 'literal', 'string')
+
+
+# Helper literals, ensured correct in test_parse_literals
+true = tag('true', 'literal', 'true')
+false = tag('false', 'literal', 'false')
+one = tag('1', 'literal', 'number')
+two = tag('2', 'literal', 'number')
+
+
+def test_parse_literals():
+    # Helper literals to cut down on typing
+
+    assert run('undef') == tag('undef', 'literal', 'undef')
+    assert run('true') == true
+    assert run('false') == false
+
+    assert run('1') == one
+    assert run('2') == two
+    assert run('1.1') == tag('1.1', 'literal', 'number')
+
+    assert run('default') == keyword('default')
+
+    assert run('"Hello"') == s("Hello")
+
+    assert run('') == tag('', 'nop')
+
+
+def test_parse_arrays():
+    assert run('[]') == tag('[]', 'array')
+    assert run('[1]') \
+        == tag(['[', '\n',
+                ind(2),
+                tag('1', 'literal', 'number'),
+                ',', '\n', ind(0),
+                ']'],
+               'array')
+
+
+def test_parse_hash():
+
+    assert run('{}') == tag('{}', 'hash')
+    assert run('''
+    {
+        k => 1,
+        v => 2,
+    }
+    ''') == \
+        tag(['{', '\n',
+             tag([ind(1), tag('k', 'qn'), ' ', '=>', ' ', one, ',', '\n',
+                  ind(1), tag('v', 'qn'), ' ', '=>', ' ', two, ',', '\n']),
+             ind(0), '}',],
+            'hash')
+
+
+def test_parse_var():
+    # var
+    assert run('$x') == link(tag('$x', 'var'), '#x')
+
+
+def test_parse_operators():
+
+    assert run('true and true') == tag([true, ' ', keyword('and'), ' ', true])
+    assert run('true or true') == tag([true, ' ', keyword('or'), ' ', true])
+
+    # Negative literal / unary minus
+    assert run('- 1') == tag('-1', 'literal', 'number')
+    assert run('- $x') == tag(['-', ' ', link(tag('$x', 'var'), '#x')])
+
+    assert run('! true') == tag(['!', ' ', true])
+
+    assert run('1 + 2') == tag([one, ' ', '+', ' ', two])
+    assert run('1 - 2') == tag([one, ' ', '-', ' ', two])
+    assert run('1 * 2') == tag([one, ' ', '*', ' ', two])
+    assert run('1 % 2') == tag([one, ' ', '%', ' ', two])
+    assert run('1 << 2') == tag([one, ' ', '<<', ' ', two])
+    assert run('1 >> 2') == tag([one, ' ', '>>', ' ', two])
+    assert run('1 >= 2') == tag([one, ' ', '>=', ' ', two])
+    assert run('1 <= 2') == tag([one, ' ', '<=', ' ', two])
+    assert run('1 > 2') == tag([one, ' ', '>', ' ', two])
+    assert run('1 < 2') == tag([one, ' ', '<', ' ', two])
+    assert run('1 / 2') == tag([one, ' ', '/', ' ', two])
+    assert run('1 == 2') == tag([one, ' ', '==', ' ', two])
+    assert run('1 != 2') == tag([one, ' ', '!=', ' ', two])
+    assert run('1 =~ 2') == tag([one, ' ', '=~', ' ', two])
+    assert run('1 !~ 2') == tag([one, ' ', '!~', ' ', two])
+
+    assert run('1 in $x') == tag([one, ' ',
+                                  keyword('in'),
+                                  ' ',
+                                  link(tag('$x', 'var'), '#x')])
+
+
+def test_parse_conditionals():
+    assert run('if true {}') \
+        == tag([keyword('if'), ' ', true, ' ', '{', '\n',
+                ind(0), '}'])
+
+    assert run('if true {} else {}') \
+        == tag([keyword('if'), ' ', true, ' ', '{', '\n',
+                ind(0), '}'])
+
+    # different if forms
+    assert run('if true {1}') \
+        == tag([keyword('if'), ' ', true, ' ', '{', '\n',
+                ind(1), one, '\n',
+                ind(0), '}'])
+
+    assert run('if true {} else {1}') \
+        == tag([tag('if', 'keyword', 'if'), ' ', true, ' ', '{', '\n',
+                ind(0), '}', ' ', tag('else', 'keyword', 'else'), ' ', '{', '\n',
+                ind(1), one, '\n',
+                ind(0), '}'])
+
+    assert run('if true {1} else {2}') \
+        == tag([keyword('if'), ' ', true, ' ', '{', '\n',
+                ind(1), one, '\n',
+                ind(0), '}', ' ', tag('else', 'keyword', 'else'), ' ', '{', '\n',
+                ind(1), two, '\n',
+                ind(0), '}'])
+
+    assert run('if true {1} elsif false {2}') \
+        == tag([keyword('if'), ' ', true, ' ', '{', '\n',
+                ind(1), one, '\n',
+                ind(0), '}', ' ', 'els',
+                tag([keyword('if'), ' ', false, ' ', '{', '\n',
+                     ind(1), two, '\n',
+                     ind(0), '}'])])
+
+    assert run('if true {1} elsif false {2} else {1}') \
+        == tag([keyword('if'), ' ', true, ' ', '{', '\n',
+                ind(1), one, '\n',
+                ind(0), '}', ' ', 'els',
+                tag([keyword('if'), ' ', false, ' ', '{', '\n',
+                     ind(1), two, '\n',
+                     ind(0), '}', ' ', keyword('else'), ' ', '{', '\n',
+                     ind(1), one, '\n',
+                     ind(0), '}'])])
+
+    assert run('unless true {}') \
+        == tag([keyword('unless'), ' ', true, ' ', '{', '\n',
+                ind(0), '}'])
+
+    assert run('unless true {1}') \
+        == tag([keyword('unless'), ' ', true, ' ', '{', '\n',
+                ind(1), one, '\n',
+                ind(0), '}'])
+
+
+def test_parse_access():
+
+    assert run('x[1]') == tag([tag('x', 'qn'), '[', one, ']'],
+                              'access')
+    assert run('X[1]') == tag([tag('X', 'qr'), '[', one, ']'],
+                              'access')
+
+
+def test_parse_misc():
+
+    assert run('(1)') == tag(['(', one, ')'], 'paren')
+
+    assert run('/hello/') == tag(['/', tag('hello', 'regex-body'), '/'], 'regex')
+
+    assert run('File <<| |>>') \
+        == tag([tag('File', 'qr'),
+                ' ',
+                tag(['<<|', ' ', '|>>'])])
+    assert run("File <<| name == 'f' |>>") \
+        == tag([tag('File', 'qr'),
+                ' ',
+                tag(['<<|', ' ',
+                     tag([tag('name', 'qn'), ' ', '==', ' ', s('f')]),
+                     ' ', '|>>'])])
+
+    assert run('File <| |>') \
+        == tag([tag('File', 'qr'), ' ',
+                tag(['<|', ' ', '|>'])])
+
+    assert run('File <| $x |>') \
+        == tag([tag('File', 'qr'), ' ',
+                tag(['<|', ' ', link(tag('$x', 'var'), '#x'), ' ', '|>'])])
+
+
+def test_parse_strings():
+    # This doesn't include "atomic" strings, or regexes, since they
+    # are trivial types and handled above
+
+    # Double quoted "decays" to single quoted
+    assert run('"hello"') == s('hello')
+
+    assert run('"hello${x}"') \
+        == tag(['"', 'hello', tag(['${', link(tag('x', 'var'), '#x'), '}'], 'str-var'), '"'],
+               'string')
+
+    assert run('"hello${1 + $x}"') \
+        == tag(['"', 'hello', tag(['${', tag([one, ' ', '+', ' ',
+                                              link(tag('$x', 'var'), '#x')]), '}'],
+                                  'str-var'), '"'],
+               'string')
+
+    # Variable like, but without interpolation
+    assert run('''
+    @(AAA)
+    Hello ${x}
+    | AAA
+    ''') \
+        == tag(['@(EOF)', '\n',
+                ind(0), 'Hello ${x}', '\n',
+                ind(0), '|', ' ', 'EOF'],
+               'heredoc', 'literal')
+
+    # Variable interpolation
+    assert run('''
+    @("BBB")
+    Hello ${x}
+    | BBB
+    ''') \
+        == tag(['@("EOF")', '\n',
+                ind(0), 'Hello ', tag(['${', link(tag('x', 'var'), '#x'), '}']), '\n',
+                ind(0), '|', ' ', 'EOF',],
+               'heredoc', 'literal')
+
+    # Variable interpolation in middle of line
+    assert run('''
+    @("BBB")
+    Hello ${x}!
+    | BBB
+    ''') \
+        == tag(['@("EOF")', '\n',
+                ind(0), 'Hello ', tag(['${', link(tag('x', 'var'), '#x'), '}']), '!', '\n',
+                ind(0), '|', ' ', 'EOF',],
+               'heredoc', 'literal')
+
+    # Delete trailning newline + interpolation with no variables
+    # also empty line
+    assert run('''
+    @("CCC")
+    Hello
+
+    World
+    |- CCC
+    ''') \
+        == tag(['@(EOF)', '\n',
+                ind(0), 'Hello', '\n',
+                '\n',
+                ind(0), 'World', '\n',
+                ind(0), '|-', ' ', 'EOF',],
+               'heredoc', 'literal')
+
+    # With escape hatch ('$'), variable at end, and no trailing
+    # whitespace
+    assert run(r'''
+    @("DDD"/$)
+    \$Hello ${x}
+    |- DDD
+    ''') \
+        == tag(['@("EOF")', '\n',
+                # Trailing newline SHALL be here. Since the newline
+                # stripping is done by the '|-' token
+                ind(0), '$Hello ', tag(['${', link(tag('x', 'var'), '#x'), '}']), '\n',
+                ind(0), '|-', ' ', 'EOF',],
+               'heredoc', 'literal')
+
+    # With escape hatch ('$'), variable at end, and WITH trailing
+    # whitespace
+    assert run(r'''
+    @("DDD"/$)
+    \$Hello ${x}
+    | DDD
+    ''') \
+        == tag(['@("EOF")', '\n',
+                ind(0), '$Hello ', tag(['${', link(tag('x', 'var'), '#x'), '}']), '\n',
+                ind(0), '|', ' ', 'EOF',],
+               'heredoc', 'literal')
+
+    # Heredoc with variables and empty lines
+    assert run('''
+    @("EEE")
+    ${x}
+
+    ${y}
+    | EEE
+    ''') == tag(['@("EOF")', '\n',
+                 ind(0), tag(['${', link(tag('x', 'var'), '#x'), '}']), '\n',
+                 '\n',
+                 ind(0), tag(['${', link(tag('y', 'var'), '#y'), '}']), '\n',
+                 ind(0), '|', ' ', 'EOF'],
+                'heredoc', 'literal')
+
+    # Empty heredoc
+    assert run('''
+    @(FFF)
+    | FFF
+    ''') == tag(['@(EOF)', '\n', ind(0), '|', ' ', 'EOF'],
+                'heredoc', 'literal')
+
+    # TODO Heredoc containing (generated) delimiter
+
+    # Heredoc containing advanced expression
+    # This is mostly to ensure that variables get dollars again
+    assert run('''
+    @("GGG")
+    ${1 + $x}
+    | GGG
+    ''') == tag(['@("EOF")', '\n',
+                 ind(0), tag(['${', tag([one, ' ', '+', ' ',
+                                         link(tag('$x', 'var'), '#x')]), '}']), '\n',
+                 ind(0), '|', ' ', 'EOF'],
+                'heredoc', 'literal')
+
+
+def test_declare():
+    pass
+    assert run('$x = 1') \
+        == tag([tag(id('$x', 'x'), 'var'), ' ', '=', ' ', one], 'declaration')
+
+
+def test_parse_resource_instanciation():
+
+    # Single instanced resources
+    assert run('''
+    file { 'filename':
+        ensure => present,
+    }
+    ''') == tag([tag('file', 'qn'), ' ', '{', ' ', s('filename'), ':', '\n',
+                ind(1), tag('ensure', 'parameter'), '', ' ', '=>', ' ', tag('present', 'qn'),
+                ',', '\n', ind(0), '}']
+                )
+
+    assert run('''
+    file { 'filename':
+        ensure => present,
+        * => {},
+    }
+    ''') == tag([tag('file', 'qn'), ' ', '{', ' ', s('filename'), ':', '\n',
+                ind(1), tag('ensure', 'parameter'), '',
+                 ' ', '=>', ' ', tag('present', 'qn'), ',', '\n',
+                ind(1), tag('*', 'parameter', 'splat'), '     ',
+                 ' ', '=>', ' ', tag('{}', 'hash'), ',', '\n',
+                 ind(0), '}']
+                )
+
+    # multi instanced resoruces
+    assert run('''
+    file {
+        'first': ensure => present ;
+        'second': k => present, * => {},
+    }
+    ''') == tag([tag('file', 'qn'), ' ', '{', '\n',
+                 ind(1), s('first'), ':', '\n',
+                 ind(2), tag('ensure', 'parameter'), '',
+                 ' ', '=>', ' ', tag('present', 'qn'), ',', '\n',
+                 ind(1), ';', '\n',
+                 ind(1), s('second'), ':', '\n',
+                 ind(2), tag('k', 'parameter'), '',
+                 ' ', '=>', ' ', tag('present', 'qn'), ',', '\n',
+                 ind(2), tag('*', 'parameter', 'splat'), '',
+                 ' ', '=>', ' ', tag('{}', 'hash'), ',', '\n',
+                 ind(1), ';', '\n',
+                 ind(0), '}',
+                 ])
+
+
+def test_parse_resource_defaults():
+    assert run('''
+    File {
+        x => 1,
+        * => 2,
+    }
+    ''') == tag([tag('File', 'qr'), ' ', '{', '\n',
+                 ind(1), tag('x', 'parameter'), '', ' ', '=>', ' ', one, ',', '\n',
+                 ind(1), tag('*', 'parameter', 'splat'), '', ' ', '=>', ' ', two, ',', '\n',
+                 ind(0), '}',
+                 ])
+
+
+def test_parse_resource_override():
+    assert run('''
+    File['this'] {
+        x => 1,
+        y +> 2,
+        * => 1,
+    }
+    ''') == tag([tag([tag('File', 'qr'), '[', s('this'), ']'], 'access'), ' ', '{', '\n',
+                 ind(1), tag('x', 'parameter'), '', ' ', '=>', ' ', one, ',', '\n',
+                 ind(1), tag('y', 'parameter'), '', ' ', '+>', ' ', two, ',', '\n',
+                 ind(1), tag('*', 'parameter', 'splat'), '', ' ', '=>', ' ', one, ',', '\n',
+                 ind(0), '}',
+                 ])
+
+
+def test_parse_call():
+    assert run('f(1)') \
+        == tag([tag('f', 'qn'), ' ', one], 'invoke')
+
+    assert run('f(1, 2)') \
+        == tag([tag('f', 'qn'), ' ', '(', one, ',', ' ', two, ')'], 'invoke')
+
+    assert run('$x.f') \
+        == tag([tag([link(tag('$x', 'var'), '#x'), '\n',
+                    ind(0), '.', tag('f', 'qn')]),
+                '(', ')'], 'call-method')
+
+    assert run('$x.f(1, 2)') \
+        == tag([tag([link(tag('$x', 'var'), '#x'), '\n',
+                    ind(0), '.', tag('f', 'qn')]),
+                '(', one, ',', ' ', two, ')'], 'call-method')
+
+    assert run('f.each |$x| {$x}') \
+        == tag([
+            tag([tag('f', 'qn'), '\n',
+                 ind(0), '.', tag('each', 'qn')]),
+            tag(['|', '$x', '|', ' ', '{', '\n',
+                 ind(1), link(tag('$x', 'var'), '#x'), '\n',
+                 ind(0), '}'], 'lambda')], 'call-method')
+
+    # TODO call
+
+
+def test_parse_arrows():
+    assert run("File['x'] -> File['y']") \
+        == tag([
+            tag([tag('File', 'qr'), '[', s('x'), ']'], 'access'),
+            '\n', ind(0), '->', ' ',
+            tag([tag('File', 'qr'), '[', s('y'), ']'], 'access'),
+            ])
+    assert run("File['x'] ~> File['y']") \
+        == tag([
+            tag([tag('File', 'qr'), '[', s('x'), ']'], 'access'),
+            '\n', ind(0), '~>', ' ',
+            tag([tag('File', 'qr'), '[', s('y'), ']'], 'access'),
+            ])
+
+
+def test_parse_resource_declaration():
+    assert run('define newtype {}') \
+        == tag([keyword('define'), ' ',
+                tag('newtype', 'name'), ' ', '{', '\n',
+                ind(0), '}'])
+
+    assert run('define newtype { 1 }') \
+        == tag([keyword('define'), ' ',
+                tag('newtype', 'name'), ' ', '{', '\n',
+                ind(1), one, '\n',
+                ind(0), '}'])
+
+    assert run('define newtype ( $x, $y = 1, Integer $z = 2 ) { 1 }') \
+        == tag([keyword('define'), ' ',
+                tag('newtype', 'name'), ' ', '(', '\n',
+                ind(1), tag(id('$x', 'x'), 'var'), ',', '\n',
+                ind(1), tag(id('$y', 'y'), 'var'), ' ', '=', ' ', one, ',', '\n',
+                ind(1), tag(tag('Integer', 'qr'), 'type'), ' ',
+                tag(id('$z', 'z'), 'var'), ' ', '=', ' ', two, ',', '\n',
+                ind(0), ')', ' ', '{', '\n',
+                ind(1), one, '\n',
+                ind(0), '}',
+                ])
+
+
+def test_parse_class_declaration():
+    assert run('class cls {}') \
+        == tag([keyword('class'), ' ', tag('cls', 'name'), ' ', '{', '\n',
+                ind(0), '}'])
+
+    assert run('class cls ($x, $y = 1, Integer $z = 2) { $x }') \
+        == tag([keyword('class'), ' ', tag('cls', 'name'), ' ', '(', '\n',
+                ind(1), tag('$x', 'var'), ',', '\n',
+                ind(1), tag('$y', 'var'), ' ', '=', ' ', one, ',', '\n',
+                ind(1), tag(tag('Integer', 'qr'), 'type'), ' ', tag('$z', 'var'),
+                ' ', '=', ' ', two, ',', '\n',
+                ind(0), ')', ' ', '{', '\n',
+                ind(1), link(tag('$x', 'var'), '#x'), '\n',
+                ind(0), '}'])
+
+
+def test_parse_function_declaration():
+    assert run('function fname {}') \
+        == tag([keyword('function'),
+                ' ', 'fname', ' ', '{', '}'])
+
+    assert run('function fname () >> String {}') \
+        == tag([keyword('function'),
+                ' ', 'fname', ' ', '>>', ' ', tag('String', 'qr'),
+                ' ', '{', '}'])
+
+    assert run('function fname ($x, $y = 1, Integer $z = 2) { $x }') \
+        == tag([keyword('function'),
+                ' ', 'fname', ' ', '(', '\n',
+                ind(1), '$x', ',', '\n',
+                ind(1), '$y', ' ', '=', ' ', one, ',', '\n',
+                ind(1), tag('Integer', 'qr'), ' ', '$z', ' ', '=', ' ', two, ',', '\n',
+                ind(0), ')', ' ', '{', '\n',
+                ind(1), link(tag('$x', 'var'), '#x'), '\n',
+                ind(0), '}',
+                ])
+
+
+def test_parse_question_mark():
+    assert run('''
+    $x ? {
+        x => 1,
+        default => 2,
+    }
+    ''') == tag([link(tag('$x', 'var'), '#x'), ' ', '?', ' ', '{', '\n',
+                 tag([ind(1), tag('x', 'qn'), ' ', '=>', ' ', one, ',', '\n',
+                      ind(1), keyword('default'), ' ', '=>', ' ', two, ',', '\n']),
+                 ind(0), '}'],
+                'case')
+
+
+def test_parse_case():
+    assert run('''
+    case true {
+         a: {}
+    }
+    ''') == \
+        tag([keyword('case'), ' ', true, ' ', '{', '\n',
+             tag([ind(1), tag('a', 'qn'), ':', ' ', '{', '\n',
+                  ind(2), tag('', 'nop'), '\n',
+                  ind(1), '}', '\n']),
+             ind(0), '}'])
diff --git a/tests/test_reflow.py b/tests/test_reflow.py
deleted file mode 100644
index 536898243698d7ace56aedbc3b5b315c6415b686..0000000000000000000000000000000000000000
--- a/tests/test_reflow.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""
-Test for reflow.
-
-TODO
-- traverse
-"""
-
-from reflow import tagged_list_to_dict
-
-
-def test_tagged_list_to_dict():
-    assert tagged_list_to_dict(['a', 1, 'b', 2]) == {'a': 1, 'b': 2}