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