diff --git a/src/ytdl_sub/script/parser.py b/src/ytdl_sub/script/parser.py index 6614775e..c2a6d200 100644 --- a/src/ytdl_sub/script/parser.py +++ b/src/ytdl_sub/script/parser.py @@ -50,6 +50,10 @@ NUMERICS_INVALID_CHAR = InvalidSyntaxException("Invalid value when parsing a num 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)" +) def UNEXPECTED_CHAR_ARGUMENT(parser: ArgumentParser): @@ -221,18 +225,26 @@ class _Parser: """ 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: + if ch == open_quotation_char and not is_escaped: return String(value=string_value) - string_value += ch - raise StringFormattingException("String not closed") + 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: ArgumentParser) -> ArgumentType: if self._read(increment_pos=False) == "%": diff --git a/tests/unit/script/types/test_string.py b/tests/unit/script/types/test_string.py index c8dd9a8e..41b3f48e 100644 --- a/tests/unit/script/types/test_string.py +++ b/tests/unit/script/types/test_string.py @@ -3,7 +3,7 @@ from typing import Tuple import pytest -from ytdl_sub.script.parser import NUMERICS_INVALID_CHAR +from ytdl_sub.script.parser import NUMERICS_INVALID_CHAR, STRINGS_NOT_CLOSED from ytdl_sub.script.parser import NUMERICS_ONLY_ARGS from ytdl_sub.script.parser import STRINGS_ONLY_ARGS from ytdl_sub.script.parser import UNEXPECTED_CHAR_ARGUMENT @@ -32,3 +32,37 @@ class TestString: def test_string_not_arg(self, string: str): with pytest.raises(InvalidSyntaxException, match=re.escape(str(STRINGS_ONLY_ARGS))): Script({"string": string}).resolve() + + @pytest.mark.parametrize( + "string, expected_string", + [ + ("{%string('323')}", "323"), + ('{%string( "4253" )}', "4253"), + ('{%string("hi")}', "hi"), + ('{%string( "asfsd" )}', "asfsd"), + ('{%string("sdfasf")}', "sdfasf"), + ("{%string( '3fsdf' )}", "3fsdf"), + ("{%string('newlines \n newlines')}", "newlines \n newlines"), + ("{%string('in function')} out of function", "in function out of function"), + ("{%string('supports \" in string')}", 'supports " in string'), + ('{%string("supports \' in string")}', "supports ' in string"), + ('{%string("\\" in string with open \\"")}', '" in string with open "'), + ("{%string('\\' in string with open \\'')}", "' in string with open '"), + ("{%string('backslash \\\\')}", "backslash \\"), + ], + ) + def test_string(self, string: str, expected_string: str): + assert Script({"string": string}).resolve() == {"string": String(expected_string)} + + @pytest.mark.parametrize( + "string", + [ + "{%string('open only single)}", + '{%string( "open only double )}', + "{%string(\"open double close single ')}", + "{%string( 'open single close double\" )}", + ], + ) + def test_string_not_closed_properly(self, string: str): + with pytest.raises(InvalidSyntaxException, match=re.escape(str(STRINGS_NOT_CLOSED))): + Script({"string": string}).resolve()