338 lines
12 KiB
Python
338 lines
12 KiB
Python
from contextlib import contextmanager
|
|
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 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.exceptions import InvalidSyntaxException
|
|
from ytdl_sub.utils.exceptions import StringFormattingException
|
|
from ytdl_sub.validators.string_formatter_validators import is_valid_source_variable_name
|
|
|
|
# pylint: disable=invalid-name
|
|
|
|
|
|
class _Parser:
|
|
def __init__(self, text: str):
|
|
self._text = text
|
|
self._pos = 0
|
|
self._ast: List[ArgumentType] = []
|
|
|
|
self._syntax_tree = self._parse()
|
|
|
|
@property
|
|
def ast(self) -> SyntaxTree:
|
|
"""
|
|
Returns
|
|
-------
|
|
Abstract syntax tree of the parsed text
|
|
"""
|
|
return self._syntax_tree
|
|
|
|
@contextmanager
|
|
def _error_formatting(self) -> None:
|
|
parked_pos = self._pos
|
|
try:
|
|
yield
|
|
except InvalidSyntaxException as exc:
|
|
border = 4
|
|
text_left = max(0, parked_pos - border)
|
|
text_right = min(len(self._text), self._pos + border)
|
|
text_len = text_right - text_left
|
|
|
|
raise InvalidSyntaxException(
|
|
"Invalid syntax:\n"
|
|
f" {self._text[text_left:text_right]}\n"
|
|
f" {' ' * border}{'^' * text_len}\n\n"
|
|
f"{str(exc)}"
|
|
) from exc
|
|
|
|
def _read(self, increment_pos: bool = True, length: int = 1) -> Optional[str]:
|
|
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 ch in ["}", ",", ")", "]"] or ch.isspace():
|
|
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_function_argument(self) -> FunctionArgument:
|
|
"""
|
|
Begin parsing function args after the first ``$``, i.e. ``$1``
|
|
"""
|
|
var_name = ""
|
|
while ch := self._read(increment_pos=False):
|
|
if ch.isspace() and not var_name:
|
|
self._pos += 1
|
|
continue
|
|
if ch in ["}", ",", ")", "]"] or ch.isspace():
|
|
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 = ""
|
|
while ch := self._read(increment_pos=False):
|
|
if not (ch.isnumeric() or ch == "."):
|
|
break
|
|
|
|
self._pos += 1
|
|
numeric_string += ch
|
|
|
|
try:
|
|
numeric_float = float(numeric_string)
|
|
except ValueError:
|
|
raise StringFormattingException(f"Invalid numeric: {numeric_string}")
|
|
|
|
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
|
|
"""
|
|
string_value = ""
|
|
open_quotation_char = self._read()
|
|
assert open_quotation_char in ["'", '"']
|
|
|
|
while ch := self._read():
|
|
if ch == open_quotation_char:
|
|
return String(value=string_value)
|
|
string_value += ch
|
|
|
|
raise StringFormattingException("String not closed")
|
|
|
|
def _parse_function_arg(self) -> ArgumentType:
|
|
if self._read(increment_pos=False) == "%":
|
|
self._pos += 1
|
|
return self._parse_function()
|
|
if self._read(increment_pos=False).isnumeric():
|
|
return self._parse_numeric()
|
|
if (self._read(increment_pos=False, length=4) or "").lower() == "true":
|
|
self._pos += 4
|
|
return Boolean(value=True)
|
|
if (self._read(increment_pos=False, length=5) or "").lower() == "false":
|
|
self._pos += 5
|
|
return Boolean(value=False)
|
|
if self._read(increment_pos=False) in ["'", '"']:
|
|
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_function_argument()
|
|
if self._read(increment_pos=False).isascii() and self._read(increment_pos=False).islower():
|
|
return self._parse_variable()
|
|
raise StringFormattingException(
|
|
"Invalid function argument, should be either a function, int, float, "
|
|
"string, boolean, or variable without brackets"
|
|
)
|
|
|
|
def _parse_args(self, breaking_chars: str = ")") -> List[ArgumentType]:
|
|
"""
|
|
Begin parsing function args after the first ``(``, i.e. ``function_name(``
|
|
"""
|
|
argument_index = 0
|
|
comma_count = 0
|
|
|
|
arguments: List[ArgumentType] = []
|
|
while ch := self._read(increment_pos=False):
|
|
if ch in breaking_chars:
|
|
break
|
|
|
|
if ch.isspace():
|
|
self._pos += 1
|
|
elif ch == ",":
|
|
comma_count += 1
|
|
if argument_index != comma_count:
|
|
raise StringFormattingException("Comma argument shenanigans")
|
|
|
|
self._pos += 1
|
|
else:
|
|
argument_index += 1
|
|
arguments.append(self._parse_function_arg())
|
|
|
|
return arguments
|
|
|
|
def _parse_function(self) -> Function:
|
|
"""
|
|
Begin parsing a function after reading the first ``%``
|
|
"""
|
|
function_name: str = ""
|
|
function_args: List[ArgumentType] = []
|
|
|
|
while ch := self._read():
|
|
if ch == ")":
|
|
return Function.from_name_and_args(name=function_name, args=function_args)
|
|
|
|
if ch != "(":
|
|
function_name += ch
|
|
else:
|
|
function_args = self._parse_args()
|
|
|
|
raise StringFormattingException("Invalid function")
|
|
|
|
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)
|
|
else:
|
|
function_args = self._parse_args(breaking_chars="]")
|
|
|
|
raise StringFormattingException("Invalid function")
|
|
|
|
def _parse_map(self) -> UnresolvedMap:
|
|
"""
|
|
Begin parsing a map after reading the first ``{``
|
|
"""
|
|
output: Dict[ArgumentType, ArgumentType] = {}
|
|
key: Optional[ArgumentType] = None
|
|
in_comma = False
|
|
|
|
with self._error_formatting():
|
|
while ch := self._read(increment_pos=False):
|
|
if ch == "}":
|
|
if key is not None:
|
|
raise InvalidSyntaxException("Map has a key with no value")
|
|
|
|
self._pos += 1
|
|
return UnresolvedMap(value=output)
|
|
elif ch == ",":
|
|
if in_comma:
|
|
raise StringFormattingException("Comma followed by comma")
|
|
if key is not None:
|
|
raise InvalidSyntaxException("Map has a key with no value")
|
|
if output is None:
|
|
raise StringFormattingException("Empty dict with comma")
|
|
in_comma = True
|
|
self._pos += 1
|
|
elif key is None:
|
|
in_comma = False
|
|
key_args = self._parse_args(breaking_chars=":")
|
|
if len(key_args) != 1:
|
|
raise StringFormattingException("Lazy parsing but got mlutiple args")
|
|
key = key_args[0]
|
|
elif key is not None and ch == ":":
|
|
self._pos += 1
|
|
value_args = self._parse_args(breaking_chars=",}")
|
|
if len(value_args) != 1:
|
|
raise InvalidSyntaxException("Map has a key with no value")
|
|
|
|
output[key] = value_args[0]
|
|
key = None
|
|
else:
|
|
raise StringFormattingException("Invalid map")
|
|
|
|
def _parse(self) -> SyntaxTree:
|
|
bracket_counter = 0
|
|
literal_str = ""
|
|
while ch := self._read():
|
|
if ch == "}":
|
|
bracket_counter -= 1
|
|
continue
|
|
if ch == "{":
|
|
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())
|
|
else:
|
|
self._ast.append(self._parse_variable())
|
|
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:
|
|
raise StringFormattingException("Bracket count mismatch")
|
|
|
|
if literal_str:
|
|
self._ast.append(String(value=literal_str))
|
|
|
|
return SyntaxTree(ast=self._ast)
|
|
|
|
|
|
def parse(text: str) -> SyntaxTree:
|
|
return _Parser(text).ast
|
|
|
|
|
|
# pylint: enable=invalid-name
|