"""Strict JSON-with-comments parsing and source-preserving edits.""" from __future__ import annotations from dataclasses import dataclass import json import re from typing import Any, Dict, List, Optional, Sequence, Tuple, Union JsonObject = Dict[str, Any] JsonPathPart = Union[str, int] _NUMBER = re.compile(r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?") @dataclass(frozen=True) class JsoncItem: node: "JsoncNode" comma: Optional[int] @dataclass(frozen=True) class JsoncMember: key: str start: int end: int value_node: "JsoncNode" comma: Optional[int] @dataclass(frozen=True) class JsoncNode: kind: str start: int end: int value: Any members: Tuple[JsoncMember, ...] = () items: Tuple[JsoncItem, ...] = () class _Parser: def __init__( self, raw: str, label: str, allow_trailing_commas: bool, ) -> None: self.raw = raw self.label = label self.allow_trailing_commas = allow_trailing_commas self.position = 0 def _error(self, message: str, position: Optional[int] = None) -> ValueError: offset = self.position if position is None else position return ValueError(f"Failed to parse {self.label}: {message} at offset {offset}") def _skip_trivia(self) -> None: while self.position < len(self.raw): if self.position == 0 and self.raw.startswith("\ufeff"): self.position += 1 continue character = self.raw[self.position] if character in " \t\r\n": self.position += 1 continue if self.raw.startswith("//", self.position): newline = self.raw.find("\n", self.position + 2) self.position = len(self.raw) if newline < 0 else newline + 1 continue if self.raw.startswith("/*", self.position): end = self.raw.find("*/", self.position + 2) if end < 0: raise self._error("unterminated block comment") self.position = end + 2 continue return def _expect(self, character: str) -> None: if self.position >= len(self.raw) or self.raw[self.position] != character: raise self._error(f'expected "{character}"') self.position += 1 def _parse_string(self) -> Tuple[str, int, int]: start = self.position self._expect('"') escaped = False while self.position < len(self.raw): character = self.raw[self.position] self.position += 1 if escaped: escaped = False continue if character == "\\": escaped = True continue if character == '"': token = self.raw[start:self.position] try: value = json.loads(token) except json.JSONDecodeError as error: raise self._error(error.msg, start + error.pos) from error return value, start, self.position if ord(character) < 0x20: raise self._error("unescaped control character", self.position - 1) raise self._error("unterminated string", start) def _parse_object(self) -> JsoncNode: start = self.position self.position += 1 members: List[JsoncMember] = [] value: JsonObject = {} seen = set() self._skip_trivia() if self.position < len(self.raw) and self.raw[self.position] == "}": self.position += 1 return JsoncNode("object", start, self.position, value) while True: self._skip_trivia() if self.position >= len(self.raw) or self.raw[self.position] != '"': raise self._error("object key must be a string") key, member_start, _ = self._parse_string() if key in seen: raise self._error(f'duplicate field "{key}"', member_start) seen.add(key) self._skip_trivia() self._expect(":") node = self._parse_value() value[key] = node.value member_end = node.end self._skip_trivia() comma: Optional[int] = None if self.position < len(self.raw) and self.raw[self.position] == ",": comma = self.position self.position += 1 self._skip_trivia() members.append(JsoncMember( key, member_start, member_end, node, comma )) if self.position < len(self.raw) and self.raw[self.position] == "}": if not self.allow_trailing_commas: raise self._error("trailing commas are not allowed", comma) self.position += 1 return JsoncNode( "object", start, self.position, value, tuple(members) ) continue members.append(JsoncMember(key, member_start, member_end, node, None)) if self.position >= len(self.raw) or self.raw[self.position] != "}": raise self._error('expected "," or "}"') self.position += 1 return JsoncNode("object", start, self.position, value, tuple(members)) def _parse_array(self) -> JsoncNode: start = self.position self.position += 1 items: List[JsoncItem] = [] values: List[Any] = [] self._skip_trivia() if self.position < len(self.raw) and self.raw[self.position] == "]": self.position += 1 return JsoncNode("array", start, self.position, values) while True: node = self._parse_value() values.append(node.value) self._skip_trivia() comma: Optional[int] = None if self.position < len(self.raw) and self.raw[self.position] == ",": comma = self.position self.position += 1 self._skip_trivia() items.append(JsoncItem(node, comma)) if self.position < len(self.raw) and self.raw[self.position] == "]": if not self.allow_trailing_commas: raise self._error("trailing commas are not allowed", comma) self.position += 1 return JsoncNode( "array", start, self.position, values, items=tuple(items) ) continue items.append(JsoncItem(node, None)) if self.position >= len(self.raw) or self.raw[self.position] != "]": raise self._error('expected "," or "]"') self.position += 1 return JsoncNode( "array", start, self.position, values, items=tuple(items) ) def _parse_value(self) -> JsoncNode: self._skip_trivia() if self.position >= len(self.raw): raise self._error("expected a value") start = self.position character = self.raw[self.position] if character == "{": return self._parse_object() if character == "[": return self._parse_array() if character == '"': value, _, end = self._parse_string() return JsoncNode("string", start, end, value) for token, literal in (("true", True), ("false", False), ("null", None)): if self.raw.startswith(token, self.position): self.position += len(token) return JsoncNode("scalar", start, self.position, literal) match = _NUMBER.match(self.raw, self.position) if match: self.position = match.end() token = match.group(0) number = float(token) if any(marker in token for marker in ".eE") else int(token) return JsoncNode("number", start, self.position, number) raise self._error("invalid value") def parse(self) -> JsoncNode: node = self._parse_value() self._skip_trivia() if self.position != len(self.raw): raise self._error("unexpected content") return node def parse_jsonc( raw: str, label: str, *, allow_trailing_commas: bool = True, ) -> Any: """Parse JSON with comments and an explicit trailing-comma policy.""" return _Parser(raw, label, allow_trailing_commas).parse().value class JsoncEditor: """Apply narrow path edits while retaining untouched source bytes.""" def __init__( self, raw: str, label: str, *, allow_trailing_commas: bool = True, ) -> None: self.raw = raw self.label = label self.allow_trailing_commas = allow_trailing_commas self._refresh() @property def value(self) -> Any: return self.root.value def _refresh(self) -> None: self.root = _Parser( self.raw, self.label, self.allow_trailing_commas, ).parse() def _resolve(self, path: Sequence[JsonPathPart]) -> JsoncNode: node = self.root for part in path: if isinstance(part, str) and node.kind == "object": member = next((item for item in node.members if item.key == part), None) if member is None: raise KeyError(f"Missing JSONC path: {list(path)}") node = member.value_node continue if isinstance(part, int) and node.kind == "array": if part < 0 or part >= len(node.items): raise IndexError(f"Invalid JSONC path: {list(path)}") node = node.items[part].node continue raise TypeError(f"Invalid JSONC path: {list(path)}") return node def _apply(self, ranges: Sequence[Tuple[int, int, str]]) -> None: next_raw = self.raw for start, end, replacement in sorted(ranges, reverse=True): next_raw = next_raw[:start] + replacement + next_raw[end:] self.raw = next_raw self._refresh() def delete(self, path: Sequence[JsonPathPart]) -> None: if not path: raise ValueError("Cannot delete the JSONC root") parent = self._resolve(path[:-1]) part = path[-1] ranges: List[Tuple[int, int, str]] = [] if isinstance(part, str) and parent.kind == "object": index = next( (position for position, item in enumerate(parent.members) if item.key == part), -1, ) if index < 0: raise KeyError(f"Missing JSONC path: {list(path)}") member = parent.members[index] ranges.append((member.start, member.end, "")) if member.comma is not None: ranges.append((member.comma, member.comma + 1, "")) elif index > 0 and parent.members[index - 1].comma is not None: comma = parent.members[index - 1].comma if comma is None: raise RuntimeError("JSONC object separator metadata is missing") ranges.append((comma, comma + 1, "")) elif isinstance(part, int) and parent.kind == "array": if part < 0 or part >= len(parent.items): raise IndexError(f"Invalid JSONC path: {list(path)}") item = parent.items[part] ranges.append((item.node.start, item.node.end, "")) if item.comma is not None: ranges.append((item.comma, item.comma + 1, "")) elif part > 0 and parent.items[part - 1].comma is not None: comma = parent.items[part - 1].comma if comma is None: raise RuntimeError("JSONC array separator metadata is missing") ranges.append((comma, comma + 1, "")) else: raise TypeError(f"Invalid JSONC path: {list(path)}") self._apply(ranges) def _eol(self) -> str: return "\r\n" if "\r\n" in self.raw else "\n" def _indent_unit(self) -> str: match = re.search(r"\r?\n([ \t]+)\S", self.raw) if match is None: return " " indentation = match.group(1) return "\t" if "\t" in indentation else " " * len(indentation) def _line_indent(self, position: int) -> str: line_start = self.raw.rfind("\n", 0, position) + 1 prefix = self.raw[line_start:position] match = re.match(r"[ \t]*", prefix) if match is None: raise RuntimeError("JSONC indentation parser failed") return match.group(0) def _json_lines(self, value: Any) -> List[str]: unit = self._indent_unit() lines = json.dumps(value, ensure_ascii=False, indent=1).splitlines() converted = [] for line in lines: depth = len(line) - len(line.lstrip(" ")) converted.append(unit * depth + line[depth:]) return converted def _insert( self, parent: JsoncNode, lines: List[str], had_trailing_comma: bool, ) -> None: close = parent.end - 1 eol = self._eol() unit = self._indent_unit() pretty = "\n" in self.raw or "\r" in self.raw ranges: List[Tuple[int, int, str]] = [] has_existing = bool(parent.members if parent.kind == "object" else parent.items) previous_end: Optional[int] = None if has_existing and not had_trailing_comma: if parent.kind == "object": previous_end = parent.members[-1].end else: previous_end = parent.items[-1].node.end if pretty: close_line_start = self.raw.rfind("\n", 0, close) + 1 close_prefix = self.raw[close_line_start:close] if close_line_start > parent.start and not close_prefix.strip(): insertion_at = close_line_start parent_indent = close_prefix prefix = "" suffix = eol else: insertion_at = close parent_indent = self._line_indent(parent.start) prefix = eol suffix = eol + parent_indent child_indent = parent_indent + unit content = eol.join(child_indent + line for line in lines) if had_trailing_comma: content += "," insertion = prefix + content + suffix else: insertion_at = close content = " ".join(lines) if had_trailing_comma: content += "," insertion = (" " if has_existing else "") + content if previous_end == insertion_at: insertion = "," + insertion elif previous_end is not None: ranges.append((previous_end, previous_end, ",")) ranges.append((insertion_at, insertion_at, insertion)) self._apply(ranges) def add_property( self, path: Sequence[JsonPathPart], key: str, value: Any, ) -> None: parent = self._resolve(path) if parent.kind != "object": raise TypeError(f"JSONC path must be an object: {list(path)}") if any(member.key == key for member in parent.members): raise KeyError(f'JSONC field "{key}" already exists') value_lines = self._json_lines(value) key_text = json.dumps(key, ensure_ascii=False) + ": " lines = [key_text + value_lines[0], *value_lines[1:]] trailing = bool(parent.members and parent.members[-1].comma is not None) self._insert(parent, lines, trailing) def append(self, path: Sequence[JsonPathPart], value: Any) -> None: parent = self._resolve(path) if parent.kind != "array": raise TypeError(f"JSONC path must be an array: {list(path)}") trailing = bool(parent.items and parent.items[-1].comma is not None) self._insert(parent, self._json_lines(value), trailing)