Add scalar compatibility corpus
This commit is contained in:
parent
29ecce451a
commit
efdce11a9a
@ -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.
|
||||
|
||||
21
README.md
21
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,
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
151
Tests/ScalarCompatibilityFixtures.swift
Normal file
151
Tests/ScalarCompatibilityFixtures.swift
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user