Add scalar compatibility corpus

This commit is contained in:
Mihaela Mihaljevic 2026-06-05 13:07:04 +02:00
parent 29ecce451a
commit efdce11a9a
6 changed files with 336 additions and 14 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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? {

View File

@ -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,

View File

@ -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))
}
}
}

View File

@ -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: !<tag:yaml.org,2002:str> 0xC", .string("0xC")),
SuccessCase("value: !!int 0xC", .int(12), forbiddenString: "0xC"),
SuccessCase("value: !!int \"1_000\"", .int(1000), forbiddenString: "1_000"),
SuccessCase("value: !<tag:yaml.org,2002:int> 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: !<tag:yaml.org,2002:str> .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,
),
),
]
}