diff --git a/src/ast_types.py b/src/ast_types.py index 12058ca..8548f51 100644 --- a/src/ast_types.py +++ b/src/ast_types.py @@ -80,3 +80,10 @@ self.success, self.failure, ) + + def __eq__(self, other): + return ( + self.test == other.test + and self.success == other.success + and self.failure == other.failure + ) diff --git a/src/parse.py b/src/parse.py index 7a90293..3f56ada 100644 --- a/src/parse.py +++ b/src/parse.py @@ -2,7 +2,7 @@ # Copyright 2022 Jookia import enum -from src.ast_types import Bool, Reference, Set, Statement, Text +from src.ast_types import Bool, Conditional, Reference, Set, Statement, Text from src.token import TokenStream @@ -40,6 +40,10 @@ PARSE_VERB = enum.auto() # pragma: no mutate PARSE_ARGUMENT = enum.auto() # pragma: no mutate PARSE_SET = enum.auto() # pragma: no mutate + PARSE_CONDITIONAL = enum.auto() # pragma: no mutate + PARSE_TEST = enum.auto() # pragma: no mutate + PARSE_SUCCESS = enum.auto() # pragma: no mutate + PARSE_FAILURE = enum.auto() # pragma: no mutate # Context used for parse error exception @@ -253,6 +257,20 @@ statement = self.parse_statement(stream, context, "EndSet") return Set(subject.value, statement) + # Parses a conditional node + def parse_conditional(self, stream, parent_context): + context = ParseContext( + ParseTask.PARSE_CONDITIONAL, stream.peek(), parent_context + ) + read_token(stream, "If", context) + test_context = ParseContext(ParseTask.PARSE_TEST, stream.peek(), context) + test = self.parse_statement(stream, test_context, "Then") + success_context = ParseContext(ParseTask.PARSE_SUCCESS, stream.peek(), context) + success = self.parse_statement(stream, success_context, "Else") + failure_context = ParseContext(ParseTask.PARSE_FAILURE, stream.peek(), context) + failure = self.parse_statement(stream, failure_context, "EndIf") + return Conditional(test, success, failure) + # Parses tokens def parse(tokens, context): diff --git a/tests/parse/test_conditional.py b/tests/parse/test_conditional.py new file mode 100644 index 0000000..2420581 --- /dev/null +++ b/tests/parse/test_conditional.py @@ -0,0 +1,224 @@ +# SPDX-License-Identifier: LGPL-2.1-only +# Copyright 2022 Jookia + +# Conditional syntax consists of the following tokens: +# - "If" +# - A test statement, terminated by "Then" +# - A success statement, terminated by "Else" +# - A failure statement, terminated by "EndIf" +# +# Parsing gives a Conditional data structure containing: +# test - The parsed test statement +# success - The parsed success statement +# failure - The parsed failure statement +# +# The following cases are errors: +# - If not being the literal "To" +# - The test not parsing correctly +# - The success not parsing correctly +# - The failure not parsing correctly +# +# The following error contexts are used: +# PARSE_CONDITIONAL - Used when parsing the general syntax +# PARSE_TEST - Used when parsing the test statement +# PARSE_SUCCESS - Used when parsing the success statement +# PARSE_FAILURE - Used when parsing the failure statement +# +# The following parse errors are generated: +# NO_TOKEN - When there's not enough tokens +# WRONG_TOKEN - When If isn't the correct values +# +# The following parsers are used and have their errors +# and data structures propagated: +# parse_statement - Used with "Then" terminator for the test statement +# parse_statement - Used with "Else" terminator for the success statement +# parse_statement - Used with "EndIf" terminator for the failure statement + +import enum + +from hypothesis import assume, given +from hypothesis.strategies import data, integers, just, one_of + +from src.ast_types import Conditional +from src.parse import ( + ParseContext, + ParseError, + ParseErrorException, + ParseTask, + Parser, + read_token, +) +from tests.parse.templates import ( + template_test_valid, + template_test_invalid, +) +from tests.test_token import ( + draw_token_random, + static_token_by_value, +) +from tests.parse.test_parse import static_parse_context + +# +# Helper functions +# + + +# Values used by the mocked parser +class MockStatement(enum.Enum): + MockTest = enum.auto() + MockSuccess = enum.auto() + MockFailure = enum.auto() + + +# Mocks and tests the parse_statement parser +# Instead of parsing a complex statement it just parses +# a the following tokens: MockTest, MockSuccess, MockFailure +# The terminator is required to be: +# - "Then" for MockTest +# - "Else" for MockSuccess +# - "EndIf" for MockFailure +class MockParser(Parser): + def parse_statement(self, stream, parent_context, terminator): + token = read_token(stream, None, parent_context) + if token.value == "MockTest" and terminator == "Then": + return MockStatement.MockTest + elif token.value == "MockSuccess" and terminator == "Else": + return MockStatement.MockSuccess + elif token.value == "MockFailure" and terminator == "EndIf": + return MockStatement.MockFailure + else: + raise ParseErrorException( + ParseError.WRONG_TOKEN, token, None, parent_context + ) + + +# A valid conditional expression and tokens +def static_conditional_valid_tokens(): + tokens = [ + static_token_by_value("If"), + static_token_by_value("MockTest"), + static_token_by_value("MockSuccess"), + static_token_by_value("MockFailure"), + ] + expected = Conditional( + MockStatement.MockTest, MockStatement.MockSuccess, MockStatement.MockFailure + ) + return (tokens, expected) + + +# Calculates the parse context for a specific token in a conditional expression +def context_at(parent_context, tokens, index): + max = len(tokens) - 1 + if max == -1: + start = None + token = None + elif max < index: + start = tokens[0] + token = None + else: + start = tokens[0] + token = tokens[index] + context = ParseContext(ParseTask.PARSE_CONDITIONAL, start, parent_context) + if index == 0: + return context + elif index == 1: + subcontext = ParseContext(ParseTask.PARSE_TEST, token, context) + return subcontext + elif index == 2: + subcontext = ParseContext(ParseTask.PARSE_SUCCESS, token, context) + return subcontext + elif index == 3: + subcontext = ParseContext(ParseTask.PARSE_FAILURE, token, context) + return subcontext + else: + assert "Should never be called" + + +# Draws the wrong statement for a given conditional position +def draw_wrong_statement_at(index): + random = draw_token_random() + test = just(static_token_by_value("MockTest")) + success = just(static_token_by_value("MockSuccess")) + failure = just(static_token_by_value("MockFailure")) + if index == 1: + return one_of([success, failure, random]) + elif index == 2: + return one_of([test, failure, random]) + elif index == 3: + return one_of([test, success, random]) + else: + assert "Should never be called" + + +# +# Test functions +# + +# Tests parsing a valid statement +# We expect the following behaviour: +# - The test statement is read and assigned +# - The success statement is read and assigned +# - The failure statement is read and assigned +def test_parse_conditional_valid(): + (tokens, expected) = static_conditional_valid_tokens() + parser = MockParser().parse_conditional + return template_test_valid(parser, tokens, expected) + + +# Tests parsing a truncated statement +# We expect the following behaviour: +# - A NO_TOKEN parse error is raised +# - The error context is PARSE_CONDITIONAL +# - The test statement has its own subcontext, PARSE_TEST +# - The success statement has its own subcontext, PARSE_SUCCESS +# - The failure statement has its own subcontext, PARSE_FAILURE +@given(data()) +def test_parse_conditional_short(data): + (tokens, _) = static_conditional_valid_tokens() + new_len = data.draw( + integers(min_value=0, max_value=(len(tokens) - 1)), label="shorten point" + ) + short_tokens = tokens[0:new_len] + parent_context = static_parse_context() + context = context_at(parent_context, short_tokens, new_len) + error = ParseErrorException(ParseError.NO_TOKEN, None, None, context) + parser = MockParser().parse_conditional + template_test_invalid(parser, parent_context, short_tokens, error) + + +# Tests parsing an invalid "If" +# We expect the following behaviour: +# - A WRONG_TOKEN parse error is raised +# - The error context is PARSE_CONDITIONAL +# - The token "If" is expected +@given(data()) +def test_parse_conditional_wrong_if(data): + (tokens, _) = static_conditional_valid_tokens() + new_if = data.draw(draw_token_random(), label="new if") + assume(new_if.value != "If") + new_tokens = [new_if] + tokens[1:] + parent_context = static_parse_context() + context = context_at(parent_context, new_tokens, 0) + error = ParseErrorException(ParseError.WRONG_TOKEN, new_if, "If", context) + parser = MockParser().parse_conditional + template_test_invalid(parser, parent_context, new_tokens, error) + + +# Tests parsing an invalid statement +# We expect the following behaviour: +# - A WRONG_TOKEN parse error is raised by the mock parser +# - The error context is PARSE_CONDITIONAL +# - Our error context is retained by parse_statement +@given(data()) +def test_parse_conditional_wrong_statement(data): + (tokens, _) = static_conditional_valid_tokens() + statement_pos = data.draw( + integers(min_value=1, max_value=(len(tokens) - 1)), label="statement position" + ) + new_statement = data.draw(draw_wrong_statement_at(statement_pos)) + new_tokens = tokens[0:statement_pos] + [new_statement] + tokens[statement_pos + 1 :] + parent_context = static_parse_context() + context = context_at(parent_context, new_tokens, statement_pos) + error = ParseErrorException(ParseError.WRONG_TOKEN, new_statement, None, context) + parser = MockParser().parse_conditional + template_test_invalid(parser, parent_context, new_tokens, error)