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 →