commit 3404b6b7e07778131871e671cf1515f0b8c9e37a Author: crosstyan Date: Fri Mar 7 16:32:15 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..457f44d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.typeCheckingMode": "basic" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b941d5 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +```zon +.{ + .name = .zls, + // Must match the `zls_version` in `build.zig` + .version = "0.15.0-dev", + // Must be kept in line with the `minimum_build_zig_version` in `build.zig`. + // Should be a Zig version that is downloadable from https://ziglang.org/download/ or a mirror. + .minimum_zig_version = "0.14.0", + // If you do not use Nix, a ZLS maintainer can take care of this. + // Whenever the dependencies are updated, run the following command: + // ```bash + // nix run github:Cloudef/zig2nix#zon2nix -- build.zig.zon > deps.nix + // rm build.zig.zon2json-lock # this file is unnecessary + // ``` + .dependencies = .{ + .known_folders = .{ + .url = "https://github.com/ziglibs/known-folders/archive/aa24df42183ad415d10bc0a33e6238c437fc0f59.tar.gz", + .hash = "known_folders-0.0.0-Fy-PJtLDAADGDOwYwMkVydMSTp_aN-nfjCZw6qPQ2ECL", + }, + .diffz = .{ + .url = "https://github.com/ziglibs/diffz/archive/ef45c00d655e5e40faf35afbbde81a1fa5ed7ffb.tar.gz", + .hash = "N-V-__8AABhrAQAQLLLGadghhPsdxTgBk9N9aLVOjXW3ay0V", + }, + .@"lsp-codegen" = .{ + .url = "https://github.com/zigtools/zig-lsp-codegen/archive/063a98c13a2293d8654086140813bdd1de6501bc.tar.gz", + .hash = "lsp_codegen-0.1.0-CMjjo0ZXCQB-rAhPYrlfzzpU0u0u2MeGvUucZ-_g32eg", + }, + .tracy = .{ + .url = "https://github.com/wolfpld/tracy/archive/refs/tags/v0.11.1.tar.gz", + .hash = "N-V-__8AAMeOlQEipHjcyu0TCftdAi9AQe7EXUDJOoVe0k-t", + .lazy = true, + }, + }, + .paths = .{""}, + .fingerprint = 0xa66330b97eb969ae, // Changing this has security and trust implications. +} +``` + +It's called `zon`, the Zig Object Notation. I think You need to parse it first (convert to json) and then parse the json. diff --git a/main.py b/main.py new file mode 100644 index 0000000..f25ba34 --- /dev/null +++ b/main.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +import json +import re +import sys +from pathlib import Path +from typing import Any, Dict, List, Union, Tuple + +import click +from loguru import logger + + +class ZonParser: + """ + A parser for Zig Object Notation (ZON) files. + """ + + content: str + pos: int + line: int + col: int + + def __init__(self, content: str): + self.content = content + self.pos = 0 + self.line = 1 + self.col = 1 + + def parse(self) -> Dict[str, Any]: + """Parse ZON content and return a Python dictionary.""" + result = self._parse_value() + return result + + def _current_char(self) -> str: + if self.pos >= len(self.content): + return "" + return self.content[self.pos] + + def _next_char(self) -> str: + self.pos += 1 + if self.pos - 1 < len(self.content): + char = self.content[self.pos - 1] + if char == "\n": + self.line += 1 + self.col = 1 + else: + self.col += 1 + return char + return "" + + def _skip_whitespace_and_comments(self): + while self.pos < len(self.content): + char = self._current_char() + + # Skip whitespace + if char.isspace(): + self._next_char() + continue + + # Skip comments + if ( + char == "/" + and self.pos + 1 < len(self.content) + and self.content[self.pos + 1] == "/" + ): + # Skip to end of line + while self.pos < len(self.content) and self._current_char() != "\n": + self._next_char() + continue + + break + + def _parse_value(self) -> Any: + self._skip_whitespace_and_comments() + + char = self._current_char() + + if char == ".": + self._next_char() # Skip the dot + + # Check if it's an object + if self._current_char() == "{": + return self._parse_object() + + # Check if it's an array + if self._current_char() == "[": + return self._parse_array() + + # It's a field name or a special value + return self._parse_identifier() + + elif char == '"': + return self._parse_string() + elif char.isdigit() or char == "-": + return self._parse_number() + elif char == "t" or char == "f": + return self._parse_boolean() + elif char == "n" and self.content[self.pos : self.pos + 4] == "null": + self.pos += 4 + return None + else: + raise ValueError( + f"Unexpected character '{char}' at line {self.line}, column {self.col}" + ) + + def _parse_object(self) -> Dict[str, Any]: + result = {} + + # Skip the opening brace + self._next_char() + + while True: + self._skip_whitespace_and_comments() + + # Check for closing brace + if self._current_char() == "}": + self._next_char() + break + + # Parse key + if self._current_char() == ".": + self._next_char() # Skip the dot + key = self._parse_identifier() + else: + raise ValueError( + f"Expected '.' before key at line {self.line}, column {self.col}" + ) + + self._skip_whitespace_and_comments() + + # Parse equals sign or check if it's a shorthand notation + if self._current_char() == "=": + self._next_char() + self._skip_whitespace_and_comments() + value = self._parse_value() + else: + # Shorthand notation where key is the same as value + value = key + + result[key] = value + + self._skip_whitespace_and_comments() + + # Check for comma + if self._current_char() == ",": + self._next_char() + elif self._current_char() != "}": + raise ValueError( + f"Expected ',' or '}}' at line {self.line}, column {self.col}" + ) + + return result + + def _parse_array(self) -> List[Any]: + result = [] + + # Skip the opening bracket + self._next_char() + + while True: + self._skip_whitespace_and_comments() + + # Check for closing bracket + if self._current_char() == "]": + self._next_char() + break + + # Parse value + value = self._parse_value() + result.append(value) + + self._skip_whitespace_and_comments() + + # Check for comma + if self._current_char() == ",": + self._next_char() + elif self._current_char() != "]": + raise ValueError( + f"Expected ',' or ']' at line {self.line}, column {self.col}" + ) + + return result + + def _parse_identifier(self) -> str: + start = self.pos + + # Handle quoted identifiers (like .@"lsp-codegen") + if ( + self._current_char() == "@" + and self.pos + 1 < len(self.content) + and self.content[self.pos + 1] == '"' + ): + self._next_char() # Skip @ + return self._parse_string() + + # Regular identifier + while self.pos < len(self.content): + char = self._current_char() + if char.isalnum() or char == "_" or char == "-": + self._next_char() + else: + break + + if start == self.pos: + raise ValueError(f"Empty identifier at line {self.line}, column {self.col}") + + return self.content[start : self.pos] + + def _parse_string(self) -> str: + result = "" + + # Skip the opening quote + self._next_char() + + while self.pos < len(self.content) and self._current_char() != '"': + if self._current_char() == "\\": + self._next_char() + if self._current_char() == "n": + result += "\n" + elif self._current_char() == "t": + result += "\t" + elif self._current_char() == "r": + result += "\r" + elif self._current_char() == '"': + result += '"' + elif self._current_char() == "\\": + result += "\\" + else: + result += "\\" + self._current_char() + else: + result += self._current_char() + self._next_char() + + if self._current_char() != '"': + raise ValueError( + f"Unterminated string at line {self.line}, column {self.col}" + ) + + self._next_char() # Skip the closing quote + return result + + def _parse_number(self) -> Union[int, float]: + start = self.pos + + # Handle hex numbers + if ( + self._current_char() == "0" + and self.pos + 1 < len(self.content) + and self.content[self.pos + 1].lower() == "x" + ): + self._next_char() # Skip 0 + self._next_char() # Skip x + + hex_start = self.pos + while self.pos < len(self.content) and ( + self._current_char().isdigit() + or self._current_char().lower() in "abcdef" + ): + self._next_char() + + hex_str = self.content[hex_start : self.pos] + return int(hex_str, 16) + + # Regular number + is_float = False + + # Handle sign + if self._current_char() == "-": + self._next_char() + + # Handle digits before decimal point + while self.pos < len(self.content) and self._current_char().isdigit(): + self._next_char() + + # Handle decimal point + if self._current_char() == ".": + is_float = True + self._next_char() + + # Handle digits after decimal point + while self.pos < len(self.content) and self._current_char().isdigit(): + self._next_char() + + # Handle exponent + if self._current_char().lower() == "e": + is_float = True + self._next_char() + + # Handle exponent sign + if self._current_char() in "+-": + self._next_char() + + # Handle exponent digits + while self.pos < len(self.content) and self._current_char().isdigit(): + self._next_char() + + num_str = self.content[start : self.pos] + + if is_float: + return float(num_str) + else: + return int(num_str) + + def _parse_boolean(self) -> bool: + if self.content[self.pos : self.pos + 4] == "true": + self.pos += 4 + return True + elif self.content[self.pos : self.pos + 5] == "false": + self.pos += 5 + return False + else: + raise ValueError( + f"Expected 'true' or 'false' at line {self.line}, column {self.col}" + ) + + +def parse_zon_file(file_path: str) -> Dict[str, Any]: + """Parse a ZON file and return a Python dictionary.""" + logger.debug(f"Parsing ZON file: {file_path}") + with open(file_path, "r") as f: + content = f.read() + + parser = ZonParser(content) + result = parser.parse() + logger.debug(f"Successfully parsed ZON file") + return result + + +@click.command() +@click.argument("file", type=click.Path(exists=True, readable=True)) +@click.option( + "-o", + "--output", + type=click.Path(writable=True), + help="Output JSON file path (default: stdout)", +) +@click.option("-p", "--pretty", is_flag=True, help="Pretty print JSON output") +@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging") +def main(file, output, pretty, verbose): + """Parse ZON files and convert to JSON. + + This tool parses Zig Object Notation (ZON) files and converts them to JSON format. + """ + # Configure logging + log_level = "DEBUG" if verbose else "INFO" + logger.remove() # Remove default handler + logger.add(sys.stderr, level=log_level) + + logger.info(f"Processing file: {file}") + + try: + result = parse_zon_file(file) + + indent = 4 if pretty else None + json_str = json.dumps(result, indent=indent) + + if output: + logger.info(f"Writing output to: {output}") + with open(output, "w") as f: + f.write(json_str) + else: + logger.debug("Writing output to stdout") + click.echo(json_str) + + except Exception as e: + logger.error(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2e324ff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "zig-fetch-py" +version = "0.1.0" +description = "A tool to parse Zig Object Notation (ZON) files and convert them to JSON" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "click>=8.1.8", + "loguru>=0.7.3", +] + +[project.scripts] +zon2json = "zig_fetch_py.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["zig_fetch_py"] + +[tool.ruff] +line-length = 100 diff --git a/test.zon b/test.zon new file mode 100644 index 0000000..1c9993a --- /dev/null +++ b/test.zon @@ -0,0 +1,29 @@ +.{ + .name = .zls, + // Must match the `zls_version` in `build.zig` + .version = "0.15.0-dev", + // Must be kept in line with the `minimum_build_zig_version` in `build.zig`. + // Should be a Zig version that is downloadable from https://ziglang.org/download/ or a mirror. + .minimum_zig_version = "0.14.0", + .dependencies = .{ + .known_folders = .{ + .url = "https://github.com/ziglibs/known-folders/archive/aa24df42183ad415d10bc0a33e6238c437fc0f59.tar.gz", + .hash = "known_folders-0.0.0-Fy-PJtLDAADGDOwYwMkVydMSTp_aN-nfjCZw6qPQ2ECL", + }, + .diffz = .{ + .url = "https://github.com/ziglibs/diffz/archive/ef45c00d655e5e40faf35afbbde81a1fa5ed7ffb.tar.gz", + .hash = "N-V-__8AABhrAQAQLLLGadghhPsdxTgBk9N9aLVOjXW3ay0V", + }, + .@"lsp-codegen" = .{ + .url = "https://github.com/zigtools/zig-lsp-codegen/archive/063a98c13a2293d8654086140813bdd1de6501bc.tar.gz", + .hash = "lsp_codegen-0.1.0-CMjjo0ZXCQB-rAhPYrlfzzpU0u0u2MeGvUucZ-_g32eg", + }, + .tracy = .{ + .url = "https://github.com/wolfpld/tracy/archive/refs/tags/v0.11.1.tar.gz", + .hash = "N-V-__8AAMeOlQEipHjcyu0TCftdAi9AQe7EXUDJOoVe0k-t", + .lazy = true, + }, + }, + .paths = .{""}, + .fingerprint = 0xa66330b97eb969ae, // Changing this has security and trust implications. +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6b6d912 --- /dev/null +++ b/uv.lock @@ -0,0 +1,61 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 }, +] + +[[package]] +name = "zig-fetch-py" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "loguru" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.8" }, + { name = "loguru", specifier = ">=0.7.3" }, +]