diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d7d430..f019be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add an explicit research-compatibility rule requiring parser, emitter, validation, and typed conversion work to start from PureYAMLResearch behavior without copying implementation code. +- Add a fixture-backed scalar and explicit-tag compatibility corpus with exact + success, string-preservation, unsupported-spelling, and invalid-tag errors. ### Changed @@ -76,3 +78,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 written in a different order than they were requested. - Make keyed default `superEncoder()` and `superDecoder()` use the standard `super` mapping key instead of the current mapping. +- Resolve Yams-compatible plain scalar spellings for `yes`/`no` booleans, + radix-prefixed integers, numeric separators, and `.inf`/`.nan` floats. diff --git a/README.md b/README.md index dbd2739..c3b6024 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,27 @@ flowchart TB #10 Typed Decoding and Encoding is complete in main. +#11 YAML Compatibility Corpus is active: + +```mermaid +flowchart TB + classDef done fill:#ddf9e4,stroke:#34c759,color:#111827 + classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827 + classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827 + classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827 + Epic11["#11 YAML Compatibility Corpus"]:::epic + Scalar24["#24 Scalar and Tag Corpus"]:::done + Collection25["#25 Collection and Anchor Corpus"]:::todo + Gaps27["#27 Unsupported YAML Gaps"]:::todo + Literal19["#19 Literal Block Emission"]:::todo + Downstream26["#26 Downstream Document Fixtures"]:::todo + Epic11 --> Scalar24 + Scalar24 --> Collection25 + Collection25 --> Gaps27 + Gaps27 --> Literal19 + Literal19 --> Downstream26 +``` + ## Status This repository starts with the first real parser milestone: block mappings, diff --git a/Sources/Parsing/EventComposer.swift b/Sources/Parsing/EventComposer.swift index eab4cf2..1940374 100644 --- a/Sources/Parsing/EventComposer.swift +++ b/Sources/Parsing/EventComposer.swift @@ -163,12 +163,12 @@ extension PureYAML.Parsing.EventComposer { case "tag:yaml.org,2002:str": return .string(value) case "tag:yaml.org,2002:int": - guard let int = Int(scalarParser.trim(value)) else { + guard let int = scalarParser.parseInteger(scalarParser.trim(value)) else { throw invalidTaggedScalar(tag: "tag:yaml.org,2002:int", value: value, mark: mark) } return .int(int) case "tag:yaml.org,2002:float": - guard let double = Double(scalarParser.trim(value)) else { + guard let double = scalarParser.parseDouble(scalarParser.trim(value)) else { throw invalidTaggedScalar(tag: "tag:yaml.org,2002:float", value: value, mark: mark) } return .double(double) @@ -185,14 +185,10 @@ extension PureYAML.Parsing.EventComposer { _ value: String, mark: PureYAML.Parsing.Mark, ) throws -> PureYAML.Model.Value { - switch scalarParser.trim(value) { - case "true", "True", "TRUE": - return .bool(true) - case "false", "False", "FALSE": - return .bool(false) - default: + guard let bool = scalarParser.parseBool(scalarParser.trim(value)) else { throw invalidTaggedScalar(tag: "tag:yaml.org,2002:bool", value: value, mark: mark) } + return .bool(bool) } func normalizedScalarTag(_ tag: String?) -> String? { diff --git a/Sources/Parsing/Scalar.swift b/Sources/Parsing/Scalar.swift index 9ced40e..74aa966 100644 --- a/Sources/Parsing/Scalar.swift +++ b/Sources/Parsing/Scalar.swift @@ -17,31 +17,92 @@ extension PureYAML.Parsing.Parser { switch value { case "", "~", "null", "Null", "NULL": return .null - case "true", "True", "TRUE": - return .bool(true) - case "false", "False", "FALSE": - return .bool(false) default: break } + if let bool = parseBool(value) { + return .bool(bool) + } if value.hasPrefix("\"") { return try .string(parseDoubleQuoted(value, line: line)) } if value.hasPrefix("'") { return try .string(parseSingleQuoted(value, line: line)) } - if let int = Int(value), !value.contains(".") { + if let int = parseInteger(value), !value.contains(".") { return .int(int) } if value.contains(".") || value.contains("e") || value.contains("E") { - if let double = Double(value) { + if let double = parseDouble(value) { return .double(double) } } return .string(value) } + func parseBool(_ text: String) -> Bool? { + switch text { + case "true", "True", "TRUE", "yes", "Yes", "YES", "on", "On", "ON": + true + case "false", "False", "FALSE", "no", "No", "NO", "off", "Off", "OFF": + false + default: + nil + } + } + + func parseInteger(_ text: String) -> Int? { + let value = removingNumericSeparators(from: text) + let negative = value.hasPrefix("-") + let hasSign = negative || value.hasPrefix("+") + let unsigned = hasSign ? String(value.dropFirst()) : value + + guard !unsigned.isEmpty else { + return nil + } + guard unsigned != "0" else { + return 0 + } + + let radixPrefixes: [(prefix: String, radix: Int)] = [ + ("0x", 16), + ("0X", 16), + ("0b", 2), + ("0B", 2), + ("0o", 8), + ("0O", 8), + ("0", 8), + ] + + for radixPrefix in radixPrefixes where unsigned.hasPrefix(radixPrefix.prefix) { + let digits = String(unsigned.dropFirst(radixPrefix.prefix.count)) + guard !digits.isEmpty, let magnitude = Int(digits, radix: radixPrefix.radix) else { + return nil + } + return negative ? -magnitude : magnitude + } + + return Int(value) + } + + func parseDouble(_ text: String) -> Double? { + switch text { + case ".inf", ".Inf", ".INF", "+.inf", "+.Inf", "+.INF": + .infinity + case "-.inf", "-.Inf", "-.INF": + -.infinity + case ".nan", ".NaN", ".NAN", "+.nan", "+.NaN", "+.NAN", "-.nan", "-.NaN", "-.NAN": + .nan + default: + Double(removingNumericSeparators(from: text)) + } + } + + func removingNumericSeparators(from text: String) -> String { + text.filter { $0 != "_" } + } + func parseSingleQuoted( _ text: String, line: Int, diff --git a/Tests/ParsingCompatibilityTests.swift b/Tests/ParsingCompatibilityTests.swift index 9bf686a..9a82d28 100644 --- a/Tests/ParsingCompatibilityTests.swift +++ b/Tests/ParsingCompatibilityTests.swift @@ -3,6 +3,67 @@ import Testing @Suite("Parsing Compatibility") struct ParsingCompatibilityTests { + @Test( + "Resolves selected Yams-compatible scalar spellings", + arguments: ScalarCompatibilityFixtures.resolvedScalars, + ) + func test_selectedScalarResolution(testCase: ScalarCompatibilityFixtures.SuccessCase) throws { + let root = try requireMapping(PureYAML.parse(testCase.yaml)) + + expectScalar(root?["value"], testCase.expected) + #expect(root?["missing"] == nil) + if let forbiddenString = testCase.forbiddenString { + #expect(root?["value"] != .string(forbiddenString)) + } + } + + @Test( + "Applies explicit built-in scalar tags", + arguments: ScalarCompatibilityFixtures.explicitScalarTags, + ) + func test_explicitBuiltInScalarTags(testCase: ScalarCompatibilityFixtures.SuccessCase) throws { + let root = try requireMapping(PureYAML.parse(testCase.yaml)) + + expectScalar(root?["value"], testCase.expected) + #expect(root?["missing"] == nil) + if let forbiddenString = testCase.forbiddenString { + #expect(root?["value"] != .string(forbiddenString)) + } + } + + @Test( + "Keeps explicit and quoted strings from resolving", + arguments: ScalarCompatibilityFixtures.stringScalars, + ) + func test_stringScalarsDoNotResolve(testCase: ScalarCompatibilityFixtures.SuccessCase) throws { + let root = try requireMapping(PureYAML.parse(testCase.yaml)) + + expectScalar(root?["value"], testCase.expected) + #expect(root?["value"] != .bool(true)) + #expect(root?["value"] != .bool(false)) + #expect(root?["missing"] == nil) + } + + @Test( + "Pins unsupported scalar spellings as strings", + arguments: ScalarCompatibilityFixtures.unsupportedScalars, + ) + func test_unsupportedScalarSpellingsStayStrings(testCase: ScalarCompatibilityFixtures.SuccessCase) throws { + let root = try requireMapping(PureYAML.parse(testCase.yaml)) + + expectScalar(root?["value"], testCase.expected) + #expect(root?["value"] != .null) + #expect(root?["missing"] == nil) + } + + @Test( + "Reports exact invalid explicit scalar tag errors", + arguments: ScalarCompatibilityFixtures.invalidExplicitScalarTags, + ) + func test_invalidExplicitScalarTags(testCase: ScalarCompatibilityFixtures.ErrorCase) { + expectParseError(testCase.yaml, testCase.expected) + } + @Test("Parses directives document markers and explicit scalar tags") func test_directivesDocumentMarkersAndExplicitScalarTags() throws { let root = try requireMapping(PureYAML.parse( @@ -27,4 +88,32 @@ struct ParsingCompatibilityTests { #expect(root?["missing"] == .null) #expect(root?["custom"] == .bool(true)) } + + private func expectScalar( + _ value: PureYAML.Model.Value?, + _ expected: ScalarCompatibilityFixtures.Expected, + ) { + switch expected { + case .null: + #expect(value == .null) + case let .bool(expected): + #expect(value == .bool(expected)) + case let .int(expected): + #expect(value == .int(expected)) + case let .double(expected): + guard case let .double(actual)? = value else { + recordIssue("expected double \(expected), got \(String(describing: value))") + return + } + #expect(actual == expected) + case .nan: + guard case let .double(actual)? = value else { + recordIssue("expected NaN double, got \(String(describing: value))") + return + } + #expect(actual.isNaN) + case let .string(expected): + #expect(value == .string(expected)) + } + } } diff --git a/Tests/ScalarCompatibilityFixtures.swift b/Tests/ScalarCompatibilityFixtures.swift new file mode 100644 index 0000000..86f57c2 --- /dev/null +++ b/Tests/ScalarCompatibilityFixtures.swift @@ -0,0 +1,151 @@ +@testable import PureYAML + +enum ScalarCompatibilityFixtures { + enum Expected { + case null + case bool(Bool) + case int(Int) + case double(Double) + case nan + case string(String) + } + + struct SuccessCase { + var yaml: String + var expected: Expected + var forbiddenString: String? + + init( + _ yaml: String, + _ expected: Expected, + forbiddenString: String? = nil, + ) { + self.yaml = yaml + self.expected = expected + self.forbiddenString = forbiddenString + } + } + + struct ErrorCase { + var yaml: String + var expected: PureYAML.Parsing.ParseError + } + + static let resolvedScalars: [SuccessCase] = [ + SuccessCase("value:", .null), + SuccessCase("value: ~", .null, forbiddenString: "~"), + SuccessCase("value: null", .null, forbiddenString: "null"), + SuccessCase("value: Null", .null, forbiddenString: "Null"), + SuccessCase("value: NULL", .null, forbiddenString: "NULL"), + SuccessCase("value: true", .bool(true), forbiddenString: "true"), + SuccessCase("value: True", .bool(true), forbiddenString: "True"), + SuccessCase("value: TRUE", .bool(true), forbiddenString: "TRUE"), + SuccessCase("value: yes", .bool(true), forbiddenString: "yes"), + SuccessCase("value: Yes", .bool(true), forbiddenString: "Yes"), + SuccessCase("value: YES", .bool(true), forbiddenString: "YES"), + SuccessCase("value: on", .bool(true), forbiddenString: "on"), + SuccessCase("value: On", .bool(true), forbiddenString: "On"), + SuccessCase("value: ON", .bool(true), forbiddenString: "ON"), + SuccessCase("value: false", .bool(false), forbiddenString: "false"), + SuccessCase("value: False", .bool(false), forbiddenString: "False"), + SuccessCase("value: FALSE", .bool(false), forbiddenString: "FALSE"), + SuccessCase("value: no", .bool(false), forbiddenString: "no"), + SuccessCase("value: No", .bool(false), forbiddenString: "No"), + SuccessCase("value: NO", .bool(false), forbiddenString: "NO"), + SuccessCase("value: off", .bool(false), forbiddenString: "off"), + SuccessCase("value: Off", .bool(false), forbiddenString: "Off"), + SuccessCase("value: OFF", .bool(false), forbiddenString: "OFF"), + SuccessCase("value: 12345", .int(12345), forbiddenString: "12345"), + SuccessCase("value: +12345", .int(12345), forbiddenString: "+12345"), + SuccessCase("value: -7", .int(-7), forbiddenString: "-7"), + SuccessCase("value: 0o14", .int(12), forbiddenString: "0o14"), + SuccessCase("value: 014", .int(12), forbiddenString: "014"), + SuccessCase("value: 0xC", .int(12), forbiddenString: "0xC"), + SuccessCase("value: 0b1010", .int(10), forbiddenString: "0b1010"), + SuccessCase("value: 1_000", .int(1000), forbiddenString: "1_000"), + SuccessCase("value: 1.23015e+3", .double(1230.15), forbiddenString: "1.23015e+3"), + SuccessCase("value: 12.3015e+02", .double(1230.15), forbiddenString: "12.3015e+02"), + SuccessCase("value: 1230.15", .double(1230.15), forbiddenString: "1230.15"), + SuccessCase("value: 1_000.25", .double(1000.25), forbiddenString: "1_000.25"), + SuccessCase("value: -.inf", .double(-.infinity), forbiddenString: "-.inf"), + SuccessCase("value: +.INF", .double(.infinity), forbiddenString: "+.INF"), + SuccessCase("value: .NaN", .nan, forbiddenString: ".NaN"), + ] + + static let explicitScalarTags: [SuccessCase] = [ + SuccessCase("value: !!str true", .string("true")), + SuccessCase("value: ! 0xC", .string("0xC")), + SuccessCase("value: !!int 0xC", .int(12), forbiddenString: "0xC"), + SuccessCase("value: !!int \"1_000\"", .int(1000), forbiddenString: "1_000"), + SuccessCase("value: ! 0b1010", .int(10), forbiddenString: "0b1010"), + SuccessCase("value: !!float .NaN", .nan, forbiddenString: ".NaN"), + SuccessCase("value: !!float \"-.inf\"", .double(-.infinity), forbiddenString: "-.inf"), + SuccessCase("value: !!bool YES", .bool(true), forbiddenString: "YES"), + SuccessCase("value: !!bool \"off\"", .bool(false), forbiddenString: "off"), + SuccessCase("value: !!null ignored", .null, forbiddenString: "ignored"), + SuccessCase( + """ + %YAML 1.2 + %TAG !yaml! tag:yaml.org,2002: + --- + value: !yaml!bool On + """, + .bool(true), + forbiddenString: "On", + ), + ] + + static let stringScalars: [SuccessCase] = [ + SuccessCase("value: \"true\"", .string("true")), + SuccessCase("value: 'false'", .string("false")), + SuccessCase("value: \"0xC\"", .string("0xC")), + SuccessCase("value: '1_000'", .string("1_000")), + SuccessCase("value: !!str yes", .string("yes")), + SuccessCase("value: ! .nan", .string(".nan")), + ] + + static let unsupportedScalars: [SuccessCase] = [ + SuccessCase("value: 1:20", .string("1:20")), + SuccessCase("value: 2002-04-28", .string("2002-04-28")), + SuccessCase("value: 09", .string("09")), + ] + + static let invalidExplicitScalarTags: [ErrorCase] = [ + ErrorCase( + yaml: "value: !!int nope", + expected: .invalidTaggedScalar( + tag: "tag:yaml.org,2002:int", + value: "nope", + line: 1, + column: 8, + ), + ), + ErrorCase( + yaml: "value: !!int 0xG", + expected: .invalidTaggedScalar( + tag: "tag:yaml.org,2002:int", + value: "0xG", + line: 1, + column: 8, + ), + ), + ErrorCase( + yaml: "value: !!float nope", + expected: .invalidTaggedScalar( + tag: "tag:yaml.org,2002:float", + value: "nope", + line: 1, + column: 8, + ), + ), + ErrorCase( + yaml: "value: !!bool maybe", + expected: .invalidTaggedScalar( + tag: "tag:yaml.org,2002:bool", + value: "maybe", + line: 1, + column: 8, + ), + ), + ] +}