diff --git a/muppet/data/__init__.py b/muppet/data/__init__.py
deleted file mode 100644
index 6666e5bff2ff87264b85e38cb5bd8b85ed379265..0000000000000000000000000000000000000000
--- a/muppet/data/__init__.py
+++ /dev/null
@@ -1,202 +0,0 @@
-"""
-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 __future__ import annotations
-
-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',
-                          'Declaration',
-                          'Link',
-                          'ID',
-                          'Documentation',
-                          'Indentation']
-"""
-Documentation of Markup.
-"""
-
-
-@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 Declaration(Tag):
-    """
-    Superset of tag, containing declaration statements.
-
-    Mostly used for class and resource parameters.
-
-    :param variable:
-        Name of the variable being declared.
-    """
-
-    variable: str
-
-
-@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 declaration(item: Markup | Sequence[Markup], *tags: str, variable: str) -> Declaration:
-    """Mark name of variable in declaration."""
-    return Declaration(item, tags=tags, variable=variable)
-
-
-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
deleted file mode 100644
index df4f0f2d25db0d54571eb675961ab9776146dae5..0000000000000000000000000000000000000000
--- a/muppet/data/html.py
+++ /dev/null
@@ -1,95 +0,0 @@
-"""HTML Renderer."""
-
-from . import (
-    Tag,
-    Declaration,
-    Link,
-    ID,
-    Documentation,
-    Renderer,
-    Indentation,
-    render,
-)
-from collections.abc import Sequence
-import html
-from dataclasses import dataclass, field
-
-
-@dataclass
-class HTMLRenderer(Renderer):
-    """
-    Render the document into HTML.
-
-    :param param_documentation:
-        A dictionary containing (rendered) documentation for each
-        parameter of the class or resource type currently being
-        rendered.
-    """
-
-    param_documentation: dict[str, str] = field(default_factory=dict)
-
-    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)
-
-        out = ''
-        if isinstance(tag, Declaration):
-            if comment := self.param_documentation.get(tag.variable):
-                if isinstance(tag.item, list) \
-                        and tag.item \
-                        and isinstance(tag.item[0], Indentation):
-                    out += render(self, tag.item[0])
-                out += f'<span class="comment">{comment.strip()}</span>\n'
-
-        tags = ' '.join(tag.tags)
-        out += f'<span class="{tags}">{inner}</span>'
-        return out
-
-    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:
-        """HTML escape and return the given string."""
-        return html.escape(s)
diff --git a/muppet/data/plain.py b/muppet/data/plain.py
deleted file mode 100644
index 0ffdcf553e8e5986b65b8099f224b30089402980..0000000000000000000000000000000000000000
--- a/muppet/data/plain.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""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 734f8bd673bf023c3c5b3f84aa6f60d0d41d71c6..26c0b8fd1c32fb982059158161e4485654ef7ee0 100644
--- a/muppet/format.py
+++ b/muppet/format.py
@@ -12,31 +12,11 @@ import html
 import sys
 import re
 from typing import (
-    Any,
-    Literal,
     Tuple,
-    TypeAlias,
-    Union,
 )
 
 from .puppet.parser import puppet_parser
-from .intersperse import intersperse
-from .data import (
-    Markup,
-    Indentation,
-    Tag,
-    Link,
-    doc,
-    id,
-    link,
-    tag,
-    declaration,
-    render,
-)
-
-from .data.html import (
-    HTMLRenderer,
-)
+import logging
 
 from .puppet.strings import (
     DataTypeAlias,
@@ -48,987 +28,18 @@ from .puppet.strings import (
     DocStringParamTag,
     DocStringExampleTag,
 )
+from muppet.puppet.ast import build_ast
+from muppet.puppet.format import serialize
+from muppet.puppet.format.html import HTMLFormatter
 
-parse_puppet = puppet_parser
 
-HashEntry: TypeAlias = Union[Tuple[Literal['=>'], str, Any],
-                             Tuple[Literal['+>'], str, Any],
-                             Tuple[Literal['splat-hash'], Any]]
+logger = logging.getLogger(__name__)
 
-Context: TypeAlias = list['str']
+# parse_puppet = puppet_parser
 
 param_doc: dict[str, str] = {}
 
-
-def ind(level: int) -> Indentation:
-    """Return a string for indentation."""
-    return Indentation(level)
-
-
-def keyword(x: str) -> Tag:
-    """Return a keyword token for the given string."""
-    return tag(x, 'keyword', x)
-
-
-def operator(op: str) -> Tag:
-    """Tag string as an operator."""
-    return tag(op, 'operator')
-
-
-def print_hash(hash: list[HashEntry],
-               indent: int,
-               context: Context) -> Tag:
-    """Print the contents of a puppet hash literal."""
-    if not hash:
-        return tag('')
-    # namelen = 0
-    items: list[Markup] = []
-    for item in hash:
-        match item:
-            case ['=>', key, value]:
-                items += [
-                    ind(indent),
-                    parse(key, indent, context),
-                    ' ', '=>', ' ',
-                    parse(value, indent, context),
-                ]
-            case _:
-                items += [tag(f'[|[{item}]|]', 'parse-error'), '\n']
-        items += [',', '\n']
-
-    return tag(items)
-
-
-def ops_namelen(ops: list[HashEntry]) -> int:
-    """Calculate max key length a list of puppet operators."""
-    namelen = 0
-    for item in ops:
-        match item:
-            case ['=>', key, _]:
-                namelen = max(namelen, len(key))
-            case ['+>', key, _]:
-                namelen = max(namelen, len(key))
-            case ['splat-hash', _]:
-                namelen = max(namelen, 1)
-            case _:
-                raise Exception("Unexpected item in resource:", item)
-    return namelen
-
-
-def print_var(x: str, dollar: bool = True) -> Link:
-    """
-    Print the given variable.
-
-    If documentation exists, then add that documentation as hoover text.
-
-    :param x:
-        The variable to print
-    :param dollar:
-        If there should be a dollar prefix.
-    """
-    dol = '$' if dollar else ''
-    if docs := param_doc.get(x):
-        s = f'{dol}{x}'
-        return link(doc(tag(s, 'var'), docs), f'#{x}')
-    else:
-        return link(tag(f'{dol}{x}', 'var'), f'#{x}')
-
-
-def declare_var(x: str) -> Tag:
-    """Returna a tag declaring that this variable exists."""
-    return tag(id(f"${x}", x), 'var')
-
-
-# TODO strip leading colons when looking up documentation
-
-
-def handle_case_body(forms: list[dict[str, Any]],
-                     indent: int, context: Context) -> Tag:
-    """Handle case body when parsing AST."""
-    ret: list[Markup] = []
-    for form in forms:
-        when = form['when']
-        then = form['then']
-        ret += [ind(indent+1)]
-        # cases = []
-
-        for sublist in intersperse([',', ' '],
-                                   [[parse(item, indent+1, context)]
-                                    for item in when]):
-            ret += sublist
-
-        ret += [':', ' ', '{', '\n']
-
-        for item in then:
-            ret += [ind(indent+2), parse(item, indent+2, context), '\n']
-        ret += [ind(indent+1), '}', '\n']
-
-    return tag(ret)
-
-
-# Hyperlinks for
-# - qn
-# - qr
-# - var (except when it's the var declaration)
-
-LineFragment: TypeAlias = str | Markup
-Line: TypeAlias = list[LineFragment]
-
-
-def parse_access(how: Any, args: list[Any], *, indent: int, context: list[str]) -> Tag:
-    """Parse access form."""
-    # TODO newlines?
-    items = []
-    items += [parse(how, indent, context), '[']
-    for sublist in intersperse([',', ' '],
-                               [[parse(arg, indent, context)]
-                                for arg in args]):
-        items += sublist
-    items += [']']
-    return tag(items, 'access')
-
-
-def parse_array(items: list[Any], *, indent: int, context: list[str]) -> Tag:
-    """Parse array form."""
-    out: list[Markup]
-    out = ['[', '\n']
-    for item in items:
-        out += [
-            ind(indent+2),
-            parse(item, indent+1, context),
-            ',',
-            '\n',
-        ]
-    out += [ind(indent), ']']
-    return tag(out, 'array')
-
-
-def parse_call(func: Any, args: list[Any], *, indent: int, context: list[str]) -> Tag:
-    """Parse call form."""
-    items = []
-    items += [parse(func, indent, context), '(']
-    for sublist in intersperse([',', ' '],
-                               [[parse(arg, indent, context)]
-                                for arg in args]):
-        items += sublist
-    items += [')']
-    return tag(items, 'call')
-
-
-def parse_call_method(func: Any, *, indent: int, context: list[str]) -> Tag:
-    """Parse call method form."""
-    items = [parse(func['functor'], indent, context)]
-
-    if not ('block' in func and func['args'] == []):
-        items += ['(']
-        for sublist in intersperse([',', ' '],
-                                   [[parse(x, indent, context)]
-                                    for x in func['args']]):
-            items += sublist
-        items += [')']
-
-    if 'block' in func:
-        items += [parse(func['block'], indent+1, context)]
-
-    return tag(items, 'call-method')
-
-
-def parse_case(test: Any, forms: Any, *, indent: int, context: list[str]) -> Tag:
-    """Parse case form."""
-    items: list[Markup] = [
-        keyword('case'),
-        ' ',
-        parse(test, indent, context),
-        ' ', '{', '\n',
-        handle_case_body(forms, indent, context),
-        ind(indent),
-        '}',
-    ]
-
-    return tag(items)
-
-
-def parse_class(name: Any, rest: dict[str, Any],
-                *, indent: int, context: list[str]) -> Tag:
-    """Parse class form."""
-    items: list[Markup] = []
-    items += [
-        keyword('class'),
-        ' ',
-        tag(name, 'name'),
-        ' ',
-    ]
-
-    if 'params' in rest:
-        items += ['(', '\n']
-        for name, data in rest['params'].items():
-            decls: list[Markup] = []
-            decls += [ind(indent+1)]
-            if 'type' in data:
-                tt = parse(data['type'], indent+1, context)
-                decls += [tag(tt, 'type'),
-                          ' ']
-            decls += [declare_var(name)]
-            if 'value' in data:
-                decls += [
-                    ' ', operator('='), ' ',
-                    # TODO this is a declaration
-                    parse(data.get('value'), indent+1, context),
-                ]
-            items += [declaration(decls, 'declaration', variable=name)]
-            items += [',', '\n']
-        items += [ind(indent), ')', ' ', '{', '\n']
-    else:
-        items += ['{', '\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)
-
-
-def parse_concat(args: list[Any], *, indent: int, context: list[str]) -> Tag:
-    """Parse concat form."""
-    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(['${', content, '}'], 'str-var')]
-            case s:
-                items += [s
-                          .replace('"', '\\"')
-                          .replace('\n', '\\n')]
-    items += '"'
-    return tag(items, 'string')
-
-
-def parse_define(name: Any, rest: dict[str, Any],
-                 *, indent: int, context: list[str]) -> Tag:
-    """Parse define form."""
-    items: list[Markup] = []
-    items += [keyword('define'),
-              ' ',
-              tag(name, 'name'),
-              ' ']
-
-    if params := rest.get('params'):
-        items += ['(', '\n']
-        for name, data in params.items():
-            decl: list[Markup] = []
-            decl += [ind(indent+1)]
-            if 'type' in data:
-                decl += [tag(parse(data['type'], indent, context),
-                             'type'),
-                         ' ']
-            # print(f'<span class="var">${name}</span>', end='')
-            decl += [declare_var(name)]
-            if 'value' in data:
-                decl += [
-                    ' ', '=', ' ',
-                    parse(data.get('value'), indent, context),
-                ]
-            items += [declaration(decl, 'declaration', variable=name)]
-            items += [',', '\n']
-
-        items += [ind(indent), ')', ' ']
-
-    items += ['{', '\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)
-
-
-def parse_function(name: Any, rest: dict[str, Any],
-                   *, indent: int, context: list[str]) -> Tag:
-    """Parse function form."""
-    items = []
-    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:
-                items += [parse(attributes['type'], indent, context),
-                          ' ']
-            items += [f'${name}']
-            if 'value' in attributes:
-                items += [
-                    ' ', '=', ' ',
-                    parse(attributes['value'], indent, context),
-                ]
-            items += [',', '\n']
-        items += [ind(indent), ')']
-
-    if 'returns' in rest:
-        items += [' ', '>>', ' ',
-                  parse(rest['returns'], indent, context)]
-
-    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)
-
-
-def parse_heredoc_concat(parts: list[Any],
-                         *, indent: int, context: list[str]) -> Tag:
-    """Parse heredoc form containing concatenation."""
-    items: list[Markup] = ['@("EOF")']
-
-    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 item1 in rest:
-                    lines += [[item1]]
-
-    for line in lines:
-        items += ['\n']
-        if line != ['']:
-            items += [ind(indent)]
-            for item2 in line:
-                if item2:
-                    items += [item2]
-
-    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')
-
-
-def parse_heredoc_text(text: str, *, indent: int, context: list[str]) -> Tag:
-    """Parse heredoc form only containing text."""
-    items: list[Markup] = []
-    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')
-
-
-def parse_if(test: Any, rest: dict[str, Any], *, indent: int, context: list[str]) -> Tag:
-    """Parse if form."""
-    items: list[Markup] = []
-    items += [
-        keyword('if'),
-        ' ',
-        parse(test, indent, context),
-        ' ', '{', '\n',
-    ]
-    if 'then' in rest:
-        for item in rest['then']:
-            items += [
-                ind(indent+1),
-                parse(item, indent+1, context),
-                '\n',
-            ]
-    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 += [keyword('else'),
-                          ' ', '{', '\n']
-                for item in el:
-                    items += [
-                        ind(indent+1),
-                        parse(item, indent+1, context),
-                        '\n',
-                    ]
-                items += [
-                    ind(indent),
-                    '}',
-                ]
-    return tag(items)
-
-
-def parse_invoke(func: Any, args: list[Any],
-                 *, indent: int, context: list[str]) -> Tag:
-    """Parse invoke form."""
-    items = [
-        parse(func, indent, context),
-        ' ',
-    ]
-    if len(args) == 1:
-        items += [parse(args[0], indent+1, context)]
-    else:
-        items += ['(']
-        for sublist in intersperse([',', ' '],
-                                   [[parse(arg, indent+1, context)]
-                                    for arg in args]):
-            items += sublist
-        items += [')']
-    return tag(items, 'invoke')
-
-
-def parse_lambda(params: dict[str, Any], body: Any,
-                 *, indent: int, context: list[str]) -> Tag:
-    """Parse lambda form."""
-    items: list[Markup] = []
-    # 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),
-            parse(entry, indent, context),
-            '\n',
-        ]
-    items += [ind(indent-1), '}']
-    return tag(items, 'lambda')
-
-
-def parse_resource(t: str, bodies: list[Any],
-                   *, indent: int, context: list[str]) -> Tag:
-    """Parse resource form."""
-    match bodies:
-        case [body]:
-            items = [
-                parse(t, indent, context),
-                ' ', '{', ' ',
-                parse(body['title'], indent, context),
-                ':', '\n',
-            ]
-            ops = body['ops']
-
-            namelen = ops_namelen(ops)
-
-            for item in ops:
-                match item:
-                    case ['=>', key, value]:
-                        pad = namelen - len(key)
-                        items += [
-                            ind(indent+1),
-                            tag(key, 'parameter'),
-                            ' '*pad, ' ', '=>', ' ',
-                            parse(value, indent+1, context),
-                            ',', '\n',
-                        ]
-
-                    case ['splat-hash', value]:
-                        items += [
-                                ind(indent+1),
-                                tag('*', 'parameter', 'splat'),
-                                ' '*(namelen-1),
-                                ' ', '=>', ' ',
-                                parse(value, indent+1, context),
-                                ',', '\n',
-                        ]
-
-                    case _:
-                        raise Exception("Unexpected item in resource:", item)
-
-            items += [
-                ind(indent),
-                '}',
-            ]
-
-            return tag(items)
-        case bodies:
-            items = []
-            items += [
-                parse(t, indent, context),
-                ' ', '{',
-            ]
-            for body in bodies:
-                items += [
-                    '\n', ind(indent+1),
-                    parse(body['title'], indent, context),
-                    ':', '\n',
-                ]
-
-                ops = body['ops']
-                namelen = ops_namelen(ops)
-
-                for item in ops:
-                    match item:
-                        case ['=>', key, value]:
-                            pad = namelen - len(key)
-                            items += [
-                                ind(indent+2),
-                                tag(key, 'parameter'),
-                                ' '*pad,
-                                ' ', '=>', ' ',
-                                parse(value, indent+2, context),
-                                ',', '\n',
-                            ]
-
-                        case ['splat-hash', value]:
-                            items += [
-                                    ind(indent+2),
-                                    tag('*', 'parameter', 'splat'),
-                                    ' '*(namelen - 1),
-                                    ' ', '=>', ' ',
-                                    parse(value, indent+2, context),
-                                    ',', '\n',
-                            ]
-
-                        case _:
-                            raise Exception("Unexpected item in resource:", item)
-
-                items += [ind(indent+1), ';']
-            items += ['\n', ind(indent), '}']
-            return tag(items)
-
-
-def parse_resource_defaults(t: str, ops: Any,
-                            *, indent: int, context: list[str]) -> Tag:
-    """Parse resource defaults form."""
-    items = [
-        parse(t, indent, context),
-        ' ', '{', '\n',
-    ]
-    namelen = ops_namelen(ops)
-    for op in ops:
-        match op:
-            case ['=>', key, value]:
-                pad = namelen - len(key)
-                items += [
-                    ind(indent+1),
-                    tag(key, 'parameter'),
-                    ' '*pad,
-                    ' ', operator('=>'), ' ',
-                    parse(value, indent+3, context),
-                    ',', '\n',
-                ]
-
-            case ['splat-hash', value]:
-                pad = namelen - 1
-                items += [
-                    ind(indent+1),
-                    tag('*', 'parameter', 'splat'),
-                    ' '*pad,
-                    ' ', operator('=>'), ' ',
-                    parse(value, indent+2, context),
-                    ',', '\n',
-                ]
-
-            case x:
-                raise Exception('Unexpected item in resource defaults:', x)
-
-    items += [ind(indent),
-              '}']
-
-    return tag(items)
-
-
-def parse_resource_override(resources: Any, ops: Any,
-                            *, indent: int, context: list[str]) -> Tag:
-    """Parse resoruce override form."""
-    items = [
-        parse(resources, indent, context),
-        ' ', '{', '\n',
-    ]
-
-    namelen = ops_namelen(ops)
-    for op in ops:
-        match op:
-            case ['=>', key, value]:
-                pad = namelen - len(key)
-                items += [
-                    ind(indent+1),
-                    tag(key, 'parameter'),
-                    ' '*pad,
-                    ' ', operator('=>'), ' ',
-                    parse(value, indent+3, context),
-                    ',', '\n',
-                ]
-
-            case ['+>', key, value]:
-                pad = namelen - len(key)
-                items += [
-                    ind(indent+1),
-                    tag(key, 'parameter'),
-                    ' '*pad,
-                    ' ', operator('+>'), ' ',
-                    parse(value, indent+2, context),
-                    ',', '\n',
-                ]
-
-            case ['splat-hash', value]:
-                pad = namelen - 1
-                items += [
-                    ind(indent+1),
-                    tag('*', 'parameter', 'splat'),
-                    ' '*pad,
-                    ' ', operator('=>'), ' ',
-                    parse(value, indent+2, context),
-                    ',', '\n',
-                ]
-
-            case _:
-                raise Exception('Unexpected item in resource override:',
-                                op)
-
-    items += [
-        ind(indent),
-        '}',
-    ]
-
-    return tag(items)
-
-
-def parse_unless(test: Any, rest: dict[str, Any],
-                 *, indent: int, context: list[str]) -> Tag:
-    """Parse unless form."""
-    items: list[Markup] = [
-        keyword('unless'),
-        ' ',
-        parse(test, indent, 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)
-
-
-def parse_operator(op: str, lhs: Any, rhs: Any,
-                   *, indent: int, context: list[str]) -> Tag:
-    """Parse binary generic operator form."""
-    return tag([
-        parse(lhs, indent, context),
-        ' ', operator(op), ' ',
-        parse(rhs, indent, context),
-    ])
-
-
-def parse(form: Any, indent: int, context: list[str]) -> Markup:
-    """
-    Print everything from a puppet parse tree.
-
-    :param from:
-        A puppet AST.
-    :param indent:
-        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:
-            return tag('undef', 'literal', 'undef')
-
-        case True:
-            return tag('true', 'literal', 'true')
-
-        case False:
-            return tag('false', 'literal', 'false')
-
-        case ['access', how, *args]:
-            return parse_access(how, args, indent=indent, context=context)
-
-        case ['and', a, b]:
-            return tag([
-                parse(a, indent, context),
-                ' ', keyword('and'), ' ',
-                parse(b, indent, context),
-            ])
-
-        case ['array']:
-            return tag('[]', 'array')
-
-        case ['array', *items]:
-            return parse_array(items, indent=indent, context=context)
-
-        case ['call', {'functor': func, 'args': args}]:
-            return parse_call(func, args, indent=indent, context=context)
-
-        case ['call-method', func]:
-            return parse_call_method(func, indent=indent, context=context)
-
-        case ['case', test, forms]:
-            return parse_case(test, forms, indent=indent, context=context)
-
-        case ['class', {'name': name, **rest}]:
-            return parse_class(name, rest, indent=indent, context=context)
-
-        case ['concat', *args]:
-            return parse_concat(args, indent=indent, context=context)
-
-        case ['collect', {'type': t, 'query': q}]:
-            return tag([parse(t, indent, context),
-                        ' ',
-                        parse(q, indent, context)])
-
-        case ['default']:
-            return keyword('default')
-
-        case ['define', {'name': name, **rest}]:
-            return parse_define(name, rest, indent=indent, context=context)
-
-        case ['exported-query']:
-            return tag(['<<|', ' ', '|>>'])
-
-        case ['exported-query', arg]:
-            return tag(['<<|', ' ', parse(arg, indent, context), ' ', '|>>'])
-
-        case ['function', {'name': name, **rest}]:
-            return parse_function(name, rest, indent=indent, context=context)
-
-        case ['hash']:
-            return tag('{}', 'hash')
-
-        case ['hash', *hash]:
-            return tag([
-                '{', '\n',
-                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]}]:
-            return parse_heredoc_concat(parts, indent=indent, context=context)
-
-        case ['heredoc', {'text': ''}]:
-            return tag(['@(EOF)', '\n', ind(indent), '|', ' ', 'EOF'],
-                       'heredoc', 'literal')
-
-        case ['heredoc', {'text': text}]:
-            return parse_heredoc_text(text, indent=indent, context=context)
-
-        case ['if', {'test': test, **rest}]:
-            return parse_if(test, rest, indent=indent, context=context)
-
-        case ['in', needle, stack]:
-            return tag([
-                parse(needle, indent, context),
-                ' ', keyword('in'), ' ',
-                parse(stack, indent, context),
-            ])
-
-        case ['invoke', {'functor': func, 'args': args}]:
-            return parse_invoke(func, args, indent=indent, context=context)
-
-        case ['nop']:
-            return tag('', 'nop')
-
-        case ['lambda', {'params': params, 'body': body}]:
-            return parse_lambda(params, body, indent=indent, context=context)
-
-        case ['or', a, b]:
-            return tag([
-                parse(a, indent, context),
-                ' ', keyword('or'), ' ',
-                parse(b, indent, context),
-            ])
-
-        case ['paren', *forms]:
-            return tag([
-                '(',
-                *(parse(form, indent+1, context)
-                  for form in forms),
-                ')',
-            ], 'paren')
-
-        # Qualified name?
-        case ['qn', x]:
-            return tag(x, 'qn')
-
-        # Qualified resource?
-        case ['qr', x]:
-            return tag(x, 'qr')
-
-        case ['regexp', s]:
-            return tag(['/', tag(s, 'regex-body'), '/'], 'regex')
-
-        # Resource instansiation with exactly one instance
-        case ['resource', {'type': t, 'bodies': [body]}]:
-            return parse_resource(t, [body], indent=indent, context=context)
-
-        # Resource instansiation with any number of instances
-        case ['resource', {'type': t, 'bodies': bodies}]:
-            return parse_resource(t, bodies, indent=indent, context=context)
-
-        case ['resource-defaults', {'type': t, 'ops': ops}]:
-            return parse_resource_defaults(t, ops, indent=indent, context=context)
-
-        case ['resource-override', {'resources': resources, 'ops': ops}]:
-            return parse_resource_override(resources, ops, indent=indent, context=context)
-
-        case ['unless', {'test': test, **rest}]:
-            return parse_unless(test, rest, indent=indent, context=context)
-
-        case ['var', x]:
-            if context[0] == 'declaration':
-                return declare_var(x)
-            else:
-                return print_var(x, True)
-
-        case ['virtual-query', q]:
-            return tag(['<|', ' ', parse(q, indent, context), ' ', '|>', ])
-
-        case ['virtual-query']:
-            return tag(['<|', ' ', '|>'])
-
-        case ['!', x]:
-            return tag([
-                operator('!'), ' ',
-                parse(x, indent, context),
-            ])
-
-        case ['-', a]:
-            return tag([
-                operator('-'), ' ',
-                parse(a, indent, context),
-            ])
-
-        case [('!=' | '+' | '-' | '*' | '%' | '<<' | '>>' | '>=' | '<=' | '>' | '<' | '/' | '==' | '=~' | '!~') as op,  # noqa: E501
-              a, b]:
-            return parse_operator(op, a, b, indent=indent, context=context)
-
-        case ['~>', left, right]:
-            return tag([
-                parse(left, indent, context),
-                '\n',
-                ind(indent),
-                operator('~>'), ' ',
-                parse(right, indent, context)
-            ])
-
-        case ['->', left, right]:
-            return tag([
-                parse(left, indent, context),
-                '\n',
-                ind(indent),
-                operator('->'), ' ',
-                parse(right, indent, context),
-            ])
-
-        case ['.', left, right]:
-            return tag([
-                parse(left, indent, context),
-                '\n',
-                ind(indent),
-                operator('.'),
-                parse(right, indent+1, context),
-            ])
-
-        case ['=', field, value]:
-            return tag([
-                parse(field, indent, ['declaration'] + context),
-                ' ', operator('='), ' ',
-                parse(value, indent, context),
-            ], 'declaration')
-
-        case ['?', condition, cases]:
-            return tag([
-                parse(condition, indent, context),
-                ' ', operator('?'), ' ', '{', '\n',
-                print_hash(cases, indent+1, context),
-                ind(indent),
-                '}',
-                ], 'case')
-
-        case form:
-            if isinstance(form, str):
-                # 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')
-            else:
-                return tag(f'[|[{form}]|]', 'parse-error')
+# --------------------------------------------------
 
 
 def format_docstring(name: str, docstring: DocString) -> Tuple[str, str]:
@@ -1114,17 +125,22 @@ def build_param_dict(docstring: DocString) -> dict[str, str]:
 
 def format_class(d_type: DefinedType | PuppetClass) -> Tuple[str, str]:
     """Format Puppet class."""
-    t = parse_puppet(d_type.source)
-    data = parse(t, 0, ['root'])
-    renderer = HTMLRenderer(build_param_dict(d_type.docstring))
     out = ''
     name = d_type.name
+    logger.debug("Formatting class %s", name)
     # print(name, file=sys.stderr)
     name, body = format_docstring(name, d_type.docstring)
     out += body
 
     out += '<pre class="highlight-muppet"><code class="puppet">'
-    out += render(renderer, data)
+    # ------ Old ---------------------------------------
+    # t = parse_puppet(d_type.source)
+    # data = parse(t, 0, ['root'])
+    # renderer = HTMLRenderer(build_param_dict(d_type.docstring))
+    # out += render(renderer, data)
+    # ------ New ---------------------------------------
+    ast = build_ast(puppet_parser(d_type.source))
+    out += serialize(ast, HTMLFormatter)
     out += '</code></pre>'
     return name, out
 
@@ -1136,33 +152,33 @@ def format_type() -> str:
 
 def format_type_alias(d_type: DataTypeAlias) -> Tuple[str, str]:
     """Format Puppet type alias."""
-    renderer = HTMLRenderer()
     out = ''
     name = d_type.name
+    logger.debug("Formatting type alias %s", name)
     # print(name, file=sys.stderr)
     title, body = format_docstring(name, d_type.docstring)
     out += body
     out += '\n'
     out += '<pre class="highlight-muppet"><code class="puppet">'
-    t = parse_puppet(d_type.alias_of)
-    data = parse(t, 0, ['root'])
-    out += render(renderer, data)
+    t = puppet_parser(d_type.alias_of)
+    data = build_ast(t)
+    out += serialize(data, HTMLFormatter)
     out += '</code></pre>\n'
     return title, out
 
 
 def format_defined_type(d_type: DefinedType) -> Tuple[str, str]:
     """Format Puppet defined type."""
-    renderer = HTMLRenderer(build_param_dict(d_type.docstring))
+    # renderer = HTMLRenderer(build_param_dict(d_type.docstring))
     out = ''
     name = d_type.name
+    logger.debug("Formatting defined type %s", name)
     # print(name, file=sys.stderr)
     title, body = format_docstring(name, d_type.docstring)
     out += body
 
     out += '<pre class="highlight-muppet"><code class="puppet">'
-    t = parse_puppet(d_type.source)
-    out += render(renderer, parse(t, 0, ['root']))
+    out += serialize(build_ast(puppet_parser(d_type.source)), HTMLFormatter)
     out += '</code></pre>\n'
     return title, out
 
@@ -1170,6 +186,7 @@ def format_defined_type(d_type: DefinedType) -> Tuple[str, str]:
 def format_resource_type(r_type: ResourceType) -> str:
     """Format Puppet resource type."""
     name = r_type.name
+    logger.debug("Formatting resource type %s", name)
     out = ''
     out += f'<h2>{name}</h2>\n'
     out += str(r_type.docstring)
@@ -1207,6 +224,7 @@ def format_puppet_function(function: Function) -> str:
     """Format Puppet function."""
     out = ''
     name = function.name
+    logger.debug("Formatting puppet function %s", name)
     out += f'<h2>{name}</h2>\n'
     t = function.type
     # docstring = function.docstring
@@ -1222,8 +240,9 @@ def format_puppet_function(function: Function) -> str:
     elif t == 'puppet':
         out += '<pre class="highlight-muppet"><code class="puppet">'
         try:
-            source = parse_puppet(function.source)
-            out += str(parse(source, 0, ['root']))
+            # source = parse_puppet(function.source)
+            # out += str(build_ast(source))
+            out += serialize(build_ast(puppet_parser(function.source)), HTMLFormatter)
         except CalledProcessError as e:
             print(e, file=sys.stderr)
             print(f"Failed on function: {name}", file=sys.stderr)
diff --git a/muppet/puppet/__main__.py b/muppet/puppet/__main__.py
new file mode 100644
index 0000000000000000000000000000000000000000..080a2db679667bcad1afb6f2f910eee15f99ac36
--- /dev/null
+++ b/muppet/puppet/__main__.py
@@ -0,0 +1,72 @@
+"""Extra executable for testing the various parser steps."""
+
+from .parser import puppet_parser
+from .ast import build_ast
+from .format.text import TextFormatter
+from .format import serialize
+import json
+import argparse
+import pprint
+import sys
+from typing import Any
+
+
+def __main() -> None:
+    parser = argparse.ArgumentParser()
+    parser.add_argument('mode',
+                        choices=['raw', 'parser', 'ast', 'serialize'],
+                        help='Mode of operation')
+
+    parser.add_argument('file',
+                        type=argparse.FileType('r'),
+                        help='Puppet file to parse')
+    parser.add_argument('--format',
+                        choices=['json', 'python'],
+                        action='store',
+                        default='json',
+                        help='Format of the output')
+    parser.add_argument('--pretty',
+                        nargs='?',
+                        action='store',
+                        type=int,
+                        help='Prettify the output, optionally specifying indent width')
+
+    args = parser.parse_args()
+
+    def output(thingy: Any) -> None:
+        match args.format:
+            case 'json':
+                json.dump(result, sys.stdout, indent=args.pretty)
+                print()
+            case 'python':
+                if indent := args.pretty:
+                    pprint.pprint(thingy, indent=indent)
+                else:
+                    print(thingy)
+
+    data = args.file.read()
+    if args.mode == 'raw':
+        output(data)
+        return
+
+    # print("raw data:", data)
+    result = puppet_parser(data)
+    if args.mode == 'parser':
+        output(result)
+        return
+
+    ast = build_ast(result)
+    if args.mode == 'ast':
+        output(ast)
+        return
+
+    reserialized = serialize(ast, TextFormatter)
+    if args.mode == 'serialize':
+        print(reserialized)
+        return
+
+    print('No mode given')
+
+
+if __name__ == '__main__':
+    __main()
diff --git a/muppet/puppet/ast.py b/muppet/puppet/ast.py
new file mode 100644
index 0000000000000000000000000000000000000000..2ce3c35263d793d5021e1345c9857d9b4f27ca2b
--- /dev/null
+++ b/muppet/puppet/ast.py
@@ -0,0 +1,724 @@
+"""The Puppet AST, in Python."""
+
+from dataclasses import dataclass, field
+import logging
+
+from typing import (
+    Any,
+    Literal,
+    Optional,
+)
+# from abc import abstractmethod
+
+
+logger = logging.getLogger(__name__)
+
+
+# --------------------------------------------------
+
+
+@dataclass
+class HashEntry:
+    """An entry in a hash table."""
+
+    k: 'Puppet'
+    v: 'Puppet'
+
+
+@dataclass
+class PuppetDeclarationParameter:
+    """
+    A parameter to a class, definition, or function.
+
+    .. code-block:: puppet
+
+        class A (
+            type k = v,
+        ) {
+        }
+    """
+
+    k: str
+    v: Optional['Puppet'] = None
+    type: Optional['Puppet'] = None
+
+
+# --------------------------------------------------
+
+@dataclass(kw_only=True)
+class Puppet:
+    """Base for puppet item."""
+
+
+@dataclass
+class PuppetLiteral(Puppet):
+    """
+    A self quoting value.
+
+    e.g. ``true``, ``undef``, ...
+    """
+
+    literal: str
+
+
+@dataclass
+class PuppetAccess(Puppet):
+    """
+    Array access and similar.
+
+    .. code-block:: puppet
+
+        how[args]
+    """
+
+    how: Puppet
+    args: list[Puppet]
+
+
+@dataclass
+class PuppetBinaryOperator(Puppet):
+    """An operator with two values."""
+
+    op: str
+    lhs: Puppet
+    rhs: Puppet
+
+
+@dataclass
+class PuppetUnaryOperator(Puppet):
+    """A prefix operator."""
+
+    op: str
+    x: Puppet
+
+
+@dataclass
+class PuppetArray(Puppet):
+    """An array of values."""
+
+    items: list[Puppet]
+
+
+@dataclass
+class PuppetCall(Puppet):
+    """
+    A function call.
+
+    .. highlight: puppet
+
+        func(args)
+    """
+
+    func: Puppet
+    args: list[Puppet]
+
+
+@dataclass
+class PuppetCallMethod(Puppet):
+    """A method call? TODO."""
+
+    func: Puppet
+    args: list[Puppet]
+    block: Optional[Puppet] = None
+
+
+@dataclass
+class PuppetCase(Puppet):
+    """A case "statement"."""
+
+    test: Puppet
+    cases: list[tuple[list[Puppet], list[Puppet]]]
+
+
+@dataclass
+class PuppetInstanciationParameter(Puppet):
+    """
+    Key-value pair used when instanciating resources.
+
+    Also used with resource defaults and resoruce overrides.
+
+    ``+>`` arrow is only valid on overrides,
+    `See <https://www.puppet.com/docs/puppet/7/lang_resources.html>`.
+
+    .. code-block:: puppet
+
+        file { 'hello':
+            k => v,
+        }
+    """
+
+    k: str
+    v: Puppet
+    arrow: Literal['=>'] | Literal['+>'] = '=>'
+
+
+@dataclass
+class PuppetClass(Puppet):
+    """A puppet class declaration."""
+
+    name: str
+    params: Optional[list[PuppetDeclarationParameter]] = None
+    body: list[Puppet] = field(default_factory=list)
+
+
+@dataclass
+class PuppetConcat(Puppet):
+    """A string with interpolated values."""
+
+    fragments: list[Puppet]
+
+
+@dataclass
+class PuppetCollect(Puppet):
+    """
+    Resource collectors.
+
+    These should be followed by a query (either exported or virtual).
+    """
+
+    type: Puppet
+    query: Puppet
+
+
+@dataclass
+class PuppetIf(Puppet):
+    """
+    A puppet if expression.
+
+    .. code-block:: puppet
+
+        if condition {
+            consequent
+        } else {
+            alretnative
+        }
+
+    ``elsif`` is parsed as an else block with a single if expression
+    in it.
+    """
+
+    condition: Puppet
+    consequent: list[Puppet]
+    alternative: Optional[list[Puppet]] = None
+
+
+@dataclass
+class PuppetUnless(Puppet):
+    """A puppet unless expression."""
+
+    condition: Puppet
+    consequent: list[Puppet]
+
+
+@dataclass
+class PuppetKeyword(Puppet):
+    """
+    A reserved word in the puppet language.
+
+    This class is seldom instanciated, since most keywords are
+    embedded in the other forms.
+    """
+
+    name: str
+
+
+@dataclass
+class PuppetExportedQuery(Puppet):
+    """
+    An exported query.
+
+    .. highlight:: puppet
+
+        <<| filter |>>
+    """
+
+    filter: Optional[Puppet] = None
+
+
+@dataclass
+class PuppetVirtualQuery(Puppet):
+    """
+    A virtual query.
+
+    .. highlight:: puppet
+
+        <| q |>
+    """
+
+    q: Optional[Puppet] = None
+
+
+@dataclass
+class PuppetFunction(Puppet):
+    """Declaration of a Puppet function."""
+
+    name: str
+    params: Optional[list[PuppetDeclarationParameter]] = None
+    returns: Optional[Puppet] = None
+    body: list[Puppet] = field(default_factory=list)
+
+
+@dataclass
+class PuppetHash(Puppet):
+    """A puppet dictionary."""
+
+    entries: list[HashEntry] = field(default_factory=list)
+
+
+@dataclass
+class PuppetHeredoc(Puppet):
+    """A puppet heredoc."""
+
+    fragments: list[Puppet]
+    syntax: Optional[str] = None
+
+
+@dataclass
+class PuppetLiteralHeredoc(Puppet):
+    """A puppet heredoc without any interpolation."""
+
+    content: str
+    syntax: Optional[str] = None
+
+
+@dataclass
+class PuppetVar(Puppet):
+    """A puppet variable."""
+
+    name: str
+
+
+@dataclass
+class PuppetLambda(Puppet):
+    """A puppet lambda."""
+
+    params: list[PuppetDeclarationParameter]
+    body: list[Puppet]
+
+
+@dataclass
+class PuppetParenthesis(Puppet):
+    """Forms surrounded by parethesis."""
+
+    form: Puppet
+
+
+@dataclass
+class PuppetQn(Puppet):
+    """Qn TODO."""
+
+    name: str
+
+
+@dataclass
+class PuppetQr(Puppet):
+    """Qr TODO."""
+
+    name: str
+
+
+@dataclass
+class PuppetRegex(Puppet):
+    """A regex literal."""
+
+    s: str
+
+
+@dataclass
+class PuppetResource(Puppet):
+    """Instansiation of one (or more) puppet resources."""
+
+    type: Puppet
+    bodies: list[tuple[Puppet, list[PuppetInstanciationParameter]]]
+
+
+@dataclass
+class PuppetNop(Puppet):
+    """A no-op."""
+
+    def serialize(self, indent: int, **_: Any) -> str:  # noqa: D102
+        return ''
+
+
+@dataclass
+class PuppetDefine(Puppet):
+    """A puppet resource declaration."""
+
+    name: str
+    params: Optional[list[PuppetDeclarationParameter]] = None
+    body: list[Puppet] = field(default_factory=list)
+
+
+@dataclass
+class PuppetString(Puppet):
+    """A puppet string literal."""
+
+    s: str
+
+
+@dataclass
+class PuppetNumber(Puppet):
+    """A puppet numeric literal."""
+
+    x: int | float
+
+
+@dataclass
+class PuppetInvoke(Puppet):
+    """
+    A puppet function invocation.
+
+    This is at least used when including classes:
+
+    .. code-block:: puppet
+
+        include ::example
+
+    Where func is ``include``, and args is ``[::example]``
+    """
+
+    func: Puppet
+    args: list[Puppet]
+
+
+@dataclass
+class PuppetResourceDefaults(Puppet):
+    """
+    Default values for a resource.
+
+    .. code-block:: puppet
+
+        File {
+            x => 10,
+        }
+    """
+
+    type: Puppet
+    ops: list[PuppetInstanciationParameter]
+
+
+@dataclass
+class PuppetResourceOverride(Puppet):
+    """
+    A resource override.
+
+    .. code-block:: puppet
+
+        File["name"] {
+            x => 10,
+        }
+    """
+
+    resource: Puppet
+    ops: list[PuppetInstanciationParameter]
+
+
+@dataclass
+class PuppetDeclaration(Puppet):
+    """A stand-alone variable declaration."""
+
+    k: Puppet  # PuppetVar
+    v: Puppet
+
+
+@dataclass
+class PuppetSelector(Puppet):
+    """
+    A puppet selector.
+
+    .. code-block:: puppet
+
+        resource ? {
+            case_match => case_body,
+        }
+    """
+
+    resource: Puppet
+    cases: list[tuple[Puppet, Puppet]]
+
+
+@dataclass
+class PuppetBlock(Puppet):
+    """Used if multiple top level items exists."""
+
+    entries: list[Puppet]
+
+
+@dataclass
+class PuppetNode(Puppet):
+    """
+    A node declaration.
+
+    Used with an ENC is not in use,
+
+    .. code-block:: puppet
+
+        node 'host.example.com' {
+            include ::profiles::example
+        }
+    """
+
+    matches: list[Puppet]
+    body: list[Puppet]
+
+
+@dataclass
+class PuppetParseError(Puppet):
+    """Anything we don't know how to handle."""
+
+    x: Any = None
+
+    def serialize(self, indent: int, **_: Any) -> str:  # noqa: D102
+        return f'INVALID INPUT: {repr(self.x)}'
+
+# ----------------------------------------------------------------------
+
+
+def parse_hash_entry(data: list[Any]) -> HashEntry:
+    """Parse a single hash entry."""
+    match data:
+        case [_, key, value]:
+            return HashEntry(k=build_ast(key), v=build_ast(value))
+        case _:
+            raise ValueError(f'Not a hash entry {data}')
+
+
+def parse_puppet_declaration_params(data: dict[str, dict[str, Any]]) \
+        -> list[PuppetDeclarationParameter]:
+    """Parse a complete set of parameters."""
+    parameters = []
+
+    for name, definition in data.items():
+        type: Optional[Puppet] = None
+        value: Optional[Puppet] = None
+        if t := definition.get('type'):
+            type = build_ast(t)
+        if v := definition.get('value'):
+            value = build_ast(v)
+
+        parameters.append(PuppetDeclarationParameter(k=name, v=value, type=type))
+
+    return parameters
+
+
+def parse_puppet_instanciation_param(data: list[Any]) -> PuppetInstanciationParameter:
+    """Parse a single parameter."""
+    match data:
+        case [arrow, key, value]:
+            return PuppetInstanciationParameter(
+                k=key, v=build_ast(value), arrow=arrow)
+        case ['splat-hash', value]:
+            return PuppetInstanciationParameter('*', build_ast(value))
+        case _:
+            raise ValueError(f'Not an instanciation parameter {data}')
+
+
+# ----------------------------------------------------------------------
+
+
+def build_ast(form: Any) -> Puppet:
+    """
+    Print everything from a puppet parse tree.
+
+    :param from:
+        A puppet AST.
+    :param indent:
+        How many levels deep in indentation the code is.
+        Will get multiplied by the indentation width.
+    """
+    # items: list[Markup]
+    match form:
+        case None:  return PuppetLiteral('undef')  # noqa: E272
+        case True:  return PuppetLiteral('true')  # noqa: E272
+        case False: return PuppetLiteral('false')
+        case ['default']: return PuppetKeyword('default')
+
+        case ['access', how, *args]:
+            return PuppetAccess(how=build_ast(how),
+                                args=[build_ast(arg) for arg in args],
+                                )
+
+        case [('and' | 'or' | 'in' | '!=' | '+' | '-' | '*' | '%'
+                | '<<' | '>>' | '>=' | '<=' | '>' | '<' | '/' | '=='
+                | '=~' | '!~' | '~>' | '->' | '.') as op,
+              a, b]:
+            return PuppetBinaryOperator(
+                    op=op,
+                    lhs=build_ast(a),
+                    rhs=build_ast(b),
+                    )
+
+        case [('!' | '-') as op, x]:
+            return PuppetUnaryOperator(op=op, x=build_ast(x))
+
+        case ['array', *items]:
+            return PuppetArray([build_ast(x) for x in items])
+
+        case ['call', {'functor': func, 'args': args}]:
+            return PuppetCall(
+                build_ast(func),
+                [build_ast(x) for x in args])
+
+        case ['call-method', {'functor': func, 'args': args, 'block': block}]:
+            return PuppetCallMethod(func=build_ast(func),
+                                    args=[build_ast(x) for x in args],
+                                    block=build_ast(block))
+
+        case ['call-method', {'functor': func, 'args': args}]:
+            return PuppetCallMethod(func=build_ast(func),
+                                    args=[build_ast(x) for x in args])
+
+        case ['case', test, forms]:
+            cases = []
+            for form in forms:
+                cases.append((
+                    [build_ast(x) for x in form['when']],
+                    [build_ast(x) for x in form['then']]))
+
+            return PuppetCase(build_ast(test), cases)
+
+        case [('class' | 'define' | 'function') as what, {'name': name, **rest}]:
+            cls = {
+                'class': PuppetClass,
+                'define': PuppetDefine,
+                'function': PuppetFunction,
+            }[what]
+
+            args = {'name': name}
+
+            if p := rest.get('params'):
+                args['params'] = parse_puppet_declaration_params(p)
+
+            if b := rest.get('body'):
+                args['body'] = [build_ast(x) for x in b]
+
+            # This is only valid for 'function', and simply will
+            # never be true for the other cases.
+            if r := rest.get('returns'):
+                args['returns'] = build_ast(r)
+
+            return cls(**args)
+
+        case ['concat', *parts]:
+            return PuppetConcat([build_ast(p) for p in parts])
+
+        case ['collect', {'type': t, 'query': q}]:
+            return PuppetCollect(build_ast(t),
+                                 build_ast(q))
+
+        case ['exported-query']:
+            return PuppetExportedQuery()
+
+        case ['exported-query', arg]:
+            return PuppetExportedQuery(build_ast(arg))
+
+        case ['hash']:
+            return PuppetHash()
+        case ['hash', *hash]:
+            return PuppetHash([parse_hash_entry(e) for e in hash])
+
+        # Parts can NEVER be empty, since that case wouldn't generate
+        # a concat element, but a "plain" text element
+        case ['heredoc', {'syntax': syntax, 'text': ['concat', *parts]}]:
+            return PuppetHeredoc([build_ast(p) for p in parts], syntax=syntax)
+
+        case ['heredoc', {'text': ['concat', *parts]}]:
+            return PuppetHeredoc([build_ast(p) for p in parts])
+
+        case ['heredoc', {'syntax': syntax, 'text': text}]:
+            return PuppetLiteralHeredoc(text, syntax=syntax)
+
+        case ['heredoc', {'text': text}]:
+            return PuppetLiteralHeredoc(text)
+
+        # Non-literal part of a string or heredoc with interpolation
+        case ['str', form]:
+            return build_ast(form)
+
+        case ['if', {'test': test, **rest}]:
+            consequent = []
+            alternative = None
+            if then := rest.get('then'):
+                consequent = [build_ast(x) for x in then]
+            if els := rest.get('else'):
+                alternative = [build_ast(x) for x in els]
+            return PuppetIf(build_ast(test), consequent, alternative)
+
+        case ['unless', {'test': test, 'then': forms}]:
+            return PuppetUnless(build_ast(test), [build_ast(x) for x in forms])
+        case ['unless', {'test': test}]:
+            return PuppetUnless(build_ast(test), [])
+
+        case ['invoke', {'functor': func, 'args': args}]:
+            return PuppetInvoke(build_ast(func), [build_ast(x) for x in args])
+
+        case ['nop']: return PuppetNop()
+
+        case ['lambda', {'params': params, 'body': body}]:
+            return PuppetLambda(params=parse_puppet_declaration_params(params),
+                                body=[build_ast(x) for x in body])
+
+        case ['lambda', {'body': body}]:
+            return PuppetLambda([], [build_ast(x) for x in body])
+
+        case ['paren', form]:
+            return PuppetParenthesis(build_ast(form))
+
+        # Qualified name and Qualified resource?
+        case ['qn', x]: return PuppetQn(x)
+        case ['qr', x]: return PuppetQr(x)
+
+        case ['var', x]: return PuppetVar(x)
+
+        case ['regexp', s]: return PuppetRegex(s)
+
+        case ['resource', {'type': t, 'bodies': bodies}]:
+            return PuppetResource(
+                    build_ast(t),
+                    [(build_ast(body['title']),
+                      [parse_puppet_instanciation_param(x) for x in body['ops']])
+                     for body in bodies])
+
+        case ['resource-defaults', {'type': t, 'ops': ops}]:
+            return PuppetResourceDefaults(
+                    build_ast(t),
+                    [parse_puppet_instanciation_param(x) for x in ops])
+
+        case ['resource-override', {'resources': resources, 'ops': ops}]:
+            return PuppetResourceOverride(
+                    build_ast(resources),
+                    [parse_puppet_instanciation_param(x) for x in ops])
+
+        case ['virtual-query']: return PuppetVirtualQuery()
+        case ['virtual-query', q]: return PuppetVirtualQuery(build_ast(q))
+
+        case ['=', field, value]:
+            return PuppetDeclaration(k=build_ast(field), v=build_ast(value))
+
+        case ['?', condition, cases]:
+            cases_ = []
+            for case in cases:
+                match case:
+                    case [_, lhs, rhs]:
+                        cases_.append((build_ast(lhs),
+                                       build_ast(rhs)))
+                    case _:
+                        raise ValueError(f"Unexepcted '?' form: {case}")
+            return PuppetSelector(build_ast(condition), cases_)
+
+        case ['block', *items]:
+            return PuppetBlock([build_ast(x) for x in items])
+
+        case ['node', {'matches': matches, 'body': body}]:
+            return PuppetNode([build_ast(x) for x in matches],
+                              [build_ast(x) for x in body])
+
+        case str(s):
+            return PuppetString(s)
+
+        case int(x): return PuppetNumber(x)
+        case float(x): return PuppetNumber(x)
+
+        case default:
+            logger.warning("Unhandled item: %a", default)
+            return PuppetParseError(default)
diff --git a/muppet/puppet/format/__init__.py b/muppet/puppet/format/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..d40f9b4252cdd252593b75dadd4673269d422447
--- /dev/null
+++ b/muppet/puppet/format/__init__.py
@@ -0,0 +1,13 @@
+"""Fromat Puppet AST's into something useful."""
+
+from .base import Serializer
+from muppet.puppet.ast import Puppet
+from typing import TypeVar
+
+
+T = TypeVar('T')
+
+
+def serialize(ast: Puppet, serializer: type[Serializer[T]]) -> T:
+    """Run the given serializer on the given data."""
+    return serializer().serialize(ast, 0)
diff --git a/muppet/puppet/format/base.py b/muppet/puppet/format/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..9afad2b92e0bae75a79db04ba44fe332b199a6ea
--- /dev/null
+++ b/muppet/puppet/format/base.py
@@ -0,0 +1,296 @@
+"""Base class for serializing AST's into something useful."""
+
+from typing import (
+    TypeVar,
+    Generic,
+    final,
+)
+import logging
+
+from muppet.puppet.ast import (
+    Puppet,
+
+    PuppetLiteral, PuppetAccess, PuppetBinaryOperator,
+    PuppetUnaryOperator, PuppetArray, PuppetCallMethod,
+    PuppetCase, PuppetDeclarationParameter,
+    PuppetInstanciationParameter, PuppetClass, PuppetConcat,
+    PuppetCollect, PuppetIf, PuppetUnless, PuppetKeyword,
+    PuppetExportedQuery, PuppetVirtualQuery, PuppetFunction,
+    PuppetHash, PuppetHeredoc, PuppetLiteralHeredoc, PuppetVar,
+    PuppetLambda,  PuppetQn, PuppetQr, PuppetRegex,
+    PuppetResource, PuppetDefine, PuppetString,
+    PuppetNumber, PuppetInvoke, PuppetResourceDefaults,
+    PuppetResourceOverride, PuppetDeclaration, PuppetSelector,
+    PuppetBlock, PuppetNode,
+    PuppetCall, PuppetParenthesis, PuppetNop,
+
+    # HashEntry,
+    # PuppetParseError,
+)
+
+
+T = TypeVar('T')
+logger = logging.getLogger(__name__)
+
+
+class Serializer(Generic[T]):
+    """
+    Base class for serialization.
+
+    the ``serialize`` method dispatches depending on the type of the argument,
+    and should be called recursively when needed.
+
+    All other methods implement the actual serialization, and MUST be extended
+    by each instance.
+    """
+
+    @classmethod
+    def _puppet_literal(cls, it: PuppetLiteral, indent: int) -> T:
+        raise NotImplementedError("puppet_literal must be implemented by subclass")
+
+    @classmethod
+    def _puppet_access(cls, it: PuppetAccess, indent: int) -> T:
+        raise NotImplementedError("puppet_access must be implemented by subclass")
+
+    @classmethod
+    def _puppet_binary_operator(cls, it: PuppetBinaryOperator, indent: int) -> T:
+        raise NotImplementedError("puppet_binary_operator must be implemented by subclass")
+
+    @classmethod
+    def _puppet_unary_operator(cls, it: PuppetUnaryOperator, indent: int) -> T:
+        raise NotImplementedError("puppet_unary_operator must be implemented by subclass")
+
+    @classmethod
+    def _puppet_array(cls, it: PuppetArray, indent: int) -> T:
+        raise NotImplementedError("puppet_array must be implemented by subclass")
+
+    @classmethod
+    def _puppet_call(cls, it: PuppetCall, indent: int) -> T:
+        raise NotImplementedError("puppet_call must be implemented by subclass")
+
+    @classmethod
+    def _puppet_call_method(cls, it: PuppetCallMethod, indent: int) -> T:
+        raise NotImplementedError("puppet_call_method must be implemented by subclass")
+
+    @classmethod
+    def _puppet_case(cls, it: PuppetCase, indent: int) -> T:
+        raise NotImplementedError("puppet_case must be implemented by subclass")
+
+    @classmethod
+    def _puppet_declaration_parameter(cls, it: PuppetDeclarationParameter, indent: int) -> T:
+        raise NotImplementedError("puppet_declaration_parameter must be implemented by subclass")
+
+    @classmethod
+    def _puppet_instanciation_parameter(cls, it: PuppetInstanciationParameter, indent: int) -> T:
+        raise NotImplementedError("puppet_instanciation_parameter must be implemented by subclass")
+
+    @classmethod
+    def _puppet_class(cls, it: PuppetClass, indent: int) -> T:
+        raise NotImplementedError("puppet_class must be implemented by subclass")
+
+    @classmethod
+    def _puppet_concat(cls, it: PuppetConcat, indent: int) -> T:
+        raise NotImplementedError("puppet_concat must be implemented by subclass")
+
+    @classmethod
+    def _puppet_collect(cls, it: PuppetCollect, indent: int) -> T:
+        raise NotImplementedError("puppet_collect must be implemented by subclass")
+
+    @classmethod
+    def _puppet_if(cls, it: PuppetIf, indent: int) -> T:
+        raise NotImplementedError("puppet_if must be implemented by subclass")
+
+    @classmethod
+    def _puppet_unless(cls, it: PuppetUnless, indent: int) -> T:
+        raise NotImplementedError("puppet_unless must be implemented by subclass")
+
+    @classmethod
+    def _puppet_keyword(cls, it: PuppetKeyword, indent: int) -> T:
+        raise NotImplementedError("puppet_keyword must be implemented by subclass")
+
+    @classmethod
+    def _puppet_exported_query(cls, it: PuppetExportedQuery, indent: int) -> T:
+        raise NotImplementedError("puppet_exported_query must be implemented by subclass")
+
+    @classmethod
+    def _puppet_virtual_query(cls, it: PuppetVirtualQuery, indent: int) -> T:
+        raise NotImplementedError("puppet_virtual_query must be implemented by subclass")
+
+    @classmethod
+    def _puppet_function(cls, it: PuppetFunction, indent: int) -> T:
+        raise NotImplementedError("puppet_function must be implemented by subclass")
+
+    @classmethod
+    def _puppet_hash(cls, it: PuppetHash, indent: int) -> T:
+        raise NotImplementedError("puppet_hash must be implemented by subclass")
+
+    @classmethod
+    def _puppet_heredoc(cls, it: PuppetHeredoc, indent: int) -> T:
+        raise NotImplementedError("puppet_heredoc must be implemented by subclass")
+
+    @classmethod
+    def _puppet_literal_heredoc(cls, it: PuppetLiteralHeredoc, indent: int) -> T:
+        raise NotImplementedError("puppet_literal_heredoc must be implemented by subclass")
+
+    @classmethod
+    def _puppet_var(cls, it: PuppetVar, indent: int) -> T:
+        raise NotImplementedError("puppet_var must be implemented by subclass")
+
+    @classmethod
+    def _puppet_lambda(cls, it: PuppetLambda, indent: int) -> T:
+        raise NotImplementedError("puppet_lambda must be implemented by subclass")
+
+    @classmethod
+    def _puppet_qn(cls, it: PuppetQn, indent: int) -> T:
+        raise NotImplementedError("puppet_qn must be implemented by subclass")
+
+    @classmethod
+    def _puppet_qr(cls, it: PuppetQr, indent: int) -> T:
+        raise NotImplementedError("puppet_qr must be implemented by subclass")
+
+    @classmethod
+    def _puppet_regex(cls, it: PuppetRegex, indent: int) -> T:
+        raise NotImplementedError("puppet_regex must be implemented by subclass")
+
+    @classmethod
+    def _puppet_resource(cls, it: PuppetResource, indent: int) -> T:
+        raise NotImplementedError("puppet_resource must be implemented by subclass")
+
+    @classmethod
+    def _puppet_define(cls, it: PuppetDefine, indent: int) -> T:
+        raise NotImplementedError("puppet_define must be implemented by subclass")
+
+    @classmethod
+    def _puppet_string(cls, it: PuppetString, indent: int) -> T:
+        raise NotImplementedError("puppet_string must be implemented by subclass")
+
+    @classmethod
+    def _puppet_number(cls, it: PuppetNumber, indent: int) -> T:
+        raise NotImplementedError("puppet_number must be implemented by subclass")
+
+    @classmethod
+    def _puppet_invoke(cls, it: PuppetInvoke, indent: int) -> T:
+        raise NotImplementedError("puppet_invoke must be implemented by subclass")
+
+    @classmethod
+    def _puppet_resource_defaults(cls, it: PuppetResourceDefaults, indent: int) -> T:
+        raise NotImplementedError("puppet_resource_defaults must be implemented by subclass")
+
+    @classmethod
+    def _puppet_resource_override(cls, it: PuppetResourceOverride, indent: int) -> T:
+        raise NotImplementedError("puppet_resource_override must be implemented by subclass")
+
+    @classmethod
+    def _puppet_declaration(cls, it: PuppetDeclaration, indent: int) -> T:
+        raise NotImplementedError("puppet_declaration must be implemented by subclass")
+
+    @classmethod
+    def _puppet_selector(cls, it: PuppetSelector, indent: int) -> T:
+        raise NotImplementedError("puppet_selector must be implemented by subclass")
+
+    @classmethod
+    def _puppet_block(cls, it: PuppetBlock, indent: int) -> T:
+        raise NotImplementedError("puppet_block must be implemented by subclass")
+
+    @classmethod
+    def _puppet_node(cls, it: PuppetNode, indent: int) -> T:
+        raise NotImplementedError("puppet_node must be implemented by subclass")
+
+    @classmethod
+    def _puppet_parenthesis(cls, it: PuppetParenthesis, indent: int) -> T:
+        raise NotImplementedError("puppet_parenthesis must be implemented by subclass")
+
+    @classmethod
+    def _puppet_nop(cls, it: PuppetNop, indent: int) -> T:
+        raise NotImplementedError("puppet_nop must be implemented by subclass")
+
+    @final
+    @classmethod
+    def serialize(cls, form: Puppet, indent: int) -> T:
+        """Dispatch depending on type."""
+        match form:
+            case PuppetLiteral():
+                return cls._puppet_literal(form, indent)
+            case PuppetAccess():
+                return cls._puppet_access(form, indent)
+            case PuppetBinaryOperator():
+                return cls._puppet_binary_operator(form, indent)
+            case PuppetUnaryOperator():
+                return cls._puppet_unary_operator(form, indent)
+            case PuppetUnaryOperator():
+                return cls._puppet_unary_operator(form, indent)
+            case PuppetArray():
+                return cls._puppet_array(form, indent)
+            case PuppetCall():
+                return cls._puppet_call(form, indent)
+            case PuppetCallMethod():
+                return cls._puppet_call_method(form, indent)
+            case PuppetCase():
+                return cls._puppet_case(form, indent)
+            case PuppetDeclarationParameter():
+                return cls._puppet_declaration_parameter(form, indent)
+            case PuppetInstanciationParameter():
+                return cls._puppet_instanciation_parameter(form, indent)
+            case PuppetClass():
+                return cls._puppet_class(form, indent)
+            case PuppetConcat():
+                return cls._puppet_concat(form, indent)
+            case PuppetCollect():
+                return cls._puppet_collect(form, indent)
+            case PuppetIf():
+                return cls._puppet_if(form, indent)
+            case PuppetUnless():
+                return cls._puppet_unless(form, indent)
+            case PuppetKeyword():
+                return cls._puppet_keyword(form, indent)
+            case PuppetExportedQuery():
+                return cls._puppet_exported_query(form, indent)
+            case PuppetVirtualQuery():
+                return cls._puppet_virtual_query(form, indent)
+            case PuppetFunction():
+                return cls._puppet_function(form, indent)
+            case PuppetHash():
+                return cls._puppet_hash(form, indent)
+            case PuppetHeredoc():
+                return cls._puppet_heredoc(form, indent)
+            case PuppetLiteralHeredoc():
+                return cls._puppet_literal_heredoc(form, indent)
+            case PuppetVar():
+                return cls._puppet_var(form, indent)
+            case PuppetLambda():
+                return cls._puppet_lambda(form, indent)
+            case PuppetQn():
+                return cls._puppet_qn(form, indent)
+            case PuppetQr():
+                return cls._puppet_qr(form, indent)
+            case PuppetRegex():
+                return cls._puppet_regex(form, indent)
+            case PuppetResource():
+                return cls._puppet_resource(form, indent)
+            case PuppetDefine():
+                return cls._puppet_define(form, indent)
+            case PuppetString():
+                return cls._puppet_string(form, indent)
+            case PuppetNumber():
+                return cls._puppet_number(form, indent)
+            case PuppetInvoke():
+                return cls._puppet_invoke(form, indent)
+            case PuppetResourceDefaults():
+                return cls._puppet_resource_defaults(form, indent)
+            case PuppetResourceOverride():
+                return cls._puppet_resource_override(form, indent)
+            case PuppetDeclaration():
+                return cls._puppet_declaration(form, indent)
+            case PuppetSelector():
+                return cls._puppet_selector(form, indent)
+            case PuppetBlock():
+                return cls._puppet_block(form, indent)
+            case PuppetNode():
+                return cls._puppet_node(form, indent)
+            case PuppetParenthesis():
+                return cls._puppet_parenthesis(form, indent)
+            case PuppetNop():
+                return cls._puppet_nop(form, indent)
+            case _:
+                logger.warn("Unexpected form: %s", form)
+                raise ValueError(f'Unexpected: {form}')
diff --git a/muppet/puppet/format/html.py b/muppet/puppet/format/html.py
new file mode 100644
index 0000000000000000000000000000000000000000..9719b534910ec6d72f5fd36be7231a202c236a1b
--- /dev/null
+++ b/muppet/puppet/format/html.py
@@ -0,0 +1,549 @@
+"""
+Reserilaize AST as HTML.
+
+This is mostly an extension of the text formatter, but with some HTML
+tags inserted. This is also why the text module is imported.
+
+.. code-block:: html
+
+    <span class="{TYPE}">{BODY}</span>
+"""
+
+import re
+import logging
+from .base import Serializer
+from muppet.puppet.ast import (
+    PuppetLiteral, PuppetAccess, PuppetBinaryOperator,
+    PuppetUnaryOperator, PuppetArray, PuppetCallMethod,
+    PuppetCase, PuppetDeclarationParameter,
+    PuppetInstanciationParameter, PuppetClass, PuppetConcat,
+    PuppetCollect, PuppetIf, PuppetUnless, PuppetKeyword,
+    PuppetExportedQuery, PuppetVirtualQuery, PuppetFunction,
+    PuppetHash, PuppetHeredoc, PuppetLiteralHeredoc, PuppetVar,
+    PuppetLambda,  PuppetQn, PuppetQr, PuppetRegex,
+    PuppetResource, PuppetDefine, PuppetString,
+    PuppetNumber, PuppetInvoke, PuppetResourceDefaults,
+    PuppetResourceOverride, PuppetDeclaration, PuppetSelector,
+    PuppetBlock, PuppetNode,
+    PuppetCall, PuppetParenthesis, PuppetNop,
+
+    HashEntry,
+    # PuppetParseError,
+)
+import html
+from .text import (
+    override,
+    find_heredoc_delimiter,
+    ind,
+    string_width,
+)
+
+
+logger = logging.getLogger(__name__)
+
+
+def span(cls: str, content: str) -> str:
+    """Wrap content in a span, and escape content."""
+    return f'<span class="{cls}">{html.escape(content)}</span>'
+
+
+def literal(x: str) -> str:
+    """Tag string as a literal."""
+    return span("literal", x)
+
+
+def op(x: str) -> str:
+    """Tag string as an operator."""
+    return span("op", x)
+
+
+def keyword(x: str) -> str:
+    """Tag string as a keyword."""
+    return span("keyword", x)
+
+
+def var(x: str) -> str:
+    """Tag string as a variable."""
+    return span("var", x)
+
+
+def string(x: str) -> str:
+    """Tag strings as a string literal."""
+    return span("string", x)
+
+
+def number(x: str) -> str:
+    """Tag string as a number literal."""
+    return span("number", x)
+
+
+class HTMLFormatter(Serializer[str]):
+    """AST formatter returning source code."""
+
+    @classmethod
+    def format_declaration_parameter(
+            cls,
+            param: PuppetDeclarationParameter,
+            indent: int) -> str:
+        """Format a single declaration parameter."""
+        out: str = ''
+        if param.type:
+            out += f'{cls.serialize(param.type, indent + 1)} '
+        out += var(f'${param.k}')
+        if param.v:
+            out += f' = {cls.serialize(param.v, indent + 1)}'
+        return out
+
+    @classmethod
+    def format_declaration_parameters(
+            cls,
+            lst: list[PuppetDeclarationParameter],
+            indent: int) -> str:
+        """
+        Print declaration parameters.
+
+        This formats the parameters for class, resoruce, and function declarations.
+        """
+        if not lst:
+            return ''
+
+        out = ' (\n'
+        for param in lst:
+            out += ind(indent + 1) + cls.format_declaration_parameter(param, indent + 1) + ',\n'
+        out += ind(indent) + ')'
+        return out
+
+    @classmethod
+    def serialize_hash_entry(
+            cls,
+            entry: HashEntry,
+            indent: int) -> str:
+        """Return a hash entry as a string."""
+        return f'{cls.serialize(entry.k, indent + 1)} => {cls.serialize(entry.v, indent + 2)}'
+
+    @override
+    @classmethod
+    def _puppet_literal(cls, it: PuppetLiteral, indent: int) -> str:
+        return literal(it.literal)
+
+    @override
+    @classmethod
+    def _puppet_access(cls, it: PuppetAccess, indent: int) -> str:
+        args = ', '.join(cls.serialize(x, indent) for x in it.args)
+
+        return f'{cls.serialize(it.how, indent)}[{args}]'
+
+    @override
+    @classmethod
+    def _puppet_binary_operator(cls, it: PuppetBinaryOperator, indent: int) -> str:
+        out = cls.serialize(it.lhs, indent)
+        out += f' {op(it.op)} '
+        out += cls.serialize(it.rhs, indent)
+        return out
+
+    @override
+    @classmethod
+    def _puppet_unary_operator(cls, it: PuppetUnaryOperator, indent: int) -> str:
+        return f'{op(it.op)} {cls.serialize(it.x, indent)}'
+
+    @override
+    @classmethod
+    def _puppet_array(cls, it: PuppetArray, indent: int) -> str:
+        if not it.items:
+            return '[]'
+        else:
+            out = '[\n'
+            for item in it.items:
+                out += ind(indent + 1) + cls.serialize(item, indent + 2) + ',\n'
+            out += ind(indent) + ']'
+            return out
+
+    @override
+    @classmethod
+    def _puppet_call(cls, it: PuppetCall, indent: int) -> str:
+        args = ', '.join(cls.serialize(x, indent) for x in it.args)
+        return f'{cls.serialize(it.func, indent)}({args})'
+
+    @override
+    @classmethod
+    def _puppet_call_method(cls, it: PuppetCallMethod, indent: int) -> str:
+        out: str = cls.serialize(it.func, indent)
+
+        if it.args:
+            args = ', '.join(cls.serialize(x, indent) for x in it.args)
+            out += f' ({args})'
+
+        if it.block:
+            out += cls.serialize(it.block, indent)
+
+        return out
+
+    @override
+    @classmethod
+    def _puppet_case(cls, it: PuppetCase, indent: int) -> str:
+        out: str = f'{keyword("case")} {cls.serialize(it.test, indent)} {{\n'
+        for (when, body) in it.cases:
+            out += ind(indent + 1)
+            out += ', '.join(cls.serialize(x, indent + 1) for x in when)
+            out += ': {\n'
+            for item in body:
+                out += ind(indent + 2) + cls.serialize(item, indent + 2) + '\n'
+            out += ind(indent + 1) + '}\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_declaration_parameter(cls, it: PuppetDeclarationParameter, indent: int) -> str:
+        out: str = ''
+        if it.type:
+            out += f'{cls.serialize(it.type, indent + 1)} '
+        out += var(f'${it.k}')
+        if it.v:
+            out += f' = {cls.serialize(it.v, indent + 1)}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_instanciation_parameter(cls, it: PuppetInstanciationParameter, indent: int) -> str:
+        return f'{it.k} {it.arrow} {cls.serialize(it.v, indent)}'
+
+    @override
+    @classmethod
+    def _puppet_class(cls, it: PuppetClass, indent: int) -> str:
+        out: str = f'{keyword("class")} {it.name}'
+        if it.params:
+            out += cls.format_declaration_parameters(it.params, indent)
+
+        out += ' {\n'
+        for form in it.body:
+            out += ind(indent+1) + cls.serialize(form, indent+1) + '\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_concat(cls, it: PuppetConcat, indent: int) -> str:
+        out = '"'
+        for item in it.fragments:
+            match item:
+                case PuppetString(s):
+                    out += s
+                case PuppetVar(x):
+                    out += var(f"${{{x}}}")
+                case puppet:
+                    out += f"${{{cls.serialize(puppet, indent)}}}"
+        out += '"'
+        # Don't escape `out`, since it contains sub-expressions
+        return f'<span class="string">{out}</span>'
+
+    @override
+    @classmethod
+    def _puppet_collect(cls, it: PuppetCollect, indent: int) -> str:
+        return f'{cls.serialize(it.type, indent)} {cls.serialize(it.query, indent + 1)}'
+
+    @override
+    @classmethod
+    def _puppet_if(cls, it: PuppetIf, indent: int) -> str:
+        out: str = f'{keyword("if")} {cls.serialize(it.condition, indent)} {{\n'
+        for item in it.consequent:
+            out += ind(indent+1) + cls.serialize(item, indent+1) + '\n'
+        out += ind(indent) + '}'
+        if alts := it.alternative:
+            # TODO elsif
+            out += f' {keyword("else")} {{\n'
+            for item in alts:
+                out += ind(indent+1) + cls.serialize(item, indent+1) + '\n'
+            out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_unless(cls, it: PuppetUnless, indent: int) -> str:
+        out: str = f'{keyword("unless")} {cls.serialize(it.condition, indent)} {{\n'
+        for item in it.consequent:
+            out += ind(indent+1) + cls.serialize(item, indent+1) + '\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_keyword(cls, it: PuppetKeyword, indent: int) -> str:
+        return it.name
+
+    @override
+    @classmethod
+    def _puppet_exported_query(cls, it: PuppetExportedQuery, indent: int) -> str:
+        out: str = op('<<|')
+        if f := it.filter:
+            out += ' ' + cls.serialize(f, indent)
+        out += ' ' + op('|>>')
+        return out
+
+    @override
+    @classmethod
+    def _puppet_virtual_query(cls, it: PuppetVirtualQuery, indent: int) -> str:
+        out: str = op('<|')
+        if f := it.q:
+            out += ' ' + cls.serialize(f, indent)
+        out += ' ' + op('|>')
+        return out
+
+    @override
+    @classmethod
+    def _puppet_function(cls, it: PuppetFunction, indent: int) -> str:
+        out: str = f'{keyword("function")} {it.name}'
+        if it.params:
+            out += cls.format_declaration_parameters(it.params, indent)
+
+        if ret := it.returns:
+            out += f' {op(">>")} {cls.serialize(ret, indent + 1)}'
+
+        out += ' {\n'
+        for item in it.body:
+            out += ind(indent + 1) + cls.serialize(item, indent + 1) + '\n'
+        out += ind(indent) + '}'
+
+        return out
+
+    @override
+    @classmethod
+    def _puppet_hash(cls, it: PuppetHash, indent: int) -> str:
+        if not it.entries:
+            return '{}'
+        else:
+            out: str = '{\n'
+            for item in it.entries:
+                out += ind(indent + 1)
+                out += cls.serialize_hash_entry(item, indent + 1)
+                out += ',\n'
+            out += ind(indent) + '}'
+            return out
+
+    @override
+    @classmethod
+    def _puppet_heredoc(cls, it: PuppetHeredoc, indent: int) -> str:
+        """
+        Serialize heredoc with interpolation.
+
+        The esacpes $, r, and t are always added and un-escaped,
+        while the rest are left as is, since they work fine in the literal.
+        """
+        syntax: str = ''
+        if it.syntax:
+            syntax = f':{it.syntax}'
+
+        # TODO find delimiter
+        body = ''
+        for frag in it.fragments:
+            match frag:
+                case PuppetString(s):
+                    # \r, \t, \, $
+                    e = re.sub('[\r\t\\\\$]', lambda m: {
+                        '\r': r'\r',
+                        '\t': r'\t',
+                        }.get(m[0], '\\' + m[0]), s)
+                    body += e
+                case PuppetVar(x):
+                    body += f'${{{x}}}'
+                case p:
+                    body += cls.serialize(p, indent + 2)
+
+        # Check if string ends with a newline
+        match it.fragments[-1]:
+            case PuppetString(s) if s.endswith('\n'):
+                eol_marker = ''
+                body = body[:-1]
+            case _:
+                eol_marker = '-'
+
+        # Aligning this to the left column is ugly, but saves us from
+        # parsing newlines in the actual string
+        return f'@("EOF"{syntax}/$rt)\n{body}\n|{eol_marker} EOF'
+
+    @override
+    @classmethod
+    def _puppet_literal_heredoc(cls, it: PuppetLiteralHeredoc, indent: int) -> str:
+        syntax: str = ''
+        if it.syntax:
+            syntax = f':{it.syntax}'
+
+        out: str = ''
+        if not it.content:
+            out += f'@(EOF{syntax})\n'
+            out += ind(indent) + '|- EOF'
+            return out
+
+        delimiter = find_heredoc_delimiter(it.content)
+
+        out += f'@({delimiter}{syntax})\n'
+
+        lines = it.content.split('\n')
+        eol: bool = False
+        if lines[-1] == '':
+            lines = lines[:-1]  # Remove last
+            eol = True
+
+        for line in lines:
+            out += ind(indent + 1) + line + '\n'
+
+        out += ind(indent + 1) + '|'
+
+        if not eol:
+            out += '-'
+
+        out += ' ' + delimiter
+
+        return out
+
+    @override
+    @classmethod
+    def _puppet_var(cls, it: PuppetVar, indent: int) -> str:
+        return var(f'${it.name}')
+
+    @override
+    @classmethod
+    def _puppet_lambda(cls, it: PuppetLambda, indent: int) -> str:
+        out: str = '|'
+        for item in it.params:
+            out += 'TODO'
+        out += '| {'
+        for form in it.body:
+            out += ind(indent + 1) + cls.serialize(form, indent + 1)
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_qn(cls, it: PuppetQn, indent: int) -> str:
+        return span('qn', it.name)
+
+    @override
+    @classmethod
+    def _puppet_qr(cls, it: PuppetQr, indent: int) -> str:
+        return span('qn', it.name)
+
+    @override
+    @classmethod
+    def _puppet_regex(cls, it: PuppetRegex, indent: int) -> str:
+        return span('regex', f'/{it.s}/')
+
+    @override
+    @classmethod
+    def _puppet_resource(cls, it: PuppetResource, indent: int) -> str:
+        out = f'{cls.serialize(it.type, indent + 1)} {{'
+        match it.bodies:
+            case [(name, values)]:
+                out += f' {cls.serialize(name, indent + 1)}:\n'
+                for v in values:
+                    out += ind(indent + 1) + cls.serialize(v, indent + 2) + ',\n'
+            case bodies:
+                out += '\n'
+                for (name, values) in bodies:
+                    out += f'{ind(indent + 1)}{cls.serialize(name, indent + 1)}:\n'
+                    for v in values:
+                        out += ind(indent + 2) + cls.serialize(v, indent + 3) + ',\n'
+                    out += ind(indent + 2) + ';\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_define(cls, it: PuppetDefine, indent: int) -> str:
+        out: str = f'{keyword("define")} {it.name}'
+        if params := it.params:
+            out += cls.format_declaration_parameters(params, indent)
+
+        out += ' {\n'
+        for form in it.body:
+            out += ind(indent + 1) + cls.serialize(form, indent + 1) + '\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_string(cls, it: PuppetString, indent: int) -> str:
+        # TODO escaping
+        return string(f"'{it.s}'")
+
+    @override
+    @classmethod
+    def _puppet_number(cls, it: PuppetNumber, indent: int) -> str:
+        return number(str(it.x))
+
+    @override
+    @classmethod
+    def _puppet_invoke(cls, it: PuppetInvoke, indent: int) -> str:
+        invoker = f'{cls.serialize(it.func, indent)}'
+        out: str = invoker
+        template: str
+        if invoker == keyword('include'):
+            template = ' {}'
+        else:
+            template = '({})'
+        out += template.format(', '.join(cls.serialize(x, indent + 1) for x in it.args))
+        return out
+
+    @override
+    @classmethod
+    def _puppet_resource_defaults(cls, it: PuppetResourceDefaults, indent: int) -> str:
+        out: str = f'{cls.serialize(it.type, indent)} {{\n'
+        for op in it.ops:
+            out += ind(indent + 1) + cls.serialize(op, indent + 1) + ',\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_resource_override(cls, it: PuppetResourceOverride, indent: int) -> str:
+        out: str = f'{cls.serialize(it.resource, indent)} {{\n'
+        for op in it.ops:
+            out += ind(indent + 1) + cls.serialize(op, indent + 1) + ',\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_declaration(cls, it: PuppetDeclaration, indent: int) -> str:
+        return f'{cls.serialize(it.k, indent)} = {cls.serialize(it.v, indent)}'
+
+    @override
+    @classmethod
+    def _puppet_selector(cls, it: PuppetSelector, indent: int) -> str:
+        out: str = f'{cls.serialize(it.resource, indent)} ? {{\n'
+        rendered_cases = [(cls.serialize(test, indent + 1),
+                           cls.serialize(body, indent + 2))
+                          for (test, body) in it.cases]
+        case_width = max(string_width(c[0], indent + 1) for c in rendered_cases)
+        for (test, body) in rendered_cases:
+            out += ind(indent + 1) + test
+            out += ' ' * (case_width - string_width(test, indent + 1))
+            out += f' => {body},\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_block(cls, it: PuppetBlock, indent: int) -> str:
+        return '\n'.join(cls.serialize(x, indent) for x in it.entries)
+
+    @override
+    @classmethod
+    def _puppet_node(cls, it: PuppetNode, indent: int) -> str:
+        out: str = keyword('node') + ' '
+        out += ', '.join(cls.serialize(x, indent) for x in it.matches)
+        out += ' {\n'
+        for item in it.body:
+            out += ind(indent + 1) + cls.serialize(item, indent + 1) + '\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_parenthesis(cls, it: PuppetParenthesis, indent: int) -> str:
+        return f'({cls.serialize(it.form, indent)})'
+
+    @override
+    @classmethod
+    def _puppet_nop(cls, it: PuppetNop, indent: int) -> str:
+        return ''
diff --git a/muppet/puppet/format/text.py b/muppet/puppet/format/text.py
new file mode 100644
index 0000000000000000000000000000000000000000..a3e772dfcba5c2a3073e79c7becf6b85626e6024
--- /dev/null
+++ b/muppet/puppet/format/text.py
@@ -0,0 +1,594 @@
+"""
+AST serializer returning source code.
+
+Can be used as a "bad" pretty printer (bad, since comments are discarded).
+"""
+
+import re
+import uuid
+import logging
+from .base import Serializer
+from muppet.puppet.ast import (
+    PuppetLiteral, PuppetAccess, PuppetBinaryOperator,
+    PuppetUnaryOperator, PuppetArray, PuppetCallMethod,
+    PuppetCase, PuppetDeclarationParameter,
+    PuppetInstanciationParameter, PuppetClass, PuppetConcat,
+    PuppetCollect, PuppetIf, PuppetUnless, PuppetKeyword,
+    PuppetExportedQuery, PuppetVirtualQuery, PuppetFunction,
+    PuppetHash, PuppetHeredoc, PuppetLiteralHeredoc, PuppetVar,
+    PuppetLambda,  PuppetQn, PuppetQr, PuppetRegex,
+    PuppetResource, PuppetDefine, PuppetString,
+    PuppetNumber, PuppetInvoke, PuppetResourceDefaults,
+    PuppetResourceOverride, PuppetDeclaration, PuppetSelector,
+    PuppetBlock, PuppetNode,
+    PuppetCall, PuppetParenthesis, PuppetNop,
+
+    HashEntry,
+    # PuppetParseError,
+)
+
+from typing import (
+    TypeVar,
+    Callable,
+)
+
+
+F = TypeVar('F', bound=Callable[..., object])
+
+# TODO replace this decorator with
+# from typing import override
+# once the target python version is changed to 3.12
+
+
+def override(f: F) -> F:
+    """
+    Return function unchanged.
+
+    Placeholder @override annotator if the actual annotation isn't
+    implemented in the current python version.
+    """
+    return f
+
+
+logger = logging.getLogger(__name__)
+
+
+def find_heredoc_delimiter(
+        source: str,
+        options: list[str] = ['EOF', 'EOL', 'STR', 'END'],
+        ) -> str:
+    """
+    Find a suitable heredoc delimiter for the given string.
+
+    Heredoc's are delimited like
+
+    .. code-block:: puppet
+
+        @(EOF)
+          Some text
+          here
+          | EOF
+
+    This looks through the text, and finds a suitable marker (``EOF``
+    here) which isn't present in the text. It first tries each given
+    option, and then randomizes until it finds one.
+
+    :param source:
+        String to search for collisions.
+    :param options:
+        Prefered delimiters, with descending priority.
+    :returns:
+        A string like EOF, guaranteed to not be present in the source.
+    """
+    for option in options:
+        if option not in source:
+            return option
+
+    while True:
+        delim = uuid.uuid4().hex
+        if delim not in source:
+            return delim
+
+
+def ind(level: int) -> str:
+    """Return indentation string of given depth."""
+    return ' ' * level * 2
+
+
+def string_width(s: str, indent: int) -> int:
+    """
+    Return the width of a rendered puppet expression.
+
+    In a perfect world, this would return the rendered width of an
+    expression, as in the total amount of columns between its leftmost
+    and rightmost (printed) character. For example, the case below
+    should return 4, and any extra highlight to the left would be
+    discarded according to ``indent``.
+
+    .. todo::
+
+        The smart width "algorithm" is currently not implemented,
+        instead, the length of the string is returned.
+
+    .. code-block:: puppet
+
+        [
+          1,
+          2,
+        ]
+
+
+    :param s:
+        The rendered puppet expression
+    :param indent:
+        The indentation level which was used when creating the string.
+    """
+    return len(s)
+
+
+class TextFormatter(Serializer[str]):
+    """AST formatter returning source code."""
+
+    @classmethod
+    def format_declaration_parameter(
+            cls,
+            param: PuppetDeclarationParameter,
+            indent: int) -> str:
+        """Format a single declaration parameter."""
+        out: str = ''
+        if param.type:
+            out += f'{cls.serialize(param.type, indent + 1)} '
+        out += f'${param.k}'
+        if param.v:
+            out += f' = {cls.serialize(param.v, indent + 1)}'
+        return out
+
+    @classmethod
+    def format_declaration_parameters(
+            cls,
+            lst: list[PuppetDeclarationParameter],
+            indent: int) -> str:
+        """
+        Print declaration parameters.
+
+        This formats the parameters for class, resoruce, and function declarations.
+        """
+        if not lst:
+            return ''
+
+        out = ' (\n'
+        for param in lst:
+            out += ind(indent + 1) + cls.format_declaration_parameter(param, indent + 1) + ',\n'
+        out += ind(indent) + ')'
+        return out
+
+    @classmethod
+    def serialize_hash_entry(
+            cls,
+            entry: HashEntry,
+            indent: int) -> str:
+        """Return a hash entry as a string."""
+        return f'{cls.serialize(entry.k, indent + 1)} => {cls.serialize(entry.v, indent + 2)}'
+
+    @override
+    @classmethod
+    def _puppet_literal(cls, it: PuppetLiteral, indent: int) -> str:
+        return it.literal
+
+    @override
+    @classmethod
+    def _puppet_access(cls, it: PuppetAccess, indent: int) -> str:
+        args = ', '.join(cls.serialize(x, indent) for x in it.args)
+
+        return f'{cls.serialize(it.how, indent)}[{args}]'
+
+    @override
+    @classmethod
+    def _puppet_binary_operator(cls, it: PuppetBinaryOperator, indent: int) -> str:
+        return f'{cls.serialize(it.lhs, indent)} {it.op} {cls.serialize(it.rhs, indent)}'
+
+    @override
+    @classmethod
+    def _puppet_unary_operator(cls, it: PuppetUnaryOperator, indent: int) -> str:
+        return f'{it.op} {cls.serialize(it.x, indent)}'
+
+    @override
+    @classmethod
+    def _puppet_array(cls, it: PuppetArray, indent: int) -> str:
+        if not it.items:
+            return '[]'
+        else:
+            out = '[\n'
+            for item in it.items:
+                out += ind(indent + 1) + cls.serialize(item, indent + 2) + ',\n'
+            out += ind(indent) + ']'
+            return out
+
+    @override
+    @classmethod
+    def _puppet_call(cls, it: PuppetCall, indent: int) -> str:
+        args = ', '.join(cls.serialize(x, indent) for x in it.args)
+        return f'{cls.serialize(it.func, indent)}({args})'
+
+    @override
+    @classmethod
+    def _puppet_call_method(cls, it: PuppetCallMethod, indent: int) -> str:
+        out: str = cls.serialize(it.func, indent)
+
+        if it.args:
+            args = ', '.join(cls.serialize(x, indent) for x in it.args)
+            out += f' ({args})'
+
+        if it.block:
+            out += cls.serialize(it.block, indent)
+
+        return out
+
+    @override
+    @classmethod
+    def _puppet_case(cls, it: PuppetCase, indent: int) -> str:
+        out: str = f'case {cls.serialize(it.test, indent)} {{\n'
+        for (when, body) in it.cases:
+            out += ind(indent + 1)
+            out += ', '.join(cls.serialize(x, indent + 1) for x in when)
+            out += ': {\n'
+            for item in body:
+                out += ind(indent + 2) + cls.serialize(item, indent + 2) + '\n'
+            out += ind(indent + 1) + '}\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_declaration_parameter(cls, it: PuppetDeclarationParameter, indent: int) -> str:
+        out: str = ''
+        if it.type:
+            out += f'{cls.serialize(it.type, indent + 1)} '
+        out += f'${it.k}'
+        if it.v:
+            out += f' = {cls.serialize(it.v, indent + 1)}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_instanciation_parameter(cls, it: PuppetInstanciationParameter, indent: int) -> str:
+        return f'{it.k} {it.arrow} {cls.serialize(it.v, indent)}'
+
+    @override
+    @classmethod
+    def _puppet_class(cls, it: PuppetClass, indent: int) -> str:
+        out: str = f'class {it.name}'
+        if it.params:
+            out += cls.format_declaration_parameters(it.params, indent)
+
+        out += ' {\n'
+        for form in it.body:
+            out += ind(indent+1) + cls.serialize(form, indent+1) + '\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_concat(cls, it: PuppetConcat, indent: int) -> str:
+        out = '"'
+        for item in it.fragments:
+            match item:
+                case PuppetString(s):
+                    out += s
+                case PuppetVar(x):
+                    out += f"${{{x}}}"
+                case puppet:
+                    out += f"${{{cls.serialize(puppet, indent)}}}"
+        out += '"'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_collect(cls, it: PuppetCollect, indent: int) -> str:
+        return f'{cls.serialize(it.type, indent)} {cls.serialize(it.query, indent + 1)}'
+
+    @override
+    @classmethod
+    def _puppet_if(cls, it: PuppetIf, indent: int) -> str:
+        out: str = f'if {cls.serialize(it.condition, indent)} {{\n'
+        for item in it.consequent:
+            out += ind(indent+1) + cls.serialize(item, indent+1) + '\n'
+        out += ind(indent) + '}'
+        if alts := it.alternative:
+            # TODO elsif
+            out += ' else {\n'
+            for item in alts:
+                out += ind(indent+1) + cls.serialize(item, indent+1) + '\n'
+            out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_unless(cls, it: PuppetUnless, indent: int) -> str:
+        out: str = f'unless {cls.serialize(it.condition, indent)} {{\n'
+        for item in it.consequent:
+            out += ind(indent+1) + cls.serialize(item, indent+1) + '\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_keyword(cls, it: PuppetKeyword, indent: int) -> str:
+        return it.name
+
+    @override
+    @classmethod
+    def _puppet_exported_query(cls, it: PuppetExportedQuery, indent: int) -> str:
+        out: str = '<<|'
+        if f := it.filter:
+            out += ' ' + cls.serialize(f, indent)
+        out += ' |>>'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_virtual_query(cls, it: PuppetVirtualQuery, indent: int) -> str:
+        out: str = '<|'
+        if f := it.q:
+            out += ' ' + cls.serialize(f, indent)
+        out += ' |>'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_function(cls, it: PuppetFunction, indent: int) -> str:
+        out: str = f'function {it.name}'
+        if it.params:
+            out += cls.format_declaration_parameters(it.params, indent)
+
+        if ret := it.returns:
+            out += f' >> {cls.serialize(ret, indent + 1)}'
+
+        out += ' {\n'
+        for item in it.body:
+            out += ind(indent + 1) + cls.serialize(item, indent + 1) + '\n'
+        out += ind(indent) + '}'
+
+        return out
+
+    @override
+    @classmethod
+    def _puppet_hash(cls, it: PuppetHash, indent: int) -> str:
+        if not it.entries:
+            return '{}'
+        else:
+            out: str = '{\n'
+            for item in it.entries:
+                out += ind(indent + 1)
+                out += cls.serialize_hash_entry(item, indent + 1)
+                out += ',\n'
+            out += ind(indent) + '}'
+            return out
+
+    @override
+    @classmethod
+    def _puppet_heredoc(cls, it: PuppetHeredoc, indent: int) -> str:
+        """
+        Serialize heredoc with interpolation.
+
+        The esacpes $, r, and t are always added and un-escaped,
+        while the rest are left as is, since they work fine in the literal.
+        """
+        syntax: str = ''
+        if it.syntax:
+            syntax = f':{it.syntax}'
+
+        # TODO find delimiter
+        body = ''
+        for frag in it.fragments:
+            match frag:
+                case PuppetString(s):
+                    # \r, \t, \, $
+                    e = re.sub('[\r\t\\\\$]', lambda m: {
+                        '\r': r'\r',
+                        '\t': r'\t',
+                        }.get(m[0], '\\' + m[0]), s)
+                    body += e
+                case PuppetVar(x):
+                    body += f'${{{x}}}'
+                case p:
+                    body += cls.serialize(p, indent + 2)
+
+        # Check if string ends with a newline
+        match it.fragments[-1]:
+            case PuppetString(s) if s.endswith('\n'):
+                eol_marker = ''
+                body = body[:-1]
+            case _:
+                eol_marker = '-'
+
+        # Aligning this to the left column is ugly, but saves us from
+        # parsing newlines in the actual string
+        return f'@("EOF"{syntax}/$rt)\n{body}\n|{eol_marker} EOF'
+
+    @override
+    @classmethod
+    def _puppet_literal_heredoc(cls, it: PuppetLiteralHeredoc, indent: int) -> str:
+        syntax: str = ''
+        if it.syntax:
+            syntax = f':{it.syntax}'
+
+        out: str = ''
+        if not it.content:
+            out += f'@(EOF{syntax})\n'
+            out += ind(indent) + '|- EOF'
+            return out
+
+        delimiter = find_heredoc_delimiter(it.content)
+
+        out += f'@({delimiter}{syntax})\n'
+
+        lines = it.content.split('\n')
+        eol: bool = False
+        if lines[-1] == '':
+            lines = lines[:-1]  # Remove last
+            eol = True
+
+        for line in lines:
+            out += ind(indent + 1) + line + '\n'
+
+        out += ind(indent + 1) + '|'
+
+        if not eol:
+            out += '-'
+
+        out += ' ' + delimiter
+
+        return out
+
+    @override
+    @classmethod
+    def _puppet_var(cls, it: PuppetVar, indent: int) -> str:
+        return f'${it.name}'
+
+    @override
+    @classmethod
+    def _puppet_lambda(cls, it: PuppetLambda, indent: int) -> str:
+        out: str = '|'
+        for item in it.params:
+            out += 'TODO'
+        out += '| {'
+        for form in it.body:
+            out += ind(indent + 1) + cls.serialize(form, indent + 1)
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_qn(cls, it: PuppetQn, indent: int) -> str:
+        return it.name
+
+    @override
+    @classmethod
+    def _puppet_qr(cls, it: PuppetQr, indent: int) -> str:
+        return it.name
+
+    @override
+    @classmethod
+    def _puppet_regex(cls, it: PuppetRegex, indent: int) -> str:
+        return f'/{it.s}/'
+
+    @override
+    @classmethod
+    def _puppet_resource(cls, it: PuppetResource, indent: int) -> str:
+        out = f'{cls.serialize(it.type, indent + 1)} {{'
+        match it.bodies:
+            case [(name, values)]:
+                out += f' {cls.serialize(name, indent + 1)}:\n'
+                for v in values:
+                    out += ind(indent + 1) + cls.serialize(v, indent + 2) + ',\n'
+            case bodies:
+                out += '\n'
+                for (name, values) in bodies:
+                    out += f'{ind(indent + 1)}{cls.serialize(name, indent + 1)}:\n'
+                    for v in values:
+                        out += ind(indent + 2) + cls.serialize(v, indent + 3) + ',\n'
+                    out += ind(indent + 2) + ';\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_define(cls, it: PuppetDefine, indent: int) -> str:
+        out: str = f'define {it.name}'
+        if params := it.params:
+            out += cls.format_declaration_parameters(params, indent)
+
+        out += ' {\n'
+        for form in it.body:
+            out += ind(indent + 1) + cls.serialize(form, indent + 1) + '\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_string(cls, it: PuppetString, indent: int) -> str:
+        # TODO escaping
+        return f"'{it.s}'"
+
+    @override
+    @classmethod
+    def _puppet_number(cls, it: PuppetNumber, indent: int) -> str:
+        return str(it.x)
+
+    @override
+    @classmethod
+    def _puppet_invoke(cls, it: PuppetInvoke, indent: int) -> str:
+        invoker = f'{cls.serialize(it.func, indent)}'
+        out: str = invoker
+        template: str
+        if invoker == 'include':
+            template = ' {}'
+        else:
+            template = '({})'
+        out += template.format(', '.join(cls.serialize(x, indent + 1) for x in it.args))
+        return out
+
+    @override
+    @classmethod
+    def _puppet_resource_defaults(cls, it: PuppetResourceDefaults, indent: int) -> str:
+        out: str = f'{cls.serialize(it.type, indent)} {{\n'
+        for op in it.ops:
+            out += ind(indent + 1) + cls.serialize(op, indent + 1) + ',\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_resource_override(cls, it: PuppetResourceOverride, indent: int) -> str:
+        out: str = f'{cls.serialize(it.resource, indent)} {{\n'
+        for op in it.ops:
+            out += ind(indent + 1) + cls.serialize(op, indent + 1) + ',\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_declaration(cls, it: PuppetDeclaration, indent: int) -> str:
+        return f'{cls.serialize(it.k, indent)} = {cls.serialize(it.v, indent)}'
+
+    @override
+    @classmethod
+    def _puppet_selector(cls, it: PuppetSelector, indent: int) -> str:
+        out: str = f'{cls.serialize(it.resource, indent)} ? {{\n'
+        rendered_cases = [(cls.serialize(test, indent + 1),
+                           cls.serialize(body, indent + 2))
+                          for (test, body) in it.cases]
+        case_width = max(string_width(c[0], indent + 1) for c in rendered_cases)
+        for (test, body) in rendered_cases:
+            out += ind(indent + 1) + test
+            out += ' ' * (case_width - string_width(test, indent + 1))
+            out += f' => {body},\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_block(cls, it: PuppetBlock, indent: int) -> str:
+        return '\n'.join(cls.serialize(x, indent) for x in it.entries)
+
+    @override
+    @classmethod
+    def _puppet_node(cls, it: PuppetNode, indent: int) -> str:
+        out: str = 'node '
+        out += ', '.join(cls.serialize(x, indent) for x in it.matches)
+        out += ' {\n'
+        for item in it.body:
+            out += ind(indent + 1) + cls.serialize(item, indent + 1) + '\n'
+        out += ind(indent) + '}'
+        return out
+
+    @override
+    @classmethod
+    def _puppet_parenthesis(cls, it: PuppetParenthesis, indent: int) -> str:
+        return f'({cls.serialize(it.form, indent)})'
+
+    @override
+    @classmethod
+    def _puppet_nop(cls, it: PuppetNop, indent: int) -> str:
+        return ''
diff --git a/muppet/puppet/parser.py b/muppet/puppet/parser.py
index b3724eb49cd31c74c82b57dd0590607f50c3370c..fb8d14eb84c868ef826ffd755797074d50303811 100644
--- a/muppet/puppet/parser.py
+++ b/muppet/puppet/parser.py
@@ -12,6 +12,7 @@ import json
 from typing import Any, TypeAlias, Union
 from ..cache import Cache
 import logging
+from collections import OrderedDict
 
 
 logger = logging.getLogger(__name__)
@@ -21,7 +22,7 @@ logger = logging.getLogger(__name__)
 cache = Cache('/home/hugo/.cache/muppet-strings')
 
 
-def tagged_list_to_dict(lst: list[Any]) -> dict[Any, Any]:
+def tagged_list_to_dict(lst: list[Any]) -> OrderedDict[Any, Any]:
     """
     Turn a tagged list into a dictionary.
 
@@ -33,8 +34,8 @@ def tagged_list_to_dict(lst: list[Any]) -> dict[Any, Any]:
         >>> tagged_list_to_dict(['a', 1, 'b', 2])
         {'a': 1, 'b': 2}
     """
-    return {lst[i]: lst[i+1]
-            for i in range(0, len(lst), 2)}
+    return OrderedDict((lst[i], lst[i+1])
+                       for i in range(0, len(lst), 2))
 
 
 def traverse(tree: Any) -> Any:
@@ -57,9 +58,9 @@ def traverse(tree: Any) -> Any:
         # `x in tree` pattern since there may be empty lists (which
         # are "False")
         if '#' in tree:
-            return {key: traverse(value)
-                    for (key, value)
-                    in tagged_list_to_dict(tree['#']).items()}
+            return OrderedDict((key, traverse(value))
+                               for (key, value)
+                               in tagged_list_to_dict(tree['#']).items())
         elif '^' in tree:
             return [traverse(subtree) for subtree in tree['^']]
         else:
@@ -109,23 +110,3 @@ def puppet_parser(code: str) -> JSON:
         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)
-        case _:
-            raise Exception("This is impossible to rearch")
-
-    json.dump(puppet_parser(inp.read()),
-              sys.stdout,
-              indent=2)
-    print()
-
-
-if __name__ == '__main__':
-    __main()
diff --git a/tests/test_ast.py b/tests/test_ast.py
new file mode 100644
index 0000000000000000000000000000000000000000..593e0f65d448d4920dc71cbd8f4397ee43e8a594
--- /dev/null
+++ b/tests/test_ast.py
@@ -0,0 +1,633 @@
+"""
+Test for building, and reserializing our Puppet ASTs.
+
+All Puppet "child" declared in :py:mod:`muppet.puppet.ast`
+should be tested here, with the exceptions noted below.
+
+Note that PuppetParseError isn't tested, since there should be no way
+to create those objects through ``build_ast``.
+
+Skipped
+-------
+
+The following forms lack a dedicated test, for the reasons
+stated bellow.
+
+exported query & virtual query
+    Only makes since in a collect statement
+
+lambda
+    Can only exist in certain contexts, so they are covered there.
+
+qn, qr, nop
+    Only exists within other forms.
+
+str
+    Not a true form, but part of string interpolation forms.
+"""
+
+from muppet.puppet.ast import (
+    Puppet, build_ast,
+
+    PuppetLiteral, PuppetAccess, PuppetBinaryOperator,
+    PuppetUnaryOperator, PuppetArray, PuppetCallMethod,
+    PuppetCase, PuppetDeclarationParameter,
+    PuppetInstanciationParameter, PuppetClass, PuppetConcat,
+    PuppetCollect, PuppetIf, PuppetUnless, PuppetKeyword,
+    PuppetExportedQuery, PuppetVirtualQuery, PuppetFunction,
+    PuppetHash, PuppetHeredoc, PuppetLiteralHeredoc, PuppetVar,
+    PuppetLambda,  PuppetQn, PuppetQr, PuppetRegex,
+    PuppetResource, PuppetDefine, PuppetString,
+    PuppetNumber, PuppetInvoke, PuppetResourceDefaults,
+    PuppetResourceOverride, PuppetDeclaration, PuppetSelector,
+    PuppetBlock, PuppetNode, PuppetCall,
+
+    HashEntry,
+
+    # # These should be tested, if I figure out how to cause them
+    # , PuppetParenthesis,
+    # , PuppetNop
+
+    # # This is intentionally ignored, since it should be impossible
+    # # to cause.
+    # , PuppetParseError
+)
+import muppet.puppet.parser
+from muppet.puppet.format import serialize
+from muppet.puppet.format.text import TextFormatter
+import pytest
+
+
+def parse(puppet_source: str) -> Puppet:
+    """Shorthand for running the parser in tests."""
+    return build_ast(muppet.puppet.parser.puppet_parser(
+        puppet_source))
+
+def ser(ast: Puppet) -> str:
+    return serialize(ast, TextFormatter)
+    
+
+# from pprint import pprint
+# def run(x):
+#     """Function for generating test cases."""
+#     pprint(parse(x))
+
+
+def test_literal():
+    s1 = "true"
+    r1 = PuppetLiteral('true')
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_keyword():
+    s1 = "default"
+    r1 = PuppetKeyword("default")
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_var():
+    s1 = "$x"
+    r1 = PuppetVar(name='x')
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_string():
+    s1 = "'Hello'"
+    r1 = PuppetString(s='Hello')
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_concat():
+    s1 = '"Hello ${name}"'
+    s2 = '"Hello ${$x + 1}"'
+    s3 = '"Hello ${4}"'
+
+    r1 = PuppetConcat(fragments=[PuppetString(s='Hello '),
+                                 PuppetVar(name='name')])
+    r2 = PuppetConcat(fragments=[PuppetString(s='Hello '),
+                                 PuppetBinaryOperator(op='+',
+                                                      lhs=PuppetVar(name='x'),
+                                                      rhs=PuppetNumber(x=1))])
+    r3 = PuppetConcat(fragments=[PuppetString(s='Hello '), PuppetVar(name='4')])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+    assert parse(s2) == r2
+    assert ser(r2) == s2
+    assert parse(s3) == r3
+    assert ser(r3) == s3
+
+
+def test_access():
+    s1 = "$x[1]"
+    r1 = PuppetAccess(how=PuppetVar(name='x'), args=[PuppetNumber(x=1)])
+
+    s2 = "$x[1, 2]"
+    r2 = PuppetAccess(how=PuppetVar(name='x'),
+                      args=[PuppetNumber(x=1), PuppetNumber(x=2)])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+    assert parse(s2) == r2
+    assert ser(r2) == s2
+
+
+def test_bin_op():
+    s1 = "$x = $y"
+    r1 = PuppetDeclaration(k=PuppetVar(name='x'), v=PuppetVar(name='y'))
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_un_op():
+    s1 = "- $x"
+    r1 = PuppetUnaryOperator(op='-', x=PuppetVar(name='x'))
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_array():
+    s1 = '[]'
+    r1 = PuppetArray([])
+
+    s2 = """
+[
+  1,
+  2,
+]
+    """.strip()
+    r2 = PuppetArray(items=[PuppetNumber(x=1),
+                            PuppetNumber(x=2)])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+    assert parse(s2) == r2
+    assert ser(r2) == s2
+
+
+def test_call():
+    s1 = "$x = f(1, 5)"
+    r1 = PuppetDeclaration(PuppetVar('x'),
+                           PuppetCall(PuppetQn('f'),
+                                      [PuppetNumber(1),
+                                       PuppetNumber(5)]))
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+@pytest.mark.xfail(reason=". is treated as a binary operator, so spaces are added")
+def test_call_method():
+    """
+    Test method calls.
+
+    This method also covers lambda.
+    """
+    s1 = 'm.f()'
+    s2 = 'm.f(1)'
+    s3 = 'm.f |$x| { 1 }'
+
+    r1 = PuppetCallMethod(func=PuppetBinaryOperator(op='.',
+                                                    lhs=PuppetQn(name='m'),
+                                                    rhs=PuppetQn(name='f')),
+                          args=[])
+    r2 = PuppetCallMethod(func=PuppetBinaryOperator(op='.',
+                                                    lhs=PuppetQn(name='m'),
+                                                    rhs=PuppetQn(name='f')),
+                          args=[PuppetNumber(x=1)])
+    r3 = PuppetCallMethod(func=PuppetBinaryOperator(op='.',
+                                                    lhs=PuppetQn(name='m'),
+                                                    rhs=PuppetQn(name='f')),
+                          args=[],
+                          block=PuppetLambda(params=[PuppetDeclarationParameter(k='x')],
+                                             body=[PuppetNumber(x=1)]))
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+    assert parse(s2) == r2
+    assert ser(r2) == s2
+
+    assert parse(s3) == r3
+    assert ser(r3) == s3
+
+
+def test_case():
+    s1 = """
+case 1 {
+  'a': {
+    1
+  }
+  /b/, /c/: {
+    2
+  }
+}
+    """.strip()
+
+    r1 = PuppetCase(test=PuppetNumber(x=1),
+                    cases=[([PuppetString(s='a')], [PuppetNumber(x=1)]),
+                           ([PuppetRegex(s='b'), PuppetRegex(s='c')],
+                            [PuppetNumber(x=2)])])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_collect():
+    s1 = "File <| 10 |>"
+    s2 = "File <<| 20 |>>"
+
+    r1 = PuppetCollect(type=PuppetQr(name='File'),
+                       query=PuppetVirtualQuery(q=PuppetNumber(x=10)))
+    r2 = PuppetCollect(type=PuppetQr(name='File'),
+                       query=PuppetExportedQuery(filter=PuppetNumber(x=20)))
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+    assert parse(s2) == r2
+    assert ser(r2) == s2
+
+
+def test_if():
+    s1 = """
+if 1 {
+  'a'
+}
+    """.strip()
+
+    s2 = """
+if 1 {
+  'a'
+} else {
+  'b'
+}
+    """.strip()
+
+    s3 = """
+if 1 {
+  'a'
+} elsif 2 {
+  'b'
+} else {
+  'c'
+}
+    """.strip()
+
+    r1 = PuppetIf(condition=PuppetNumber(x=1),
+                  consequent=[PuppetString(s='a')])
+
+    r2 = PuppetIf(condition=PuppetNumber(x=1),
+                  consequent=[PuppetString(s='a')],
+                  alternative=[PuppetString(s='b')])
+
+    r3 = PuppetIf(condition=PuppetNumber(x=1),
+                  consequent=[PuppetString(s='a')],
+                  alternative=[PuppetIf(condition=PuppetNumber(x=2),
+                                        consequent=[PuppetString(s='b')],
+                                        alternative=[PuppetString(s='c')])])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+    assert parse(s2) == r2
+    assert ser(r2) == s2
+    assert parse(s3) == r3
+    # TODO elsif
+    # assert ser(r3) == s3
+
+
+def test_unless():
+    s1 = """
+unless 1 {
+  'a'
+}
+    """.strip()
+    r1 = PuppetUnless(condition=PuppetNumber(x=1),
+                      consequent=[PuppetString(s='a')])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_hash():
+    s1 = "{}"
+    # TODO alignment
+    s2 = """
+{
+  a => 1,
+  long_key => 'Hello',
+}
+    """.strip()
+
+    r1 = PuppetHash(entries=[])
+    r2 = PuppetHash([HashEntry(k=PuppetQn(name='a'),
+                               v=PuppetNumber(x=1)),
+                     HashEntry(k=PuppetQn(name='long_key'),
+                               v=PuppetString(s='Hello'))])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+    assert parse(s2) == r2
+    assert ser(r2) == s2
+
+
+def test_heredoc():
+    s1 = r"""@("EOF"/$rt)
+\$ ${x}
+\r \\
+| EOF"""
+
+    r1 = PuppetHeredoc([PuppetString(s='$ '),
+                        PuppetVar(name='x'),
+                        PuppetString(s='\n\r \\\n')])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_literalheredoc():
+    s1 = """
+@(EOF)
+  Hello, ${world}"
+  | EOF
+    """.strip()
+
+    # Test both that EOF isn't hard coded
+    # Note that this is slightly hacky with giving EOL, since we need
+    # to "know" that is how the code is implemented.
+    #
+    # Test syntax at same ttime
+    s2 = """
+@(EOL:txt)
+  EOF
+  |- EOL
+  """.strip()
+
+    r1 = PuppetLiteralHeredoc('Hello, ${world}"\n')
+    r2 = PuppetLiteralHeredoc('EOF', syntax='txt')
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+    assert parse(s2) == r2
+    assert ser(r2) == s2
+
+
+@pytest.mark.skip(reason="When is this triggered?")
+def test_parenthesis():
+    # TODO
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_regex():
+    s1 = "/test/"
+    r1 = PuppetRegex('test')
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_invoke():
+    s1 = "f()"
+    s2 = "f(1, 2)"
+    s3 = "include ::example"
+
+    r1 = PuppetInvoke(func=PuppetQn(name='f'), args=[])
+    r2 = PuppetInvoke(func=PuppetQn(name='f'),
+                      args=[PuppetNumber(x=1), PuppetNumber(x=2)])
+    r3 = PuppetInvoke(func=PuppetQn(name='include'),
+                      args=[PuppetQn(name='::example')])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+    assert parse(s2) == r2
+    assert ser(r2) == s2
+
+    assert parse(s3) == r3
+    assert ser(r3) == s3
+
+
+def test_resourcedefaults():
+    s1 = """
+File {
+  path => '/',
+}
+    """.strip()
+
+    r1 = PuppetResourceDefaults(
+        type=PuppetQr(name='File'),
+        ops=[PuppetInstanciationParameter(k='path',
+                                          v=PuppetString(s='/'),
+                                          arrow='=>')])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_resourceoverride():
+    s1 = """
+File['/'] {
+  ensure => absent,
+}
+    """.strip()
+
+    r1 = PuppetResourceOverride(
+        resource=PuppetAccess(how=PuppetQr(name='File'),
+                              args=[PuppetString(s='/')]),
+        ops=[PuppetInstanciationParameter(k='ensure',
+                                          v=PuppetQn(name='absent'),
+                                          arrow='=>')])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_declaration():
+    s1 = "$x = 10"
+    r1 = PuppetDeclaration(k=PuppetVar(name='x'), v=PuppetNumber(x=10))
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_selector():
+    s1 = """
+$test ? {
+  Int   => 1,
+  Float => 2,
+}
+    """.strip()
+
+    r1= PuppetSelector(resource=PuppetVar(name='test'),
+                       cases=[(PuppetQr(name='Int'), PuppetNumber(x=1)),
+                              (PuppetQr(name='Float'), PuppetNumber(x=2))])
+
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_block():
+    s1 = """
+1
+2
+""".strip()
+    r1 = PuppetBlock(entries=[PuppetNumber(x=1), PuppetNumber(x=2)])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_node():
+    s1 = """
+node 'node.example.com' {
+  include profiles::example
+}
+    """.strip()
+
+    r1 = PuppetNode(matches=[PuppetString('node.example.com')],
+                    body=[PuppetInvoke(func=PuppetQn('include'),
+                                       args=[PuppetQn('profiles::example')])])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_resource():
+
+    # TODO alignment
+    s1 = """
+file { '/path':
+  key => 'value',
+  * => {},
+}
+    """.strip()
+
+    s2 = """
+file {
+  default:
+    mode => '0700',
+    ;
+  [
+    '/a',
+    '/b',
+  ]:
+    user => 'root',
+    ;
+}
+    """.strip()
+
+    r1 = PuppetResource(type=PuppetQn(name='file'),
+                        bodies=[(PuppetString(s='/path'),
+                                 [PuppetInstanciationParameter(k='key',
+                                                               v=PuppetString(s='value'),
+                                                               arrow='=>'),
+                                  PuppetInstanciationParameter(k='*',
+                                                               v=PuppetHash(entries=[]),
+                                                               arrow='=>')])])
+
+    r2 = PuppetResource(
+            type=PuppetQn(name='file'),
+            bodies=[(PuppetKeyword(name='default'),
+                     [PuppetInstanciationParameter(k='mode',
+                                                   v=PuppetString(s='0700'),
+                                                   arrow='=>')]),
+                    (PuppetArray(items=[PuppetString('/a'),
+                                        PuppetString('/b')]),
+                     [PuppetInstanciationParameter(k='user',
+                                                   v=PuppetString('root'),
+                                                   arrow='=>')])])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+    assert parse(s2) == r2
+    assert ser(r2) == s2
+
+
+def test_define():
+    s1 = """
+define a::b (
+  String $x,
+  $y,
+  $z = 20,
+  String $w = '10',
+) {
+  include ::something
+}
+    """.strip()
+
+    r1 = PuppetDefine(name='a::b',
+                      params=[PuppetDeclarationParameter(k='x',
+                                                         v=None,
+                                                         type=PuppetQr(name='String')),
+                              PuppetDeclarationParameter(k='y', v=None, type=None),
+                              PuppetDeclarationParameter(k='z',
+                                                         v=PuppetNumber(x=20),
+                                                         type=None),
+                              PuppetDeclarationParameter(k='w',
+                                                         v=PuppetString(s='10'),
+                                                         type=PuppetQr(name='String'))],
+                      body=[PuppetInvoke(func=PuppetQn(name='include'),
+                                         args=[PuppetQn(name='::something')])])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_function():
+    s1 = """
+function f (
+  $x,
+) >> String {
+  $x
+}
+    """.strip()
+
+    r1 = PuppetFunction(
+        name='f',
+        params=[PuppetDeclarationParameter(k='x')],
+        returns=PuppetQr(name='String'),
+        body=[PuppetVar(name='x')])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
+
+
+def test_class():
+    s1 = """
+class name (
+  String $x = 'Hello',
+  Int $y = 10,
+) {
+  notice($x)
+}
+    """.strip()
+
+    r1 = PuppetClass(name='name',
+                     params=[
+                         PuppetDeclarationParameter(
+                             k='x',
+                             v=PuppetString(s='Hello'),
+                             type=PuppetQr(name='String')),
+                         PuppetDeclarationParameter(
+                             k='y',
+                             v=PuppetNumber(x=10),
+                             type=PuppetQr(name='Int'))
+                     ],
+                     body=[PuppetInvoke(func=PuppetQn(name='notice'),
+                                        args=[PuppetVar(name='x')])])
+
+    assert parse(s1) == r1
+    assert ser(r1) == s1
diff --git a/tests/test_parse.py b/tests/test_parse.py
deleted file mode 100644
index f7f2f68bbb5378adf456cbea0aa3cb051e892028..0000000000000000000000000000000000000000
--- a/tests/test_parse.py
+++ /dev/null
@@ -1,538 +0,0 @@
-"""
-Unit 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), '}'])