Created
June 10, 2026 06:55
-
-
Save RoCry/df2296ce781e9e855ac7807fd54788c0 to your computer and use it in GitHub Desktop.
improve json double quote
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| from __future__ import annotations | |
| import argparse | |
| import io | |
| import json | |
| import re | |
| import sys | |
| from collections.abc import Sequence | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from rich.console import Console | |
| type JSONValue = dict[str, "JSONValue"] | list["JSONValue"] | str | int | float | bool | None | |
| type JSONPathPart = str | int | |
| type JSONPath = tuple[JSONPathPart, ...] | |
| DEFAULT_MAX_DECODE_DEPTH = 20 | |
| DECODED_STRING_COMMENT = "/* jsonx: was a JSON string */" | |
| INLINE_STRING_COMMENT = "/* jsonx: was a string containing inline JSON */" | |
| INLINE_VALUE_COMMENT = "/* jsonx: extracted from string */" | |
| JSONX_COMMENT_HIGHLIGHT = "\x1b[1;30;103m" | |
| JSON_KEY_COLOR = "\x1b[36m" | |
| JSON_STRING_COLOR = "\x1b[32m" | |
| JSON_LITERAL_COLOR = "\x1b[35m" | |
| JSON_PUNCT_COLOR = "\x1b[90m" | |
| ANSI_RESET = "\x1b[0m" | |
| JSON_NUMBER_PATTERN = re.compile(r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?") | |
| @dataclass(slots=True) | |
| class ConvertedDocument: | |
| value: JSONValue | |
| decoded_string_paths: set[JSONPath] | |
| @dataclass(slots=True) | |
| class InlineJsonSegment: | |
| label: str | |
| start: int | |
| end: int | |
| value: JSONValue | |
| def convert_json_strings(value: JSONValue, *, max_decode_depth: int = DEFAULT_MAX_DECODE_DEPTH) -> JSONValue: | |
| converted, _ = _convert_json_strings_with_annotations( | |
| value=value, | |
| path=(), | |
| max_decode_depth=max_decode_depth, | |
| ) | |
| return converted | |
| def convert_text(text: str, *, max_decode_depth: int = DEFAULT_MAX_DECODE_DEPTH) -> str: | |
| return format_documents( | |
| documents=convert_documents(text=text, max_decode_depth=max_decode_depth), | |
| color=False, | |
| ) | |
| def convert_documents(text: str, *, max_decode_depth: int = DEFAULT_MAX_DECODE_DEPTH) -> list[JSONValue]: | |
| return [ | |
| document.value for document in convert_documents_with_annotations(text=text, max_decode_depth=max_decode_depth) | |
| ] | |
| def convert_documents_with_annotations( | |
| text: str, *, max_decode_depth: int = DEFAULT_MAX_DECODE_DEPTH | |
| ) -> list[ConvertedDocument]: | |
| documents = parse_json_documents(text=text) | |
| return [ | |
| ConvertedDocument(value=converted, decoded_string_paths=decoded_string_paths) | |
| for document in documents | |
| for converted, decoded_string_paths in [ | |
| _convert_json_strings_with_annotations( | |
| value=document, | |
| path=(), | |
| max_decode_depth=max_decode_depth, | |
| ) | |
| ] | |
| ] | |
| def format_documents(*, documents: list[JSONValue], color: bool) -> str: | |
| if not color: | |
| return "\n\n".join(json.dumps(document, ensure_ascii=False, indent=2) for document in documents) + "\n" | |
| output = io.StringIO() | |
| console = Console(file=output, force_terminal=True, color_system="standard") | |
| for index, document in enumerate(documents): | |
| if index > 0: | |
| console.print() | |
| console.print_json(json=json.dumps(document, ensure_ascii=False)) | |
| return output.getvalue() | |
| def format_converted_documents(*, documents: list[ConvertedDocument], color: bool, comments: bool) -> str: | |
| if not comments: | |
| return format_documents(documents=[document.value for document in documents], color=color) | |
| output_text = ( | |
| "\n\n".join( | |
| _format_jsonc_value( | |
| value=document.value, | |
| decoded_string_paths=document.decoded_string_paths, | |
| path=(), | |
| indent=0, | |
| ) | |
| for document in documents | |
| ) | |
| + "\n" | |
| ) | |
| if not color: | |
| return output_text | |
| return _highlight_jsonc_syntax(text=output_text) | |
| def parse_json_documents(text: str) -> list[JSONValue]: | |
| decoder = json.JSONDecoder() | |
| documents: list[JSONValue] = [] | |
| index = 0 | |
| while True: | |
| index = _skip_whitespace(text=text, index=index) | |
| if index >= len(text): | |
| break | |
| try: | |
| document, index = decoder.raw_decode(text, index) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError(f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}") from exc | |
| documents.append(document) | |
| if not documents: | |
| raise ValueError("input contains no JSON documents") | |
| return documents | |
| def main(argv: Sequence[str] | None = None) -> int: | |
| parser = argparse.ArgumentParser(description="Pretty-print JSON and recursively expand stringified JSON values.") | |
| parser.add_argument( | |
| "input", nargs="?", default="-", help="Input JSON file. Defaults to stdin. Use '-' to read stdin." | |
| ) | |
| parser.add_argument("-o", "--output", help="Write output to this file instead of stdout.") | |
| comment_group = parser.add_mutually_exclusive_group() | |
| comment_group.add_argument( | |
| "--comments", | |
| dest="comments", | |
| action="store_true", | |
| default=True, | |
| help="Emit JSONC comments marking converted values. This is the default.", | |
| ) | |
| comment_group.add_argument( | |
| "--no-comments", | |
| dest="comments", | |
| action="store_false", | |
| help="Emit strict JSON without JSONC annotations.", | |
| ) | |
| color_group = parser.add_mutually_exclusive_group() | |
| color_group.add_argument( | |
| "--color", dest="color", action="store_true", default=None, help="Force ANSI color output." | |
| ) | |
| color_group.add_argument("--no-color", dest="color", action="store_false", help="Disable ANSI color output.") | |
| args = parser.parse_args(argv) | |
| try: | |
| input_text = sys.stdin.read() if args.input == "-" else Path(args.input).read_text(encoding="utf-8") | |
| color = args.color if args.color is not None else args.output is None and sys.stdout.isatty() | |
| output_text = format_converted_documents( | |
| documents=convert_documents_with_annotations(text=input_text), | |
| color=color, | |
| comments=args.comments, | |
| ) | |
| if args.output: | |
| Path(args.output).write_text(output_text, encoding="utf-8") | |
| else: | |
| sys.stdout.write(output_text) | |
| except (OSError, ValueError) as exc: | |
| parser.exit(status=1, message=f"json-pretty-convert: {exc}\n") | |
| return 0 | |
| def _convert_json_strings_with_annotations( | |
| value: JSONValue, | |
| *, | |
| path: JSONPath, | |
| max_decode_depth: int, | |
| ) -> tuple[JSONValue, set[JSONPath]]: | |
| match value: | |
| case dict(): | |
| converted: dict[str, JSONValue] = {} | |
| decoded_string_paths: set[JSONPath] = set() | |
| for key, child in value.items(): | |
| child_value, child_paths = _convert_json_strings_with_annotations( | |
| value=child, | |
| path=(*path, key), | |
| max_decode_depth=max_decode_depth, | |
| ) | |
| converted[key] = child_value | |
| decoded_string_paths.update(child_paths) | |
| return converted, decoded_string_paths | |
| case list(): | |
| converted_items: list[JSONValue] = [] | |
| decoded_string_paths = set() | |
| for index, child in enumerate(value): | |
| child_value, child_paths = _convert_json_strings_with_annotations( | |
| value=child, | |
| path=(*path, index), | |
| max_decode_depth=max_decode_depth, | |
| ) | |
| converted_items.append(child_value) | |
| decoded_string_paths.update(child_paths) | |
| return converted_items, decoded_string_paths | |
| case str(): | |
| return _decode_json_string_with_annotations( | |
| text=value, | |
| path=path, | |
| max_decode_depth=max_decode_depth, | |
| ) | |
| case _: | |
| return value, set() | |
| def _decode_json_string_with_annotations( | |
| *, | |
| text: str, | |
| path: JSONPath, | |
| max_decode_depth: int, | |
| ) -> tuple[JSONValue, set[JSONPath]]: | |
| current = text | |
| for _ in range(max_decode_depth): | |
| candidate = current.strip() | |
| if not _looks_like_embedded_json(text=candidate): | |
| return current, set() | |
| try: | |
| decoded = json.loads(candidate) | |
| except json.JSONDecodeError: | |
| return current, set() | |
| if isinstance(decoded, dict | list): | |
| converted, decoded_string_paths = _convert_json_strings_with_annotations( | |
| value=decoded, | |
| path=path, | |
| max_decode_depth=max_decode_depth, | |
| ) | |
| decoded_string_paths.add(path) | |
| return converted, decoded_string_paths | |
| if isinstance(decoded, str): | |
| current = decoded | |
| continue | |
| return current, set() | |
| raise ValueError(f"exceeded nested JSON string decode depth at {_format_path(path=path)}") | |
| def _format_jsonc_value( | |
| *, | |
| value: JSONValue, | |
| decoded_string_paths: set[JSONPath], | |
| path: JSONPath, | |
| indent: int, | |
| ) -> str: | |
| comment = f"{DECODED_STRING_COMMENT} " if path in decoded_string_paths else "" | |
| match value: | |
| case dict(): | |
| body = _format_jsonc_object( | |
| value=value, | |
| decoded_string_paths=decoded_string_paths, | |
| path=path, | |
| indent=indent, | |
| ) | |
| case list(): | |
| body = _format_jsonc_array( | |
| value=value, | |
| decoded_string_paths=decoded_string_paths, | |
| path=path, | |
| indent=indent, | |
| ) | |
| case str(): | |
| body = _format_jsonc_string(value=value, path=path, indent=indent) | |
| case _: | |
| body = json.dumps(value, ensure_ascii=False) | |
| return f"{comment}{body}" | |
| def _format_jsonc_object( | |
| *, | |
| value: dict[str, JSONValue], | |
| decoded_string_paths: set[JSONPath], | |
| path: JSONPath, | |
| indent: int, | |
| ) -> str: | |
| if not value: | |
| return "{}" | |
| child_indent = " " * (indent + 2) | |
| current_indent = " " * indent | |
| lines = ["{"] | |
| items = list(value.items()) | |
| for index, (key, child) in enumerate(items): | |
| comma = "," if index < len(items) - 1 else "" | |
| child_path = (*path, key) | |
| child_text = _format_jsonc_value( | |
| value=child, | |
| decoded_string_paths=decoded_string_paths, | |
| path=child_path, | |
| indent=indent + 2, | |
| ) | |
| lines.append(f"{child_indent}{json.dumps(key, ensure_ascii=False)}: {child_text}{comma}") | |
| lines.append(f"{current_indent}}}") | |
| return "\n".join(lines) | |
| def _format_jsonc_array( | |
| *, | |
| value: list[JSONValue], | |
| decoded_string_paths: set[JSONPath], | |
| path: JSONPath, | |
| indent: int, | |
| ) -> str: | |
| if not value: | |
| return "[]" | |
| child_indent = " " * (indent + 2) | |
| current_indent = " " * indent | |
| lines = ["["] | |
| for index, child in enumerate(value): | |
| comma = "," if index < len(value) - 1 else "" | |
| child_text = _format_jsonc_value( | |
| value=child, | |
| decoded_string_paths=decoded_string_paths, | |
| path=(*path, index), | |
| indent=indent + 2, | |
| ) | |
| lines.append(f"{child_indent}{child_text}{comma}") | |
| lines.append(f"{current_indent}]") | |
| return "\n".join(lines) | |
| def _format_jsonc_string(*, value: str, path: JSONPath, indent: int) -> str: | |
| segments = _extract_inline_json_segments(text=value) | |
| if not segments: | |
| return json.dumps(value, ensure_ascii=False) | |
| text_with_placeholders = _replace_segments_with_placeholders(text=value, segments=segments) | |
| child_indent = " " * (indent + 2) | |
| current_indent = " " * indent | |
| lines = [f"{INLINE_STRING_COMMENT} {{"] | |
| lines.append(f"{child_indent}{json.dumps('_text')}: {json.dumps(text_with_placeholders, ensure_ascii=False)},") | |
| used_labels = {"_text"} | |
| for index, segment in enumerate(segments): | |
| label = _unique_inline_label(label=segment.label, used_labels=used_labels) | |
| used_labels.add(label) | |
| converted_value, nested_paths = _convert_json_strings_with_annotations( | |
| value=segment.value, | |
| path=(*path, label), | |
| max_decode_depth=DEFAULT_MAX_DECODE_DEPTH, | |
| ) | |
| child_text = _format_jsonc_value( | |
| value=converted_value, | |
| decoded_string_paths=nested_paths, | |
| path=(*path, label), | |
| indent=indent + 2, | |
| ) | |
| comma = "," if index < len(segments) - 1 else "" | |
| lines.append( | |
| f"{child_indent}{json.dumps(label, ensure_ascii=False)}: {INLINE_VALUE_COMMENT} {child_text}{comma}" | |
| ) | |
| lines.append(f"{current_indent}}}") | |
| return "\n".join(lines) | |
| def _extract_inline_json_segments(*, text: str) -> list[InlineJsonSegment]: | |
| decoder = json.JSONDecoder() | |
| segments: list[InlineJsonSegment] = [] | |
| index = 0 | |
| previous_end = 0 | |
| while index < len(text): | |
| if text[index] not in "{[": | |
| index += 1 | |
| continue | |
| try: | |
| decoded, end = decoder.raw_decode(text, index) | |
| except json.JSONDecodeError: | |
| index += 1 | |
| continue | |
| if not isinstance(decoded, dict | list): | |
| index += 1 | |
| continue | |
| segments.append( | |
| InlineJsonSegment( | |
| label=_inline_json_label(text=text[previous_end:index], fallback_index=len(segments) + 1), | |
| start=index, | |
| end=end, | |
| value=decoded, | |
| ) | |
| ) | |
| previous_end = end | |
| index = end | |
| return segments | |
| def _inline_json_label(*, text: str, fallback_index: int) -> str: | |
| prefix = text.rstrip() | |
| if ":" not in prefix: | |
| return f"inline_json_{fallback_index}" | |
| label = prefix.rsplit(":", maxsplit=1)[0].rsplit(",", maxsplit=1)[-1].splitlines()[-1].strip() | |
| if not label or len(label) > 40: | |
| return f"inline_json_{fallback_index}" | |
| return label | |
| def _replace_segments_with_placeholders(*, text: str, segments: list[InlineJsonSegment]) -> str: | |
| parts: list[str] = [] | |
| cursor = 0 | |
| for segment in segments: | |
| parts.append(text[cursor : segment.start]) | |
| parts.append("…") | |
| cursor = segment.end | |
| parts.append(text[cursor:]) | |
| return "".join(parts) | |
| def _unique_inline_label(*, label: str, used_labels: set[str]) -> str: | |
| if label not in used_labels: | |
| return label | |
| suffix = 2 | |
| while f"{label}_{suffix}" in used_labels: | |
| suffix += 1 | |
| return f"{label}_{suffix}" | |
| def _format_path(*, path: JSONPath) -> str: | |
| if not path: | |
| return "$" | |
| return "$" + "".join(f"[{part}]" if isinstance(part, int) else f".{part}" for part in path) | |
| def _highlight_jsonc_syntax(*, text: str) -> str: | |
| parts: list[str] = [] | |
| index = 0 | |
| while index < len(text): | |
| if comment := _comment_at(text=text, index=index): | |
| parts.append(_ansi(text=comment, color=JSONX_COMMENT_HIGHLIGHT)) | |
| index += len(comment) | |
| continue | |
| char = text[index] | |
| if char == '"': | |
| end = _json_string_end(text=text, index=index) | |
| token = text[index:end] | |
| color = JSON_KEY_COLOR if _is_object_key(text=text, index=end) else JSON_STRING_COLOR | |
| parts.append(_ansi(text=token, color=color)) | |
| index = end | |
| continue | |
| if (char == "-" or char.isdigit()) and (match := JSON_NUMBER_PATTERN.match(text, index)): | |
| parts.append(_ansi(text=match.group(0), color=JSON_LITERAL_COLOR)) | |
| index = match.end() | |
| continue | |
| if literal := _literal_at(text=text, index=index): | |
| parts.append(_ansi(text=literal, color=JSON_LITERAL_COLOR)) | |
| index += len(literal) | |
| continue | |
| if char in "{}[]:,": | |
| parts.append(_ansi(text=char, color=JSON_PUNCT_COLOR)) | |
| else: | |
| parts.append(char) | |
| index += 1 | |
| return "".join(parts) | |
| def _comment_at(*, text: str, index: int) -> str | None: | |
| for comment in (DECODED_STRING_COMMENT, INLINE_STRING_COMMENT, INLINE_VALUE_COMMENT): | |
| if text.startswith(comment, index): | |
| return comment | |
| return None | |
| def _json_string_end(*, text: str, index: int) -> int: | |
| cursor = index + 1 | |
| escaped = False | |
| while cursor < len(text): | |
| char = text[cursor] | |
| if escaped: | |
| escaped = False | |
| elif char == "\\": | |
| escaped = True | |
| elif char == '"': | |
| return cursor + 1 | |
| cursor += 1 | |
| return len(text) | |
| def _is_object_key(*, text: str, index: int) -> bool: | |
| while index < len(text) and text[index].isspace(): | |
| index += 1 | |
| return index < len(text) and text[index] == ":" | |
| def _literal_at(*, text: str, index: int) -> str | None: | |
| for literal in ("true", "false", "null"): | |
| end = index + len(literal) | |
| if text.startswith(literal, index) and (end == len(text) or not text[end].isalpha()): | |
| return literal | |
| return None | |
| def _ansi(*, text: str, color: str) -> str: | |
| return f"{color}{text}{ANSI_RESET}" | |
| def _looks_like_embedded_json(*, text: str) -> bool: | |
| if len(text) < 2: | |
| return False | |
| pairs = {"{": "}", "[": "]", '"': '"'} | |
| return pairs.get(text[0]) == text[-1] | |
| def _skip_whitespace(*, text: str, index: int) -> int: | |
| while index < len(text) and text[index].isspace(): | |
| index += 1 | |
| return index | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment