Skip to content

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 →