ytdl-sub/src/ytdl_sub/script/parser.py
2023-12-13 14:23:04 -08:00

509 lines
17 KiB
Python

from enum import Enum
from typing import Dict
from typing import List
from typing import Optional
from ytdl_sub.script.types.array import UnresolvedArray
from ytdl_sub.script.types.function import ArgumentType
from ytdl_sub.script.types.function import Function
from ytdl_sub.script.types.map import UnresolvedMap
from ytdl_sub.script.types.resolvable import Boolean
from ytdl_sub.script.types.resolvable import Float
from ytdl_sub.script.types.resolvable import Integer
from ytdl_sub.script.types.resolvable import Lambda
from ytdl_sub.script.types.resolvable import NonHashable
from ytdl_sub.script.types.resolvable import String
from ytdl_sub.script.types.syntax_tree import SyntaxTree
from ytdl_sub.script.types.variable import FunctionArgument
from ytdl_sub.script.types.variable import Variable
from ytdl_sub.script.utils.exception_formatters import ParserExceptionFormatter
from ytdl_sub.script.utils.exceptions import UNREACHABLE
from ytdl_sub.script.utils.exceptions import IncompatibleFunctionArguments
from ytdl_sub.script.utils.exceptions import InvalidSyntaxException
from ytdl_sub.script.utils.exceptions import UserException
from ytdl_sub.utils.exceptions import StringFormattingException
from ytdl_sub.validators.string_formatter_validators import is_valid_source_variable_name
# pylint: disable=invalid-name
# pylint: disable=too-many-branches
# pylint: disable=too-many-return-statements
# pylint: disable=consider-using-ternary
class ParsedArgType(Enum):
SCRIPT = "script"
FUNCTION = "function"
ARRAY = "array"
MAP_KEY = "map key"
MAP_VALUE = "map value"
BRACKET_NOT_CLOSED = InvalidSyntaxException("Bracket not properly closed")
NUMERICS_ONLY_ARGS = InvalidSyntaxException(
"Numerics can only be used as arguments to functions, maps, or arrays"
)
NUMERICS_INVALID_CHAR = InvalidSyntaxException("Invalid value when parsing a numeric")
STRINGS_ONLY_ARGS = InvalidSyntaxException(
"Strings can only be used as arguments to functions, maps, or arrays"
)
STRINGS_NOT_CLOSED = InvalidSyntaxException(
"String was not closed properly. "
"Must open and close with the same type of quote (single/double)"
)
BOOLEAN_ONLY_ARGS = InvalidSyntaxException(
"Booleans can only be used as arguments to functions, maps, or arrays"
)
FUNCTION_INVALID_CHAR = InvalidSyntaxException("Invalid value when parsing a function")
def _UNEXPECTED_CHAR_ARGUMENT(arg_type: ParsedArgType):
return InvalidSyntaxException(f"Unexpected character when parsing {arg_type.value} arguments")
def _UNEXPECTED_COMMA_ARGUMENT(arg_type: ParsedArgType):
return InvalidSyntaxException(f"Unexpected comma when parsing {arg_type.value} arguments")
MAP_KEY_WITH_NO_VALUE = InvalidSyntaxException("Map has a key with no value")
MAP_KEY_MULTIPLE_VALUES = InvalidSyntaxException(
"Map key has multiple values when there should only be one"
)
MAP_MISSING_KEY = InvalidSyntaxException("Map has a missing key")
MAP_KEY_NOT_HASHABLE = InvalidSyntaxException(
"Map key must be a hashable type (Integer, Float, Boolean, String)"
)
def _is_variable_start(char: str) -> bool:
return char.isalpha() and char.islower()
def _is_function_name_char(char: str) -> bool:
return (char.isalpha() and char.islower()) or char.isnumeric() or char == "_"
def _is_numeric_start(char: str) -> bool:
return char.isnumeric() or char == "-"
def _is_string_start(char: str) -> bool:
return char in ["'", '"']
def _is_breakable(char: str) -> bool:
return char in ["}", ",", ")", "]"] or char.isspace()
def _is_boolean_true(string: Optional[str]) -> bool:
return string == "True"
def _is_boolean_false(string: Optional[str]) -> bool:
return string == "False"
class _Parser:
def __init__(self, text: str):
self._text = text
self._pos = 0
self._error_highlight_pos = 0
self._ast: List[ArgumentType] = []
try:
self._syntax_tree = self._parse()
except UserException as exc:
raise ParserExceptionFormatter(
self._text, self._error_highlight_pos, self._pos, exc
).highlight() from exc
@property
def ast(self) -> SyntaxTree:
"""
Returns
-------
Abstract syntax tree of the parsed text
"""
return self._syntax_tree
def _set_highlight_position(self, pos: Optional[int] = None) -> None:
self._error_highlight_pos = pos if pos is not None else self._pos
def _read(self, increment_pos: bool = True, length: int = 1) -> Optional[str]:
if self._pos >= len(self._text):
return None
try:
ch = self._text[self._pos : (self._pos + length)]
except IndexError:
return None
if increment_pos:
self._pos += length
return ch
def _parse_variable(self) -> Variable:
var_name = ""
while ch := self._read(increment_pos=False):
if ch.isspace() and not var_name:
self._pos += 1
continue
if _is_breakable(ch):
break
is_lower = ch.isascii() and ch.islower()
if not var_name and not is_lower:
raise StringFormattingException("invalid var name")
if not (is_lower or ch.isnumeric() or ch == "_"):
raise StringFormattingException("invalid var name")
var_name += ch
self._pos += 1
if not var_name:
raise StringFormattingException("invalid var name")
assert is_valid_source_variable_name(var_name, raise_exception=False)
return Variable(var_name)
def _parse_custom_function_argument(self) -> FunctionArgument:
"""
Begin parsing function args after the first ``$``, i.e. ``$0``
"""
var_name = ""
while ch := self._read(increment_pos=False):
if ch.isspace() and not var_name:
self._pos += 1
continue
if _is_breakable(ch):
break
is_numeric = ch.isnumeric()
if not is_numeric:
raise StringFormattingException("invalid function var name")
var_name += ch
self._pos += 1
if not var_name:
raise StringFormattingException("invalid var name")
return FunctionArgument(name=f"${var_name}")
def _parse_numeric(self) -> Integer | Float:
numeric_string = ""
if self._read(increment_pos=False) == "-":
numeric_string += "-"
self._pos += 1
if has_decimal := (self._read(increment_pos=False) == "."):
numeric_string += "."
self._pos += 1
while ch := self._read(increment_pos=False):
if ch == "-":
raise NUMERICS_INVALID_CHAR
if ch == ".":
if has_decimal:
raise NUMERICS_INVALID_CHAR
has_decimal = True
self._pos += 1
numeric_string += ch
elif ch.isnumeric():
self._pos += 1
numeric_string += ch
elif _is_breakable(ch):
break
else:
self._set_highlight_position()
raise NUMERICS_INVALID_CHAR
if numeric_string in (".", "-"):
raise NUMERICS_INVALID_CHAR
try:
numeric_float = float(numeric_string)
except ValueError as exc:
raise UNREACHABLE from exc
if (numeric_int := int(numeric_float)) == numeric_float:
return Integer(value=numeric_int)
return Float(value=numeric_float)
def _parse_string(self) -> String:
"""
Begin parsing a string, including the quotation value
"""
self._set_highlight_position()
string_value = ""
open_quotation_char = self._read()
if not _is_string_start(open_quotation_char):
raise UNREACHABLE
is_escaped = False
while ch := self._read():
if ch == open_quotation_char and not is_escaped:
return String(value=string_value)
if ch == "\\" and not is_escaped:
is_escaped = True
continue
string_value += ch
is_escaped = False
raise STRINGS_NOT_CLOSED
def _parse_function_arg(self, argument_parser: ParsedArgType) -> ArgumentType:
if self._read(increment_pos=False) == "%":
self._pos += 1
return self._parse_function()
if _is_numeric_start(self._read(increment_pos=False)):
return self._parse_numeric()
if _is_boolean_true(self._read(increment_pos=False, length=4)):
self._pos += 4
return Boolean(value=True)
if _is_boolean_false(self._read(increment_pos=False, length=5)):
self._pos += 5
return Boolean(value=False)
if _is_string_start(self._read(increment_pos=False)):
return self._parse_string()
if self._read(increment_pos=False) == "[":
self._pos += 1
return self._parse_array()
if self._read(increment_pos=False) == "{":
self._pos += 1
return self._parse_map()
if self._read(increment_pos=False) == "$":
self._pos += 1
return self._parse_custom_function_argument()
if _is_variable_start(self._read(increment_pos=False)):
return self._parse_variable()
self._set_highlight_position()
raise _UNEXPECTED_CHAR_ARGUMENT(arg_type=argument_parser)
def _parse_args(
self, argument_parser: ParsedArgType, breaking_chars: str = ")"
) -> List[ArgumentType]:
"""
Begin parsing function args after the first ``(``, i.e. ``function_name(``
"""
comma_count = 0
arguments: List[ArgumentType] = []
while ch := self._read(increment_pos=False):
if ch in breaking_chars:
# i.e. ["arg", ] which is invalid
if arguments and len(arguments) == comma_count:
raise _UNEXPECTED_COMMA_ARGUMENT(argument_parser)
break
if ch.isspace():
self._pos += 1
elif ch == ",":
self._set_highlight_position()
comma_count += 1
if len(arguments) != comma_count:
raise _UNEXPECTED_COMMA_ARGUMENT(argument_parser)
self._pos += 1
else:
arguments.append(self._parse_function_arg(argument_parser=argument_parser))
return arguments
def _parse_function(self) -> Function | Lambda:
"""
Begin parsing a function after reading the first ``%``
"""
function_name: str = ""
function_args: Optional[List[ArgumentType]] = None
function_start_pos = self._pos
while ch := self._read():
if ch == ")":
if function_args is not None:
# Had '(' to indicate there are args
try:
return Function.from_name_and_args(name=function_name, args=function_args)
except IncompatibleFunctionArguments:
self._set_highlight_position(function_start_pos)
raise
# Go back one so the parent function can close using the ')'
self._pos -= 1
return Lambda(function_name=function_name)
if _is_function_name_char(ch):
function_name += ch
elif ch == "(":
function_args = self._parse_args(argument_parser=ParsedArgType.FUNCTION)
elif ch.isspace() or ch == ",":
# function with no args, it's a lambda
return Lambda(function_name=function_name)
else:
break
self._set_highlight_position(pos=self._pos - 1)
raise FUNCTION_INVALID_CHAR
def _parse_array(self) -> UnresolvedArray:
"""
Begin parsing an array after reading the first ``[``
"""
function_args: List[ArgumentType] = []
while ch := self._read(increment_pos=False):
if ch == "]":
self._pos += 1
return UnresolvedArray(value=function_args)
function_args = self._parse_args(
argument_parser=ParsedArgType.ARRAY, breaking_chars="]"
)
raise UNREACHABLE
def _parse_map(self) -> UnresolvedMap:
"""
Begin parsing a map after reading the first ``{``
"""
output: Dict[ArgumentType, ArgumentType] = {}
key: Optional[ArgumentType] = None
in_comma = False
self._set_highlight_position()
while ch := self._read(increment_pos=False):
if ch == "}":
if key is not None:
raise MAP_KEY_WITH_NO_VALUE
self._pos += 1
return UnresolvedMap(value=output)
if ch == ",":
if in_comma:
raise _UNEXPECTED_COMMA_ARGUMENT(ParsedArgType.MAP_KEY)
if key is not None:
raise MAP_KEY_WITH_NO_VALUE
if not output:
raise _UNEXPECTED_COMMA_ARGUMENT(ParsedArgType.MAP_KEY)
in_comma = True
self._pos += 1
elif key is None:
self._set_highlight_position()
in_comma = False
key_args = self._parse_args(
argument_parser=ParsedArgType.MAP_KEY, breaking_chars=":}"
)
if len(key_args) == 0 and self._read(increment_pos=False) == "}":
continue # will return the map next iteration
if len(key_args) == 0:
raise MAP_MISSING_KEY
if len(key_args) > 1:
raise MAP_KEY_MULTIPLE_VALUES
key = key_args[0]
elif key is not None and ch == ":":
self._set_highlight_position()
self._pos += 1
value_args = self._parse_args(
argument_parser=ParsedArgType.MAP_VALUE, breaking_chars=",}"
)
if len(value_args) == 0:
raise MAP_KEY_WITH_NO_VALUE
if isinstance(key, NonHashable):
raise MAP_KEY_NOT_HASHABLE
if len(value_args) > 1:
raise UNREACHABLE
output[key] = value_args[0]
key = None
else:
raise UNREACHABLE
def _parse(self) -> SyntaxTree:
bracket_counter_pos_stack: List[int] = []
bracket_counter = 0
literal_str = ""
while ch := self._read():
if ch == "}":
if bracket_counter == 0:
raise BRACKET_NOT_CLOSED
del bracket_counter_pos_stack[-1]
bracket_counter -= 1
continue
if ch == "{":
bracket_counter_pos_stack.append(self._pos - 1) # pos incremented when read
bracket_counter += 1
if literal_str:
self._ast.append(String(value=literal_str))
literal_str = ""
# Allow whitespace after bracket opening
while ch1 := self._read(increment_pos=False):
if not ch1.isspace():
break
self._pos += 1
if ch1 is None:
raise StringFormattingException(
"Open bracket at the end was not properly closed"
)
if ch1 == "%":
self._pos += 1
self._ast.append(self._parse_function())
elif ch1 == "[":
self._pos += 1
self._ast.append(self._parse_array())
elif ch1 == "{":
self._pos += 1
self._ast.append(self._parse_map())
elif _is_variable_start(ch1):
self._ast.append(self._parse_variable())
elif _is_numeric_start(ch1):
raise NUMERICS_ONLY_ARGS
elif _is_string_start(ch1):
raise STRINGS_ONLY_ARGS
elif _is_boolean_true(
self._read(increment_pos=False, length=4)
) or _is_boolean_false(self._read(increment_pos=False, length=5)):
raise BOOLEAN_ONLY_ARGS
else:
raise _UNEXPECTED_CHAR_ARGUMENT(arg_type=ParsedArgType.SCRIPT)
elif bracket_counter == 0:
# Only accumulate literal str if not in brackets
literal_str += ch
else:
# Should only be possible to get here if it's a space
assert ch.isspace()
if bracket_counter != 0:
self._error_highlight_pos = bracket_counter_pos_stack[-1]
raise BRACKET_NOT_CLOSED
if literal_str:
self._ast.append(String(value=literal_str))
return SyntaxTree(ast=self._ast)
def parse(text: str) -> SyntaxTree:
"""
Entrypoint for parsing ytdl-sub code into a Syntax Tree
"""
return _Parser(text).ast
# pylint: enable=invalid-name