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_commais 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 #
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.