JSON Repair — PHP 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 PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
/**
* json-repair — salvage broken JSON back to valid, pretty-printed JSON.
*
* Language: PHP (8.1+, 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 throws out of json_repair() (parse
* failures land in RepairResult->error via JsonException).
* - Functionally equivalent to the TS / Go references: same inputs -> same
* outputs.
* - Self-contained: stdlib only.
*
* PHP strings are byte strings, so every pass first explodes the text into
* an array of UTF-8 code points (preg_split('//u')) — the same "walk runes"
* model as the Go twin — and implodes back at the end. The final parse +
* pretty-print use json_decode/json_encode; PHP pretty-prints with a 4-space
* indent, so the halving pass re-indents to the reference's 2 spaces.
* TS \b word boundaries become explicit ASCII word-char neighbour checks
* (the exact trick the Go twin uses).
*/
/** BOM, ZWSP, ZWNJ, ZWJ, word joiner — invisible characters that break parsers. */
const JSON_REPAIR_INVISIBLE = ["\u{feff}", "\u{200b}", "\u{200c}", "\u{200d}", "\u{2060}"];
/** Result of a repair run (mirrors the TS RepairResult). */
final class RepairResult
{
public function __construct(
public string $text = '',
public array $fixes = [],
public bool $changed = false,
public bool $ok = false,
public ?string $error = null,
) {}
}
/** Explode a string into an array of UTF-8 code points. */
function json_repair_codepoints(string $text): array
{
$points = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY);
return $points === false ? [] : $points;
}
/** True when the (single code point) character is whitespace. ASCII set +
* NBSP — the practical subset of TS /\s/ these passes reason about. */
function json_repair_is_space(string $ch): bool
{
return $ch !== '' && strspn($ch, " \t\n\r\x0b\x0c\u{00a0}") === strlen($ch);
}
function json_repair_is_key_start(string $ch): bool
{
return ($ch >= 'A' && $ch <= 'Z') || ($ch >= 'a' && $ch <= 'z') || $ch === '_' || $ch === '$';
}
function json_repair_is_key_char(string $ch): bool
{
return json_repair_is_key_start($ch) || ($ch >= '0' && $ch <= '9') || $ch === '-';
}
function json_repair_is_word(string $ch): bool
{
return ($ch >= 'A' && $ch <= 'Z') || ($ch >= 'a' && $ch <= 'z')
|| ($ch >= '0' && $ch <= '9') || $ch === '_';
}
/**
* [start, end] index pairs of the double-quoted strings in the code-point
* array (quotes included). An unterminated string extends to the last character.
*/
function json_repair_spans(array $r): array
{
$spans = [];
$open = -1;
$n = count($r);
for ($i = 0; $i < $n; $i++) {
if ($open < 0) {
if ($r[$i] === '"') {
$open = $i;
}
} elseif ($r[$i] === '\\') {
$i++; // skip the escaped character
} elseif ($r[$i] === '"') {
$spans[] = [$open, $i];
$open = -1;
}
}
if ($open >= 0) {
$spans[] = [$open, $n - 1];
}
return $spans;
}
/** True when index i sits inside one of the ordered, non-overlapping spans. */
function json_repair_inside(array $spans, int $i): bool
{
foreach ($spans as [$start, $end]) {
if ($i < $start) {
return false; // spans ascend: before this one means before all after it
}
if ($i <= $end) {
return true;
}
}
return false;
}
function json_repair_implode(array $r, int $from, int $length): string
{
return implode('', array_slice($r, $from, $length));
}
/** Remove copy-paste invisible characters: BOM and zero-width joiners/spaces. */
function json_repair_strip_invisible(string $text): string
{
foreach (JSON_REPAIR_INVISIBLE as $ch) {
$text = str_replace($ch, '', $text);
}
return $text;
}
/** Strip // line comments and block comments, string-aware. */
function json_repair_strip_comments(string $text): string
{
$r = json_repair_codepoints($text);
$n = count($r);
$spans = json_repair_spans($r);
$cuts = []; // [start, endExclusive)
for ($i = 0; $i < $n; $i++) {
if (json_repair_inside($spans, $i)) {
continue;
}
if ($r[$i] === '/' && $i + 1 < $n && $r[$i + 1] === '/') {
$end = $n;
for ($k = $i; $k < $n; $k++) {
if ($r[$k] === "\n") {
$end = $k;
break;
}
}
$cuts[] = [$i, $end]; // keep the newline itself
$i = $end - 1;
} elseif ($r[$i] === '/' && $i + 1 < $n && $r[$i + 1] === '*') {
$end = $n;
for ($k = $i + 2; $k + 1 < $n; $k++) {
if ($r[$k] === '*' && $r[$k + 1] === '/') {
$end = $k + 2;
break;
}
}
$cuts[] = [$i, $end];
$i = $end - 1;
}
}
$out = '';
$prev = 0;
foreach ($cuts as [$a, $b]) {
$out .= json_repair_implode($r, $prev, $a - $prev);
$prev = $b;
}
$out .= json_repair_implode($r, $prev, $n - $prev);
return $out;
}
/**
* Convert single-quoted strings/keys to double-quoted JSON strings:
* escape inner double quotes, collapse \' to ', keep every other escape.
*/
function json_repair_single_to_double_quotes(string $text): string
{
$r = json_repair_codepoints($text);
$n = count($r);
$out = '';
$i = 0;
while ($i < $n) {
$ch = $r[$i];
if ($ch === '"') {
// Copy a double-quoted string verbatim (apostrophes stay put).
$j = $i + 1;
while ($j < $n) {
if ($r[$j] === '\\') {
$j += 2;
} elseif ($r[$j] === '"') {
$j++;
break;
} else {
$j++;
}
}
$j = min($j, $n);
$out .= json_repair_implode($r, $i, $j - $i);
$i = $j;
} elseif ($ch === "'") {
$body = '';
$j = $i + 1;
while ($j < $n) {
$c = $r[$j];
if ($c === '\\' && $j + 1 < $n) {
$next = $r[$j + 1];
$body .= $next === "'" ? "'" : $c . $next;
$j += 2;
} elseif ($c === "'") {
$j++;
break;
} elseif ($c === '"') {
$body .= '\\"';
$j++;
} else {
$body .= $c;
$j++;
}
}
$out .= '"' . $body . '"';
$i = $j;
} else {
$out .= $ch;
$i++;
}
}
return $out;
}
/** Wrap bare identifier keys ({name: 1} -> {"name": 1}), string-aware. */
function json_repair_quote_unquoted_keys(string $text): string
{
$r = json_repair_codepoints($text);
$n = count($r);
$spans = json_repair_spans($r);
$edits = [];
for ($i = 0; $i < $n; $i++) {
if (json_repair_inside($spans, $i)) {
continue;
}
$ch = $r[$i];
if ($ch !== '{' && $ch !== ',') {
continue;
}
$j = $i + 1;
while ($j < $n && json_repair_is_space($r[$j])) {
$j++;
}
if ($j >= $n || !json_repair_is_key_start($r[$j])) {
continue;
}
$k = $j;
while ($k < $n && json_repair_is_key_char($r[$k])) {
$k++;
}
$l = $k;
while ($l < $n && json_repair_is_space($r[$l])) {
$l++;
}
if ($l < $n && $r[$l] === ':') {
$edits[] = [$j, $k];
}
}
$out = '';
$prev = 0;
foreach ($edits as [$a, $b]) {
$out .= json_repair_implode($r, $prev, $a - $prev);
$out .= '"' . json_repair_implode($r, $a, $b - $a) . '"';
$prev = $b;
}
$out .= json_repair_implode($r, $prev, $n - $prev);
return $out;
}
/** 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. */
function json_repair_fix_python_constants(string $text): string
{
$r = json_repair_codepoints($text);
$n = count($r);
$spans = json_repair_spans($r);
$constants = ['True' => 'true', 'False' => 'false', 'None' => 'null'];
$words = [];
foreach ($constants as $word => $rep) {
$words[] = [json_repair_codepoints($word), $rep];
}
$edits = []; // [start, end, replacement)
for ($i = 0; $i < $n; $i++) {
foreach ($words as [$w, $rep]) {
$len = count($w);
if ($i + $len > $n) {
continue;
}
for ($k = 0; $k < $len; $k++) {
if ($r[$i + $k] !== $w[$k]) {
continue 2;
}
}
$before = $i === 0 || !json_repair_is_word($r[$i - 1]);
$after = $i + $len >= $n || !json_repair_is_word($r[$i + $len]);
if ($before && $after && !json_repair_inside($spans, $i)) {
$edits[] = [$i, $i + $len, $rep];
}
}
}
$out = '';
$prev = 0;
foreach ($edits as [$a, $b, $rep]) {
$out .= json_repair_implode($r, $prev, $a - $prev);
$out .= $rep;
$prev = $b;
}
$out .= json_repair_implode($r, $prev, $n - $prev);
return $out;
}
/** Remove commas followed only by whitespace and a closing } or ],
* string-aware. */
function json_repair_strip_trailing_commas(string $text): string
{
$r = json_repair_codepoints($text);
$n = count($r);
$spans = json_repair_spans($r);
$cuts = [];
for ($i = 0; $i < $n; $i++) {
if ($r[$i] !== ',' || json_repair_inside($spans, $i)) {
continue;
}
$j = $i + 1;
while ($j < $n && json_repair_is_space($r[$j])) {
$j++;
}
if ($j < $n && ($r[$j] === '}' || $r[$j] === ']')) {
$cuts[] = $i;
}
}
$out = '';
$prev = 0;
foreach ($cuts as $c) {
$out .= json_repair_implode($r, $prev, $c - $prev);
$prev = $c + 1;
}
$out .= json_repair_implode($r, $prev, $n - $prev);
return $out;
}
/** 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. */
function json_repair_close_truncated(string $text): string
{
$r = json_repair_codepoints($text);
$n = count($r);
$inString = false;
$stack = [];
for ($i = 0; $i < $n; $i++) {
$ch = $r[$i];
if ($inString) {
if ($ch === '\\') {
$i++;
} elseif ($ch === '"') {
$inString = false;
}
continue;
}
if ($ch === '"') {
$inString = true;
} elseif ($ch === '{' || $ch === '[') {
$stack[] = $ch;
} elseif ($ch === '}' || $ch === ']') {
if ($stack) {
array_pop($stack);
}
}
}
$out = $text . ($inString ? '"' : '');
$out = rtrim($out, " \t\n\r\x0b\x0c\u{00a0},");
if (str_ends_with($out, ':')) {
$out .= ' null';
}
$closers = '';
for ($i = count($stack) - 1; $i >= 0; $i--) {
$closers .= $stack[$i] === '{' ? '}' : ']';
}
return $out . $closers;
}
/** Parse + pretty-print with the reference's 2-space indent. Throws
* JsonException on invalid JSON — exactly the contract of the TS prettyPrint. */
function json_repair_pretty_print(string $text): string
{
// json_decode rejects trailing garbage, like JSON.parse.
$value = json_decode($text, false, 512, JSON_THROW_ON_ERROR);
$json = json_encode(
$value,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
// PHP pretty-prints with a 4-space indent; the reference uses 2 — halve it.
return (string) preg_replace_callback(
'/^ +/m',
static fn (array $m): string => str_repeat(' ', intdiv(strlen($m[0]), 2)),
$json
);
}
/** The pass pipeline: [label, pass]; each applied only while it changes the text. */
function json_repair_passes(): array
{
return [
['Removed invisible characters (BOM / zero-width)', 'json_repair_strip_invisible'],
['Converted single quotes to double quotes', 'json_repair_single_to_double_quotes'],
['Stripped JavaScript comments', 'json_repair_strip_comments'],
['Quoted unquoted keys', 'json_repair_quote_unquoted_keys'],
['Converted Python constants (True/False/None)', 'json_repair_fix_python_constants'],
['Removed trailing commas', 'json_repair_strip_trailing_commas'],
['Closed truncated brackets', 'json_repair_close_truncated'],
];
}
/**
* Repair broken JSON and pretty-print the result. Never throws.
* - 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
*/
function json_repair(string $text): RepairResult
{
if (trim($text) === '') {
return new RepairResult(error: 'Input is empty');
}
// Already clean: pretty-print and say so.
try {
return new RepairResult(text: json_repair_pretty_print($text), ok: true);
} catch (JsonException) {
// fall through to the repair passes
}
$work = $text;
$fixes = [];
foreach (json_repair_passes() as [$label, $fn]) {
$next = $fn($work);
if ($next !== $work) {
$fixes[] = $label;
$work = $next;
}
}
try {
return new RepairResult(
text: json_repair_pretty_print($work),
fixes: $fixes,
changed: (bool) $fixes,
ok: true
);
} catch (JsonException $e) {
return new RepairResult(fixes: $fixes, changed: (bool) $fixes, ok: false, error: $e->getMessage());
}
}
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 →