diff --git a/json-test/json-test.py b/json-test/json-test.py new file mode 100644 index 0000000000000000000000000000000000000000..30efea84a6127b64ebf48f79d2967bfcd3a1ad65 --- /dev/null +++ b/json-test/json-test.py @@ -0,0 +1,69 @@ +"""Unit tests for JSON parser written in my parser combinator.""" + + +import json +from json2 import ( + json_value, + json_string, + _json_keyword, + json_number, +) + +from muppet.parser_combinator import ParserCombinator + + +def test_string(): # noqa: D103 + assert ParserCombinator('"Hello"').get(json_string) == "Hello" + + +def test_number_int(): # noqa: D103 + assert ParserCombinator("1").get(json_number) == [1] + + +def test_number_decimal(): # noqa: D103 + assert ParserCombinator("1.1").get(json_number) == [1.1] + + +def test_number_exp(): # noqa: D103 + assert ParserCombinator("1e-10").get(json_number) == [1e-10] + + +def test_number_full(): # noqa: D103 + assert ParserCombinator("-1.1e10").get(json_number) == [-1.1e10] + + +def test_keyword(): # noqa: D103 + assert ParserCombinator("true").get(_json_keyword) == [True] + assert ParserCombinator("false").get(_json_keyword) == [False] + assert ParserCombinator("null").get(_json_keyword) == [None] + + +tests = { + 'integer': 10, + 'negative': -10, + 'decimal': 1.2, + 'float': 1e+20, + 'string': "Hello, World", + 'object': {'a': 10, 'b': 20}, + 'array': [1, 2, 3, 4], + 'nested': [{}, {'a': 10}], + 'keyword': True, +} + + +def test_good(): # noqa: D103 + for key, value in tests.items(): + serialized = json.dumps(value) + deserialized = ParserCombinator(serialized).get(json_value) + assert repr(deserialized[0]) + + +def test_pre_serialized(): # noqa: D103 + pre_serialized = { + 'escaped': r'"Hello \u0041 World"', + } + + for key, value in pre_serialized.items(): + print(key) + deserialized = ParserCombinator(value).get(json_string) + assert deserialized[0] diff --git a/json-test/json2.py b/json-test/json2.py new file mode 100644 index 0000000000000000000000000000000000000000..7d53e4d407f5886dd71b20479aaf0bc3d527db80 --- /dev/null +++ b/json-test/json2.py @@ -0,0 +1,175 @@ +""" +A basic JSON parser. + +This parser shouldn't be used, but is instead here to demonstrate how +to use the parser combinator library. + +Besides the obvious, note can be taken of +- handlers and their type transformations +- lambdas for lazy evaluation. +""" + +from muppet.parser_combinator import ( + MatchCompound, + MatchObject, + ParseDirective, + complement, + const, + count, + digit, + discard, + hexdig, + many, + many1, + name, + optional, + or_, + s, + space, + tag, +) +from typing import Optional, TypeVar +import math + + +T = TypeVar('T') + + +def force(t: Optional[T]) -> T: + """ + Discard the None part of an optional value. + + Only use this when you /know/ that the value exists. + + :raises AssertionError: + If the value was ``None`` after all. + """ + assert t + return t + + +def handle_int(xs: list[MatchObject]) -> list[int]: + """ + Convert matched to an integer. + + Apply when parsing integers, such as:: + + (many digit) @ handle_int + + Note that this only works if adjacant joining is working. + """ + return [int(xs[0])] + + +def _handle_exp(parts: list[MatchObject]) -> list[int]: + """Convert the exponential part of a float to its integer value.""" + total = force(__find('dig', parts)).matched[0] + if sign := __find('sign', parts): + if sign.matched[0][0] == '-': + total *= -1 + return [total] + + +def _handle_number(parts: list[MatchObject]) -> list[float]: + """ + Construct a float from its components. + + A float is structured as ``±{base}.{dec}e{exp}``. + + :param base: + The integer part of the float. SHOULD be positive for this function. + :param exp: + The exponent part of the float. + :param dec: + The decimal part of the float. + :param neg: + Is the value negative? + :returns: + The constructed float. + """ + total: float = 0 + print(parts) + # string: str = '' + if base := __find('base', parts): + total += base.matched[0] + + if frac := __find('fractional', parts): + d = frac.matched[0] + total += d / 10**(math.floor(math.log10(d)) + 1) + + if exp := __find('exp', parts): + total *= 10**exp.matched[0] + + if __find('minus', parts): + total *= -1 + + return [total] + + +ws = discard(name('ws', many(space))) +digit_19 = or_(*(chr(x + ord('0')) for x in range(1, 10))) + +_hex_esc = (discard(r'\u') & count(hexdig, 3, 5)) @ (lambda x: chr(int(x[0], 16))) + +_json_esc = (_hex_esc | + s(r'\"') @ const('"') | + s(r'\/') @ const("/") | + s(r'\b') @ const("\b") | + s(r'\n') @ const("\n") | + s(r'\r') @ const("\r") | + s(r'\t') @ const("\t") | + s(r'\\') @ const("\\")) + +_json_char = name('_json_char', _json_esc | complement(r'\"')) + +json_string = name('json_string', + discard('"') + & many(_json_char) + & discard('"')) \ + @ (lambda x: x[0]) + +_fraction = discard(".") & tag('fractional', many1(digit) @ handle_int) + +_exponent = tag('exp', + (discard(s("e") | "E") + & optional(tag('sign', s("-") | "+")) + & tag('dig', many1(digit) @ handle_int)) @ _handle_exp) + +json_number = (optional(tag('minus', "-")) + & tag('base', (s("0") | digit_19 & many(digit)) @ handle_int) + & optional(_fraction) + & optional(_exponent)) @ _handle_number + +_json_keyword = s("true") @ (lambda _: [True]) \ + | s("false") @ (lambda _: [False]) \ + | s("null") @ (lambda _: [None]) + + +_json_kv = tag('kv', + ws & tag('key', json_string) & + ws & discard(":") + & tag('value', lambda: json_value)) + +json_object = tag( + 'object', discard("{") & (_json_kv & many(discard(",") & _json_kv) | ws) & discard("}")) + +json_array = tag('array', discard("[") + & (lambda: (json_value & many(discard(",") & json_value)) | ws) + & discard("]")) + +json_value: ParseDirective \ + = (ws & (json_string | + json_number | + _json_keyword | + json_object | + json_array) + & ws) + + +def __find(key: str, objs: list[MatchObject]) -> Optional[MatchObject]: + """Locate the first matching object of type key.""" + for item in objs: + match item: + case MatchCompound(type=s) if s == key: + return item + return None