aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--muppet/data/__init__.py202
-rw-r--r--muppet/data/html.py95
-rw-r--r--muppet/data/plain.py50
-rw-r--r--muppet/format.py1037
-rw-r--r--muppet/puppet/__main__.py72
-rw-r--r--muppet/puppet/ast.py724
-rw-r--r--muppet/puppet/format/__init__.py13
-rw-r--r--muppet/puppet/format/base.py296
-rw-r--r--muppet/puppet/format/html.py549
-rw-r--r--muppet/puppet/format/text.py594
-rw-r--r--muppet/puppet/parser.py33
-rw-r--r--tests/test_ast.py633
-rw-r--r--tests/test_parse.py538
13 files changed, 2916 insertions, 1920 deletions
diff --git a/muppet/data/__init__.py b/muppet/data/__init__.py
deleted file mode 100644
index 6666e5b..0000000
--- 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 df4f0f2..0000000
--- 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 0ffdcf5..0000000
--- 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 734f8bd..26c0b8f 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 0000000..080a2db
--- /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 0000000..2ce3c35
--- /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 0000000..d40f9b4
--- /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 0000000..9afad2b
--- /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 0000000..9719b53
--- /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 0000000..a3e772d
--- /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 b3724eb..fb8d14e 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 0000000..593e0f6
--- /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 f7f2f68..0000000
--- 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), '}'])