Initial project import
This commit is contained in:
commit
23f2d29e32
11957 files changed
+1754577
No files matched your search
@@ -0,0 +1,52 @@
|
||||
## Description
|
||||
|
||||
`yaml` provides a pure-V YAML reader and writer for common configuration files.
|
||||
It supports nested mappings, sequences, flow-style collections, block scalars,
|
||||
tree access through `yaml.Any`, and generic struct encode/decode.
|
||||
|
||||
The generic `encode`/`decode` path delegates to the main `json` module so it
|
||||
matches existing JSON field behavior, including `@[json: 'name']`.
|
||||
|
||||
## Usage
|
||||
|
||||
```v
|
||||
import yaml
|
||||
|
||||
struct Config {
|
||||
name string
|
||||
enabled bool
|
||||
ports []int
|
||||
}
|
||||
|
||||
const config_text = '
|
||||
name: app
|
||||
enabled: true
|
||||
ports:
|
||||
- 8080
|
||||
- 9090
|
||||
'
|
||||
|
||||
fn main() {
|
||||
doc := yaml.parse_text(config_text)!
|
||||
assert doc.value('ports[1]').int() == 9090
|
||||
|
||||
config := yaml.decode[Config](config_text)!
|
||||
assert config.name == 'app'
|
||||
assert yaml.encode(config).contains('"name": "app"')
|
||||
}
|
||||
```
|
||||
|
||||
## File helpers
|
||||
|
||||
```v
|
||||
import yaml
|
||||
|
||||
struct Config {
|
||||
name string
|
||||
}
|
||||
|
||||
fn main() {
|
||||
config := yaml.decode_file[Config]('config.yml')!
|
||||
yaml.encode_file('config.out.yml', config)!
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,204 @@
|
||||
module yaml
|
||||
|
||||
import strings
|
||||
|
||||
// write_spaces appends `n` spaces to `sb`.
|
||||
fn write_spaces(mut sb strings.Builder, n int) {
|
||||
for _ in 0 .. n {
|
||||
sb.write_u8(` `)
|
||||
}
|
||||
}
|
||||
|
||||
// emit_yaml_any streams `value` into `sb` as block-style YAML.
|
||||
fn emit_yaml_any(mut sb strings.Builder, value Any, indent int) {
|
||||
match value {
|
||||
map[string]Any { emit_yaml_map(mut sb, value, indent) }
|
||||
[]Any { emit_yaml_array(mut sb, value, indent) }
|
||||
else { emit_yaml_scalar(mut sb, value) }
|
||||
}
|
||||
}
|
||||
|
||||
// emit_yaml_map writes `value` as a block-style YAML mapping. An empty map
|
||||
// is emitted as the inline `{}` form; otherwise each key is JSON-quoted (see
|
||||
// `write_json_escaped_string`) and nested containers indent one level deeper.
|
||||
fn emit_yaml_map(mut sb strings.Builder, value map[string]Any, indent int) {
|
||||
if value.len == 0 {
|
||||
sb.write_string('{}')
|
||||
return
|
||||
}
|
||||
mut first := true
|
||||
for key, item in value {
|
||||
if !first {
|
||||
sb.write_u8(`\n`)
|
||||
}
|
||||
first = false
|
||||
write_spaces(mut sb, indent)
|
||||
write_json_escaped_string(mut sb, key)
|
||||
match item {
|
||||
map[string]Any, []Any {
|
||||
sb.write_u8(`:`)
|
||||
sb.write_u8(`\n`)
|
||||
emit_yaml_any(mut sb, item, indent + 2)
|
||||
}
|
||||
else {
|
||||
sb.write_string(': ')
|
||||
emit_yaml_scalar(mut sb, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emit_yaml_array writes `value` as a block-style YAML sequence. An empty
|
||||
// array is emitted as the inline `[]` form; otherwise each item is prefixed
|
||||
// with `- ` and nested containers indent one level deeper.
|
||||
fn emit_yaml_array(mut sb strings.Builder, value []Any, indent int) {
|
||||
if value.len == 0 {
|
||||
sb.write_string('[]')
|
||||
return
|
||||
}
|
||||
mut first := true
|
||||
for item in value {
|
||||
if !first {
|
||||
sb.write_u8(`\n`)
|
||||
}
|
||||
first = false
|
||||
write_spaces(mut sb, indent)
|
||||
match item {
|
||||
map[string]Any, []Any {
|
||||
sb.write_u8(`-`)
|
||||
sb.write_u8(`\n`)
|
||||
emit_yaml_any(mut sb, item, indent + 2)
|
||||
}
|
||||
else {
|
||||
sb.write_string('- ')
|
||||
emit_yaml_scalar(mut sb, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emit_yaml_scalar writes `value` as a single YAML scalar token: strings go
|
||||
// through `write_json_escaped_string`, booleans / numbers / null print their
|
||||
// literal form. The container branch is type-required by V's exhaustive
|
||||
// `match` over `Any` but is unreachable: `emit_yaml_any` routes maps and
|
||||
// arrays to their dedicated emitters before falling back here.
|
||||
fn emit_yaml_scalar(mut sb strings.Builder, value Any) {
|
||||
match value {
|
||||
string { write_json_escaped_string(mut sb, value) }
|
||||
bool { sb.write_string(if value { 'true' } else { 'false' }) }
|
||||
f64 { sb.write_string(value.str()) }
|
||||
i64 { sb.write_string(value.str()) }
|
||||
int { sb.write_string(value.str()) }
|
||||
u64 { sb.write_string(value.str()) }
|
||||
Null { sb.write_string('null') }
|
||||
[]Any, map[string]Any { emit_yaml_any(mut sb, value, 0) }
|
||||
}
|
||||
}
|
||||
|
||||
// write_json_escaped_string writes `value` as a JSON string literal directly
|
||||
// into `sb`. Matches `json2.encode`'s rules: standard short escapes for control
|
||||
// chars, `\u00XX` for the rest below 0x20, and UTF-8 bytes passed through
|
||||
// verbatim (no per-byte `\uXXXX` re-escape). Passes safe runs through in bulk
|
||||
// via `write_string` on the original slice — the overwhelmingly common case for
|
||||
// human YAML — and only switches to per-byte handling at escape boundaries.
|
||||
fn write_json_escaped_string(mut sb strings.Builder, value string) {
|
||||
sb.write_u8(`"`)
|
||||
mut start := 0
|
||||
for i := 0; i < value.len; i++ {
|
||||
c := value[i]
|
||||
if c >= 0x20 && c != `"` && c != `\\` {
|
||||
continue
|
||||
}
|
||||
if start < i {
|
||||
sb.write_string(value[start..i])
|
||||
}
|
||||
match c {
|
||||
`"` {
|
||||
sb.write_string('\\"')
|
||||
}
|
||||
`\\` {
|
||||
sb.write_string('\\\\')
|
||||
}
|
||||
`\n` {
|
||||
sb.write_string('\\n')
|
||||
}
|
||||
`\r` {
|
||||
sb.write_string('\\r')
|
||||
}
|
||||
`\t` {
|
||||
sb.write_string('\\t')
|
||||
}
|
||||
`\b` {
|
||||
sb.write_string('\\b')
|
||||
}
|
||||
`\f` {
|
||||
sb.write_string('\\f')
|
||||
}
|
||||
else {
|
||||
sb.write_string('\\u00')
|
||||
hex := '0123456789abcdef'
|
||||
sb.write_u8(hex[(c >> 4) & 0xf])
|
||||
sb.write_u8(hex[c & 0xf])
|
||||
}
|
||||
}
|
||||
|
||||
start = i + 1
|
||||
}
|
||||
if start < value.len {
|
||||
sb.write_string(value[start..])
|
||||
}
|
||||
sb.write_u8(`"`)
|
||||
}
|
||||
|
||||
// emit_any_as_json writes `a` as a compact JSON document.
|
||||
fn emit_any_as_json(mut sb strings.Builder, a Any) {
|
||||
match a {
|
||||
map[string]Any {
|
||||
sb.write_u8(`{`)
|
||||
mut first := true
|
||||
for key, value in a {
|
||||
if !first {
|
||||
sb.write_u8(`,`)
|
||||
}
|
||||
first = false
|
||||
write_json_escaped_string(mut sb, key)
|
||||
sb.write_u8(`:`)
|
||||
emit_any_as_json(mut sb, value)
|
||||
}
|
||||
sb.write_u8(`}`)
|
||||
}
|
||||
[]Any {
|
||||
sb.write_u8(`[`)
|
||||
mut first := true
|
||||
for value in a {
|
||||
if !first {
|
||||
sb.write_u8(`,`)
|
||||
}
|
||||
first = false
|
||||
emit_any_as_json(mut sb, value)
|
||||
}
|
||||
sb.write_u8(`]`)
|
||||
}
|
||||
string {
|
||||
write_json_escaped_string(mut sb, a)
|
||||
}
|
||||
bool {
|
||||
sb.write_string(if a { 'true' } else { 'false' })
|
||||
}
|
||||
f64 {
|
||||
sb.write_string(a.str())
|
||||
}
|
||||
i64 {
|
||||
sb.write_string(a.str())
|
||||
}
|
||||
int {
|
||||
sb.write_string(a.str())
|
||||
}
|
||||
u64 {
|
||||
sb.write_string(a.str())
|
||||
}
|
||||
Null {
|
||||
sb.write_string('null')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
module yaml
|
||||
|
||||
struct FlowParser {
|
||||
src string
|
||||
mut:
|
||||
pos int
|
||||
}
|
||||
|
||||
fn parse_flow_value(src string) !Any {
|
||||
mut parser := FlowParser{
|
||||
src: src
|
||||
}
|
||||
value := parser.parse_value()!
|
||||
parser.skip_space()
|
||||
if parser.pos != parser.src.len {
|
||||
return error('yaml: unexpected trailing flow content')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
fn (mut p FlowParser) parse_value() !Any {
|
||||
p.skip_space()
|
||||
if p.pos >= p.src.len {
|
||||
return error('yaml: unexpected end of flow value')
|
||||
}
|
||||
return match p.src[p.pos] {
|
||||
`[` { p.parse_array() }
|
||||
`{` { p.parse_object() }
|
||||
`"`, `'` { Any(parse_quoted_flow_string(mut p)!) }
|
||||
else { parse_scalar(p.parse_plain_token()) }
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut p FlowParser) parse_array() !Any {
|
||||
p.pos++
|
||||
mut items := []Any{}
|
||||
for {
|
||||
p.skip_space()
|
||||
if p.pos >= p.src.len {
|
||||
return error('yaml: unterminated flow array')
|
||||
}
|
||||
if p.src[p.pos] == `]` {
|
||||
p.pos++
|
||||
break
|
||||
}
|
||||
items << p.parse_value()!
|
||||
p.skip_space()
|
||||
if p.pos >= p.src.len {
|
||||
return error('yaml: unterminated flow array')
|
||||
}
|
||||
if p.src[p.pos] == `,` {
|
||||
p.pos++
|
||||
continue
|
||||
}
|
||||
if p.src[p.pos] == `]` {
|
||||
p.pos++
|
||||
break
|
||||
}
|
||||
return error('yaml: expected `,` or `]` in flow array')
|
||||
}
|
||||
return Any(items)
|
||||
}
|
||||
|
||||
fn (mut p FlowParser) parse_object() !Any {
|
||||
p.pos++
|
||||
mut result := map[string]Any{}
|
||||
for {
|
||||
p.skip_space()
|
||||
if p.pos >= p.src.len {
|
||||
return error('yaml: unterminated flow object')
|
||||
}
|
||||
if p.src[p.pos] == `}` {
|
||||
p.pos++
|
||||
break
|
||||
}
|
||||
key := p.parse_key()!
|
||||
p.skip_space()
|
||||
if p.pos >= p.src.len || p.src[p.pos] != `:` {
|
||||
return error('yaml: expected `:` in flow object')
|
||||
}
|
||||
p.pos++
|
||||
result[key] = p.parse_value()!
|
||||
p.skip_space()
|
||||
if p.pos >= p.src.len {
|
||||
return error('yaml: unterminated flow object')
|
||||
}
|
||||
if p.src[p.pos] == `,` {
|
||||
p.pos++
|
||||
continue
|
||||
}
|
||||
if p.src[p.pos] == `}` {
|
||||
p.pos++
|
||||
break
|
||||
}
|
||||
return error('yaml: expected `,` or `}` in flow object')
|
||||
}
|
||||
return Any(result)
|
||||
}
|
||||
|
||||
fn (mut p FlowParser) parse_key() !string {
|
||||
p.skip_space()
|
||||
if p.pos >= p.src.len {
|
||||
return error('yaml: unexpected end of flow key')
|
||||
}
|
||||
if p.src[p.pos] in [`"`, `'`] {
|
||||
return parse_quoted_flow_string(mut p)
|
||||
}
|
||||
start := p.pos
|
||||
for p.pos < p.src.len {
|
||||
ch := p.src[p.pos]
|
||||
if ch == `:` {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
}
|
||||
return p.src[start..p.pos].trim_space()
|
||||
}
|
||||
|
||||
fn (mut p FlowParser) parse_plain_token() string {
|
||||
start := p.pos
|
||||
mut bracket_depth := 0
|
||||
mut brace_depth := 0
|
||||
for p.pos < p.src.len {
|
||||
ch := p.src[p.pos]
|
||||
if ch == `[` {
|
||||
bracket_depth++
|
||||
} else if ch == `]` {
|
||||
if bracket_depth == 0 {
|
||||
break
|
||||
}
|
||||
bracket_depth--
|
||||
} else if ch == `{` {
|
||||
brace_depth++
|
||||
} else if ch == `}` {
|
||||
if brace_depth == 0 {
|
||||
break
|
||||
}
|
||||
brace_depth--
|
||||
} else if ch == `,` && bracket_depth == 0 && brace_depth == 0 {
|
||||
break
|
||||
}
|
||||
p.pos++
|
||||
}
|
||||
return p.src[start..p.pos].trim_space()
|
||||
}
|
||||
|
||||
fn (mut p FlowParser) skip_space() {
|
||||
for p.pos < p.src.len && p.src[p.pos].is_space() {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_quoted_flow_string(mut p FlowParser) !string {
|
||||
start := p.pos
|
||||
quote := p.src[p.pos]
|
||||
p.pos++
|
||||
mut escape := false
|
||||
for p.pos < p.src.len {
|
||||
ch := p.src[p.pos]
|
||||
if quote == `"` {
|
||||
if escape {
|
||||
escape = false
|
||||
} else if ch == `\\` {
|
||||
escape = true
|
||||
} else if ch == `"` {
|
||||
p.pos++
|
||||
return parse_quoted_string(p.src[start..p.pos])
|
||||
}
|
||||
} else if ch == `'` {
|
||||
if p.pos + 1 < p.src.len && p.src[p.pos + 1] == `'` {
|
||||
p.pos += 2
|
||||
continue
|
||||
}
|
||||
p.pos++
|
||||
return parse_quoted_string(p.src[start..p.pos])
|
||||
}
|
||||
p.pos++
|
||||
}
|
||||
return error('yaml: unterminated quoted flow string')
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,139 @@
|
||||
module yaml
|
||||
|
||||
import time
|
||||
import x.json2
|
||||
|
||||
fn (a Any) value_(current Any, key []string) ?Any {
|
||||
if key.len == 0 {
|
||||
return none
|
||||
}
|
||||
k, index := parse_array_key(key[0])
|
||||
value := match current {
|
||||
[]Any {
|
||||
if k != '' {
|
||||
return none
|
||||
}
|
||||
current[index] or { return none }
|
||||
}
|
||||
map[string]Any {
|
||||
v := current[k] or { return none }
|
||||
if index > -1 {
|
||||
if v is []Any {
|
||||
v[index] or { return none }
|
||||
} else {
|
||||
return none
|
||||
}
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
else {
|
||||
return none
|
||||
}
|
||||
}
|
||||
|
||||
if key.len <= 1 {
|
||||
return value
|
||||
}
|
||||
return match value {
|
||||
[]Any, map[string]Any { a.value_(value, key[1..]) }
|
||||
else { none }
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_dotted_key(key string) ![]string {
|
||||
mut out := []string{}
|
||||
mut buf := ''
|
||||
mut in_string := false
|
||||
mut delimiter := u8(` `)
|
||||
for ch in key {
|
||||
if ch in [`"`, `'`] {
|
||||
if !in_string {
|
||||
delimiter = ch
|
||||
in_string = true
|
||||
continue
|
||||
}
|
||||
if ch == delimiter {
|
||||
in_string = false
|
||||
if buf != '' {
|
||||
out << buf
|
||||
}
|
||||
buf = ''
|
||||
delimiter = ` `
|
||||
continue
|
||||
}
|
||||
}
|
||||
buf += ch.ascii_str()
|
||||
if !in_string && ch == `.` {
|
||||
buf = buf[..buf.len - 1]
|
||||
if buf != '' {
|
||||
out << buf
|
||||
}
|
||||
buf = ''
|
||||
}
|
||||
}
|
||||
if buf != '' {
|
||||
out << buf
|
||||
}
|
||||
if in_string {
|
||||
return error('yaml: missing closing string delimiter `${delimiter.ascii_str()}`')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fn parse_array_key(key string) (string, int) {
|
||||
mut index := -1
|
||||
mut k := key
|
||||
if k.contains('[') {
|
||||
index = k.all_after('[').all_before(']').int()
|
||||
if k.starts_with('[') {
|
||||
k = ''
|
||||
} else {
|
||||
k = k.all_before('[')
|
||||
}
|
||||
}
|
||||
return k, index
|
||||
}
|
||||
|
||||
fn from_json2(value json2.Any) Any {
|
||||
return match value {
|
||||
[]json2.Any {
|
||||
mut arr := []Any{cap: value.len}
|
||||
for item in value {
|
||||
arr << from_json2(item)
|
||||
}
|
||||
Any(arr)
|
||||
}
|
||||
map[string]json2.Any {
|
||||
mut out := map[string]Any{}
|
||||
for key, item in value {
|
||||
out[key] = from_json2(item)
|
||||
}
|
||||
Any(out)
|
||||
}
|
||||
bool {
|
||||
Any(value)
|
||||
}
|
||||
f32, f64 {
|
||||
Any(f64(value))
|
||||
}
|
||||
i8, i16, i32, int {
|
||||
Any(int(value))
|
||||
}
|
||||
i64 {
|
||||
Any(value)
|
||||
}
|
||||
u8, u16, u32, u64 {
|
||||
Any(u64(value))
|
||||
}
|
||||
string {
|
||||
Any(value)
|
||||
}
|
||||
time.Time {
|
||||
Any(value.str())
|
||||
}
|
||||
json2.Null {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
module yaml
|
||||
|
||||
import x.json2
|
||||
|
||||
// json_logically_eq compares two JSON strings by decoding and re-encoding
|
||||
// both sides via json2, normalizing whitespace and treating an empty input
|
||||
// as the literal "null" document.
|
||||
//
|
||||
// For tests only. Lives in a non-_test.v file because V compiles each
|
||||
// `_test.v` file as its own binary, so a helper cannot otherwise be shared
|
||||
// across the module's test files. Not exported (lowercase name) so it stays
|
||||
// invisible to consumers of `yaml`.
|
||||
fn json_logically_eq(a string, b string) !bool {
|
||||
a_norm := if a.trim_space() == '' { 'null' } else { a }
|
||||
b_norm := if b.trim_space() == '' { 'null' } else { b }
|
||||
pa := json2.decode[json2.Any](a_norm)!
|
||||
pb := json2.decode[json2.Any](b_norm)!
|
||||
return json2.encode(pa, json2.EncoderOptions{}) == json2.encode(pb, json2.EncoderOptions{})
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
module yaml
|
||||
|
||||
import json
|
||||
import os
|
||||
import strings
|
||||
import x.json2
|
||||
|
||||
// Null is a simple representation of the YAML `null` value.
|
||||
pub struct Null {}
|
||||
|
||||
// null is an instance of `Null`, to ease comparisons with it.
|
||||
pub const null = Any(Null{})
|
||||
|
||||
// Any is the tree representation used by the YAML module.
|
||||
pub type Any = []Any | Null | bool | f64 | i64 | int | map[string]Any | string | u64
|
||||
|
||||
// Doc is a parsed YAML document.
|
||||
pub struct Doc {
|
||||
pub:
|
||||
root Any
|
||||
}
|
||||
|
||||
// parse_file parses the YAML file at `path`.
|
||||
pub fn parse_file(path string) !Doc {
|
||||
return parse_text(os.read_file(path)!)
|
||||
}
|
||||
|
||||
// parse_text parses the YAML document provided in `text`.
|
||||
pub fn parse_text(text string) !Doc {
|
||||
mut normalized := text
|
||||
if normalized.contains_u8(`\r`) {
|
||||
normalized = normalized.replace('\r\n', '\n').replace('\r', '\n')
|
||||
}
|
||||
if normalized.len >= 3 && normalized[0] == 0xef && normalized[1] == 0xbb
|
||||
&& normalized[2] == 0xbf {
|
||||
normalized = normalized[3..]
|
||||
}
|
||||
// `split('\n')` would otherwise turn the canonical trailing line break into
|
||||
// a phantom empty last line, which the block-scalar reader then treats as a
|
||||
// genuine blank line and over-counts during chomping.
|
||||
if normalized.ends_with('\n') {
|
||||
normalized = normalized[..normalized.len - 1]
|
||||
}
|
||||
trimmed := normalized.trim_space()
|
||||
if trimmed == '' {
|
||||
return Doc{
|
||||
root: null
|
||||
}
|
||||
}
|
||||
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
||||
// JSON-superset fast path. `parse_flow_value` already consumes the
|
||||
// flow-style grammar that YAML borrows from JSON, so it builds the
|
||||
// `yaml.Any` tree directly — no second-pass `from_json2` rebuild.
|
||||
// Falls through to the block parser if the body is anything other
|
||||
// than a clean flow document.
|
||||
if val := parse_flow_value(trimmed) {
|
||||
return Doc{
|
||||
root: val
|
||||
}
|
||||
}
|
||||
}
|
||||
mut parser := Parser{
|
||||
lines: normalized.split('\n')
|
||||
}
|
||||
return Doc{
|
||||
root: parser.parse()!
|
||||
}
|
||||
}
|
||||
|
||||
// decode decodes YAML text into the target type `T`.
|
||||
// The generic encode/decode path uses the main `json` module for field parity.
|
||||
pub fn decode[T](yaml_text string) !T {
|
||||
doc := parse_text(yaml_text)!
|
||||
return doc.decode[T]()
|
||||
}
|
||||
|
||||
// decode_file decodes the YAML file at `path` into the target type `T`.
|
||||
pub fn decode_file[T](path string) !T {
|
||||
return decode[T](os.read_file(path)!)
|
||||
}
|
||||
|
||||
// encode encodes the value `value` into a YAML string.
|
||||
// The generic encode/decode path uses the main `json` module for field parity.
|
||||
pub fn encode[T](value T) string {
|
||||
json_text := json.encode(value)
|
||||
raw := json2.decode[json2.Any](json_text) or { return '' }
|
||||
return from_json2(raw).to_yaml()
|
||||
}
|
||||
|
||||
// encode_file encodes `value` as YAML and writes it to `path`.
|
||||
pub fn encode_file[T](path string, value T) ! {
|
||||
os.write_file(path, encode(value))!
|
||||
}
|
||||
|
||||
// decode decodes the YAML document into the target type `T`. An empty
|
||||
// document (parsed as the YAML 1.2 null node) decodes to a default-initialized
|
||||
// `T`, matching the common "empty config file = use defaults" idiom.
|
||||
pub fn (d Doc) decode[T]() !T {
|
||||
if d.root is Null {
|
||||
return json.decode(T, '{}')!
|
||||
}
|
||||
return json.decode(T, d.to_json())!
|
||||
}
|
||||
|
||||
// to_any converts the YAML document to `yaml.Any`.
|
||||
pub fn (d Doc) to_any() Any {
|
||||
return d.root
|
||||
}
|
||||
|
||||
// to_json converts the YAML document to JSON.
|
||||
pub fn (d Doc) to_json() string {
|
||||
return d.root.to_json()
|
||||
}
|
||||
|
||||
// to_yaml converts the YAML document back to YAML text.
|
||||
pub fn (d Doc) to_yaml() string {
|
||||
return d.root.to_yaml()
|
||||
}
|
||||
|
||||
// value queries a value from the YAML document.
|
||||
// `key` supports dotted keys and array indexing like `servers[0].host`.
|
||||
pub fn (d Doc) value(key string) Any {
|
||||
return d.root.value(key)
|
||||
}
|
||||
|
||||
// value_opt queries a value from the YAML document and returns an error when missing.
|
||||
pub fn (d Doc) value_opt(key string) !Any {
|
||||
return d.root.value_opt(key)
|
||||
}
|
||||
|
||||
// str returns a display-friendly string form of `Any`.
|
||||
pub fn (a Any) str() string {
|
||||
return a.string()
|
||||
}
|
||||
|
||||
// string returns `Any` as a string when possible, or a YAML representation otherwise.
|
||||
pub fn (a Any) string() string {
|
||||
return match a {
|
||||
string { a }
|
||||
bool, f64, i64, int, u64 { a.str() }
|
||||
Null { 'null' }
|
||||
[]Any, map[string]Any { a.to_yaml() }
|
||||
}
|
||||
}
|
||||
|
||||
// int returns `Any` as an `int`.
|
||||
pub fn (a Any) int() int {
|
||||
return match a {
|
||||
int {
|
||||
a
|
||||
}
|
||||
i64 {
|
||||
int(a)
|
||||
}
|
||||
u64 {
|
||||
int(a)
|
||||
}
|
||||
f64 {
|
||||
int(a)
|
||||
}
|
||||
bool {
|
||||
if a {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
string {
|
||||
a.int()
|
||||
}
|
||||
else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// i64 returns `Any` as an `i64`.
|
||||
pub fn (a Any) i64() i64 {
|
||||
return match a {
|
||||
i64 {
|
||||
a
|
||||
}
|
||||
int {
|
||||
i64(a)
|
||||
}
|
||||
u64 {
|
||||
i64(a)
|
||||
}
|
||||
f64 {
|
||||
i64(a)
|
||||
}
|
||||
bool {
|
||||
if a {
|
||||
i64(1)
|
||||
} else {
|
||||
i64(0)
|
||||
}
|
||||
}
|
||||
string {
|
||||
a.i64()
|
||||
}
|
||||
else {
|
||||
i64(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// u64 returns `Any` as a `u64`.
|
||||
pub fn (a Any) u64() u64 {
|
||||
return match a {
|
||||
u64 {
|
||||
a
|
||||
}
|
||||
int {
|
||||
u64(a)
|
||||
}
|
||||
i64 {
|
||||
u64(a)
|
||||
}
|
||||
f64 {
|
||||
u64(a)
|
||||
}
|
||||
bool {
|
||||
if a {
|
||||
u64(1)
|
||||
} else {
|
||||
u64(0)
|
||||
}
|
||||
}
|
||||
string {
|
||||
a.u64()
|
||||
}
|
||||
else {
|
||||
u64(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// f64 returns `Any` as an `f64`.
|
||||
pub fn (a Any) f64() f64 {
|
||||
return match a {
|
||||
f64 {
|
||||
a
|
||||
}
|
||||
int {
|
||||
f64(a)
|
||||
}
|
||||
i64 {
|
||||
f64(a)
|
||||
}
|
||||
u64 {
|
||||
f64(a)
|
||||
}
|
||||
bool {
|
||||
if a {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
string {
|
||||
a.f64()
|
||||
}
|
||||
else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bool returns `Any` as a `bool`.
|
||||
pub fn (a Any) bool() bool {
|
||||
return match a {
|
||||
bool {
|
||||
a
|
||||
}
|
||||
int {
|
||||
a != 0
|
||||
}
|
||||
i64 {
|
||||
a != 0
|
||||
}
|
||||
u64 {
|
||||
a != 0
|
||||
}
|
||||
f64 {
|
||||
a != 0.0
|
||||
}
|
||||
string {
|
||||
lower := a.to_lower()
|
||||
lower in ['true', 'yes', 'on', '1']
|
||||
}
|
||||
else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// array returns `Any` as an array.
|
||||
pub fn (a Any) array() []Any {
|
||||
return match a {
|
||||
[]Any {
|
||||
a
|
||||
}
|
||||
map[string]Any {
|
||||
mut arr := []Any{cap: a.len}
|
||||
for _, value in a {
|
||||
arr << value
|
||||
}
|
||||
arr
|
||||
}
|
||||
else {
|
||||
[a]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// as_map returns `Any` as a map.
|
||||
pub fn (a Any) as_map() map[string]Any {
|
||||
return match a {
|
||||
map[string]Any {
|
||||
a
|
||||
}
|
||||
[]Any {
|
||||
mut out := map[string]Any{}
|
||||
for i, value in a {
|
||||
out['${i}'] = value
|
||||
}
|
||||
out
|
||||
}
|
||||
else {
|
||||
{
|
||||
'0': a
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// default_to returns `value` when `a` is `Null`.
|
||||
pub fn (a Any) default_to(value Any) Any {
|
||||
return match a {
|
||||
Null { value }
|
||||
else { a }
|
||||
}
|
||||
}
|
||||
|
||||
// value queries a value from the current node using dotted keys and array indices.
|
||||
pub fn (a Any) value(key string) Any {
|
||||
return a.value_opt(key) or { null }
|
||||
}
|
||||
|
||||
// value_opt queries a value from the current node and returns an error when missing.
|
||||
// A YAML key whose value is the explicit `null` literal returns that `Null`
|
||||
// (it is not treated as missing); only an absent key or a non-traversable
|
||||
// path raises an error.
|
||||
pub fn (a Any) value_opt(key string) !Any {
|
||||
key_split := parse_dotted_key(key) or { return error('yaml: invalid dotted key `${key}`') }
|
||||
return a.value_(a, key_split) or { error('yaml: no value for key `${key}`') }
|
||||
}
|
||||
|
||||
// value queries a value from the map.
|
||||
pub fn (m map[string]Any) value(key string) Any {
|
||||
return Any(m).value(key)
|
||||
}
|
||||
|
||||
// value queries a value from the array.
|
||||
pub fn (a []Any) value(key string) Any {
|
||||
return Any(a).value(key)
|
||||
}
|
||||
|
||||
// as_strings returns the contents of the array as `[]string`.
|
||||
pub fn (a []Any) as_strings() []string {
|
||||
mut out := []string{cap: a.len}
|
||||
for value in a {
|
||||
out << value.string()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// as_strings returns the contents of the map as `map[string]string`.
|
||||
pub fn (m map[string]Any) as_strings() map[string]string {
|
||||
mut out := map[string]string{}
|
||||
for key, value in m {
|
||||
out[key] = value.string()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// to_json converts `Any` to JSON.
|
||||
pub fn (a Any) to_json() string {
|
||||
mut sb := strings.new_builder(256)
|
||||
emit_any_as_json(mut sb, a)
|
||||
return sb.str()
|
||||
}
|
||||
|
||||
// to_yaml converts `Any` to YAML.
|
||||
pub fn (a Any) to_yaml() string {
|
||||
mut sb := strings.new_builder(256)
|
||||
emit_yaml_any(mut sb, a, 0)
|
||||
return sb.str()
|
||||
}
|
||||
|
||||
// to_yaml converts a YAML array to YAML text.
|
||||
pub fn (a []Any) to_yaml() string {
|
||||
return Any(a).to_yaml()
|
||||
}
|
||||
|
||||
// to_yaml converts a YAML map to YAML text.
|
||||
pub fn (m map[string]Any) to_yaml() string {
|
||||
return Any(m).to_yaml()
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
module yaml
|
||||
|
||||
// Conformance-style coverage. Each case mirrors a pattern from the YAML 1.2
|
||||
// spec (or the public yaml-test-suite) that's within the subset V's parser
|
||||
// implements: plain mappings, sequences, scalars, flow style, JSON-superset
|
||||
// documents. Anchors, aliases, tags, merge keys, multi-document streams and
|
||||
// the chomp/indent indicators on block scalars are intentionally NOT covered:
|
||||
// V's parser does not support them today.
|
||||
//
|
||||
// Each case is `name | yaml_input | expected_json` and is verified by parsing
|
||||
// `yaml_input`, serializing to JSON, and comparing against the expected JSON
|
||||
// via `json_logically_eq` (see `test_helpers.v`).
|
||||
//
|
||||
// Scalar variants (booleans, nulls, numeric underscores), quoted-string
|
||||
// escapes, block scalars, `[]`/`{}`, and the empty document are exercised by
|
||||
// `yaml_edge_cases_test.v` with stricter, typed assertions; not duplicated here.
|
||||
|
||||
struct ConformanceCase {
|
||||
name string
|
||||
src string
|
||||
expected string
|
||||
}
|
||||
|
||||
const cases = [
|
||||
ConformanceCase{
|
||||
name: 'single scalar mapping'
|
||||
src: 'key: value'
|
||||
expected: '{"key":"value"}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'integer values'
|
||||
src: 'a: 1\nb: -2\nc: 0\nd: 9223372036854775807'
|
||||
expected: '{"a":1,"b":-2,"c":0,"d":9223372036854775807}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'float values'
|
||||
src: 'a: 1.5\nb: -0.001\nc: 1.0e10\nd: -1.5e-3'
|
||||
expected: '{"a":1.5,"b":-0.001,"c":10000000000,"d":-0.0015}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'simple sequence'
|
||||
src: 'items:\n - one\n - two\n - three'
|
||||
expected: '{"items":["one","two","three"]}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'nested mapping under sequence item'
|
||||
src: 'servers:\n - host: a\n port: 1\n - host: b\n port: 2'
|
||||
expected: '{"servers":[{"host":"a","port":1},{"host":"b","port":2}]}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'flow sequence inline'
|
||||
src: 'tags: [web, api, db]'
|
||||
expected: '{"tags":["web","api","db"]}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'flow mapping inline'
|
||||
src: 'config: {host: a, port: 8080}'
|
||||
expected: '{"config":{"host":"a","port":8080}}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'mixed flow and block'
|
||||
src: 'top:\n inner:\n - {k: v, n: 1}\n - [a, b, c]'
|
||||
expected: '{"top":{"inner":[{"k":"v","n":1},["a","b","c"]]}}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'deeply nested mapping'
|
||||
src: 'a:\n b:\n c:\n d:\n e: end'
|
||||
expected: '{"a":{"b":{"c":{"d":{"e":"end"}}}}}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'sequence of sequences'
|
||||
src: 'matrix:\n - [1, 2, 3]\n - [4, 5, 6]\n - [7, 8, 9]'
|
||||
expected: '{"matrix":[[1,2,3],[4,5,6],[7,8,9]]}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'unicode in string values'
|
||||
src: 'a: "café"\nb: "日本語"\nc: plain café'
|
||||
expected: '{"a":"café","b":"日本語","c":"plain café"}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'comment is stripped'
|
||||
src: '# leading comment\nkey: value # trailing comment'
|
||||
expected: '{"key":"value"}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'document separator markers are tolerated'
|
||||
src: '---\na: 1\n...'
|
||||
expected: '{"a":1}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'JSON-superset input (object)'
|
||||
src: '{"a": 1, "b": [2, 3]}'
|
||||
expected: '{"a":1,"b":[2,3]}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'JSON-superset input (array root)'
|
||||
src: '[1, 2, {"three": 3}]'
|
||||
expected: '[1,2,{"three":3}]'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'sequence containing null'
|
||||
src: 'a:\n - one\n - ~\n - three'
|
||||
expected: '{"a":["one",null,"three"]}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'mapping with quoted keys containing dots'
|
||||
src: 'plain: 1\n"a.b": 2\n"c.d.e": 3'
|
||||
expected: '{"plain":1,"a.b":2,"c.d.e":3}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'mixed types in flow sequence'
|
||||
src: 'mix: [1, "two", true, null, 3.14]'
|
||||
expected: '{"mix":[1,"two",true,null,3.14]}'
|
||||
},
|
||||
ConformanceCase{
|
||||
name: 'nested flow inside block sequence'
|
||||
src: 'items:\n - {a: 1, b: [x, y]}\n - {a: 2, b: [z]}'
|
||||
expected: '{"items":[{"a":1,"b":["x","y"]},{"a":2,"b":["z"]}]}'
|
||||
},
|
||||
]!
|
||||
|
||||
fn test_yaml_conformance_cases() ! {
|
||||
mut failed := []string{}
|
||||
for c in cases {
|
||||
doc := parse_text(c.src) or {
|
||||
failed << '${c.name}: parse error: ${err}'
|
||||
continue
|
||||
}
|
||||
got := doc.to_json()
|
||||
matched := json_logically_eq(got, c.expected) or {
|
||||
failed << '${c.name}: invalid JSON produced: ${got} (vs expected ${c.expected}): ${err}'
|
||||
continue
|
||||
}
|
||||
if !matched {
|
||||
failed << '${c.name}: got ${got}, want ${c.expected}'
|
||||
}
|
||||
}
|
||||
if failed.len > 0 {
|
||||
eprintln('YAML conformance failures (${failed.len}/${cases.len}):')
|
||||
for f in failed {
|
||||
eprintln(' - ${f}')
|
||||
}
|
||||
assert false, '${failed.len} conformance case(s) failed'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
module yaml
|
||||
|
||||
import os
|
||||
import x.json2
|
||||
|
||||
// Edge-case coverage for parse_text + scalar parsing + serialization.
|
||||
// Complements yaml_test.v which covers the happy path. Each fn targets one
|
||||
// specific gap that a previous regression in the module would have silently
|
||||
// passed.
|
||||
|
||||
fn test_parse_strips_utf8_bom() ! {
|
||||
src := '\xef\xbb\xbfname: app\nport: 8080\n'
|
||||
doc := parse_text(src)!
|
||||
assert doc.value('name').string() == 'app'
|
||||
assert doc.value('port').int() == 8080
|
||||
}
|
||||
|
||||
fn test_parse_normalizes_crlf_and_cr() ! {
|
||||
a := parse_text('a: 1\r\nb: 2\r\n')!
|
||||
assert a.value('a').int() == 1
|
||||
assert a.value('b').int() == 2
|
||||
b := parse_text('a: 1\rb: 2\r')!
|
||||
assert b.value('a').int() == 1
|
||||
assert b.value('b').int() == 2
|
||||
}
|
||||
|
||||
fn test_parse_empty_and_whitespace_only_documents() ! {
|
||||
// Per YAML 1.2, a document with no content is the null node.
|
||||
for src in ['', ' ', '\n\n', ' \n \n'] {
|
||||
doc := parse_text(src)!
|
||||
assert doc.root is Null
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyDecodeTarget {
|
||||
name string
|
||||
port int
|
||||
}
|
||||
|
||||
fn test_decode_empty_document_yields_default_struct() ! {
|
||||
// An empty config file should decode to a zero-initialized struct, not
|
||||
// raise an error — even though the YAML root is Null per spec.
|
||||
for src in ['', ' ', '\n\n'] {
|
||||
got := decode[EmptyDecodeTarget](src)!
|
||||
assert got.name == ''
|
||||
assert got.port == 0
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_null_variants() ! {
|
||||
doc := parse_text('
|
||||
a: ~
|
||||
b: null
|
||||
c: Null
|
||||
d: NULL
|
||||
e:
|
||||
')!
|
||||
for k in ['a', 'b', 'c', 'd', 'e'] {
|
||||
v := doc.value(k)
|
||||
assert v is Null, '${k} should be Null, got ${typeof(v).name}'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_bool_yaml11_variants() ! {
|
||||
doc := parse_text('
|
||||
t1: true
|
||||
t2: True
|
||||
t3: TRUE
|
||||
t4: yes
|
||||
t5: YES
|
||||
t6: on
|
||||
t7: On
|
||||
f1: false
|
||||
f2: False
|
||||
f3: no
|
||||
f4: NO
|
||||
f5: off
|
||||
f6: Off
|
||||
')!
|
||||
for k in ['t1', 't2', 't3', 't4', 't5', 't6', 't7'] {
|
||||
assert doc.value(k).bool(), '${k} should be true'
|
||||
}
|
||||
for k in ['f1', 'f2', 'f3', 'f4', 'f5', 'f6'] {
|
||||
assert !doc.value(k).bool(), '${k} should be false'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_numeric_underscores_and_signs() ! {
|
||||
doc := parse_text('
|
||||
a: 1_000_000
|
||||
b: -42
|
||||
c: +17
|
||||
d: 1.5e10
|
||||
e: -1.0e-5
|
||||
')!
|
||||
assert doc.value('a').i64() == 1_000_000
|
||||
assert doc.value('b').int() == -42
|
||||
assert doc.value('c').u64() == 17
|
||||
assert doc.value('d').f64() == 1.5e10
|
||||
assert doc.value('e').f64() == -1.0e-5
|
||||
}
|
||||
|
||||
fn test_parse_quoted_string_escapes() ! {
|
||||
doc := parse_text('
|
||||
a: "line1\\nline2"
|
||||
b: "tab\\there"
|
||||
c: "quote: \\""
|
||||
d: "unicode: \\u00e9"
|
||||
e: \'sing\'\'le\'
|
||||
')!
|
||||
assert doc.value('a').string() == 'line1\nline2'
|
||||
assert doc.value('b').string() == 'tab\there'
|
||||
assert doc.value('c').string() == 'quote: "'
|
||||
assert doc.value('d').string() == 'unicode: é'
|
||||
assert doc.value('e').string() == "sing'le"
|
||||
}
|
||||
|
||||
fn test_parse_comment_inside_quoted_string_is_preserved() ! {
|
||||
doc := parse_text('a: "value with # not a comment"\nb: real # comment trimmed\n')!
|
||||
assert doc.value('a').string() == 'value with # not a comment'
|
||||
assert doc.value('b').string() == 'real'
|
||||
}
|
||||
|
||||
fn test_parse_nested_flow_style() ! {
|
||||
doc := parse_text('root: {a: [1, [2, 3], {b: c, d: [e, f]}], g: 4}\n')!
|
||||
assert doc.value('root.a[0]').int() == 1
|
||||
assert doc.value('root.a[1]').array().len == 2
|
||||
assert doc.value('root.a[2].b').string() == 'c'
|
||||
assert doc.value('root.a[2].d[1]').string() == 'f'
|
||||
assert doc.value('root.g').int() == 4
|
||||
}
|
||||
|
||||
fn test_parse_block_scalar_literal_and_folded() ! {
|
||||
doc := parse_text('
|
||||
literal: |
|
||||
line1
|
||||
line2
|
||||
|
||||
line4
|
||||
folded: >
|
||||
hello
|
||||
world
|
||||
|
||||
|
||||
next paragraph
|
||||
')!
|
||||
assert doc.value('literal').string() == 'line1\nline2\n\nline4\n'
|
||||
assert doc.value('folded').string() == 'hello world\n\nnext paragraph\n'
|
||||
}
|
||||
|
||||
fn test_parse_rejects_tabs_in_indentation() {
|
||||
if _ := parse_text('a:\n\tb: 1\n') {
|
||||
assert false, 'tabs in indentation should error'
|
||||
} else {
|
||||
msg := err.msg()
|
||||
assert msg.contains('tabs are not supported')
|
||||
// Error must point at the offending line so the caller can locate it.
|
||||
// The tab is on line 2 of the input.
|
||||
assert msg.contains('line 2'), 'error should report line number, got: ${msg}'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_rejects_unexpected_indentation_in_mapping() {
|
||||
if _ := parse_text('a: 1\n b: 2\n') {
|
||||
assert false, 'over-indented mapping entry should error'
|
||||
} else {
|
||||
assert err.msg().contains('unexpected indentation')
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_json_superset_path() ! {
|
||||
// JSON-shaped input takes the json2 fast path in parse_text.
|
||||
doc := parse_text('{"a": [1, 2, {"b": "c"}], "d": null}')!
|
||||
assert doc.value('a[0]').int() == 1
|
||||
assert doc.value('a[2].b').string() == 'c'
|
||||
assert doc.value('d') is Null
|
||||
}
|
||||
|
||||
fn test_parse_empty_inline_collections() ! {
|
||||
doc := parse_text('a: []\nb: {}\nc: [[]]\nd: [{}]\n')!
|
||||
assert doc.value('a').array().len == 0
|
||||
assert doc.value('b').as_map().len == 0
|
||||
assert doc.value('c').array().len == 1
|
||||
assert doc.value('d').array().len == 1
|
||||
}
|
||||
|
||||
fn test_parse_deeply_nested_structure() ! {
|
||||
mut src := 'root:\n'
|
||||
mut indent := ' '
|
||||
for i in 0 .. 30 {
|
||||
src += '${indent}level${i}:\n'
|
||||
indent += ' '
|
||||
}
|
||||
src += '${indent}leaf: 42\n'
|
||||
doc := parse_text(src)!
|
||||
mut node := doc.value('root')
|
||||
for i in 0 .. 30 {
|
||||
node = node.value('level${i}')
|
||||
}
|
||||
assert node.value('leaf').int() == 42
|
||||
}
|
||||
|
||||
fn test_to_yaml_roundtrip_preserves_structure() ! {
|
||||
src := 'name: app
|
||||
servers:
|
||||
- host: a
|
||||
port: 1
|
||||
- host: b
|
||||
port: 2
|
||||
'
|
||||
doc := parse_text(src)!
|
||||
yaml_text := doc.to_yaml()
|
||||
doc2 := parse_text(yaml_text)!
|
||||
assert doc2.value('name').string() == 'app'
|
||||
assert doc2.value('servers[0].host').string() == 'a'
|
||||
assert doc2.value('servers[1].port').int() == 2
|
||||
}
|
||||
|
||||
fn test_to_yaml_is_stable_across_many_calls() ! {
|
||||
// Anti-regression for a real crash that used to surface only after many
|
||||
// repeated `to_yaml` calls on the same Doc (sumtype recursion through the
|
||||
// json2.Any rebuild path under -prod -gc boehm). 1000 iterations are
|
||||
// enough to flush the original failure mode without bloating CI runtime.
|
||||
doc := parse_text('
|
||||
name: my-app
|
||||
version: 1.2.3
|
||||
servers:
|
||||
- host: a
|
||||
port: 1
|
||||
- host: b
|
||||
port: 2
|
||||
features:
|
||||
enable_cache: true
|
||||
enable_metrics: true
|
||||
')!
|
||||
first := doc.to_yaml()
|
||||
for _ in 0 .. 1000 {
|
||||
assert doc.to_yaml() == first
|
||||
}
|
||||
}
|
||||
|
||||
fn test_to_json_emits_valid_json_for_unicode() ! {
|
||||
doc := parse_text('a: "café"\nb: "中文"\n')!
|
||||
out := doc.to_json()
|
||||
// Re-parse the output instead of asserting on a substring: this catches
|
||||
// real corruption of the strings, while staying agnostic to whitespace
|
||||
// and key ordering choices in the emitter.
|
||||
parsed := json2.decode[json2.Any](out)!
|
||||
mapped := parsed as map[string]json2.Any
|
||||
a := mapped['a'] or { return error('missing key a in re-parsed output') }
|
||||
b := mapped['b'] or { return error('missing key b in re-parsed output') }
|
||||
assert a.str() == 'café'
|
||||
assert b.str() == '中文'
|
||||
}
|
||||
|
||||
fn test_to_json_escapes_special_chars() ! {
|
||||
doc := parse_text('a: "tab\there"\nb: "quote: \\""\n')!
|
||||
out := doc.to_json()
|
||||
parsed := json2.decode[json2.Any](out)!
|
||||
mapped := parsed as map[string]json2.Any
|
||||
a := mapped['a'] or { return error('missing key a in re-parsed output') }
|
||||
b := mapped['b'] or { return error('missing key b in re-parsed output') }
|
||||
assert a.str() == 'tab\there'
|
||||
assert b.str() == 'quote: "'
|
||||
}
|
||||
|
||||
fn test_to_yaml_quotes_keys_consistently() ! {
|
||||
doc := parse_text('plain: 1\n"a.b": 2\n')!
|
||||
out := doc.to_yaml()
|
||||
// Both keys go through yaml_quote_string -> json.encode, so both end up
|
||||
// quoted. This guards against a future change that would silently switch
|
||||
// to plain-style and break round-tripping for keys containing dots.
|
||||
assert out.contains('"plain":')
|
||||
assert out.contains('"a.b":')
|
||||
}
|
||||
|
||||
fn test_value_returns_null_for_missing_path() ! {
|
||||
doc := parse_text('a: 1\nb:\n c: 2\n')!
|
||||
assert doc.value('z') is Null
|
||||
assert doc.value('a.does.not.exist') is Null
|
||||
assert doc.value('b.c.d') is Null
|
||||
}
|
||||
|
||||
fn test_value_opt_errors_on_missing() ! {
|
||||
doc := parse_text('a: 1\n')!
|
||||
if _ := doc.value_opt('z') {
|
||||
assert false, 'expected error for missing key'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_value_returns_null_on_array_out_of_bounds() ! {
|
||||
doc := parse_text('a: [1, 2, 3]\n')!
|
||||
assert doc.value('a[99]') is Null
|
||||
}
|
||||
|
||||
fn test_parse_skips_yaml_directives() ! {
|
||||
// `%YAML`, `%TAG`, and any other `%`-prefixed directive line is consumed
|
||||
// without becoming part of the document.
|
||||
doc := parse_text('%YAML 1.2\n%TAG !e! tag:example.com,2000:app/\n---\nname: app\n')!
|
||||
assert doc.value('name').string() == 'app'
|
||||
}
|
||||
|
||||
fn test_parse_anchor_and_alias_resolution() ! {
|
||||
// `&id` registers the value, `*id` returns the same value at use sites.
|
||||
doc := parse_text('a: &x hello\nb: *x\nlist:\n - &y 42\n - *y\n')!
|
||||
assert doc.value('a').string() == 'hello'
|
||||
assert doc.value('b').string() == 'hello'
|
||||
assert doc.value('list[0]').int() == 42
|
||||
assert doc.value('list[1]').int() == 42
|
||||
}
|
||||
|
||||
fn test_parse_unknown_alias_returns_null() ! {
|
||||
doc := parse_text('a: *missing\n')!
|
||||
assert doc.value('a') is Null
|
||||
}
|
||||
|
||||
fn test_parse_file_happy_path() ! {
|
||||
path := os.join_path(os.vtmp_dir(), 'yaml_pf_${os.getpid()}.yml')
|
||||
defer {
|
||||
os.rm(path) or {}
|
||||
}
|
||||
os.write_file(path, 'name: app\nport: 8080\n')!
|
||||
doc := parse_file(path)!
|
||||
assert doc.value('name').string() == 'app'
|
||||
assert doc.value('port').int() == 8080
|
||||
}
|
||||
|
||||
fn test_parse_file_returns_error_on_missing_path() {
|
||||
missing := os.join_path(os.vtmp_dir(), 'yaml_does_not_exist_${os.getpid()}.yml')
|
||||
if _ := parse_file(missing) {
|
||||
assert false, 'parse_file on missing path should error'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_flow_collection_spanning_multiple_lines() ! {
|
||||
// Flow `[ ]` and `{ }` may wrap across lines; the parser must accumulate
|
||||
// until brackets balance.
|
||||
doc := parse_text('arr: [\n 1,\n 2,\n 3\n]\n')!
|
||||
assert doc.value('arr').array().len == 3
|
||||
assert doc.value('arr[2]').int() == 3
|
||||
doc2 := parse_text('obj: {\n a: 1,\n b: 2\n}\n')!
|
||||
assert doc2.value('obj.a').int() == 1
|
||||
assert doc2.value('obj.b').int() == 2
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
module yaml
|
||||
|
||||
// JSON round-trip: parse_text() should accept any JSON document (YAML 1.2 is
|
||||
// a JSON superset), and to_json() should produce a JSON document that
|
||||
// re-parses to the same logical tree. This guards against regressions in
|
||||
// either the parser's JSON-superset fast path or the serializer's escaping.
|
||||
|
||||
const json_corpus = [
|
||||
// scalars
|
||||
'true',
|
||||
'false',
|
||||
'null',
|
||||
'0',
|
||||
'-1',
|
||||
'1234567890',
|
||||
'3.14159',
|
||||
'1.5e-10',
|
||||
'""',
|
||||
'"hello"',
|
||||
'"with\\"quote"',
|
||||
'"with\\nnewline"',
|
||||
'"unicode: \\u00e9 \\u4e2d"',
|
||||
'"verbatim utf8: café 中"',
|
||||
// arrays
|
||||
'[]',
|
||||
'[1]',
|
||||
'[1,2,3]',
|
||||
'["a","b","c"]',
|
||||
'[null,true,false,1,1.5,"s"]',
|
||||
'[[1,2],[3,4],[]]',
|
||||
'[{"k":"v"},{"k":"w"}]',
|
||||
// objects
|
||||
'{}',
|
||||
'{"a":1}',
|
||||
'{"a":1,"b":2,"c":3}',
|
||||
'{"nested":{"deep":{"deeper":42}}}',
|
||||
'{"mixed":[1,{"k":"v"},null,true]}',
|
||||
'{"empty_arr":[],"empty_obj":{}}',
|
||||
'{"unicode_key_café":"value","quoted.key":"v2"}',
|
||||
// edge sizes
|
||||
'[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19]',
|
||||
'{"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g":7,"h":8,"i":9,"j":10}',
|
||||
]!
|
||||
|
||||
fn test_json_corpus_roundtrip() ! {
|
||||
mut failed := []string{}
|
||||
for input in json_corpus {
|
||||
doc := parse_text(input) or {
|
||||
failed << 'parse error on ${input}: ${err}'
|
||||
continue
|
||||
}
|
||||
out := doc.to_json()
|
||||
eq := json_logically_eq(input, out) or {
|
||||
failed << '${input} -> ${out}: invalid JSON produced (${err})'
|
||||
continue
|
||||
}
|
||||
if !eq {
|
||||
failed << '${input} -> ${out}: not logically equal'
|
||||
}
|
||||
}
|
||||
if failed.len > 0 {
|
||||
eprintln('JSON round-trip failures (${failed.len}/${json_corpus.len}):')
|
||||
for f in failed {
|
||||
eprintln(' - ${f}')
|
||||
}
|
||||
assert false, '${failed.len} round-trip case(s) failed'
|
||||
}
|
||||
}
|
||||
|
||||
// Anti-regression for the "first call works, repeated call breaks" family
|
||||
// that used to surface in the json2.Any rebuild path: parse + to_json must
|
||||
// be byte-stable across iterations on the same input. 500 iterations are
|
||||
// enough to flush the original failure mode without bloating CI runtime.
|
||||
fn test_json_roundtrip_is_idempotent_under_repetition() ! {
|
||||
src := '{"users":[{"id":1,"name":"alice","tags":["x","y"]},{"id":2,"name":"bob","tags":[]}],"meta":{"count":2,"page":null,"flags":{"a":true,"b":false}}}'
|
||||
mut prev := parse_text(src)!.to_json()
|
||||
for _ in 0 .. 500 {
|
||||
curr := parse_text(prev)!.to_json()
|
||||
assert curr == prev, 'round-trip drift detected'
|
||||
prev = curr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
module yaml
|
||||
|
||||
import os
|
||||
|
||||
enum Role {
|
||||
worker
|
||||
manager
|
||||
}
|
||||
|
||||
struct Address {
|
||||
city string
|
||||
zip int
|
||||
}
|
||||
|
||||
struct AppConfig {
|
||||
name string
|
||||
role Role @[json: 'role']
|
||||
enabled bool
|
||||
ports []int
|
||||
address Address
|
||||
notes string
|
||||
}
|
||||
|
||||
fn test_parse_doc_queries_and_block_scalars() ! {
|
||||
doc := parse_text('title: Example
|
||||
config: { enabled: true, retries: 3 }
|
||||
servers:
|
||||
- host: api.local
|
||||
ports: [80, 443]
|
||||
- host: jobs.local
|
||||
quoted:
|
||||
"a.b": 7
|
||||
notes: |
|
||||
first line
|
||||
second line
|
||||
folded: >
|
||||
hello
|
||||
world
|
||||
')!
|
||||
|
||||
assert doc.value('title').string() == 'Example'
|
||||
assert doc.value('config.enabled').bool()
|
||||
assert doc.value('config.retries').int() == 3
|
||||
assert doc.value('servers[0].host').string() == 'api.local'
|
||||
assert doc.value('servers[0].ports[1]').int() == 443
|
||||
assert doc.value('quoted."a.b"').int() == 7
|
||||
assert doc.value('notes').string() == 'first line\nsecond line\n'
|
||||
assert doc.value('folded').string() == 'hello world\n'
|
||||
}
|
||||
|
||||
fn test_generic_encode_decode_with_json_attrs() ! {
|
||||
config := AppConfig{
|
||||
name: 'worker'
|
||||
role: .manager
|
||||
enabled: true
|
||||
ports: [8080, 9090]
|
||||
address: Address{
|
||||
city: 'Springwood'
|
||||
zip: 1428
|
||||
}
|
||||
notes: 'line one\nline two'
|
||||
}
|
||||
|
||||
encoded := encode(config)
|
||||
assert encoded.contains('"role": "manager"')
|
||||
assert encoded.contains('"address":')
|
||||
assert encoded.contains('- 8080')
|
||||
|
||||
decoded := decode[AppConfig](encoded)!
|
||||
assert decoded == config
|
||||
}
|
||||
|
||||
fn test_file_helpers() ! {
|
||||
path := os.join_path(os.vtmp_dir(), 'yaml_test_${os.getpid()}.yml')
|
||||
defer {
|
||||
os.rm(path) or {}
|
||||
}
|
||||
config := AppConfig{
|
||||
name: 'batch11'
|
||||
role: .worker
|
||||
enabled: false
|
||||
ports: [7000]
|
||||
address: Address{
|
||||
city: 'Moscow'
|
||||
zip: 101000
|
||||
}
|
||||
notes: 'plain'
|
||||
}
|
||||
|
||||
encode_file(path, config)!
|
||||
decoded := decode_file[AppConfig](path)!
|
||||
assert decoded == config
|
||||
|
||||
doc := parse_file(path)!
|
||||
assert doc.value('name').string() == 'batch11'
|
||||
assert doc.value('role').string() == 'worker'
|
||||
assert doc.value('address.city').string() == 'Moscow'
|
||||
}
|
||||
Reference in new issue
Block a user