Skip to content

JSON Validator — Go source

Validate JSON and pinpoint errors with line and column numbers. Clear valid/invalid verdict plus the exact error location - runs entirely in your browser.

This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package jsonvalidator is the Go twin of CosmoDev's src/lib/jsonValidate.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
// json-validator_test.go share vectors with src/lib/jsonValidate.test.ts so
// the two implementations are held to the same contract.
//
// Position recovery is engine-dependent, exactly as in the TS lib:
//
//   - The TS lib runs JSON.parse (V8), which embeds the error offset in its
//     SyntaxError message as "at position N"; LocateError/ExtractOffset read
//     it back out of the message string. JavaScriptCore (bun) omits it, in
//     which case line/column gracefully fall back to null.
//   - Go's encoding/json instead reports the offset on the typed error
//     (*json.SyntaxError.Offset, a byte offset into the input). ValidateJson
//     reads that typed offset directly and feeds it to the same OffsetToLineCol
//     helper the message-based path uses. The exported message-based helpers
//     (ExtractOffset / LocateError) are preserved verbatim for callers that
//     already hold an engine message.
//
// Note: src/lib/jsonValidate.ts does NOT use WebCrypto or any hashing — it is
// pure JSON parsing — so this twin needs no crypto; stdlib only.
package jsonvalidator

import (
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"strconv"
	"strings"
)

// ValidationResult mirrors the ValidationResult interface in jsonValidate.ts.
// Error, Line, and Column are pointers so a nil value represents the TS null
// (e.g. a valid parse yields Error=nil, Line=nil, Column=nil).
type ValidationResult struct {
	Valid  bool
	Error  *string // nil when valid (TS: error: null)
	Line   *int    // nil when no position is available (TS: line: null)
	Column *int    // nil when no position is available (TS: column: null)
}

// offsetRe matches V8's "at position N" — the same regex as extractOffset() in
// jsonValidate.ts. JavaScriptCore omits the position, in which case there is no
// match and recovery returns null.
var offsetRe = regexp.MustCompile(`at position (\d+)`)

// jscPrefixRe strips JavaScriptCore's "JSON Parse error: " prefix — the same
// regex (case-insensitive, anchored) as normalizeError() in jsonValidate.ts.
var jscPrefixRe = regexp.MustCompile(`(?i)^JSON Parse error:\s*`)

// strptr returns a pointer to s; intptr returns a pointer to i. They build the
// nullable fields of ValidationResult and the LocateError return values.
func strptr(s string) *string { return &s }
func intptr(i int) *int       { return &i }

// ExtractOffset reads the 0-based char offset from a SyntaxError message (V8:
// "at position N"). It is the Go twin of extractOffset() in jsonValidate.ts.
// The bool is false (mirroring TS null) when no position is present.
func ExtractOffset(message string) (int, bool) {
	m := offsetRe.FindStringSubmatch(message)
	if m == nil {
		return 0, false
	}
	n, err := strconv.Atoi(m[1])
	if err != nil {
		return 0, false
	}
	return n, true
}

// ErrorMessage coerces a caught value into a human-readable message. It is the
// Go twin of errorMessage() in jsonValidate.ts: an error yields its message,
// nil yields "null", and anything else is formatted with %v (TS String(value)).
func ErrorMessage(value any) string {
	if err, ok := value.(error); ok {
		return err.Error()
	}
	if value == nil {
		return "null"
	}
	return fmt.Sprintf("%v", value)
}

// NormalizeError strips JavaScriptCore's "JSON Parse error: " prefix for
// display. It is the Go twin of normalizeError() in jsonValidate.ts; V8-style
// messages are returned untouched.
func NormalizeError(message string) string {
	return jscPrefixRe.ReplaceAllString(message, "")
}

// OffsetToLineCol converts a 0-based char offset into a 1-based {line, column}.
// It is the Go twin of offsetToLineCol() in jsonValidate.ts: '\n', a lone '\r',
// and '\r\n' each count as a single line break, and an offset past the end is
// clamped to the text length. Bytes are iterated (mirroring the TS char indexing);
// for the contract's ASCII inputs a byte index equals a code-point index.
func OffsetToLineCol(text string, offset int) (line, column int) {
	line, column = 1, 1
	max := min(offset, len(text)) // clamp an offset past the end to the text length
	for i := 0; i < max; i++ {
		switch text[i] {
		case '\n':
			line++
			column = 1
		case '\r':
			line++
			column = 1
			if i+1 < len(text) && text[i+1] == '\n' {
				i++ // treat CRLF as a single break
			}
		default:
			column++
		}
	}
	return line, column
}

// LocateError maps a parse error message to a {line, column} (or nils) using
// the input text. It is the Go twin of locateError() in jsonValidate.ts: the
// offset is read from the message via ExtractOffset, and nil is returned for
// both when the message carries no position.
func LocateError(text, message string) (line, column *int) {
	offset, ok := ExtractOffset(message)
	if !ok {
		return nil, nil
	}
	l, c := OffsetToLineCol(text, offset)
	return intptr(l), intptr(c)
}

// locateSyntaxError recovers a 1-based {line, column} from a Go JSON parse
// error. encoding/json reports the offset on the typed *json.SyntaxError (a
// byte offset into the input) rather than in the message, so we read it
// directly and feed it to OffsetToLineCol. Returns nils when the error carries
// no offset, mirroring the TS lib's null fallback for engines that omit it.
func locateSyntaxError(text string, err error) (line, column *int) {
	var se *json.SyntaxError
	if errors.As(err, &se) {
		l, c := OffsetToLineCol(text, int(se.Offset))
		return intptr(l), intptr(c)
	}
	return nil, nil
}

// ValidateJson validates a JSON string. It is the Go twin of validateJson() in
// jsonValidate.ts and never panics:
//   - empty / whitespace-only input → invalid, "Input is empty", no location
//   - valid JSON                     → Valid: true, nil error/line/column
//   - invalid JSON                   → Valid: false, a cleaned error message,
//                                      and a 1-based line/column when the
//                                      engine reports an offset (else nil)
func ValidateJson(text string) ValidationResult {
	if strings.TrimSpace(text) == "" {
		return ValidationResult{Valid: false, Error: strptr("Input is empty")}
	}
	var v any
	if err := json.Unmarshal([]byte(text), &v); err != nil {
		line, col := locateSyntaxError(text, err)
		return ValidationResult{
			Valid:  false,
			Error:  strptr(NormalizeError(ErrorMessage(err))),
			Line:   line,
			Column: col,
		}
	}
	return ValidationResult{Valid: true}
}

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 →