JSON Repair — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package jsonrepair is the Go twin of CosmoDev's src/lib/jsonRepair.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in
// lock-step). Pure + deterministic, never panics. The table-driven tests in
// jsonrepair_test.go share vectors with src/lib/jsonRepair.test.ts so the two
// implementations are held to the same contract.
//
// Repair shepherds broken-but-salvageable JSON (trailing commas, single
// quotes, unquoted keys, JS/Python dialect drift, copy-paste invisible
// characters, truncation) back to text a JSON parser accepts, then
// pretty-prints it. Every pass is a small pure function so each can be tested
// alone. PrettyPrint preserves key insertion order (like the TS
// JSON.stringify) — Go's map iteration would sort keys, so the printer walks
// the token stream instead.
package jsonrepair
import (
"strings"
"unicode"
)
// RepairResult mirrors the TS RepairResult. Err is "" when OK (the TS null).
type RepairResult struct {
Text string
Fixes []string
Changed bool
OK bool
Err string
}
// IsProbablyJson is the cheap "should we bother repairing this" check:
// JSON containers start with { or [.
func IsProbablyJson(text string) bool {
t := strings.TrimSpace(text)
return strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")
}
// ErrorMessage coerces a parse failure into a human-readable message.
func ErrorMessage(err error) string {
if err == nil {
return ""
}
return err.Error()
}
// span is a [start, end] rune-index pair of a double-quoted string (quotes
// included). An unterminated string extends to the last character.
type span struct{ start, end int }
// StringSpans returns the double-quoted string spans in text (quotes
// included). An unterminated string extends to the last character. Indexes
// are rune offsets (the TS lib indexes UTF-16 units — identical for the
// ASCII/ BMP content these passes reason about).
func StringSpans(text string) []span {
r := []rune(text)
var spans []span
open := -1
for i := 0; i < len(r); i++ {
switch {
case open < 0:
if r[i] == '"' {
open = i
}
case r[i] == '\\':
i++ // skip the escaped character
case r[i] == '"':
spans = append(spans, span{open, i})
open = -1
}
}
if open >= 0 {
spans = append(spans, span{open, len(r) - 1})
}
return spans
}
// insideSpan reports whether rune index i sits inside one of the ordered,
// non-overlapping spans.
func insideSpan(spans []span, i int) bool {
for _, s := range spans {
if i < s.start {
return false // spans ascend: before this one means before all after it
}
if i <= s.end {
return true
}
}
return false
}
// invisibleRunes are the copy-paste characters that break parsers: BOM, ZWSP,
// ZWNJ, ZWJ, word joiner.
var invisibleRunes = []rune{'\uFEFF', '\u200B', '\u200C', '\u200D', '\u2060'}
// StripInvisible removes copy-paste invisible characters: BOM and zero-width
// joiners/spaces.
func StripInvisible(text string) string {
for _, ch := range invisibleRunes {
text = strings.ReplaceAll(text, string(ch), "")
}
return text
}
// StripComments removes // line comments and block comments, string-aware.
func StripComments(text string) string {
r := []rune(text)
spans := StringSpans(text)
type cut struct{ start, end int } // [start, endExclusive)
var cuts []cut
for i := 0; i < len(r); i++ {
if insideSpan(spans, i) {
continue
}
if r[i] == '/' && i+1 < len(r) && r[i+1] == '/' {
end := indexRune(r, '\n', i)
if end == -1 {
end = len(r)
}
cuts = append(cuts, cut{i, end}) // keep the newline itself
i = end - 1
} else if r[i] == '/' && i+1 < len(r) && r[i+1] == '*' {
close := indexRunes(r, "*/", i+2)
end := len(r)
if close != -1 {
end = close + 2
}
cuts = append(cuts, cut{i, end})
i = end - 1
}
}
var out strings.Builder
prev := 0
for _, c := range cuts {
out.WriteString(string(r[prev:c.start]))
prev = c.end
}
out.WriteString(string(r[prev:]))
return out.String()
}
func indexRune(r []rune, ch rune, from int) int {
for i := from; i < len(r); i++ {
if r[i] == ch {
return i
}
}
return -1
}
func indexRunes(r []rune, sep string, from int) int {
s := []rune(sep)
for i := from; i+len(s) <= len(r); i++ {
match := true
for k, c := range s {
if r[i+k] != c {
match = false
break
}
}
if match {
return i
}
}
return -1
}
// SingleToDoubleQuotes converts single-quoted strings/keys to double-quoted
// JSON strings: escape inner double quotes, collapse \' to ', keep every
// other escape.
func SingleToDoubleQuotes(text string) string {
r := []rune(text)
var out strings.Builder
i := 0
for i < len(r) {
ch := r[i]
if ch == '"' {
// Copy a double-quoted string verbatim (apostrophes stay put).
j := i + 1
for j < len(r) {
if r[j] == '\\' {
j += 2
} else if r[j] == '"' {
j++
break
} else {
j++
}
}
out.WriteString(string(r[i:j]))
i = j
} else if ch == '\'' {
var body strings.Builder
j := i + 1
for j < len(r) {
c := r[j]
if c == '\\' && j+1 < len(r) {
next := r[j+1]
if next == '\'' {
body.WriteRune('\'')
} else {
body.WriteRune(c)
body.WriteRune(next)
}
j += 2
} else if c == '\'' {
j++
break
} else if c == '"' {
body.WriteString("\\\"")
j++
} else {
body.WriteRune(c)
j++
}
}
out.WriteString("\"" + body.String() + "\"")
i = j
} else {
out.WriteRune(ch)
i++
}
}
return out.String()
}
func isKeyStart(ch rune) bool {
return ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' || ch == '_' || ch == '$'
}
func isKeyChar(ch rune) bool {
return isKeyStart(ch) || ch >= '0' && ch <= '9' || ch == '-'
}
func isSpaceRune(ch rune) bool { return unicode.IsSpace(ch) }
// QuoteUnquotedKeys wraps bare identifier keys ({name: 1} -> {"name": 1}),
// string-aware.
func QuoteUnquotedKeys(text string) string {
r := []rune(text)
spans := StringSpans(text)
type edit struct{ start, end int }
var edits []edit
for i := 0; i < len(r); i++ {
if insideSpan(spans, i) {
continue
}
ch := r[i]
if ch != '{' && ch != ',' {
continue
}
j := i + 1
for j < len(r) && isSpaceRune(r[j]) {
j++
}
if j >= len(r) || !isKeyStart(r[j]) {
continue
}
k := j
for k < len(r) && isKeyChar(r[k]) {
k++
}
l := k
for l < len(r) && isSpaceRune(r[l]) {
l++
}
if l < len(r) && r[l] == ':' {
edits = append(edits, edit{j, k})
}
}
var out strings.Builder
prev := 0
for _, e := range edits {
out.WriteString(string(r[prev:e.start]))
out.WriteString("\"" + string(r[e.start:e.end]) + "\"")
prev = e.end
}
out.WriteString(string(r[prev:]))
return out.String()
}
var pyConstants = map[string]string{
"True": "true",
"False": "false",
"None": "null",
}
// FixPythonConstants rewrites bare Python constants (True/False/None) to JSON
// (true/false/null), string-aware.
func FixPythonConstants(text string) string {
r := []rune(text)
spans := StringSpans(text)
type edit struct {
start, end int
rep string
}
var edits []edit
for i := 0; i < len(r); i++ {
for _, word := range []string{"True", "False", "None"} {
w := []rune(word)
if i+len(w) <= len(r) && string(r[i:i+len(w)]) == word {
before := i == 0 || !isWordRune(r[i-1])
after := i+len(w) >= len(r) || !isWordRune(r[i+len(w)])
if before && after && !insideSpan(spans, i) {
edits = append(edits, edit{i, i + len(w), pyConstants[word]})
}
}
}
}
var out strings.Builder
prev := 0
for _, e := range edits {
out.WriteString(string(r[prev:e.start]))
out.WriteString(e.rep)
prev = e.end
}
out.WriteString(string(r[prev:]))
return out.String()
}
func isWordRune(ch rune) bool {
return ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' || ch == '_'
}
// StripTrailingCommas removes commas followed only by whitespace and a
// closing } or ], string-aware.
func StripTrailingCommas(text string) string {
r := []rune(text)
spans := StringSpans(text)
var cuts []int
for i := 0; i < len(r); i++ {
if r[i] != ',' || insideSpan(spans, i) {
continue
}
j := i + 1
for j < len(r) && isSpaceRune(r[j]) {
j++
}
if j < len(r) && (r[j] == '}' || r[j] == ']') {
cuts = append(cuts, i)
}
}
var out strings.Builder
prev := 0
for _, c := range cuts {
out.WriteString(string(r[prev:c]))
prev = c + 1
}
out.WriteString(string(r[prev:]))
return out.String()
}
// CloseTruncated recovers 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.
func CloseTruncated(text string) string {
r := []rune(text)
inString := false
var stack []rune
for i := 0; i < len(r); i++ {
ch := r[i]
if inString {
if ch == '\\' {
i++
} else if ch == '"' {
inString = false
}
continue
}
switch ch {
case '"':
inString = true
case '{', '[':
stack = append(stack, ch)
case '}', ']':
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
}
}
out := text
if inString {
out += "\""
}
out = strings.TrimRight(out, " \t\n\r,")
if strings.HasSuffix(out, ":") {
out += " null"
}
var closers strings.Builder
for i := len(stack) - 1; i >= 0; i-- {
if stack[i] == '{' {
closers.WriteRune('}')
} else {
closers.WriteRune(']')
}
}
return out + closers.String()
}
type pass struct {
label string
fn func(string) string
}
var passes = []pass{
{"Removed invisible characters (BOM / zero-width)", StripInvisible},
{"Converted single quotes to double quotes", SingleToDoubleQuotes},
{"Stripped JavaScript comments", StripComments},
{"Quoted unquoted keys", QuoteUnquotedKeys},
{"Converted Python constants (True/False/None)", FixPythonConstants},
{"Removed trailing commas", StripTrailingCommas},
{"Closed truncated brackets", CloseTruncated},
}
// Repair repairs broken JSON and pretty-prints the result. Never returns an
// error — failures land in RepairResult.Err.
// - 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
func Repair(text string) RepairResult {
if strings.TrimSpace(text) == "" {
return RepairResult{Text: "", Fixes: []string{}, Changed: false, OK: false, Err: "Input is empty"}
}
// Already clean: pretty-print and say so.
if pretty, err := PrettyPrint(text); err == nil {
return RepairResult{Text: pretty, Fixes: []string{}, Changed: false, OK: true, Err: ""}
}
work := text
var fixes []string
for _, p := range passes {
next := p.fn(work)
if next != work {
fixes = append(fixes, p.label)
work = next
}
}
pretty, err := PrettyPrint(work)
if err != nil {
return RepairResult{Text: "", Fixes: fixes, Changed: len(fixes) > 0, OK: false, Err: err.Error()}
}
return RepairResult{Text: pretty, Fixes: fixes, Changed: len(fixes) > 0, OK: true, Err: ""}
}
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 →