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:
parent
5c34ebd8b6
commit
bf069fad39
@ -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",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
13
Sources/PureYAMLJSON/JSON.Convert.Failure.swift
Normal file
13
Sources/PureYAMLJSON/JSON.Convert.Failure.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
64
Sources/PureYAMLJSON/JSON.Convert.swift
Normal file
64
Sources/PureYAMLJSON/JSON.Convert.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Sources/PureYAMLJSON/JSON.Format.swift
Normal file
11
Sources/PureYAMLJSON/JSON.Format.swift
Normal 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
|
||||
}
|
||||
}
|
||||
52
Sources/PureYAMLJSON/JSON.Rule.Builtins.swift
Normal file
52
Sources/PureYAMLJSON/JSON.Rule.Builtins.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
35
Sources/PureYAMLJSON/JSON.Validator.swift
Normal file
35
Sources/PureYAMLJSON/JSON.Validator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Sources/PureYAMLJSON/JSON.Writer.Failure.swift
Normal file
11
Sources/PureYAMLJSON/JSON.Writer.Failure.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
19
Sources/PureYAMLJSON/JSON.Writer.Options.swift
Normal file
19
Sources/PureYAMLJSON/JSON.Writer.Options.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
129
Sources/PureYAMLJSON/JSON.Writer.swift
Normal file
129
Sources/PureYAMLJSON/JSON.Writer.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Sources/PureYAMLJSON/JSON.swift
Normal file
14
Sources/PureYAMLJSON/JSON.swift
Normal 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 {}
|
||||
}
|
||||
73
Tests/PureYAMLJSONTests/JSONConvertTests.swift
Normal file
73
Tests/PureYAMLJSONTests/JSONConvertTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
53
Tests/PureYAMLJSONTests/JSONRuleTests.swift
Normal file
53
Tests/PureYAMLJSONTests/JSONRuleTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
50
Tests/PureYAMLJSONTests/JSONWriterTests.swift
Normal file
50
Tests/PureYAMLJSONTests/JSONWriterTests.swift
Normal 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\"")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user