Skip to content

JSON Repair — Python source

Fix broken JSON - trailing commas, single quotes, unquoted keys, comments, Python constants, BOM and truncated documents - and get clean pretty-printed JSON plus a list of every repair applied. Runs entirely in your browser.

This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.

"""json-repair — salvage broken JSON back to valid, pretty-printed JSON.

Language: Python (3.9+, standard library only)
Source:   CosmoDev polyglot showcase port of the JSON Repair tool, ported
          from src/lib/jsonRepair.ts (the canonical TypeScript
          implementation) and cli/json-repair/json-repair.go (the live Go CLI
          twin).
License:  display source — part of CosmoDev's polyglot tool pages.

Design goals:
  - Pure + deterministic; never raises (failures land in ``error``).
  - Functionally equivalent to the TS / Go references: same inputs -> same
    outputs.
  - Self-contained: stdlib only (``json`` is part of the language, not a pip
    package). The final parse/pretty-print uses ``json.loads`` +
    ``json.dumps(indent=2, ensure_ascii=False)``: ``dict`` preserves key
    insertion order like ``JSON.stringify``, and ``ensure_ascii=False`` keeps
    non-ASCII code points literal like every other port. ``parse_constant``
    rejects ``NaN``/``Infinity``, which ``JSON.parse`` rejects too.

Divergences, documented: TS ``/\\s/`` whitespace becomes ``str.isspace()``
(same Unicode-whitespace spirit as the Go twin's ``unicode.IsSpace``), and
TS ``\\b`` word boundaries become explicit ASCII word-char neighbour checks
(the exact trick the Go twin uses).
"""
from __future__ import annotations

import json
import re
from dataclasses import dataclass, field
from typing import List, Optional, Tuple

# BOM, ZWSP, ZWNJ, ZWJ, word joiner — invisible characters that break parsers.
_INVISIBLE = ("\ufeff", "\u200b", "\u200c", "\u200d", "\u2060")

_PY_CONSTANTS = {"True": "true", "False": "false", "None": "null"}


@dataclass
class RepairResult:
    """Result of a repair run (mirrors the TS ``RepairResult``)."""

    text: str = ""
    fixes: List[str] = field(default_factory=list)
    changed: bool = False
    ok: bool = False
    error: Optional[str] = None


def string_spans(text: str) -> List[Tuple[int, int]]:
    """[start, end] index pairs of the double-quoted strings in text (quotes
    included). An unterminated string extends to the last character."""
    spans: List[Tuple[int, int]] = []
    open_: Optional[int] = None
    i = 0
    n = len(text)
    while i < n:
        ch = text[i]
        if open_ is None:
            if ch == '"':
                open_ = i
        elif ch == "\\":
            i += 1  # skip the escaped character
        elif ch == '"':
            spans.append((open_, i))
            open_ = None
        i += 1
    if open_ is not None:
        spans.append((open_, n - 1))
    return spans


def _inside(spans: List[Tuple[int, int]], i: int) -> bool:
    """True when index i sits inside one of the ordered, non-overlapping spans."""
    for start, end in spans:
        if i < start:
            return False  # spans ascend: before this one means before all after it
        if i <= end:
            return True
    return False


def _is_key_start(ch: str) -> bool:
    return ("A" <= ch <= "Z") or ("a" <= ch <= "z") or ch in ("_", "$")


def _is_key_char(ch: str) -> bool:
    return _is_key_start(ch) or ("0" <= ch <= "9") or ch == "-"


def _is_word(ch: str) -> bool:
    return ("A" <= ch <= "Z") or ("a" <= ch <= "z") or ("0" <= ch <= "9") or ch == "_"


def strip_invisible(text: str) -> str:
    """Remove copy-paste invisible characters: BOM and zero-width joiners/spaces."""
    for ch in _INVISIBLE:
        text = text.replace(ch, "")
    return text


def strip_comments(text: str) -> str:
    """Strip // line comments and block comments, string-aware."""
    spans = string_spans(text)
    cuts = []  # [start, end_exclusive)
    n = len(text)
    i = 0
    while i < n:
        if _inside(spans, i):
            i += 1
            continue
        if text[i] == "/" and i + 1 < n and text[i + 1] == "/":
            end = text.find("\n", i)
            end = n if end == -1 else end
            cuts.append((i, end))  # keep the newline itself
            i = end
        elif text[i] == "/" and i + 1 < n and text[i + 1] == "*":
            close = text.find("*/", i + 2)
            end = n if close == -1 else close + 2
            cuts.append((i, end))
            i = end
        else:
            i += 1
    out = text
    for a, b in reversed(cuts):
        out = out[:a] + out[b:]
    return out


def single_to_double_quotes(text: str) -> str:
    """Convert single-quoted strings/keys to double-quoted JSON strings:
    escape inner double quotes, collapse \\' to ', keep every other escape."""
    out: List[str] = []
    i = 0
    n = len(text)
    while i < n:
        ch = text[i]
        if ch == '"':
            # Copy a double-quoted string verbatim (apostrophes stay put).
            j = i + 1
            while j < n:
                if text[j] == "\\":
                    j += 2
                elif text[j] == '"':
                    j += 1
                    break
                else:
                    j += 1
            j = min(j, n)
            out.append(text[i:j])
            i = j
        elif ch == "'":
            body: List[str] = []
            j = i + 1
            while j < n:
                c = text[j]
                if c == "\\" and j + 1 < n:
                    nxt = text[j + 1]
                    body.append("'" if nxt == "'" else c + nxt)
                    j += 2
                elif c == "'":
                    j += 1
                    break
                elif c == '"':
                    body.append('\\"')
                    j += 1
                else:
                    body.append(c)
                    j += 1
            out.append('"' + "".join(body) + '"')
            i = j
        else:
            out.append(ch)
            i += 1
    return "".join(out)


def quote_unquoted_keys(text: str) -> str:
    """Wrap bare identifier keys ({name: 1} -> {"name": 1}), string-aware."""
    spans = string_spans(text)
    edits = []
    n = len(text)
    i = 0
    while i < n:
        if _inside(spans, i):
            i += 1
            continue
        ch = text[i]
        if ch not in "{,":
            i += 1
            continue
        j = i + 1
        while j < n and text[j].isspace():
            j += 1
        if j >= n or not _is_key_start(text[j]):
            i += 1
            continue
        k = j
        while k < n and _is_key_char(text[k]):
            k += 1
        l = k
        while l < n and text[l].isspace():
            l += 1
        if l < n and text[l] == ":":
            edits.append((j, k))
        i += 1
    out = text
    for a, b in reversed(edits):
        out = out[:a] + '"' + out[a:b] + '"' + out[b:]
    return out


def fix_python_constants(text: str) -> str:
    """Rewrite bare Python constants (True/False/None) to JSON
    (true/false/null), string-aware. Word boundaries are explicit ASCII
    word-char neighbour checks — the same trick the Go twin uses for \\b."""
    spans = string_spans(text)
    edits = []  # (start, end, replacement)
    n = len(text)
    i = 0
    while i < n:
        matched = None
        for word in _PY_CONSTANTS:
            if text.startswith(word, i):
                matched = word
                break
        if matched is not None:
            end = i + len(matched)
            before = i == 0 or not _is_word(text[i - 1])
            after = end >= n or not _is_word(text[end])
            if before and after and not _inside(spans, i):
                edits.append((i, end, _PY_CONSTANTS[matched]))
                i = end
                continue
        i += 1
    out = text
    for a, b, rep in reversed(edits):
        out = out[:a] + rep + out[b:]
    return out


def strip_trailing_commas(text: str) -> str:
    """Remove commas followed only by whitespace and a closing } or ],
    string-aware."""
    spans = string_spans(text)
    n = len(text)
    cuts = []
    i = 0
    while i < n:
        if text[i] == "," and not _inside(spans, i):
            j = i + 1
            while j < n and text[j].isspace():
                j += 1
            if j < n and text[j] in "}]":
                cuts.append(i)
        i += 1
    out = text
    for c in reversed(cuts):
        out = out[:c] + out[c + 1 :]
    return out


def close_truncated(text: str) -> str:
    """Recover truncated JSON: close an unterminated string, drop a dangling
    comma, give a dangling colon a null value, then close every still-open
    bracket in reverse order."""
    n = len(text)
    in_string = False
    stack: List[str] = []
    i = 0
    while i < n:
        ch = text[i]
        if in_string:
            if ch == "\\":
                i += 1
            elif ch == '"':
                in_string = False
            i += 1
            continue
        if ch == '"':
            in_string = True
        elif ch in "{[":
            stack.append(ch)
        elif ch in "}]":
            if stack:
                stack.pop()
        i += 1
    out = text + ('"' if in_string else "")
    out = re.sub(r"[\s,]+$", "", out)
    if out.endswith(":"):
        out += " null"
    return out + "".join("}" if c == "{" else "]" for c in reversed(stack))


def _reject_constant(value: str) -> object:
    raise ValueError(f"invalid JSON constant: {value}")


def _pretty_print(text: str) -> str:
    """Parse + pretty-print (2-space indent), throwing on invalid JSON —
    exactly the contract of the TS ``prettyPrint``."""
    return json.dumps(
        json.loads(text, parse_constant=_reject_constant),
        indent=2,
        ensure_ascii=False,
    )


_PASSES = [
    ("Removed invisible characters (BOM / zero-width)", strip_invisible),
    ("Converted single quotes to double quotes", single_to_double_quotes),
    ("Stripped JavaScript comments", strip_comments),
    ("Quoted unquoted keys", quote_unquoted_keys),
    ("Converted Python constants (True/False/None)", fix_python_constants),
    ("Removed trailing commas", strip_trailing_commas),
    ("Closed truncated brackets", close_truncated),
]


def repair(text: str) -> RepairResult:
    """Repair broken JSON and pretty-print the result. Never raises.

    - empty / whitespace-only input -> ok=False, "Input is empty"
    - input that already parses -> same shape, changed=False, no fixes
    - repairable input -> pretty text + a description per pass that fired
    - unrepairable input -> ok=False, the parser's error message
    """
    if not isinstance(text, str) or not text.strip():
        return RepairResult(error="Input is empty")
    # Already clean: pretty-print and say so.
    try:
        return RepairResult(text=_pretty_print(text), ok=True)
    except ValueError:
        pass  # fall through to the repair passes
    work = text
    fixes: List[str] = []
    for label, pass_fn in _PASSES:
        nxt = pass_fn(work)
        if nxt != work:
            fixes.append(label)
            work = nxt
    changed = bool(fixes)
    try:
        return RepairResult(text=_pretty_print(work), fixes=fixes, changed=changed, ok=True)
    except ValueError as exc:
        return RepairResult(fixes=fixes, changed=changed, ok=False, error=str(exc))

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →