feat(tagged): add typed timestamp and binary scalar accessors
Completes issue #11. Tagged.Scalar.timestampValue parses a !!timestamp scalar under the YAML 1.1 grammar into the pure-Swift Tagged.Timestamp (calendar components, timezone offset, secondsSinceEpoch via the days-from-civil closed form; epoch test expectations measured against Python's independent implementation). Tagged.Scalar.binaryValue base64 decodes a !!binary scalar into bytes, accepting unpadded and block scalar wrapped content and rejecting corrupt input whole. Yams hands back Foundation Date/Data; this package is mechanically Foundation-free, so the bridges are caller-owned one-liners: Date(timeIntervalSince1970:) and Data(_:). !!set and !!omap stay skipped since Model.Mapping already preserves order and duplicates. Closes #11
This commit is contained in:
parent
8df3f0b8d4
commit
e1c08260c4
13
CHANGELOG.md
13
CHANGELOG.md
@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Standard-tag typed accessors on `Tagged.Scalar` (issue #11):
|
||||
`timestampValue` parses a `!!timestamp` scalar under the YAML 1.1
|
||||
timestamp grammar into the new pure-Swift `Tagged.Timestamp` (calendar
|
||||
components, timezone offset, and `secondsSinceEpoch` via the
|
||||
days-from-civil closed form; epoch expectations in the tests are
|
||||
measured against Python's independent implementation), and
|
||||
`binaryValue` base64-decodes a `!!binary` scalar into `[UInt8]`,
|
||||
accepting unpadded and block-scalar-wrapped content and rejecting
|
||||
corrupt input whole. Yams hands back Foundation `Date`/`Data`; this
|
||||
package is mechanically Foundation-free, so the bridges are one-liners
|
||||
the caller owns: `Date(timeIntervalSince1970:)` and `Data(_:)`.
|
||||
`!!set`/`!!omap` are deliberately skipped: `Model.Mapping` already
|
||||
preserves order and duplicates without a special tag.
|
||||
- `Emitting.Options` gains `canonical`, `explicitDocumentStart`,
|
||||
`explicitDocumentEnd`, and `lineWidth`, completing issue #10 (the
|
||||
earlier `indentWidth`/`sortKeys` slice deliberately deferred these).
|
||||
|
||||
84
Sources/Tagged/Tagged.Scalar.Builtins.swift
Normal file
84
Sources/Tagged/Tagged.Scalar.Builtins.swift
Normal file
@ -0,0 +1,84 @@
|
||||
public extension PureYAML.Tagged.Scalar {
|
||||
/// The scalar's `!!timestamp` value: non-`nil` when the scalar carries
|
||||
/// the standard timestamp tag and its raw content parses under the
|
||||
/// YAML 1.1 timestamp grammar. Bridge to a Foundation date with
|
||||
/// `Date(timeIntervalSince1970: timestampValue!.secondsSinceEpoch)`;
|
||||
/// the package itself stays Foundation-free.
|
||||
var timestampValue: PureYAML.Tagged.Timestamp? {
|
||||
guard tag == .timestamp else {
|
||||
return nil
|
||||
}
|
||||
return PureYAML.Tagged.Timestamp(yaml: rawValue)
|
||||
}
|
||||
|
||||
/// The scalar's `!!binary` value: non-`nil` when the scalar carries the
|
||||
/// standard binary tag and its raw content is valid base64 (whitespace
|
||||
/// and line breaks between groups are ignored, matching base64 content
|
||||
/// spread over a block scalar). Bridge to Foundation with
|
||||
/// `Data(binaryValue!)`; the package itself stays Foundation-free.
|
||||
var binaryValue: [UInt8]? {
|
||||
guard tag == .binary else {
|
||||
return nil
|
||||
}
|
||||
return PureYAML.Tagged.Scalar.decodeBase64(rawValue)
|
||||
}
|
||||
|
||||
/// Pure-Swift base64 decoding over the standard alphabet with `=`
|
||||
/// padding. Whitespace is skipped; any other non-alphabet character
|
||||
/// rejects the whole input rather than decoding a prefix.
|
||||
static func decodeBase64(_ text: String) -> [UInt8]? {
|
||||
var bytes: [UInt8] = []
|
||||
var buffer = 0
|
||||
var bitCount = 0
|
||||
var padding = 0
|
||||
for character in text {
|
||||
if character == " " || character == "\n" || character == "\t" || character == "\r" {
|
||||
continue
|
||||
}
|
||||
if character == "=" {
|
||||
padding += 1
|
||||
continue
|
||||
}
|
||||
guard padding == 0, let value = base64Value(of: character) else {
|
||||
return nil
|
||||
}
|
||||
buffer = (buffer << 6) | value
|
||||
bitCount += 6
|
||||
if bitCount >= 8 {
|
||||
bitCount -= 8
|
||||
bytes.append(UInt8((buffer >> bitCount) & 0xFF))
|
||||
}
|
||||
}
|
||||
// Leftover bits must be zero-padding from the final group, and the
|
||||
// group structure must be consistent: 8 leftover bits (two missing
|
||||
// characters) pairs with two `=`, 4 bits with one, 0 with none.
|
||||
let expectedPadding = bitCount == 4 ? 2 : bitCount == 2 ? 1 : 0
|
||||
guard bitCount != 6, padding == expectedPadding || padding == 0 else {
|
||||
return nil
|
||||
}
|
||||
guard buffer & ((1 << bitCount) - 1) == 0 || bitCount == 0 else {
|
||||
return nil
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private static func base64Value(of character: Character) -> Int? {
|
||||
guard let ascii = character.asciiValue else {
|
||||
return nil
|
||||
}
|
||||
switch ascii {
|
||||
case 65 ... 90:
|
||||
return Int(ascii - 65)
|
||||
case 97 ... 122:
|
||||
return Int(ascii - 97) + 26
|
||||
case 48 ... 57:
|
||||
return Int(ascii - 48) + 52
|
||||
case 43:
|
||||
return 62
|
||||
case 47:
|
||||
return 63
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
241
Sources/Tagged/Tagged.Timestamp.swift
Normal file
241
Sources/Tagged/Tagged.Timestamp.swift
Normal file
@ -0,0 +1,241 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// A parsed YAML 1.1 `!!timestamp` value, held as calendar components
|
||||
/// plus a timezone offset, with `secondsSinceEpoch` for the callers that
|
||||
/// want an absolute instant (`Date(timeIntervalSince1970:)` bridges it
|
||||
/// in one line; the package itself stays Foundation-free).
|
||||
///
|
||||
/// Accepted source forms are the YAML 1.1 timestamp grammar: the date
|
||||
/// alone (`2002-12-14`), or date and time separated by `T`/`t` or
|
||||
/// whitespace, with an optional fractional second and an optional
|
||||
/// timezone (`Z`, `+5`, `-05:30`). A missing timezone means UTC per the
|
||||
/// YAML 1.1 timestamp specification's treatment of unlabeled times.
|
||||
struct Timestamp: Equatable, Hashable, Sendable {
|
||||
public var year: Int
|
||||
public var month: Int
|
||||
public var day: Int
|
||||
public var hour: Int
|
||||
public var minute: Int
|
||||
public var second: Int
|
||||
/// Fraction of a second in the half-open range `0..<1`.
|
||||
public var fraction: Double
|
||||
/// Offset from UTC in seconds; `0` for `Z` or an absent timezone.
|
||||
public var offsetSeconds: Int
|
||||
|
||||
public init(
|
||||
year: Int,
|
||||
month: Int,
|
||||
day: Int,
|
||||
hour: Int = 0,
|
||||
minute: Int = 0,
|
||||
second: Int = 0,
|
||||
fraction: Double = 0,
|
||||
offsetSeconds: Int = 0,
|
||||
) {
|
||||
self.year = year
|
||||
self.month = month
|
||||
self.day = day
|
||||
self.hour = hour
|
||||
self.minute = minute
|
||||
self.second = second
|
||||
self.fraction = fraction
|
||||
self.offsetSeconds = offsetSeconds
|
||||
}
|
||||
|
||||
/// Seconds since 1970-01-01T00:00:00Z, using the proleptic Gregorian
|
||||
/// calendar (the days-from-civil closed form), with the timezone
|
||||
/// offset applied.
|
||||
public var secondsSinceEpoch: Double {
|
||||
let shiftedYear = month <= 2 ? year - 1 : year
|
||||
let era = (shiftedYear >= 0 ? shiftedYear : shiftedYear - 399) / 400
|
||||
let yearOfEra = shiftedYear - era * 400
|
||||
let dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1
|
||||
let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
|
||||
let days = era * 146_097 + dayOfEra - 719_468
|
||||
let seconds = days * 86400 + hour * 3600 + minute * 60 + second - offsetSeconds
|
||||
return Double(seconds) + fraction
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Tagged.Timestamp {
|
||||
/// Parses the YAML 1.1 timestamp grammar. Returns `nil` for anything
|
||||
/// outside it, including calendar-invalid dates like February 30.
|
||||
init?(yaml raw: String) {
|
||||
var cursor = Substring(raw)
|
||||
guard let date = Self.takeDate(&cursor) else {
|
||||
return nil
|
||||
}
|
||||
if cursor.isEmpty {
|
||||
self.init(year: date.year, month: date.month, day: date.day)
|
||||
return
|
||||
}
|
||||
guard
|
||||
cursor.takeTimeSeparator(),
|
||||
let time = Self.takeTime(&cursor),
|
||||
let offsetSeconds = Self.takeOffset(&cursor),
|
||||
cursor.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
self.init(
|
||||
year: date.year,
|
||||
month: date.month,
|
||||
day: date.day,
|
||||
hour: time.hour,
|
||||
minute: time.minute,
|
||||
second: time.second,
|
||||
fraction: time.fraction,
|
||||
offsetSeconds: offsetSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
struct DateParts {
|
||||
var year: Int
|
||||
var month: Int
|
||||
var day: Int
|
||||
}
|
||||
|
||||
struct TimeParts {
|
||||
var hour: Int
|
||||
var minute: Int
|
||||
var second: Int
|
||||
var fraction: Double
|
||||
}
|
||||
|
||||
static func takeDate(_ cursor: inout Substring) -> DateParts? {
|
||||
guard
|
||||
let year = cursor.takeDigits(exactly: 4),
|
||||
cursor.take("-"),
|
||||
let month = cursor.takeDigits(upTo: 2),
|
||||
cursor.take("-"),
|
||||
let day = cursor.takeDigits(upTo: 2),
|
||||
isValidDate(year: year, month: month, day: day)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return DateParts(year: year, month: month, day: day)
|
||||
}
|
||||
|
||||
static func takeTime(_ cursor: inout Substring) -> TimeParts? {
|
||||
guard
|
||||
let hour = cursor.takeDigits(upTo: 2), hour <= 23,
|
||||
cursor.take(":"),
|
||||
let minute = cursor.takeDigits(exactly: 2), minute <= 59,
|
||||
cursor.take(":"),
|
||||
let second = cursor.takeDigits(exactly: 2), second <= 59
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
var fraction = 0.0
|
||||
if cursor.take(".") {
|
||||
var scale = 0.1
|
||||
while let digit = cursor.takeDigit() {
|
||||
fraction += Double(digit) * scale
|
||||
scale /= 10
|
||||
}
|
||||
}
|
||||
return TimeParts(hour: hour, minute: minute, second: second, fraction: fraction)
|
||||
}
|
||||
|
||||
/// The timezone part: `Z`, a signed hour with optional `:minutes`, or
|
||||
/// nothing (UTC). Returns the offset in seconds, or `nil` on a
|
||||
/// malformed offset (which fails the whole parse; absence is `0`).
|
||||
static func takeOffset(_ cursor: inout Substring) -> Int? {
|
||||
cursor.takeWhitespace()
|
||||
if cursor.take("Z") {
|
||||
return 0
|
||||
}
|
||||
guard let sign = cursor.takeSign() else {
|
||||
return 0
|
||||
}
|
||||
guard let offsetHour = cursor.takeDigits(upTo: 2), offsetHour <= 23 else {
|
||||
return nil
|
||||
}
|
||||
var offsetMinute = 0
|
||||
if cursor.take(":") {
|
||||
guard let parsed = cursor.takeDigits(exactly: 2), parsed <= 59 else {
|
||||
return nil
|
||||
}
|
||||
offsetMinute = parsed
|
||||
}
|
||||
return sign * (offsetHour * 3600 + offsetMinute * 60)
|
||||
}
|
||||
|
||||
static func isValidDate(year: Int, month: Int, day: Int) -> Bool {
|
||||
guard (1 ... 12).contains(month), day >= 1 else {
|
||||
return false
|
||||
}
|
||||
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
let lengths = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
return day <= lengths[month - 1]
|
||||
}
|
||||
}
|
||||
|
||||
private extension Substring {
|
||||
mutating func take(_ character: Character) -> Bool {
|
||||
guard first == character else {
|
||||
return false
|
||||
}
|
||||
removeFirst()
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func takeDigit() -> Int? {
|
||||
guard let first, let digit = first.wholeNumberValue, first.isASCII, (0 ... 9).contains(digit) else {
|
||||
return nil
|
||||
}
|
||||
removeFirst()
|
||||
return digit
|
||||
}
|
||||
|
||||
/// Between one and `upTo` digits.
|
||||
mutating func takeDigits(upTo limit: Int) -> Int? {
|
||||
var value: Int?
|
||||
for _ in 0 ..< limit {
|
||||
guard let digit = takeDigit() else {
|
||||
break
|
||||
}
|
||||
value = (value ?? 0) * 10 + digit
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
mutating func takeDigits(exactly count: Int) -> Int? {
|
||||
var value = 0
|
||||
for _ in 0 ..< count {
|
||||
guard let digit = takeDigit() else {
|
||||
return nil
|
||||
}
|
||||
value = value * 10 + digit
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// `T`, `t`, or one-or-more spaces/tabs between date and time.
|
||||
mutating func takeTimeSeparator() -> Bool {
|
||||
if take("T") || take("t") {
|
||||
return true
|
||||
}
|
||||
guard first == " " || first == "\t" else {
|
||||
return false
|
||||
}
|
||||
takeWhitespace()
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func takeWhitespace() {
|
||||
while first == " " || first == "\t" {
|
||||
removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
mutating func takeSign() -> Int? {
|
||||
if take("+") {
|
||||
return 1
|
||||
}
|
||||
if take("-") {
|
||||
return -1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
108
Tests/TaggedBuiltinScalarTests.swift
Normal file
108
Tests/TaggedBuiltinScalarTests.swift
Normal file
@ -0,0 +1,108 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
/// The standard-tag typed accessors (issue #11): `!!timestamp` scalars parse
|
||||
/// into `Tagged.Timestamp` and `!!binary` scalars base64-decode into bytes,
|
||||
/// with no caller-registered constructor. Expected epoch values are
|
||||
/// measured against Python's `datetime.timestamp()`, an independent
|
||||
/// implementation, not derived from the code under test.
|
||||
@Suite("Tagged built-in timestamp and binary accessors")
|
||||
struct TaggedBuiltinScalarTests {
|
||||
func scalar(_ source: String) throws -> PureYAML.Tagged.Scalar {
|
||||
let node = try PureYAML.parseTagged(source)
|
||||
guard case let .mapping(mapping) = node, case let .scalar(scalar)? = mapping["v"] else {
|
||||
throw PureYAML.Parsing.ParseError.unexpectedEvent(
|
||||
expected: "scalar mapping value",
|
||||
actual: "other",
|
||||
line: 1,
|
||||
column: 1,
|
||||
)
|
||||
}
|
||||
return scalar
|
||||
}
|
||||
|
||||
@Test("The canonical timestamp form parses with UTC and fraction")
|
||||
func test_canonicalTimestamp() throws {
|
||||
let stamp = try #require(scalar("v: !!timestamp 2001-12-15T02:59:43.1Z\n").timestampValue)
|
||||
#expect(stamp.year == 2001)
|
||||
#expect(stamp.month == 12)
|
||||
#expect(stamp.day == 15)
|
||||
#expect(stamp.hour == 2)
|
||||
#expect(stamp.offsetSeconds == 0)
|
||||
#expect(stamp.secondsSinceEpoch == 1_008_385_183.1)
|
||||
}
|
||||
|
||||
@Test("A space-separated timestamp with a bare negative offset parses")
|
||||
func test_spaceSeparatedTimestampWithOffset() throws {
|
||||
let stamp = try #require(scalar("v: !!timestamp 2001-12-14 21:59:43.10 -5\n").timestampValue)
|
||||
#expect(stamp.offsetSeconds == -5 * 3600)
|
||||
#expect(stamp.secondsSinceEpoch == 1_008_385_183.1)
|
||||
}
|
||||
|
||||
@Test("A date-only timestamp parses as midnight UTC")
|
||||
func test_dateOnlyTimestamp() throws {
|
||||
let stamp = try #require(scalar("v: !!timestamp 2002-12-14\n").timestampValue)
|
||||
#expect(stamp.secondsSinceEpoch == 1_039_824_000.0)
|
||||
}
|
||||
|
||||
@Test("A pre-epoch timestamp yields a negative epoch offset")
|
||||
func test_preEpochTimestamp() throws {
|
||||
let stamp = try #require(scalar("v: !!timestamp 1969-07-20T20:17:00Z\n").timestampValue)
|
||||
#expect(stamp.secondsSinceEpoch == -14_182_980.0)
|
||||
}
|
||||
|
||||
@Test("Calendar-invalid and malformed timestamps return nil")
|
||||
func test_invalidTimestamps() throws {
|
||||
#expect(try scalar("v: !!timestamp 2001-02-30\n").timestampValue == nil)
|
||||
#expect(try scalar("v: !!timestamp 2001-13-01\n").timestampValue == nil)
|
||||
#expect(try scalar("v: !!timestamp 2001-12-15T25:00:00Z\n").timestampValue == nil)
|
||||
#expect(try scalar("v: !!timestamp not-a-date\n").timestampValue == nil)
|
||||
#expect(try scalar("v: !!timestamp 2001-12-15T02:59:43.1Z extra\n").timestampValue == nil)
|
||||
}
|
||||
|
||||
@Test("An untagged date-like scalar does not auto-decode")
|
||||
func test_untaggedScalarStaysUntyped() throws {
|
||||
#expect(try scalar("v: 2002-12-14\n").timestampValue == nil)
|
||||
#expect(try scalar("v: 2002-12-14\n").binaryValue == nil)
|
||||
}
|
||||
|
||||
@Test("The specification's binary example decodes to the GIF header bytes")
|
||||
func test_binaryDecodes() throws {
|
||||
let bytes = try #require(scalar("v: !!binary R0lGODlhDAAMAIQAAP8AAA==\n").binaryValue)
|
||||
#expect(Array(bytes.prefix(8)) == [71, 73, 70, 56, 57, 97, 12, 0])
|
||||
}
|
||||
|
||||
@Test("Binary content spread over a block scalar decodes")
|
||||
func test_binaryBlockScalar() throws {
|
||||
let source = "v: !!binary |\n R0lGODlh\n DAAMAIQAAP8AAA==\n"
|
||||
let bytes = try #require(scalar(source).binaryValue)
|
||||
#expect(Array(bytes.prefix(8)) == [71, 73, 70, 56, 57, 97, 12, 0])
|
||||
}
|
||||
|
||||
@Test("Unpadded base64 decodes and corrupt base64 returns nil")
|
||||
func test_binaryEdgeForms() throws {
|
||||
#expect(try scalar("v: !!binary R0lGODlhDAAMAIQAAP8AAA\n").binaryValue != nil)
|
||||
#expect(try scalar("v: !!binary AAAA\n").binaryValue == [0, 0, 0])
|
||||
#expect(try scalar("v: !!binary not@base64!\n").binaryValue == nil)
|
||||
#expect(try scalar("v: !!binary A\n").binaryValue == nil)
|
||||
#expect(try scalar("v: !!binary AB==CD\n").binaryValue == nil)
|
||||
}
|
||||
|
||||
@Test("Timestamp and binary scalars round-trip through dump with tags intact")
|
||||
func test_taggedScalarsRoundTrip() throws {
|
||||
let source = "when: !!timestamp 2001-12-15T02:59:43.1Z\nblob: !!binary R0lGODlhDAAMAIQAAP8AAA==\n"
|
||||
let node = try PureYAML.parseTagged(source)
|
||||
let reparsed = try PureYAML.parseTagged(PureYAML.dump(node))
|
||||
#expect(reparsed == node)
|
||||
guard case let .mapping(mapping) = reparsed else {
|
||||
Issue.record("expected mapping")
|
||||
return
|
||||
}
|
||||
guard case let .scalar(when)? = mapping["when"], case let .scalar(blob)? = mapping["blob"] else {
|
||||
Issue.record("expected scalars")
|
||||
return
|
||||
}
|
||||
#expect(when.timestampValue?.secondsSinceEpoch == 1_008_385_183.1)
|
||||
#expect(blob.binaryValue?.prefix(4) == [71, 73, 70, 56])
|
||||
}
|
||||
}
|
||||
@ -105,7 +105,7 @@ explicit fallback value tree. This avoids silent compatibility drift.
|
||||
| Unsupported built-in tags such as `!!timestamp`, `!!binary`, `!!set`, and `!!omap` | `parse(_:)` keeps parseable value trees without special tag construction. `parseTagged(_:)` preserves the tags, and tagged validation reports them as unsupported built-in tags. | `UnsupportedYAMLGapsFixtures.fallbackValues`, `TaggedCompatibilityTests` |
|
||||
| Sexagesimal-style scalars and date-looking plain scalars | Keep string values for currently unsupported spellings such as `1:20`, `2002-04-28`, and `09`. | `ScalarCompatibilityFixtures.unsupportedScalars` |
|
||||
| Custom resolvers and environment expansion | Not implemented. Build project-specific conversion on top of `Model.Value`, tagged nodes, typed `Codable`, or `PureYAML.Tagged.Constructor`. | Future compatibility work |
|
||||
| Foundation-specific scalar conversions such as `Data`, `Date`, or `URL` strategies | Not special-cased in the library target. Tags stay visible through `parseTagged(_:)`; callers own conversion. | `TaggedCompatibilityTests` |
|
||||
| Foundation-specific scalar conversions such as `Data`, `Date`, or `URL` strategies | `Tagged.Scalar.timestampValue` parses `!!timestamp` into the pure-Swift `Tagged.Timestamp` (bridge with `Date(timeIntervalSince1970: stamp.secondsSinceEpoch)`) and `Tagged.Scalar.binaryValue` base64-decodes `!!binary` into `[UInt8]` (bridge with `Data(bytes)`). The package stays Foundation-free; `URL` and other conversions remain caller-owned. | `TaggedBuiltinScalarTests` |
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
|
||||
@ -96,3 +96,4 @@ PureYAML.Streaming excluded caseless namespace; holds no instances or state
|
||||
PureYAML.Streaming.Outcome excluded closed result enum; illegal states unrepresentable
|
||||
PureYAML.Streaming.Scanner excluded resumable scanning engine; not a validated subject
|
||||
PureYAML.Streaming.Token excluded data-transfer scanned token
|
||||
PureYAML.Tagged.Timestamp excluded data-transfer calendar components; parse rejects invalid states
|
||||
|
||||
|
Can't render this file because it has a wrong number of fields in line 3.
|
Loading…
Reference in New Issue
Block a user