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