diff --git a/Package.swift b/Package.swift index 0e9c8ff..f7f04a1 100644 --- a/Package.swift +++ b/Package.swift @@ -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", + ), ], ) diff --git a/Sources/PureYAMLJSON/JSON.Convert.Failure.swift b/Sources/PureYAMLJSON/JSON.Convert.Failure.swift new file mode 100644 index 0000000..35680ee --- /dev/null +++ b/Sources/PureYAMLJSON/JSON.Convert.Failure.swift @@ -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) + } +} diff --git a/Sources/PureYAMLJSON/JSON.Convert.swift b/Sources/PureYAMLJSON/JSON.Convert.swift new file mode 100644 index 0000000..b25326d --- /dev/null +++ b/Sources/PureYAMLJSON/JSON.Convert.swift @@ -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)) + } + } + } +} diff --git a/Sources/PureYAMLJSON/JSON.Format.swift b/Sources/PureYAMLJSON/JSON.Format.swift new file mode 100644 index 0000000..351a358 --- /dev/null +++ b/Sources/PureYAMLJSON/JSON.Format.swift @@ -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 + } +} diff --git a/Sources/PureYAMLJSON/JSON.Rule.Builtins.swift b/Sources/PureYAMLJSON/JSON.Rule.Builtins.swift new file mode 100644 index 0000000..21967dc --- /dev/null +++ b/Sources/PureYAMLJSON/JSON.Rule.Builtins.swift @@ -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 + } + } +} diff --git a/Sources/PureYAMLJSON/JSON.Validator.swift b/Sources/PureYAMLJSON/JSON.Validator.swift new file mode 100644 index 0000000..1e30717 --- /dev/null +++ b/Sources/PureYAMLJSON/JSON.Validator.swift @@ -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 + } + } +} diff --git a/Sources/PureYAMLJSON/JSON.Writer.Failure.swift b/Sources/PureYAMLJSON/JSON.Writer.Failure.swift new file mode 100644 index 0000000..0b00ad0 --- /dev/null +++ b/Sources/PureYAMLJSON/JSON.Writer.Failure.swift @@ -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) + } +} diff --git a/Sources/PureYAMLJSON/JSON.Writer.Options.swift b/Sources/PureYAMLJSON/JSON.Writer.Options.swift new file mode 100644 index 0000000..716562e --- /dev/null +++ b/Sources/PureYAMLJSON/JSON.Writer.Options.swift @@ -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) + } +} diff --git a/Sources/PureYAMLJSON/JSON.Writer.swift b/Sources/PureYAMLJSON/JSON.Writer.swift new file mode 100644 index 0000000..bf0ebe7 --- /dev/null +++ b/Sources/PureYAMLJSON/JSON.Writer.swift @@ -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 + } + } +} diff --git a/Sources/PureYAMLJSON/JSON.swift b/Sources/PureYAMLJSON/JSON.swift new file mode 100644 index 0000000..9bad1ef --- /dev/null +++ b/Sources/PureYAMLJSON/JSON.swift @@ -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 {} +} diff --git a/Tests/PureYAMLJSONTests/JSONConvertTests.swift b/Tests/PureYAMLJSONTests/JSONConvertTests.swift new file mode 100644 index 0000000..0584f1d --- /dev/null +++ b/Tests/PureYAMLJSONTests/JSONConvertTests.swift @@ -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)) + } +} diff --git a/Tests/PureYAMLJSONTests/JSONRuleTests.swift b/Tests/PureYAMLJSONTests/JSONRuleTests.swift new file mode 100644 index 0000000..d2557ee --- /dev/null +++ b/Tests/PureYAMLJSONTests/JSONRuleTests.swift @@ -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) + } +} diff --git a/Tests/PureYAMLJSONTests/JSONWriterTests.swift b/Tests/PureYAMLJSONTests/JSONWriterTests.swift new file mode 100644 index 0000000..571bb39 --- /dev/null +++ b/Tests/PureYAMLJSONTests/JSONWriterTests.swift @@ -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\"") + } +} diff --git a/scripts/check-forbidden-patterns.sh b/scripts/check-forbidden-patterns.sh index 51af3e2..4f3a136 100644 --- a/scripts/check-forbidden-patterns.sh +++ b/scripts/check-forbidden-patterns.sh @@ -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