Skip to content

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 →