Skip to content

x.jsonc #

v-jsonc

JSONC parsing, validation and decoding for V.

JSONC is RFC 8259 plus // and /* */ comments. It is the format that tsconfig.json, VS Code's settings.json and many editor and tool configurations are written in. Plain JSON parsers reject those files because of the comments, and a JSON5 parser accepts them but also accepts a good deal that the format does not allow, so a typo in a config file passes unnoticed.

This module reads JSONC strictly: comments are allowed and the rest of the JSON5 syntax is refused, with the position of the problem.

Contents

  • Install
  • Parse
  • Validate
  • Decode into V types
  • Positions
  • Trailing commas
  • Stripping comments
  • What is accepted and what is refused
  • Errors
  • Examples

Install

The module ships with V, so there is nothing to install: import x.jsonc.

Parse

parse returns the dynamic Any tree, and parse_text returns a Doc that also supports path lookup.

import x.jsonc

fn main() {
    text := '{
    // the port to bind
    "host": "0.0.0.0",
    "port": 8080
}'
    value := jsonc.parse(text) or { panic(err) }
    println(value.as_map()['port'] or { panic('no port') }.int())
}

parse_file reads the document from disk. Doc.str() renders compact JSONC without comments or trailing commas. Valid decimal numbers that overflow f64, such as 1e400, remain signed infinity in the dynamic tree and render as 1e999 or -1e999, so the rendered document can be parsed again without losing the overflow.

Validate

is_valid answers whether a document is JSONC, and violation returns the first reason it is not.

import x.jsonc

fn main() {
    println(jsonc.is_valid('{"a": 1 /* fine */}'))
    println(jsonc.is_valid('{a: 1}'))
    if v := jsonc.violation('{a: 1}') {
        println(v.msg())
    }
}

Decode into V types

decode reads a document into a struct. The rules are the ones json2 uses, because the same decoder does the work: structs with embedded structs flattened into the parent, enums by name or by value, map[string]T, arrays, options, and every scalar width.

A field is matched by name, or by an @[json: 'name'] attribute when the document spells it differently, which is the usual case for a camelCase config key. A missing key leaves the field at its default, and an explicit null clears an option field.

import x.jsonc

struct CompilerOptions {
    out_dir string @[json: 'outDir']
    strict  bool
}

struct TsConfig {
    compiler_opts CompilerOptions @[json: 'compilerOptions']
    include       []string
}

fn main() {
    cfg := jsonc.decode[TsConfig]('{
    // where the output goes
    "compilerOptions": { "outDir": "dist", "strict": true },
    "include": ["src"]
}') or { panic(err) }
    println(cfg.compiler_opts.out_dir) // dist
    println(cfg.include) // ['src']
}

decode_file reads the document from disk, and decode_any converts a tree that was parsed already. Typed decoding preserves 64-bit integers; dynamic trees use json2.Any, whose default numeric variant is f64 and has floating-point precision limits. Use str() and as_array() to read strings and arrays. decode_any preserves overflowing numbers as signed infinity, consistently with decode.

The dialect is checked before the decoder runs, so a document with a JSON5-only construct is refused even when the target struct has a field for it.

Positions

Every dialect violation carries a line, a column, and the byte range the offending text occupies, so an editor can underline exactly it. The range covers the whole offending text: the token, the character that is not whitespace, the escape sequence from its backslash, or the single-quoted string from quote to quote. It never ends inside a multi-byte character, so slicing the original text with it is safe. A byte that is not valid UTF-8 counts as one character. CR, LF, U+2028 and U+2029 advance the line and reset the column; CRLF counts once.

When several violations are present, the one reported is the first in the document.

violation is a convenience that returns the ParseError directly. When parse_text or another entry point refuses a document for its dialect, its error is that same ParseError, and if err is jsonc.ParseError { ... } reaches its position.

import x.jsonc

fn main() {
    if v := jsonc.violation('{\n  "a": 1,\n  b: 2\n}') {
        println('line ${v.pos.line}, column ${v.pos.col}')
        println('bytes ${v.pos.offset}..${v.pos.end_offset}')
    }
}

Trailing commas

A comma before a closing } or ] is refused by default, which is what the JSONC parser that VS Code uses does. Configuration files that allow one are read with the option set.

import x.jsonc

fn main() {
    opts := jsonc.ParseOpts{
        allow_trailing_comma: true
    }
    doc := jsonc.parse_text_opts('{"a": [1, 2,],}', opts) or { panic(err) }
    println(doc.str()) // {"a":[1,2]}
}

The option relaxes that one rule and nothing else.

Stripping comments

strip_comments replaces comment contents with spaces, byte for byte, preserving CR, LF, U+2028 and U+2029 exactly. Remaining characters keep their original byte offsets and line numbers, including after Unicode line separators inside block comments.

import x.jsonc

fn main() {
    text := '{\n// a note\n"a": 1 /* another */\n}'
    println(jsonc.strip_comments(text).len == text.len) // true, no byte moved
    println(jsonc.parse(text) or { panic(err) }.str())
}

json2 rejects comments by default. Strip them first to parse the remaining text as ordinary JSON, or pass json5: true to accept the comments directly. Unicode separators retained from comments are not RFC 8259 whitespace between tokens; parse stripped output containing them with json5: true.

What is accepted and what is refused

Accepted, because they are JSONC:

  • // line comments and /* */ block comments;
  • a leading byte order mark;
  • every escape in RFC 8259, including \uXXXX;
  • unescaped U+2028 and U+2029 inside quoted strings;
  • the four characters RFC 8259 allows between tokens: space, tab, CR and LF;
  • a trailing comma, when allow_trailing_comma is set.

Refused, because they are JSON5 extensions rather than JSONC:

Construct Example
single-quoted string {'a': 1}
unquoted key {a: 1}
non-string key {1: 1}, {true: 1}
trailing comma, by default [1, 2,]
hexadecimal integer 0x10
leading dot .5
no digit after a decimal point 1., 1.e2
leading + +1
leading zero 01, 00.5
Infinity and NaN Infinity
\', \v, \0, \xNN "\v"
ES6 \u{...} escape "\u{1F600}"
escaped line break "a\<newline>b"
unescaped control character a raw tab inside a string

Refused, because RFC 8259 does not allow them between tokens even though the JSON5 scanner skips them as whitespace:

Construct Example
vertical tab, form feed U+000B, U+000C
no-break space U+00A0
Unicode space separators and line terminators U+1680, U+2000, U+202F, U+2028
an interior byte order mark {"a": 1<FEFF>}

A byte order mark is accepted only at the very start of the document. These characters remain legal inside a comment, which is opaque, and inside a string, where the string rules decide.

Errors

Dialect violations and malformed input use different error types.

A ParseError, prefixed jsonc:, is well formed JSON5 that is not valid JSONC. It is the ParseError above, with a position.

Anything else is malformed input, and the error comes from the JSON5 parser as a json2.JsonScanError. That happens before the dialect is considered, so a syntax error is never reported as a dialect problem. Raw LF and CR inside a quoted string are syntax errors in the shared JSON5 parser.

Decoding errors come from json2 and describe the value that does not fit the target type. The decoder is shared with JSON and JSON5.

Examples

See the examples directory for a complete runnable program.

./v run vlib/x/jsonc/examples/tour.v

fn decode #

fn decode[T](text string) !T

decode parses the JSONC document in text and decodes it into T.

The target rules are json2's: structs, embedded structs, enums, maps, arrays, options and scalar types. Fields use @[json: 'name'] for renaming. Missing keys retain their defaults. Dialect validation precedes decoding.

fn decode_any #

fn decode_any[T](value Any) !T

decode_any converts an already parsed tree into T using the json2 decoder. Decimal overflow retained as infinity is preserved during conversion.

fn decode_file #

fn decode_file[T](path string) !T

decode_file reads and decodes the JSONC document at path into T.

fn is_valid #

fn is_valid(text string) bool

is_valid reports whether text is valid JSONC.

A document that is not well formed JSON is not valid JSONC either, so this answers the question rather than reporting why. Use parse_text when the reason matters: it returns a ParseError for a JSON5 extension that JSONC does not allow, and the JSON5 parser's own error for malformed input.

fn is_valid_opts #

fn is_valid_opts(text string, opts ParseOpts) bool

is_valid_opts reports whether text is valid JSONC under opts.

fn parse #

fn parse(text string) !Any

parse parses the JSONC document in text and returns its root Any.

fn parse_file #

fn parse_file(path string) !Doc

parse_file reads and parses the JSONC document at path.

fn parse_file_opts #

fn parse_file_opts(path string, opts ParseOpts) !Doc

parse_file_opts reads and parses the JSONC document at path under opts.

fn parse_opts #

fn parse_opts(text string, opts ParseOpts) !Any

parse_opts parses the JSONC document in text under opts and returns its root Any.

fn parse_text #

fn parse_text(text string) !Doc

parse_text parses the JSONC document in text and returns a Doc.

fn parse_text_opts #

fn parse_text_opts(text string, opts ParseOpts) !Doc

parse_text_opts parses the JSONC document in text under opts and returns a Doc.

fn strip_comments #

fn strip_comments(text string) string

strip_comments returns text with every comment replaced by spaces.

The replacement is byte for byte, preserving CR, LF, U+2028 and U+2029 inside comments, so remaining characters keep their byte offsets and line numbers. Retained Unicode separators require JSON5 mode when parsing the stripped text. A comment that is never closed is stripped to the end of the input.

fn violation #

fn violation(text string) ?ParseError

violation returns the first JSONC strictness violation in text, or none when the document is valid.

This is a convenience for a caller that needs to know where the problem is: the payload is the ParseError itself, so its position is reachable directly. parse_text, decode and their variants return the same violation as their error, where if err is jsonc.ParseError reaches it as well:

if v := jsonc.violation(text) {
    println('${v.pos.line}:${v.pos.col} bytes ${v.pos.offset}..${v.pos.end_offset}')
}

Input that is not well formed JSON has no violation to report, because the JSON5 parser owns it; parse_text returns that error instead.

fn violation_opts #

fn violation_opts(text string, opts ParseOpts) ?ParseError

violation_opts returns the first JSONC strictness violation in text under opts, or none when the document is valid.

type Any #

type Any = json2.Any

Any is the dynamic tree a parsed document produces, shared with json2.

type Doc #

type Doc = json2.Doc

Doc is a parsed document with json2's path lookup and JSONC rendering.

fn (Doc) str #

@[manualfree]
fn (d Doc) str() string

str renders the document as JSONC, using overflowing decimal numbers for infinity. NaN values in manually wrapped trees render as null.

struct ParseError #

struct ParseError {
	Error
pub:
	message string
	pos     Pos
}

ParseError describes input that is well formed JSON5 but not valid JSONC.

JSONC is RFC 8259 plus // and /* */ comments, so a ParseError always names a JSON5 extension that strict mode rejects. Malformed JSON is not reported this way: it is reported by the underlying JSON5 parser, whose error is returned unchanged.

fn (ParseError) msg #

fn (e &ParseError) msg() string

msg formats a ParseError for IError.msg().

struct ParseOpts #

struct ParseOpts {
pub:
	// allow_trailing_comma accepts a comma before a closing `}` or `]`. It is
	// false by default, which matches the JSONC parser that VS Code uses;
	// TypeScript's own configuration files are read with it set to true.
	allow_trailing_comma bool
}

ParseOpts tunes how strictly a document is read.

struct Pos #

struct Pos {
pub:
	line       int
	col        int
	offset     int // byte offset of the first character
	end_offset int // byte offset one past the last character
}

Pos is a location in the source text. Unlike the position carried by a syntax error from the underlying JSON5 parser, it also knows the byte range the offending text occupies, so an editor can underline exactly it.