diff --git a/tests/test_parser.py b/tests/test_parser.py index e3beab9..d5f522c 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -13,11 +13,17 @@ class TestZonParser: """Test cases for the ZonParser class.""" def test_parse_empty_object(self): - """Test parsing an empty object.""" - parser = ZonParser(".{}") + """Test parsing an empty object with empty_tuple_as_dict=True.""" + parser = ZonParser(".{}", empty_tuple_as_dict=True) result = parser.parse() assert result == {} + def test_parse_empty_tuple_as_list(self): + """Test parsing an empty tuple as list with empty_tuple_as_dict=False.""" + parser = ZonParser(".{}", empty_tuple_as_dict=False) + result = parser.parse() + assert result == [] + def test_parse_simple_object(self): """Test parsing a simple object with string values.""" parser = ZonParser( @@ -42,16 +48,6 @@ class TestZonParser: result = parser.parse() assert result == {"metadata": {"name": "test", "version": "1.0.0"}} - def test_parse_array(self): - """Test parsing an array.""" - parser = ZonParser( - """.{ - .tags = .["tag1", "tag2", "tag3"], - }""" - ) - result = parser.parse() - assert result == {"tags": ["tag1", "tag2", "tag3"]} - def test_parse_numbers(self): """Test parsing different number formats.""" parser = ZonParser( @@ -169,6 +165,65 @@ class TestZonParser: "mixed_tuple": [1, "two", True], } + def test_parse_nested_tuples(self): + """Test parsing nested tuples.""" + parser = ZonParser( + """.{ + .nested_tuple = .{ .{1, 2}, .{3, 4} }, + }""" + ) + result = parser.parse() + assert result == {"nested_tuple": [[1, 2], [3, 4]]} + + def test_parse_objects_in_tuple(self): + """Test parsing objects within tuples.""" + parser = ZonParser( + """.{ + .object_in_tuple = .{ .{.x = 1, .y = 2}, .{.x = 3, .y = 4} }, + }""" + ) + result = parser.parse() + assert result == {"object_in_tuple": [{"x": 1, "y": 2}, {"x": 3, "y": 4}]} + + def test_parse_complex_nested_structures(self): + """Test parsing complex nested structures.""" + parser = ZonParser( + """.{ + .metadata = .{ + .name = "example", + .version = "1.0.0", + }, + .simple_tuple = .{1, 2, 3}, + .nested = .{ + .tuple_in_object = .{4, 5, 6}, + .object_in_tuple = .{ .{.x = 1, .y = 2}, .{.x = 3, .y = 4} }, + }, + .nested_tuple = .{ .{1, 2}, .{3, 4} }, + .empty = .{}, + }""" + ) + result = parser.parse() + assert result == { + "metadata": {"name": "example", "version": "1.0.0"}, + "simple_tuple": [1, 2, 3], + "nested": { + "tuple_in_object": [4, 5, 6], + "object_in_tuple": [{"x": 1, "y": 2}, {"x": 3, "y": 4}], + }, + "nested_tuple": [[1, 2], [3, 4]], + "empty": [], + } + + def test_parse_tuple_as_array(self): + """Test parsing a tuple as an array.""" + parser = ZonParser( + """.{ + .tags = .{"tag1", "tag2", "tag3"}, + }""" + ) + result = parser.parse() + assert result == {"tags": ["tag1", "tag2", "tag3"]} + class TestZonFileParser: """Test cases for the file parsing functions.""" diff --git a/zig_fetch_py/parser.py b/zig_fetch_py/parser.py index 22572b0..5b10514 100644 --- a/zig_fetch_py/parser.py +++ b/zig_fetch_py/parser.py @@ -14,17 +14,20 @@ class ZonParser: A parser for Zig Object Notation (ZON) files. """ - def __init__(self, content: str): + def __init__(self, content: str, empty_tuple_as_dict: bool = False): """ Initialize the parser with ZON content. Args: content: The ZON content to parse + empty_tuple_as_dict: If True, empty tuples (.{}) will be parsed as empty dictionaries ({}) + If False, empty tuples will be parsed as empty lists ([]) """ self.content = content self.pos = 0 self.line = 1 self.col = 1 + self.empty_tuple_as_dict = empty_tuple_as_dict def parse(self) -> Dict[str, Any]: """Parse ZON content and return a Python dictionary.""" @@ -71,6 +74,7 @@ class ZonParser: break def _parse_value(self) -> Any: + """Parse a ZON value.""" self._skip_whitespace_and_comments() char = self._current_char() @@ -100,33 +104,59 @@ class ZonParser: ) def _parse_object(self) -> Union[Dict[str, Any], List[Any]]: - """Parse a ZON object (anonymous struct) or tuple.""" + """Parse a ZON object or tuple.""" # Skip the opening brace self._next_char() - # Check if it's a tuple (values separated by commas without keys) - is_tuple = False - pos_before_check = self.pos - line_before_check = self.line - col_before_check = self.col + # Look ahead to see if this is a tuple or an object + pos_before = self.pos + line_before = self.line + col_before = self.col - # Look ahead to see if this is a tuple self._skip_whitespace_and_comments() - if self._current_char() != ".": - # If it doesn't start with a dot for a key, it might be a tuple - # or an empty object - if self._current_char() != "}": - is_tuple = True - # Reset position after look-ahead - self.pos = pos_before_check - self.line = line_before_check - self.col = col_before_check + # Check if it's empty + if self._current_char() == "}": + # Need to determine if it should be an empty object or empty tuple + # Use the configuration option to decide + self._next_char() # Skip the closing brace + return ( + {} if self.empty_tuple_as_dict else [] + ) # Empty dict or list based on config + + # Look at the first character to determine if it's a tuple or object + is_tuple = True + if self._current_char() == ".": + # Look ahead one more character + self._next_char() + # If the next character is an object, it could be a nested tuple + if self._current_char() == "{": + # This is potentially a nested tuple starting with .{ + # Go back to the dot and let the normal parsing decide + self.pos -= 1 + elif ( + self._current_char() == "@" + or self._current_char().isalnum() + or self._current_char() == "_" + ): + # This looks like a field name, so it's probably an object + is_tuple = False + else: + # Unexpected character after dot, could be a syntax error + is_tuple = False + + # Reset position + self.pos = pos_before + self.line = line_before + self.col = col_before if is_tuple: return self._parse_tuple() + else: + return self._parse_struct() - # Regular object parsing + def _parse_struct(self) -> Dict[str, Any]: + """Parse a ZON struct/object with key-value pairs.""" result = {} while True: @@ -171,10 +201,25 @@ class ZonParser: return result - def _parse_tuple(self) -> List[Any]: - """Parse a tuple in ZON format.""" + def _parse_tuple(self) -> Union[Dict[str, Any], List[Any]]: + """ + Parse a ZON tuple as a list of values or empty dict based on configuration. + + Returns: + List[Any] for non-empty tuples, or Dict[str, Any] if empty and empty_tuple_as_dict=True + """ result = [] + # Skip the opening brace (already done in _parse_object) + self._skip_whitespace_and_comments() + + # Check for empty tuple + if self._current_char() == "}": + self._next_char() + return ( + {} if self.empty_tuple_as_dict else [] + ) # Empty dict or list based on config + while True: self._skip_whitespace_and_comments() @@ -183,17 +228,27 @@ class ZonParser: self._next_char() break - # Check for nested tuple or object with dot prefix + # Handle the special case of nested tuple/object with dot prefix if self._current_char() == ".": + # Save position before the dot + pos_before = self.pos + line_before = self.line + col_before = self.col + self._next_char() # Skip the dot - # Check if it's a nested object or tuple + # If we have a nested object/tuple if self._current_char() == "{": + # Parse the nested object/tuple value = self._parse_object() result.append(value) else: - # It's a field name or a special value - self.pos -= 1 # Move back to include the dot + # Not a nested tuple/object, reset position and parse normally + self.pos = pos_before + self.line = line_before + self.col = col_before + + # Parse as normal value value = self._parse_value() result.append(value) else: @@ -346,12 +401,14 @@ class ZonParser: ) -def parse_zon_file(file_path: str) -> Dict[str, Any]: +def parse_zon_file(file_path: str, empty_tuple_as_dict: bool = False) -> Dict[str, Any]: """ Parse a ZON file and return a Python dictionary. Args: file_path: Path to the ZON file + empty_tuple_as_dict: If True, empty tuples (.{}) will be parsed as empty dictionaries ({}) + If False, empty tuples will be parsed as empty lists ([]) Returns: Dictionary representation of the ZON file @@ -360,23 +417,27 @@ def parse_zon_file(file_path: str) -> Dict[str, Any]: with open(file_path, "r") as f: content = f.read() - parser = ZonParser(content) + parser = ZonParser(content, empty_tuple_as_dict=empty_tuple_as_dict) result = parser.parse() logger.debug(f"Successfully parsed ZON file") return result -def zon_to_json(zon_content: str, indent: Optional[int] = None) -> str: +def zon_to_json( + zon_content: str, indent: Optional[int] = None, empty_tuple_as_dict: bool = False +) -> str: """ Convert ZON content to JSON string. Args: zon_content: ZON content as string indent: Number of spaces for indentation (None for compact JSON) + empty_tuple_as_dict: If True, empty tuples (.{}) will be parsed as empty dictionaries ({}) + If False, empty tuples will be parsed as empty lists ([]) Returns: JSON string """ - parser = ZonParser(zon_content) + parser = ZonParser(zon_content, empty_tuple_as_dict=empty_tuple_as_dict) result = parser.parse() return json.dumps(result, indent=indent)