Newer
Older
NewLang / tests / parse / test_error.py
# SPDX-License-Identifier: LGPL-2.1-only
# Copyright 2022 Jookia <contact@jookia.org>

# Parse error reporting consists of the following data structures:
#
# ParseTasks represent a task such as parsing a file or directive.
#
# ParseContexts represent a step during the parsing process for the purposes of
# error reporting.
# A context contains:
# - A ParseTask specifying the step
# - The Token the task started at (possibly None)
# - A parent ParseContext (possibly None) if this task is a component
#   of another task
#
# ParseErrors represent an error encountered during a parsing task, such as an
# unexpected token or invalid token.
#
# ParseErrorExceptions represent an error encounted during parsing.
# An error exception contains:
# - A ParseError detailing the error
# - The Token the error is at (possibly None)
# - An expected string (possibly None), used if the parser is expecting
#   a single, specific token such as a keyword
# - A ParseContext detailing the current parsing task

from hypothesis import given
from hypothesis.strategies import composite, integers, sampled_from, text

from src.i18n import Message
from src.parse import (
    ParseContext,
    ParseError,
    ParseErrorException,
    ParseTask,
    format_context,
    format_exception,
    format_full_error,
)
from tests.templates import template_test_structure
from tests.test_token import draw_token_random, static_token

#
# Helper functions
#


# Draws a strategy, with 25% of draws being None
@composite
def draw_maybe(draw, strategy):
    chance = draw(integers(min_value=1, max_value=4))
    if chance == 1:
        return None
    else:
        return draw(strategy)


# Draws a random parse task
@composite
def draw_parse_task(draw):
    return draw(sampled_from(list(ParseTask)))


# Draws a random parse context without a parent
@composite
def draw_parse_context(draw):
    task = draw(draw_parse_task())
    token = draw(draw_maybe(draw_token_random()))
    context = draw(draw_maybe(draw_parse_context()))
    return ParseContext(task, token, context)


# Static parse context
def static_parse_context():
    task = ParseTask.TEST_TASK
    token = static_token()
    return ParseContext(task, token, None)


# Draws a random parse error
@composite
def draw_parse_error(draw):
    return draw(sampled_from(list(ParseError)))


# Draws a random parse error exception
@composite
def draw_parse_error_exception(draw):
    error = draw(draw_parse_error())
    token = draw(draw_maybe(draw_token_random()))
    expected = draw(draw_maybe(text()))
    context = draw(draw_parse_context())
    return ParseErrorException(error, token, expected, context)


#
# Test functions
#


# Test parse context structure
@template_test_structure(
    ParseContext,
    draw_parse_context(),
    task=draw_parse_task(),
    token=draw_maybe(draw_token_random()),
    parent=draw_maybe(draw_parse_context()),
)
def test_parse_context_structure():
    pass


# Test parse error exception structure
@template_test_structure(
    ParseErrorException,
    draw_parse_error_exception(),
    error=draw_parse_error(),
    token=draw_maybe(draw_token_random()),
    expected=draw_maybe(text()),
    context=draw_maybe(draw_parse_context()),
)
def test_parse_error_exception_structure():
    pass


# Tests formatting a ParseContext
# We expect the following behaviour:
# - A Message is returned
# - The message ID begins with ParseContext
# - The first parameter is task
# - If the token field is set, the ID is appended with
#   "At" and the second and third parameters are the token's
#   location's line and offset
# Two combinations are possible:
# - Message("ParseContext", [task])
# - Message("ParseContextAt", [task, line, offset])
@given(draw_parse_context())
def test_parse_error_format_context(context):
    task = context.task
    has_location = context.token is not None
    if has_location:
        line = context.token.location.line
        offset = context.token.location.offset
        expected = Message("ParseContextAt", [task, line, offset])
    else:
        expected = Message("ParseContext", [task])
    value = format_context(context)
    assert expected == value


# Tests formatting a ParseErrorException
# We expect the following behaviour:
# - A Message is returned
# - The message ID begins with ParseError
# - If the expected field is set, the ID is appended with
#   "Expected" and the first parameter is the expected value
# - Otherwise the first parameter is the error
# - If the token field is set, the ID is appended with
#   "At" and the second and third parameters are the token's
#   location's line and offset
# Four combinations are possible:
# - Message("ParseError", [error])
# - Message("ParseErrorAt", [error, line, offset])
# - Message("ParseErrorExpected", [expected])
# - Message("ParseErrorExpectedAt", [expected, line, offset])
@given(draw_parse_error_exception())
def test_parse_error_format_exception(exception):
    has_expected = exception.expected is not None
    has_location = exception.token is not None
    # Variables used for message parameters
    err = exception.error
    expect = exception.expected
    if has_location:
        line = exception.token.location.line
        offset = exception.token.location.offset
    else:
        line = None
        offset = None
    # Truth table used for message lookup
    # Indexes are has_expected and has_location
    messages = [
        # Cases without an expected token:
        [Message("ParseError", [err]), Message("ParseErrorAt", [err, line, offset])],
        # Cases with an expected token:
        [
            Message("ParseErrorExpected", [expect]),
            Message("ParseErrorExpectedAt", [expect, line, offset]),
        ],
    ]
    expected = messages[has_expected][has_location]
    value = format_exception(exception)
    assert expected == value


# Tests formatting a full error
# We expect the following behaviour:
# - An array of Messages are returned
# - The first message is the exception formatted
# - The second messages is the exception's context formatted
# - Subsequent messages are of any context decedents formatted
@given(draw_parse_error_exception())
def test_parse_error_format_full_error(exception):
    expected = [format_exception(exception)]
    context = exception.context
    while context is not None:
        expected.append(format_context(context))
        context = context.parent
    value = format_full_error(exception)
    assert expected == value