feat(json): add PureYAMLJSON product, a standalone YAML <-> JSON converter

PureYAMLJSON is a separate library product layered on the core engine. JSON is
a subset of YAML 1.2, so the existing parser already reads both into one value
tree; this product adds the missing half and the value-level policy:

- JSON.Writer: a pure-Swift RFC-8259 serializer (no Foundation; WASI-clean),
  pretty or compact, rejecting non-string keys and non-finite numbers.
- JSON representability rules expressed in PureYAML's own Validation framework:
  mappingKeysAreStrings and numbersAreFinite join the core mappingKeysAreUnique.
- JSON.Validator: the representability rule set plus issues(for:)/isRepresentable.
- JSON.Convert: standalone convert(_:to:) with the round-trip law (parse ->
  validate representability -> emit -> re-parse must equal the source). To JSON
  rejects complex keys and non-finite numbers; to YAML rejects duplicate keys,
  which are legal in JSON but not in YAML.

19 tests cover the writer, the rules, and both conversion directions including
the dual-rejection edges. Carved out of the catch-all Sources/Tests paths via
exclude so the core PureYAML target is unchanged.
This commit is contained in:
Mihaela Mihaljevic 2026-06-25 00:31:46 +02:00
parent 5c34ebd8b6
commit bf069fad39
14 changed files with 549 additions and 4 deletions

View File

@ -9,20 +9,36 @@ let package = Package(
name: "PureYAML",
targets: ["PureYAML"],
),
.library(
name: "PureYAMLJSON",
targets: ["PureYAMLJSON"],
),
],
dependencies: [],
targets: [
.target(
name: "PureYAML",
path: "Sources",
exclude: ["PureYAMLJSON"],
),
.target(
name: "PureYAMLJSON",
dependencies: ["PureYAML"],
path: "Sources/PureYAMLJSON",
),
.testTarget(
name: "PureYAMLTests",
dependencies: ["PureYAML"],
path: "Tests",
exclude: ["PureYAMLJSONTests"],
resources: [
.copy("Fixtures"),
],
),
.testTarget(
name: "PureYAMLJSONTests",
dependencies: ["PureYAMLJSON"],
path: "Tests/PureYAMLJSONTests",
),
],
)

View File

@ -0,0 +1,13 @@
import PureYAML
public extension PureYAML.JSON.Convert {
/// A reason a YAML <-> JSON conversion did not complete.
enum Failure: Error, Equatable, Sendable {
/// The source text did not parse.
case sourceInvalid(String)
/// The value cannot be represented in the target format.
case notRepresentable([PureYAML.Validation.Issue])
/// The emitted output did not parse back to the source value.
case roundTripMismatch(PureYAML.JSON.Format)
}
}

View File

@ -0,0 +1,64 @@
import PureYAML
public extension PureYAML.JSON {
/// Standalone YAML <-> JSON conversion. It depends only on the parsing/emitting
/// engine and the JSON writer; it knows nothing about any editor or UI, so it is
/// usable on its own (a CLI, a server, a build step, a test).
///
/// A conversion succeeds only when, in order:
/// 1. the source parses,
/// 2. the value is representable in the target format, and
/// 3. the emitted output parses back to a value identical to the source
/// (a lossless round-trip check that also guards against emitter defects).
///
/// JSON is a subset of YAML 1.2, so the same engine parses both the source and
/// the re-parsed output; only "to JSON" can be rejected for representability (a
/// value may use a non-string key or a non-finite number). "To YAML" still
/// rejects duplicate keys, which are legal in JSON but not in YAML.
enum Convert {
/// Convert source text to the target format, validated end to end.
public static func convert(_ source: String, to target: Format, pretty: Bool = true) throws -> String {
let value = try parse(source)
let output = try emit(value, as: target, pretty: pretty)
guard try parse(output) == value else {
throw Failure.roundTripMismatch(target)
}
return output
}
/// Emit a value tree in the target format. The value is validated for
/// representability first; the round-trip check is not run here (use
/// ``convert(_:to:pretty:)`` for the fully validated path).
public static func emit(_ value: PureYAML.Model.Value, as target: Format, pretty: Bool = true) throws -> String {
let issues = validator(for: target).collect(value).issues
guard issues.isEmpty else {
throw Failure.notRepresentable(issues)
}
switch target {
case .json:
return try PureYAML.JSON.Writer.string(from: value, options: .init(pretty: pretty, indent: 2))
case .yaml:
return PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, collectionStyle: .block))
}
}
/// The validator for a target format. JSON additionally requires string keys
/// and finite numbers; YAML allows complex keys but still forbids duplicates.
public static func validator(for target: Format) -> PureYAML.Validation.Validator {
switch target {
case .json:
PureYAML.JSON.Validator.representability
case .yaml:
PureYAML.Validation.Validator(rules: [.mappingKeysAreUnique])
}
}
private static func parse(_ text: String) throws -> PureYAML.Model.Value {
do {
return try PureYAML.parse(text)
} catch {
throw Failure.sourceInvalid(String(describing: error))
}
}
}
}

View File

@ -0,0 +1,11 @@
import PureYAML
public extension PureYAML.JSON {
/// A serialization format the converter can emit. The source is always parsed by
/// PureYAML (JSON is a subset of YAML, so one parser reads both); only the target
/// needs to be named.
enum Format: String, CaseIterable, Sendable, Equatable {
case yaml
case json
}
}

View File

@ -0,0 +1,52 @@
import PureYAML
public extension PureYAML.Validation.Rule {
/// Reports any mapping key that is not a string. JSON object names must be
/// strings, so a YAML complex key (a sequence or mapping used as a key) has no
/// JSON form. PureYAML core already ships ``mappingKeysAreUnique``; this rule and
/// ``numbersAreFinite`` complete the JSON-representability set.
static var mappingKeysAreStrings: Self {
Self(description: "Mapping keys are strings") { context in
guard case let .mapping(mapping) = context.subject else {
return []
}
return mapping.pairs.compactMap { pair in
guard pair.keyNode.stringValue == nil else {
return nil
}
return PureYAML.Validation.Issue(
severity: .error,
reason: "Mapping key '\(pair.keyNode.description)' is not a string",
path: context.path.appending(.complexKey(pair.keyNode)),
)
}
} when: { context in
if case .mapping = context.subject {
return true
}
return false
}
}
/// Reports any non-finite number (NaN or infinity). JSON has no literal for
/// these, so a value tree carrying one is not representable as JSON.
static var numbersAreFinite: Self {
Self(description: "Numbers are finite") { context in
guard case let .double(number) = context.subject, !number.isFinite else {
return []
}
return [
PureYAML.Validation.Issue(
severity: .error,
reason: "Number '\(number)' is not finite",
path: context.path,
),
]
} when: { context in
if case .double = context.subject {
return true
}
return false
}
}
}

View File

@ -0,0 +1,35 @@
import PureYAML
public extension PureYAML.JSON {
/// JSON-representability checks built on PureYAML's own validation framework.
/// The parser already guarantees the input is well-formed YAML (and so covers
/// JSON syntax); this layer reports the value-level reasons a tree cannot be
/// emitted as JSON: a non-string mapping key, a non-finite number, or (for a
/// deterministic single-meaning document) a duplicate key.
///
/// JSON the format tolerates duplicate object names, but a tree that carries two
/// pairs with the same key emits an ambiguous document, so the representability
/// validator rejects duplicates for determinism. Drop ``unique`` from the rule
/// set if you want the permissive reading.
enum Validator {
/// The full JSON-representability rule set: unique keys, string keys, finite
/// numbers. Suitable to pass straight to `PureYAML.JSON.Convert`.
public static var representability: PureYAML.Validation.Validator {
PureYAML.Validation.Validator(rules: [
.mappingKeysAreUnique,
.mappingKeysAreStrings,
.numbersAreFinite,
])
}
/// Every reason the value cannot be emitted as strict JSON, in document order.
public static func issues(for value: PureYAML.Model.Value) -> [PureYAML.Validation.Issue] {
representability.collect(value).issues
}
/// Whether the value can be emitted as strict JSON.
public static func isRepresentable(_ value: PureYAML.Model.Value) -> Bool {
issues(for: value).isEmpty
}
}
}

View File

@ -0,0 +1,11 @@
import PureYAML
public extension PureYAML.JSON.Writer {
/// A reason a value tree cannot be written as strict JSON.
enum Failure: Error, Sendable, Equatable {
/// A YAML complex (sequence/mapping) key has no JSON object-name form.
case nonStringKey(String)
/// JSON cannot represent NaN or infinity.
case nonFiniteNumber(Double)
}
}

View File

@ -0,0 +1,19 @@
import PureYAML
public extension PureYAML.JSON.Writer {
/// Formatting options for the JSON writer.
struct Options: Sendable, Equatable {
/// Indent with newlines (`true`) or emit a single compact line (`false`).
public var pretty: Bool
/// Spaces per indentation level when `pretty`.
public var indent: Int
public init(pretty: Bool = true, indent: Int = 2) {
self.pretty = pretty
self.indent = indent
}
public static let pretty = Options(pretty: true, indent: 2)
public static let compact = Options(pretty: false, indent: 0)
}
}

View File

@ -0,0 +1,129 @@
import PureYAML
public extension PureYAML.JSON {
/// A pure-Swift JSON serializer for the YAML/JSON value tree. Converting a
/// parsed document to strict JSON is the core "emit as JSON" operation; since
/// JSON is a subset of YAML 1.2, any value PureYAML parses can be emitted as
/// JSON unless it uses a YAML-only feature with no JSON representation (a
/// non-string mapping key, or a non-finite number).
enum Writer {
/// Serialize a value tree to a JSON string.
public static func string(from value: PureYAML.Model.Value, options: Options = .pretty) throws -> String {
var out = ""
try write(value, into: &out, depth: 0, options: options)
return out
}
private static func write(
_ value: PureYAML.Model.Value,
into out: inout String,
depth: Int,
options: Options,
) throws {
switch value {
case .null:
out += "null"
case let .bool(flag):
out += flag ? "true" : "false"
case let .int(number):
out += String(number)
case let .double(number):
guard number.isFinite else { throw Failure.nonFiniteNumber(number) }
out += String(number)
case let .string(text):
out += escaped(text)
case let .sequence(items):
try writeArray(items, into: &out, depth: depth, options: options)
case let .mapping(mapping):
try writeObject(mapping, into: &out, depth: depth, options: options)
}
}
private static func writeArray(
_ items: [PureYAML.Model.Value],
into out: inout String,
depth: Int,
options: Options,
) throws {
guard !items.isEmpty else {
out += "[]"
return
}
out += "["
for (index, item) in items.enumerated() {
out += index == 0 ? "" : ","
newline(&out, depth: depth + 1, options: options)
try write(item, into: &out, depth: depth + 1, options: options)
}
newline(&out, depth: depth, options: options)
out += "]"
}
private static func writeObject(
_ mapping: PureYAML.Model.Mapping,
into out: inout String,
depth: Int,
options: Options,
) throws {
guard !mapping.pairs.isEmpty else {
out += "{}"
return
}
out += "{"
for (index, pair) in mapping.pairs.enumerated() {
guard let key = pair.keyNode.stringValue else {
throw Failure.nonStringKey(pair.keyNode.description)
}
out += index == 0 ? "" : ","
newline(&out, depth: depth + 1, options: options)
out += escaped(key)
out += options.pretty ? ": " : ":"
try write(pair.value, into: &out, depth: depth + 1, options: options)
}
newline(&out, depth: depth, options: options)
out += "}"
}
private static func newline(_ out: inout String, depth: Int, options: Options) {
guard options.pretty else { return }
out += "\n"
out += String(repeating: " ", count: depth * options.indent)
}
/// Quote and escape a string per RFC 8259: `"` and `\` are escaped, the
/// named short escapes are used where they apply, and any remaining control
/// character below U+0020 becomes a `\u00XX` sequence.
private static func escaped(_ text: String) -> String {
var out = "\""
for scalar in text.unicodeScalars {
switch scalar {
case "\"": out += "\\\""
case "\\": out += "\\\\"
case "\u{08}": out += "\\b"
case "\u{0C}": out += "\\f"
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\t": out += "\\t"
default:
if scalar.value < 0x20 {
out += "\\u" + hex4(scalar.value)
} else {
out.unicodeScalars.append(scalar)
}
}
}
out += "\""
return out
}
private static func hex4(_ value: UInt32) -> String {
let digits = "0123456789abcdef"
var result = ""
for shift in stride(from: 12, through: 0, by: -4) {
let index = Int((value >> UInt32(shift)) & 0xF)
result.append(digits[digits.index(digits.startIndex, offsetBy: index)])
}
return result
}
}
}

View File

@ -0,0 +1,14 @@
import PureYAML
public extension PureYAML {
/// Strict-JSON support layered on the shared YAML value tree: a pure-Swift
/// RFC-8259 emitter, JSON-representability rules expressed in PureYAML's own
/// validation framework, and a standalone YAML <-> JSON converter.
///
/// JSON is a subset of YAML 1.2, so the parser PureYAML already ships reads both
/// JSON and YAML sources into the same ``Model/Value`` tree. This module adds the
/// missing half (emitting that tree as JSON) plus the value-level checks that
/// decide whether a tree is representable as JSON at all. Anchor file; concrete
/// types live in the sibling `JSON.*` files.
enum JSON {}
}

View File

@ -0,0 +1,73 @@
import PureYAML
@testable import PureYAMLJSON
import Testing
@Suite("JSON Convert both directions")
struct JSONConvertTests {
private let sampleJSON = """
{"name": "PureYAML", "n": 1, "ratio": 1.5, "open": true, "tags": ["yaml", "json"], "nested": {"a": 1, "b": [null, false]}}
"""
@Test("JSON to YAML is block style and round-trips to the same value")
func test_jsonToYAML() throws {
let yaml = try PureYAML.JSON.Convert.convert(sampleJSON, to: .yaml)
#expect(!yaml.hasPrefix("{"))
#expect(yaml.contains("name: PureYAML"))
#expect(yaml.contains("tags:"))
#expect(try PureYAML.parse(yaml) == PureYAML.parse(sampleJSON))
}
@Test("YAML to JSON round-trips to the same value")
func test_yamlToJSON() throws {
let yaml = "name: PureYAML\nn: 1\nlist:\n - a\n - 2\nnested:\n flag: true"
let json = try PureYAML.JSON.Convert.convert(yaml, to: .json, pretty: false)
#expect(json.hasPrefix("{"))
#expect(try PureYAML.parse(json) == PureYAML.parse(yaml))
}
@Test("JSON to YAML to JSON returns the original document")
func test_jsonRoundTripThroughYAML() throws {
let yaml = try PureYAML.JSON.Convert.convert(sampleJSON, to: .yaml)
let json = try PureYAML.JSON.Convert.convert(yaml, to: .json, pretty: false)
#expect(json == "{\"name\":\"PureYAML\",\"n\":1,\"ratio\":1.5,\"open\":true,\"tags\":[\"yaml\",\"json\"],\"nested\":{\"a\":1,\"b\":[null,false]}}")
}
@Test("YAML to JSON to YAML returns an equivalent document")
func test_yamlRoundTripThroughJSON() throws {
let yaml = "count: 3\nratio: 0.25\nitems:\n - x\n - y\nflag: false"
let value = try PureYAML.parse(yaml)
let json = try PureYAML.JSON.Convert.convert(yaml, to: .json)
let backToYAML = try PureYAML.JSON.Convert.convert(json, to: .yaml)
#expect(try PureYAML.parse(backToYAML) == value)
}
@Test("A string that looks like a bool stays a string both ways")
func test_stringLikeBoolPreserved() throws {
let json = "{\"a\": true, \"b\": \"true\"}"
let yaml = try PureYAML.JSON.Convert.convert(json, to: .yaml)
#expect(try PureYAML.parse(yaml) == PureYAML.parse(json))
let backToJSON = try PureYAML.JSON.Convert.convert(yaml, to: .json, pretty: false)
#expect(backToJSON == "{\"a\":true,\"b\":\"true\"}")
}
@Test("Duplicate keys are rejected both ways (legal JSON, illegal YAML)")
func test_duplicateKeysRejected() {
let duplicated = "{\"a\": 1, \"a\": 2}"
#expect(throws: PureYAML.JSON.Convert.Failure.self) {
_ = try PureYAML.JSON.Convert.convert(duplicated, to: .yaml)
}
#expect(throws: PureYAML.JSON.Convert.Failure.self) {
_ = try PureYAML.JSON.Convert.convert(duplicated, to: .json)
}
}
@Test("A complex YAML key blocks JSON but converts to YAML")
func test_complexKeyJSONvsYAML() throws {
let yaml = "? [1, 2]\n: value"
#expect(throws: PureYAML.JSON.Convert.Failure.self) {
_ = try PureYAML.JSON.Convert.convert(yaml, to: .json)
}
let asYAML = try PureYAML.JSON.Convert.convert(yaml, to: .yaml)
#expect(try PureYAML.parse(asYAML) == PureYAML.parse(yaml))
}
}

View File

@ -0,0 +1,53 @@
import PureYAML
@testable import PureYAMLJSON
import Testing
@Suite("JSON representability rules")
struct JSONRuleTests {
private func mapping(_ pairs: [PureYAML.Model.Pair]) -> PureYAML.Model.Value {
.mapping(.init(pairs))
}
@Test("mappingKeysAreStrings fails on a complex key")
func test_mappingKeysAreStrings_fails() {
let document = mapping([.init(keyNode: .sequence([.int(1)]), value: .int(2))])
let issues = PureYAML.Validation.Validator(rules: [.mappingKeysAreStrings]).collect(document).issues
#expect(issues.count == 1)
#expect(issues.first?.reason == "Mapping key '[1]' is not a string")
}
@Test("mappingKeysAreStrings passes when every key is a string")
func test_mappingKeysAreStrings_passes() {
let document = mapping([.init(key: "a", value: .int(1))])
let issues = PureYAML.Validation.Validator(rules: [.mappingKeysAreStrings]).collect(document).issues
#expect(issues.isEmpty)
}
@Test("numbersAreFinite fails on infinity at the value's path")
func test_numbersAreFinite_fails() {
let document = mapping([.init(key: "x", value: .double(.infinity))])
let issues = PureYAML.Validation.Validator(rules: [.numbersAreFinite]).collect(document).issues
#expect(issues.count == 1)
#expect(issues.first?.reason == "Number 'inf' is not finite")
}
@Test("numbersAreFinite passes on a large finite double")
func test_numbersAreFinite_passes() {
let document = mapping([.init(key: "x", value: .double(.greatestFiniteMagnitude))])
let issues = PureYAML.Validation.Validator(rules: [.numbersAreFinite]).collect(document).issues
#expect(issues.isEmpty)
}
@Test("The representability set catches a duplicate key")
func test_representability_catchesDuplicate() {
let document = mapping([.init(key: "a", value: .int(1)), .init(key: "a", value: .int(2))])
#expect(!PureYAML.JSON.Validator.isRepresentable(document))
}
@Test("A plain string-keyed finite tree is representable")
func test_representability_acceptsPlainTree() {
let document = mapping([.init(key: "a", value: .int(1)), .init(key: "b", value: .double(1.5))])
#expect(PureYAML.JSON.Validator.isRepresentable(document))
#expect(PureYAML.JSON.Validator.issues(for: document).isEmpty)
}
}

View File

@ -0,0 +1,50 @@
import PureYAML
@testable import PureYAMLJSON
import Testing
@Suite("JSON Writer")
struct JSONWriterTests {
@Test("Emits a compact object on one line")
func test_emitsCompactObject() throws {
let value = try PureYAML.parse("a: 1\nb:\n - true\n - null")
let json = try PureYAML.JSON.Writer.string(from: value, options: .compact)
#expect(json == "{\"a\":1,\"b\":[true,null]}")
}
@Test("Pretty output indents and survives a round-trip")
func test_emitsPrettyObject() throws {
let value = try PureYAML.parse("a: 1\nb:\n - x")
let json = try PureYAML.JSON.Writer.string(from: value, options: .pretty)
#expect(json.contains("\n"))
#expect(try PureYAML.parse(json) == value)
}
@Test("Empty containers emit as [] and {}")
func test_emitsEmptyContainers() throws {
let object = try PureYAML.JSON.Writer.string(from: .mapping(.init([])), options: .compact)
let array = try PureYAML.JSON.Writer.string(from: .sequence([]), options: .compact)
#expect(object == "{}")
#expect(array == "[]")
}
@Test("A non-string mapping key is rejected")
func test_rejectsNonStringKey() {
let value = PureYAML.Model.Value.mapping(.init([.init(keyNode: .sequence([.int(1)]), value: .int(2))]))
#expect(throws: PureYAML.JSON.Writer.Failure.self) {
_ = try PureYAML.JSON.Writer.string(from: value)
}
}
@Test("A non-finite number is rejected")
func test_rejectsNonFiniteNumber() {
#expect(throws: PureYAML.JSON.Writer.Failure.nonFiniteNumber(.infinity)) {
_ = try PureYAML.JSON.Writer.string(from: .double(.infinity))
}
}
@Test("Control characters and quotes are escaped per RFC 8259")
func test_escapesControlCharacters() throws {
let json = try PureYAML.JSON.Writer.string(from: .string("a\"b\\c\n\u{01}"), options: .compact)
#expect(json == "\"a\\\"b\\\\c\\n\\u0001\"")
}
}

View File

@ -32,10 +32,14 @@ package_forbidden=$(
)
report_matches "Package.swift must not declare external, binary, or system-library targets" "$package_forbidden"
# The core PureYAML target stays standard-library-only and imports nothing. The
# layered PureYAMLJSON target builds on the core, so it may import the first-party
# PureYAML module (and only that); no Foundation, no external, no system modules.
source_imports=$(
grep -RInE '^[[:space:]]*import[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' Sources --include '*.swift' 2>/dev/null || true
grep -RInE '^[[:space:]]*import[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' Sources --include '*.swift' 2>/dev/null \
| grep -vE '^Sources/PureYAMLJSON/[^:]+:[0-9]+:[[:space:]]*import[[:space:]]+PureYAML[[:space:]]*$' || true
)
report_matches "Sources must stay standard-library-only and import no modules" "$source_imports"
report_matches "Sources must stay standard-library-only (only PureYAMLJSON may import the core PureYAML module)" "$source_imports"
source_platform_branches=$(
grep -RInE '^[[:space:]]*#if[[:space:]]+(os|canImport)[[:space:]]*\(' Sources --include '*.swift' 2>/dev/null || true
@ -73,11 +77,12 @@ report_matches "Sources must not use traps, forced casts, try!, or force unwraps
test_imports=$(
grep -RInE '^[[:space:]]*(@testable[[:space:]]+)?import[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' Tests --include '*.swift' 2>/dev/null \
| grep -vE '^[^:]+:[0-9]+:[[:space:]]*@testable[[:space:]]+import[[:space:]]+PureYAML[[:space:]]*$' \
| grep -vE '^[^:]+:[0-9]+:[[:space:]]*@testable[[:space:]]+import[[:space:]]+PureYAML(JSON)?[[:space:]]*$' \
| grep -vE '^[^:]+:[0-9]+:[[:space:]]*import[[:space:]]+PureYAML[[:space:]]*$' \
| grep -vE '^[^:]+:[0-9]+:[[:space:]]*import[[:space:]]+Foundation[[:space:]]*$' \
| grep -vE '^[^:]+:[0-9]+:[[:space:]]*import[[:space:]]+Testing[[:space:]]*$' || true
)
report_matches "Tests may import only Foundation, Testing, and @testable PureYAML" "$test_imports"
report_matches "Tests may import only Foundation, Testing, PureYAML, and @testable PureYAML/PureYAMLJSON" "$test_imports"
test_xctest=$(
grep -RInE '\bXCTest(Case|Expectation)?\b|\bXCTAssert[A-Za-z]*\b|^[[:space:]]*import[[:space:]]+XCTest[[:space:]]*$' Tests --include '*.swift' 2>/dev/null || true