Compare commits

14 Commits
Author SHA1 Message Date
HEL 80bfcd0d1a Merge pull request 'Syntax prototype and basic parser' (#1) from feat/syntax-prototype into main
Reviewed-on: #1
2026-05-19 08:34:07 +00:00
HEL 697f4d5003 tests(parser): add midas lexer tests 2026-05-19 07:44:29 +02:00
HEL 7a831a1afc fix(parser): handle extra tokens in Midas parser 2026-05-18 18:43:35 +02:00
HEL 6922e49cdf tests(parser): add tests for midas parser 2026-05-18 18:43:03 +02:00
HEL ae02bab030 tests(parser): add tests for annotation parser 2026-05-18 14:42:27 +02:00
HEL 7581a35be4 tests(parser): add syntax error test 2026-05-18 13:43:12 +02:00
HEL 6f0c0ce326 tests(parser): add literal value test 2026-05-18 13:35:23 +02:00
HEL 1b1fbb834e fix(parser): fix bang equal
consume equal token when matching bang-equal
2026-05-18 13:22:11 +02:00
HEL f41c757838 tests(parser): complete simple annotation lexer tests 2026-05-18 13:21:23 +02:00
HEL 453c72af6d tests(parser): add basic lexer test
add a basic test for the annotation lexer to check punctuation tokens
2026-05-18 13:11:00 +02:00
HEL ee308fe223 fix(parser): update examples 2026-05-18 12:47:21 +02:00
HEL 3f199ff134 feat(parser): add Midas pretty printer 2026-05-18 12:43:05 +02:00
HEL 74ac9c5381 feat(parser): parse Midas type constraints 2026-05-18 12:18:41 +02:00
HEL e10d71a66b feat(parser): split annotation and Midas keywords 2026-05-18 11:33:33 +02:00
13 changed files with 775 additions and 21 deletions

No files matched your search

+27 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import Generic, Optional, TypeVar from typing import Any, Generic, Optional, TypeVar
from lexer.token import Token from lexer.token import Token
@@ -79,6 +79,12 @@ class Expr(ABC):
def accept(self, visitor: Visitor[T]) -> T: ... def accept(self, visitor: Visitor[T]) -> T: ...
class Visitor(ABC, Generic[T]): class Visitor(ABC, Generic[T]):
@abstractmethod
def visit_wildcard_expr(self, expr: WildcardExpr) -> T: ...
@abstractmethod
def visit_literal_expr(self, expr: LiteralExpr) -> T: ...
@abstractmethod @abstractmethod
def visit_type_expr(self, expr: TypeExpr) -> T: ... def visit_type_expr(self, expr: TypeExpr) -> T: ...
@@ -89,6 +95,22 @@ class Expr(ABC):
def visit_type_body_expr(self, expr: TypeBodyExpr) -> T: ... def visit_type_body_expr(self, expr: TypeBodyExpr) -> T: ...
@dataclass(frozen=True)
class WildcardExpr(Expr):
token: Token
def accept(self, visitor: Expr.Visitor[T]) -> T:
return visitor.visit_wildcard_expr(self)
@dataclass(frozen=True)
class LiteralExpr(Expr):
value: Any
def accept(self, visitor: Expr.Visitor[T]) -> T:
return visitor.visit_literal_expr(self)
@dataclass(frozen=True) @dataclass(frozen=True)
class TypeExpr(Expr): class TypeExpr(Expr):
name: Token name: Token
@@ -100,6 +122,10 @@ class TypeExpr(Expr):
@dataclass(frozen=True) @dataclass(frozen=True)
class ConstraintExpr(Expr): class ConstraintExpr(Expr):
left: Expr
op: Token
right: Expr
def accept(self, visitor: Expr.Visitor[T]) -> T: def accept(self, visitor: Expr.Visitor[T]) -> T:
return visitor.visit_constraint_expr(self) return visitor.visit_constraint_expr(self)
+98 -8
View File
@@ -102,7 +102,7 @@ class AnnotationAstPrinter(AstPrinter, a.Expr.Visitor[None], a.Stmt.Visitor[None
if i == len(expr.constraints) - 1: if i == len(expr.constraints) - 1:
self._mark_last() self._mark_last()
constraint.accept(self) constraint.accept(self)
def visit_constraint_expr(self, expr: a.ConstraintExpr) -> None: def visit_constraint_expr(self, expr: a.ConstraintExpr) -> None:
self._write_line("ConstraintExpr") self._write_line("ConstraintExpr")
with self._child_level(): with self._child_level():
@@ -110,9 +110,9 @@ class AnnotationAstPrinter(AstPrinter, a.Expr.Visitor[None], a.Stmt.Visitor[None
with self._child_level(): with self._child_level():
self._mark_last() self._mark_last()
expr.left.accept(self) expr.left.accept(self)
self._write_line(f"operator: {expr.op.lexeme}") self._write_line(f"operator: {expr.op.lexeme}")
self._write_line("right", last=True) self._write_line("right", last=True)
with self._child_level(): with self._child_level():
self._mark_last() self._mark_last()
@@ -133,14 +133,14 @@ class AnnotationAstPrinter(AstPrinter, a.Expr.Visitor[None], a.Stmt.Visitor[None
name_text: str = "None" if expr.name is None else f'"{expr.name.lexeme}"' name_text: str = "None" if expr.name is None else f'"{expr.name.lexeme}"'
self._write_line(f"name: {name_text}") self._write_line(f"name: {name_text}")
self._write_optional_child("type", expr.type, last=True) self._write_optional_child("type", expr.type, last=True)
def visit_wildcard_expr(self, expr: a.WildcardExpr) -> None: def visit_wildcard_expr(self, expr: a.WildcardExpr) -> None:
self._write_line("WildcardExpr") self._write_line("WildcardExpr")
def visit_literal_expr(self, expr: a.LiteralExpr) -> None: def visit_literal_expr(self, expr: a.LiteralExpr) -> None:
self._write_line("LiteralExpr") self._write_line("LiteralExpr")
with self._child_level(): with self._child_level():
self._write_line(f'value: {expr.value}', last=True) self._write_line(f"value: {expr.value}", last=True)
class AnnotationPrinter(a.Expr.Visitor[str], a.Stmt.Visitor[str]): class AnnotationPrinter(a.Expr.Visitor[str], a.Stmt.Visitor[str]):
@@ -158,12 +158,12 @@ class AnnotationPrinter(a.Expr.Visitor[str], a.Stmt.Visitor[str]):
for constraint in expr.constraints: for constraint in expr.constraints:
parts.append("(" + constraint.accept(self) + ")") parts.append("(" + constraint.accept(self) + ")")
return " + ".join(parts) return " + ".join(parts)
def visit_constraint_expr(self, expr: a.ConstraintExpr) -> str: def visit_constraint_expr(self, expr: a.ConstraintExpr) -> str:
parts: list[str] = [ parts: list[str] = [
expr.left.accept(self), expr.left.accept(self),
expr.op.lexeme, expr.op.lexeme,
expr.right.accept(self) expr.right.accept(self),
] ]
return " ".join(parts) return " ".join(parts)
@@ -257,6 +257,18 @@ class MidasAstPrinter(AstPrinter, m.Expr.Visitor[None], m.Stmt.Visitor[None]):
def visit_constraint_expr(self, expr: m.ConstraintExpr): def visit_constraint_expr(self, expr: m.ConstraintExpr):
self._write_line("ConstraintExpr") self._write_line("ConstraintExpr")
with self._child_level():
self._write_line("left")
with self._child_level():
self._mark_last()
expr.left.accept(self)
self._write_line(f"operator: {expr.op.lexeme}")
self._write_line("right", last=True)
with self._child_level():
self._mark_last()
expr.right.accept(self)
def visit_type_body_expr(self, expr: m.TypeBodyExpr): def visit_type_body_expr(self, expr: m.TypeBodyExpr):
self._write_line("TypeBodyExpr") self._write_line("TypeBodyExpr")
@@ -268,3 +280,81 @@ class MidasAstPrinter(AstPrinter, m.Expr.Visitor[None], m.Stmt.Visitor[None]):
if i == len(expr.properties) - 1: if i == len(expr.properties) - 1:
self._mark_last() self._mark_last()
property.accept(self) property.accept(self)
def visit_wildcard_expr(self, expr: m.WildcardExpr) -> None:
self._write_line("WildcardExpr")
def visit_literal_expr(self, expr: m.LiteralExpr) -> None:
self._write_line("LiteralExpr")
with self._child_level():
self._write_line(f"value: {expr.value}", last=True)
class MidasPrinter(m.Expr.Visitor[str], m.Stmt.Visitor[str]):
def __init__(self, indent: int = 4):
self.indent: int = indent
self.level: int = 0
def indented(self, text: str) -> str:
return " " * (self.level * self.indent) + text
def print(self, expr: m.Expr | m.Stmt):
self.level = 0
return expr.accept(self)
def visit_type_stmt(self, stmt: m.TypeStmt):
bases: list[str] = [
b.accept(self)
for b in stmt.bases
]
res: str = self.indented(f"type {stmt.name.lexeme}<{', '.join(bases)}>")
if stmt.body is not None:
res += " {\n"
self.level += 1
res += stmt.body.accept(self)
self.level -= 1
res += "\n" + self.indented("}")
return res
def visit_property_stmt(self, stmt: m.PropertyStmt):
return f"{stmt.name.lexeme}: {stmt.type.accept(self)}"
def visit_op_stmt(self, stmt: m.OpStmt):
left: str = stmt.left.accept(self)
op: str = stmt.op.lexeme
right: str = stmt.right.accept(self)
result: str = stmt.result.accept(self)
return self.indented(f"op <{left}> {op} <{right}> = <{result}>")
def visit_constraint_stmt(self, stmt: m.ConstraintStmt):
name: str = stmt.name.lexeme
constraint: str = stmt.constraint.accept(self)
return self.indented(f"constraint {name} = {constraint}")
def visit_type_expr(self, expr: m.TypeExpr):
parts: list[str] = [expr.name.lexeme]
for constraint in expr.constraints:
parts.append("(" + constraint.accept(self) + ")")
return " + ".join(parts)
def visit_constraint_expr(self, expr: m.ConstraintExpr):
parts: list[str] = [
expr.left.accept(self),
expr.op.lexeme,
expr.right.accept(self),
]
return " ".join(parts)
def visit_type_body_expr(self, expr: m.TypeBodyExpr):
properties: list[str] = [
self.indented(prop.accept(self))
for prop in expr.properties
]
return "\n".join(properties)
def visit_wildcard_expr(self, expr: m.WildcardExpr):
return "_"
def visit_literal_expr(self, expr: m.LiteralExpr):
return str(expr.value)
@@ -7,7 +7,7 @@ from __future__ import annotations
df: Frame[ df: Frame[
verified: bool, verified: bool,
birth_year: int, birth_year: int,
height: float, height: float + ( _ > 0 ) + ( _ < 250 ),
name: str, name: str,
date: datetime, date: datetime,
float, # unnamed float, # unnamed
@@ -16,7 +16,7 @@ op <Latitude> - <Latitude> = <LatitudeDiff>
op <Longitude> - <Longitude> = <LongitudeDiff> op <Longitude> - <Longitude> = <LongitudeDiff>
// Simple custom type with a constraint // Simple custom type with a constraint
type Age<int + (0 <= _ < 150)> type Age<int + (0 <= _) + (_ < 150)>
// Predefined custom constraints that can be referenced in other definitions // Predefined custom constraints that can be referenced in other definitions
constraint Positive = _ >= 0 constraint Positive = _ >= 0
+6 -2
View File
@@ -1,4 +1,5 @@
from lexer.base import Lexer from lexer.base import Lexer
from lexer.keyword import ANNOTATION_KEYWORDS
from lexer.token import TokenType from lexer.token import TokenType
@@ -27,7 +28,7 @@ class AnnotationLexer(Lexer):
TokenType.EQUAL_EQUAL if self.match("=") else TokenType.EQUAL TokenType.EQUAL_EQUAL if self.match("=") else TokenType.EQUAL
) )
case "!": case "!":
if self.peek() == "=": if self.match("="):
self.add_token(TokenType.BANG_EQUAL) self.add_token(TokenType.BANG_EQUAL)
else: else:
self.error("Unexpected single bang. Did you mean '!=' ?") self.error("Unexpected single bang. Did you mean '!=' ?")
@@ -86,7 +87,10 @@ class AnnotationLexer(Lexer):
""" """
while self.peek().isalnum() or self.peek() == "_": while self.peek().isalnum() or self.peek() == "_":
self.advance() self.advance()
self.add_token(TokenType.IDENTIFIER)
lexeme: str = self.source[self.start : self.idx]
token_type: TokenType = ANNOTATION_KEYWORDS.get(lexeme, TokenType.IDENTIFIER)
self.add_token(token_type)
def scan_comment(self): def scan_comment(self):
"""Scan the rest of a comment and add it as a token """Scan the rest of a comment and add it as a token
+8 -1
View File
@@ -1,9 +1,16 @@
from lexer.token import TokenType from lexer.token import TokenType
KEYWORDS: dict[str, TokenType] = { ANNOTATION_KEYWORDS: dict[str, TokenType] = {
"True": TokenType.TRUE,
"False": TokenType.FALSE,
"None": TokenType.NONE,
}
MIDAS_KEYWORDS: dict[str, TokenType] = {
"type": TokenType.TYPE, "type": TokenType.TYPE,
"op": TokenType.OP, "op": TokenType.OP,
"constraint": TokenType.CONSTRAINT, "constraint": TokenType.CONSTRAINT,
"true": TokenType.TRUE, "true": TokenType.TRUE,
"false": TokenType.FALSE, "false": TokenType.FALSE,
"none": TokenType.NONE,
} }
+3 -3
View File
@@ -1,5 +1,5 @@
from lexer.base import Lexer from lexer.base import Lexer
from lexer.keyword import KEYWORDS from lexer.keyword import MIDAS_KEYWORDS
from lexer.token import TokenType from lexer.token import TokenType
@@ -32,7 +32,7 @@ class MidasLexer(Lexer):
TokenType.EQUAL_EQUAL if self.match("=") else TokenType.EQUAL TokenType.EQUAL_EQUAL if self.match("=") else TokenType.EQUAL
) )
case "!": case "!":
if self.peek() == "=": if self.match("="):
self.add_token(TokenType.BANG_EQUAL) self.add_token(TokenType.BANG_EQUAL)
else: else:
self.error("Unexpected single bang. Did you mean '!=' ?") self.error("Unexpected single bang. Did you mean '!=' ?")
@@ -102,7 +102,7 @@ class MidasLexer(Lexer):
self.advance() self.advance()
lexeme: str = self.source[self.start : self.idx] lexeme: str = self.source[self.start : self.idx]
token_type: TokenType = KEYWORDS.get(lexeme, TokenType.IDENTIFIER) token_type: TokenType = MIDAS_KEYWORDS.get(lexeme, TokenType.IDENTIFIER)
self.add_token(token_type) self.add_token(token_type)
def scan_comment(self): def scan_comment(self):
-2
View File
@@ -68,8 +68,6 @@ class AnnotationParser(Parser):
constraints: list[ConstraintExpr] = [] constraints: list[ConstraintExpr] = []
while not self.is_at_end() and self.match(TokenType.PLUS): while not self.is_at_end() and self.match(TokenType.PLUS):
print(self.peek())
print(self.tokens)
self.consume(TokenType.LEFT_PAREN, "Expected '(' before type constraint") self.consume(TokenType.LEFT_PAREN, "Expected '(' before type constraint")
constraints.append(self.constraint_expr()) constraints.append(self.constraint_expr())
self.consume(TokenType.RIGHT_PAREN, "Expected ')' after type constraint") self.consume(TokenType.RIGHT_PAREN, "Expected ')' after type constraint")
+41 -2
View File
@@ -3,12 +3,15 @@ from typing import Optional
from core.ast.midas import ( from core.ast.midas import (
ConstraintExpr, ConstraintExpr,
ConstraintStmt, ConstraintStmt,
Expr,
LiteralExpr,
OpStmt, OpStmt,
PropertyStmt, PropertyStmt,
Stmt, Stmt,
TypeBodyExpr, TypeBodyExpr,
TypeExpr, TypeExpr,
TypeStmt, TypeStmt,
WildcardExpr,
) )
from lexer.token import Token, TokenType from lexer.token import Token, TokenType
from parser.base import Parser from parser.base import Parser
@@ -59,6 +62,7 @@ class MidasParser(Parser):
return self.op_declaration() return self.op_declaration()
if self.match(TokenType.CONSTRAINT): if self.match(TokenType.CONSTRAINT):
return self.constraint_declaration() return self.constraint_declaration()
raise self.error(self.peek(), "Unexpected token")
except ParsingError: except ParsingError:
self.synchronize() self.synchronize()
return None return None
@@ -96,7 +100,9 @@ class MidasParser(Parser):
constraints: list[ConstraintExpr] = [] constraints: list[ConstraintExpr] = []
while not self.is_at_end() and self.match(TokenType.PLUS): while not self.is_at_end() and self.match(TokenType.PLUS):
self.consume(TokenType.LEFT_PAREN, "Expected '(' before type constraint")
constraints.append(self.constraint_expr()) constraints.append(self.constraint_expr())
self.consume(TokenType.RIGHT_PAREN, "Expected ')' after type constraint")
return TypeExpr(name=name, constraints=constraints) return TypeExpr(name=name, constraints=constraints)
@@ -106,8 +112,41 @@ class MidasParser(Parser):
Returns: Returns:
ConstraintExpr: the parsed type constraint expression ConstraintExpr: the parsed type constraint expression
""" """
# TODO
return ConstraintExpr() left: Expr = self.constraint_value()
op: Token = self.constraint_operator()
right: Expr = self.constraint_value()
return ConstraintExpr(left=left, op=op, right=right)
def constraint_value(self) -> Expr:
if self.match(TokenType.UNDERSCORE):
return WildcardExpr(self.previous())
return self.literal()
def literal(self) -> LiteralExpr:
if self.match(TokenType.FALSE):
return LiteralExpr(False)
if self.match(TokenType.TRUE):
return LiteralExpr(True)
if self.match(TokenType.NONE):
return LiteralExpr(None)
if self.match(TokenType.NUMBER):
return LiteralExpr(self.previous().value)
raise self.error(self.peek(), "Expected literal")
def constraint_operator(self) -> Token:
if self.match(
TokenType.LESS,
TokenType.LESS_EQUAL,
TokenType.GREATER,
TokenType.GREATER_EQUAL,
TokenType.EQUAL_EQUAL,
TokenType.BANG_EQUAL,
):
return self.previous()
raise self.error(self.peek(), "Expected constraint operator")
def type_body_expr(self) -> TypeBodyExpr: def type_body_expr(self) -> TypeBodyExpr:
"""Parse a type definition body """Parse a type definition body
+129
View File
@@ -0,0 +1,129 @@
from typing import Any
import pytest
from lexer.annotations import AnnotationLexer
from lexer.token import Token, TokenType
def scan(source: str) -> list[Token]:
return AnnotationLexer(source).process()
def assert_n_tokens(tokens: list[Token], n: int):
assert len(tokens) == n + 1
assert tokens[-1].type == TokenType.EOF
@pytest.mark.parametrize(
"src,expected",
[
("(", TokenType.LEFT_PAREN),
(")", TokenType.RIGHT_PAREN),
("[", TokenType.LEFT_BRACKET),
("]", TokenType.RIGHT_BRACKET),
(":", TokenType.COLON),
(",", TokenType.COMMA),
("_", TokenType.UNDERSCORE),
],
)
def test_punctuation(src: str, expected: TokenType):
tokens: list[Token] = scan(src)
assert_n_tokens(tokens, 1)
assert tokens[0].type == expected
@pytest.mark.parametrize(
"src,expected",
[
("+", TokenType.PLUS),
(">", TokenType.GREATER),
(">=", TokenType.GREATER_EQUAL),
("<", TokenType.LESS),
("<=", TokenType.LESS_EQUAL),
("=", TokenType.EQUAL),
("==", TokenType.EQUAL_EQUAL),
("!=", TokenType.BANG_EQUAL),
],
)
def test_operators(src: str, expected: TokenType):
tokens: list[Token] = scan(src)
assert_n_tokens(tokens, 1)
assert tokens[0].type == expected
@pytest.mark.parametrize(
"src,expected",
[
("a", TokenType.IDENTIFIER),
("foo", TokenType.IDENTIFIER),
("foo1", TokenType.IDENTIFIER),
("foo_", TokenType.IDENTIFIER),
("foo_bar1_baz2", TokenType.IDENTIFIER),
("FOO_BAR1_BAZ2", TokenType.IDENTIFIER),
("True", TokenType.TRUE),
("False", TokenType.FALSE),
("None", TokenType.NONE),
],
)
def test_identifiers_keywords(src: str, expected: TokenType):
tokens: list[Token] = scan(src)
assert_n_tokens(tokens, 1)
assert tokens[0].type == expected
@pytest.mark.parametrize(
"src,expected",
[
("#", TokenType.COMMENT),
("# This is a comment", TokenType.COMMENT),
(" ", TokenType.WHITESPACE),
("\t", TokenType.WHITESPACE),
("\r", TokenType.WHITESPACE),
(" \t \t", TokenType.WHITESPACE),
("\n", TokenType.NEWLINE),
],
)
def test_misc(src: str, expected: TokenType):
tokens: list[Token] = scan(src)
assert_n_tokens(tokens, 1)
assert tokens[0].type == expected
@pytest.mark.parametrize(
"src,expected_type,expected_value",
[
("0", TokenType.NUMBER, 0),
("0.0", TokenType.NUMBER, 0),
("1234.56", TokenType.NUMBER, 1234.56),
],
)
def test_literals(src: str, expected_type: TokenType, expected_value: Any):
tokens: list[Token] = scan(src)
assert_n_tokens(tokens, 1)
assert tokens[0].type == expected_type
assert tokens[0].value == expected_value
def test_single_bang_error():
with pytest.raises(SyntaxError):
scan("!")
@pytest.mark.parametrize(
"src",
[
"-",
"*",
"/",
"{",
"}",
"@",
'"',
"'",
".",
],
)
def test_unexpected_character(src: str):
with pytest.raises(SyntaxError):
scan(src)
+129
View File
@@ -0,0 +1,129 @@
from typing import Any
import pytest
from lexer.midas import MidasLexer
from lexer.token import Token, TokenType
def scan(source: str) -> list[Token]:
return MidasLexer(source).process()
def assert_n_tokens(tokens: list[Token], n: int):
assert len(tokens) == n + 1
assert tokens[-1].type == TokenType.EOF
@pytest.mark.parametrize(
"src,expected",
[
("(", TokenType.LEFT_PAREN),
(")", TokenType.RIGHT_PAREN),
("[", TokenType.LEFT_BRACKET),
("]", TokenType.RIGHT_BRACKET),
("{", TokenType.LEFT_BRACE),
("}", TokenType.RIGHT_BRACE),
(":", TokenType.COLON),
(",", TokenType.COMMA),
("_", TokenType.UNDERSCORE),
],
)
def test_punctuation(src: str, expected: TokenType):
tokens: list[Token] = scan(src)
assert_n_tokens(tokens, 1)
assert tokens[0].type == expected
@pytest.mark.parametrize(
"src,expected",
[
("+", TokenType.PLUS),
("-", TokenType.MINUS),
("*", TokenType.STAR),
("/", TokenType.SLASH),
(">", TokenType.GREATER),
(">=", TokenType.GREATER_EQUAL),
("<", TokenType.LESS),
("<=", TokenType.LESS_EQUAL),
("=", TokenType.EQUAL),
("==", TokenType.EQUAL_EQUAL),
("!=", TokenType.BANG_EQUAL),
],
)
def test_operators(src: str, expected: TokenType):
tokens: list[Token] = scan(src)
assert_n_tokens(tokens, 1)
assert tokens[0].type == expected
@pytest.mark.parametrize(
"src,expected",
[
("a", TokenType.IDENTIFIER),
("foo", TokenType.IDENTIFIER),
("foo1", TokenType.IDENTIFIER),
("foo_", TokenType.IDENTIFIER),
("foo_bar1_baz2", TokenType.IDENTIFIER),
("FOO_BAR1_BAZ2", TokenType.IDENTIFIER),
("true", TokenType.TRUE),
("false", TokenType.FALSE),
("none", TokenType.NONE),
],
)
def test_identifiers_keywords(src: str, expected: TokenType):
tokens: list[Token] = scan(src)
assert_n_tokens(tokens, 1)
assert tokens[0].type == expected
@pytest.mark.parametrize(
"src,expected",
[
("// This is a comment", TokenType.COMMENT),
("/* This is a comment */", TokenType.COMMENT),
(" ", TokenType.WHITESPACE),
("\t", TokenType.WHITESPACE),
("\r", TokenType.WHITESPACE),
(" \t \t", TokenType.WHITESPACE),
("\n", TokenType.NEWLINE),
],
)
def test_misc(src: str, expected: TokenType):
tokens: list[Token] = scan(src)
assert_n_tokens(tokens, 1)
assert tokens[0].type == expected
@pytest.mark.parametrize(
"src,expected_type,expected_value",
[
("0", TokenType.NUMBER, 0),
("0.0", TokenType.NUMBER, 0),
("1234.56", TokenType.NUMBER, 1234.56),
],
)
def test_literals(src: str, expected_type: TokenType, expected_value: Any):
tokens: list[Token] = scan(src)
assert_n_tokens(tokens, 1)
assert tokens[0].type == expected_type
assert tokens[0].value == expected_value
def test_single_bang_error():
with pytest.raises(SyntaxError):
scan("!")
@pytest.mark.parametrize(
"src",
[
"@",
'"',
"'",
".",
],
)
def test_unexpected_character(src: str):
with pytest.raises(SyntaxError):
scan(src)
+130
View File
@@ -0,0 +1,130 @@
from typing import Optional
import pytest
from core.ast.annotations import (
AnnotationStmt,
ConstraintExpr,
Expr,
LiteralExpr,
SchemaElementExpr,
SchemaExpr,
Stmt,
TypeExpr,
WildcardExpr,
)
from lexer.annotations import AnnotationLexer
from lexer.position import Position
from lexer.token import Token
from parser.annotations import AnnotationParser
class AstSerializer(Stmt.Visitor[str], Expr.Visitor[str]):
def serialize(self, stmt: Stmt):
return stmt.accept(self)
def visit_annotation_stmt(self, stmt: AnnotationStmt) -> str:
schema: str = ""
if stmt.schema is not None:
schema = " " + stmt.schema.accept(self)
return f"(annotation {stmt.name.lexeme}{schema})"
def visit_schema_expr(self, expr: SchemaExpr) -> str:
elements: list[str] = [elmt.accept(self) for elmt in expr.elements]
return f"(schema {' '.join(elements)})"
def visit_schema_element_expr(self, expr: SchemaElementExpr) -> str:
name: str = expr.name.lexeme if expr.name is not None else "_"
type: str = expr.type.accept(self) if expr.type is not None else "_"
return f"({name} {type})"
def visit_type_expr(self, expr: TypeExpr) -> str:
res: str = f"({expr.name.lexeme}"
for constraint in expr.constraints:
res += " " + constraint.accept(self)
res += ")"
return res
def visit_constraint_expr(self, expr: ConstraintExpr) -> str:
return f"(constraint {expr.left.accept(self)} {expr.op.lexeme} {expr.right.accept(self)})"
def visit_wildcard_expr(self, expr: WildcardExpr) -> str:
return "(_)"
def visit_literal_expr(self, expr: LiteralExpr) -> str:
return f"({expr.value})"
def parse(source: str) -> Optional[Stmt]:
tokens: list[Token] = AnnotationLexer(source).process()
return AnnotationParser(tokens).parse()
def must_parse(source: str) -> Stmt:
stmt: Optional[Stmt] = parse(source)
assert stmt is not None
return stmt
def ast_str(source: str) -> str:
stmt: Stmt = must_parse(source)
return AstSerializer().serialize(stmt)
@pytest.mark.parametrize(
"src,expected",
[
("Type", "(annotation Type)"),
("Type[]", "(annotation Type (schema ))"),
(
"""
Frame[
verified: bool,
birth_year: int,
height: float + ( _ > 0 ) + ( _ < 250 ),
name: str,
date: datetime,
float, # unnamed
unknown: _, # untyped
_ # unnamed and untyped
]
""",
"(annotation Frame (schema (verified (bool)) (birth_year (int)) (height (float (constraint (_) > (0.0)) (constraint (_) < (250.0)))) (name (str)) (date (datetime)) (_ (float)) (unknown _) (_ _)))",
),
],
)
def test_expressions(src: str, expected: str):
assert ast_str(src) == expected
@pytest.mark.parametrize(
"src,pos,should_fail",
[
("", (1, 1), True),
("42", (1, 1), True),
("True", (1, 1), True),
("Type[", (1, 6), True),
("Type[] Type2", (1, 8), False),
("Type[bool:]", (1, 11), True),
("Type[3]", (1, 6), True),
("Type[bool float]", (1, 11), True),
("Type[bool (_ < 2)]", (1, 11), True),
("Type[bool + _ < 2)]", (1, 13), True),
("Type[bool + (_ < 2]", (1, 19), True),
("Type[bool + (< 2)]", (1, 14), True),
("Type[bool + (_ + 2)]", (1, 16), True),
("Type[bool + (Foo + Bar)]", (1, 14), True),
# ("Type[bool,]", (1, 11), True), # trailing comma is accepted, TODO: update parser or EBNF
("Type[bool, Type[]]", (1, 16), True),
("Type[foo: 3]", (1, 11), True),
],
)
def test_parsing_error(src: str, pos: tuple[int, int], should_fail: bool):
tokens: list[Token] = AnnotationLexer(src).process()
parser: AnnotationParser = AnnotationParser(tokens)
stmt: Optional[Stmt] = parser.parse()
if should_fail:
assert stmt is None
assert len(parser.errors) != 0
error_pos: Position = parser.errors[0].token.position
assert (error_pos.line, error_pos.column) == pos
+202
View File
@@ -0,0 +1,202 @@
import textwrap
import pytest
from core.ast.midas import (
ConstraintExpr,
ConstraintStmt,
Expr,
LiteralExpr,
OpStmt,
PropertyStmt,
Stmt,
TypeBodyExpr,
TypeExpr,
TypeStmt,
WildcardExpr,
)
from lexer.midas import MidasLexer
from lexer.position import Position
from lexer.token import Token
from parser.midas import MidasParser
class AstSerializer(Stmt.Visitor[str], Expr.Visitor[str]):
def serialize(self, stmt: Stmt):
return stmt.accept(self)
def visit_type_stmt(self, stmt: TypeStmt) -> str:
res: str = f"(type_def {stmt.name.lexeme}"
for base in stmt.bases:
res += " " + base.accept(self)
if stmt.body is not None:
res += " " + stmt.body.accept(self)
res += ")"
return res
def visit_type_expr(self, expr: TypeExpr) -> str:
res: str = f"({expr.name.lexeme}"
for constraint in expr.constraints:
res += " " + constraint.accept(self)
res += ")"
return res
def visit_constraint_expr(self, expr: ConstraintExpr) -> str:
return f"(constraint {expr.left.accept(self)} {expr.op.lexeme} {expr.right.accept(self)})"
def visit_wildcard_expr(self, expr: WildcardExpr) -> str:
return "(_)"
def visit_literal_expr(self, expr: LiteralExpr) -> str:
return f"({expr.value})"
def visit_type_body_expr(self, expr: TypeBodyExpr) -> str:
res: str = "(body"
for prop in expr.properties:
res += " " + prop.accept(self)
res += ")"
return res
def visit_property_stmt(self, stmt: PropertyStmt) -> str:
return f"(property {stmt.name.lexeme} {stmt.type.accept(self)})"
def visit_op_stmt(self, stmt: OpStmt) -> str:
left: str = stmt.left.accept(self)
right: str = stmt.right.accept(self)
result: str = stmt.result.accept(self)
return f"(op_def {left} {stmt.op.lexeme} {right} {result})"
def visit_constraint_stmt(self, stmt: ConstraintStmt) -> str:
return f"(constraint_def {stmt.name.lexeme} {stmt.constraint.accept(self)})"
def parse(source: str) -> list[Stmt]:
tokens: list[Token] = MidasLexer(source).process()
return MidasParser(tokens).parse()
def ast_str(source: str) -> list[str]:
stmts: list[Stmt] = parse(source)
return [AstSerializer().serialize(stmt) for stmt in stmts]
@pytest.mark.parametrize(
"src,expected",
[
("type Foo<>", "(type_def Foo)"),
("type Foo<Bar>", "(type_def Foo (Bar))"),
("type Foo<Bar, Baz>", "(type_def Foo (Bar) (Baz))"),
(
"type Foo<Bar + (_ < 2), Baz>",
"(type_def Foo (Bar (constraint (_) < (2.0))) (Baz))",
),
(
"""
type Foo<> {
foo: Bar
}
""",
"(type_def Foo (body (property foo (Bar))))",
),
(
"""
type Foo<> {
foo: Bar + (_ != none)
foo2: Bar2 + (0 <= _) + (_ <= 100)
}
""",
"(type_def Foo (body (property foo (Bar (constraint (_) != (None)))) (property foo2 (Bar2 (constraint (0.0) <= (_)) (constraint (_) <= (100.0))))))",
),
("op <A> + <B> = <C>", "(op_def (A) + (B) (C))"),
(
"op <A + (_ < 100)> + <B + (_ < 100)> = <C + (_ < 200)>",
"(op_def (A (constraint (_) < (100.0))) + (B (constraint (_) < (100.0))) (C (constraint (_) < (200.0))))",
),
(
"constraint Positive = _ >= 0",
"(constraint_def Positive (constraint (_) >= (0.0)))",
),
],
)
def test_expressions(src: str, expected: str | list[str]):
if isinstance(expected, str):
expected = [expected]
assert ast_str(src) == expected
@pytest.mark.parametrize(
"src,pos",
[
###
# Misc
###
("42", (1, 1)),
("true", (1, 1)),
("foo", (1, 1)),
###
# Type statements
###
("type", (1, 5)),
("type true", (1, 6)),
("type Foo", (1, 9)),
("type Foo<1>", (1, 10)),
# ("type Foo<float,>", (1, 16)), # trailing comma is accepted, TODO: update parser or EBNF
("type Foo<float, 1>", (1, 17)),
("type Foo<float", (1, 15)),
("type Foo<float> { 3 }", (1, 19)),
(
"""
type Foo<float> {
foo
}
""",
(4, 1),
),
(
"""
type Foo<float> {
foo: 3
}
""",
(3, 10),
),
###
# Operation statements
###
("op", (1, 3)),
("op float", (1, 4)),
("op <", (1, 5)),
("op <float", (1, 10)),
("op <float>", (1, 11)),
("op <float> +", (1, 13)),
("op <float> + float", (1, 14)),
("op <float> + <", (1, 15)),
("op <float> + <float", (1, 20)),
("op <float> + <float>", (1, 21)),
("op <float> + <float> =", (1, 23)),
("op <float> + <float> = float", (1, 24)),
("op <float> + <float> = <", (1, 25)),
("op <float> + <float> = <float", (1, 30)),
("op <float + 3> + <float> = <float>", (1, 13)),
("op <float> + <float + 3> = <float>", (1, 23)),
("op <float> + <float> = <float + 3>", (1, 33)),
###
# Constraint statements
###
("constraint", (1, 11)),
("constraint 3", (1, 12)),
("constraint Foo", (1, 15)),
("constraint Foo =", (1, 17)),
("constraint Foo = 3", (1, 19)),
("constraint Foo = 3 <", (1, 21)),
],
)
def test_parsing_error(src: str, pos: tuple[int, int]):
src = textwrap.dedent(src)
tokens: list[Token] = MidasLexer(src).process()
parser: MidasParser = MidasParser(tokens)
stmt: list[Stmt] = parser.parse()
assert len(stmt) == 0
assert len(parser.errors) != 0
error_pos: Position = parser.errors[0].token.position
assert (error_pos.line, error_pos.column) == pos