Initial project import

This commit is contained in:
Agila Forge Importer committed 2026-07-13 15:20:32 +00:00
commit 23f2d29e32
11957 files changed
+1754577

No files matched your search

+52
View File
@@ -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)!
}
```
+204
View File
@@ -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')
}
}
}
+180
View File
@@ -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
+139
View File
@@ -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
}
}
}
+19
View File
@@ -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{})
}
+410
View File
@@ -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()
}
+145
View File
@@ -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'
}
}
+344
View File
@@ -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
}
}
+98
View File
@@ -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'
}