diff --git a/Sources/Parsing/LibYAML.CharacterClass.swift b/Sources/Parsing/LibYAML.CharacterClass.swift new file mode 100644 index 0000000..77153de --- /dev/null +++ b/Sources/Parsing/LibYAML.CharacterClass.swift @@ -0,0 +1,94 @@ +extension PureYAML.Parsing.LibYAML { + /// Port of libyaml's character-class macros (`IS_*` in `yaml_private.h`). + /// libyaml inspects UTF-8 bytes; this operates on `Unicode.Scalar`, where the + /// multi-byte line breaks NEL (`U+0085`), LS (`U+2028`), and PS (`U+2029`) are + /// single scalars. End of input is `nil`, which is libyaml's `\0` sentinel, so + /// `isZ(nil)` is true and every other predicate is false for `nil`. + enum CharacterClass { + /// `IS_Z`: the end of the input (libyaml's `\0`). + static func isZ(_ scalar: Unicode.Scalar?) -> Bool { + scalar == nil + } + + /// `IS_SPACE`. + static func isSpace(_ scalar: Unicode.Scalar?) -> Bool { + scalar == " " + } + + /// `IS_TAB`. + static func isTab(_ scalar: Unicode.Scalar?) -> Bool { + scalar == "\t" + } + + /// `IS_BLANK`: space or tab. + static func isBlank(_ scalar: Unicode.Scalar?) -> Bool { + isSpace(scalar) || isTab(scalar) + } + + /// `IS_BREAK`: CR, LF, NEL, LS, or PS. + static func isBreak(_ scalar: Unicode.Scalar?) -> Bool { + switch scalar { + case "\r", "\n", "\u{85}", "\u{2028}", "\u{2029}": true + default: false + } + } + + /// `IS_BREAKZ`: a line break or the end of input. + static func isBreakZ(_ scalar: Unicode.Scalar?) -> Bool { + isBreak(scalar) || isZ(scalar) + } + + /// `IS_SPACEZ`: a space, a line break, or the end of input. + static func isSpaceZ(_ scalar: Unicode.Scalar?) -> Bool { + isSpace(scalar) || isBreakZ(scalar) + } + + /// `IS_BLANKZ`: blank (space or tab), a line break, or the end of input. + static func isBlankZ(_ scalar: Unicode.Scalar?) -> Bool { + isBlank(scalar) || isBreakZ(scalar) + } + + /// `IS_DIGIT`. + static func isDigit(_ scalar: Unicode.Scalar?) -> Bool { + guard let scalar else { return false } + return scalar >= "0" && scalar <= "9" + } + + /// `IS_ALPHA`: a digit, ASCII letter, `_`, or `-` (the directive, anchor, + /// and tag-handle name characters). + static func isAlpha(_ scalar: Unicode.Scalar?) -> Bool { + guard let scalar else { return false } + return (scalar >= "0" && scalar <= "9") + || (scalar >= "A" && scalar <= "Z") + || (scalar >= "a" && scalar <= "z") + || scalar == "_" + || scalar == "-" + } + + /// `IS_HEX`: a hexadecimal digit. + static func isHex(_ scalar: Unicode.Scalar?) -> Bool { + guard let scalar else { return false } + return (scalar >= "0" && scalar <= "9") + || (scalar >= "A" && scalar <= "F") + || (scalar >= "a" && scalar <= "f") + } + + /// `IS_ASCII`. + static func isASCII(_ scalar: Unicode.Scalar?) -> Bool { + guard let scalar else { return false } + return scalar.value <= 0x7F + } + + /// `IS_PRINTABLE`: the YAML `c-printable` set. + static func isPrintable(_ scalar: Unicode.Scalar?) -> Bool { + guard let scalar else { return false } + let value = scalar.value + return value == 0x09 || value == 0x0A || value == 0x0D + || (value >= 0x20 && value <= 0x7E) + || value == 0x85 + || (value >= 0xA0 && value <= 0xD7FF) + || (value >= 0xE000 && value <= 0xFFFD) + || (value >= 0x10000 && value <= 0x10FFFF) + } + } +} diff --git a/Tests/LibYAMLCharacterClassTests.swift b/Tests/LibYAMLCharacterClassTests.swift new file mode 100644 index 0000000..6f08c19 --- /dev/null +++ b/Tests/LibYAMLCharacterClassTests.swift @@ -0,0 +1,65 @@ +@testable import PureYAML +import Testing + +/// Verifies the ported character-class predicates match libyaml's `IS_*` macros, +/// including end-of-input as `nil` and the multi-byte line breaks. +@Suite("LibYAML Character Class") +struct LibYAMLCharacterClassTests { + private typealias Classify = PureYAML.Parsing.LibYAML.CharacterClass + + @Test("end of input is Z and a break, nothing else") + func test_endOfInput() { + #expect(Classify.isZ(nil)) + #expect(Classify.isBreakZ(nil)) + #expect(Classify.isBlankZ(nil)) + #expect(!Classify.isBreak(nil)) + #expect(!Classify.isBlank(nil)) + #expect(!Classify.isAlpha(nil)) + } + + @Test("blanks are space and tab only") + func test_blank() { + #expect(Classify.isSpace(" ")) + #expect(Classify.isTab("\t")) + #expect(Classify.isBlank(" ")) + #expect(Classify.isBlank("\t")) + #expect(!Classify.isBlank("\n")) + #expect(!Classify.isBlank("x")) + } + + @Test("line breaks include CR, LF, NEL, LS, PS") + func test_break() { + for scalar in ["\r", "\n", "\u{85}", "\u{2028}", "\u{2029}"] as [Unicode.Scalar] { + #expect(Classify.isBreak(scalar)) + #expect(Classify.isBreakZ(scalar)) + #expect(Classify.isBlankZ(scalar)) + } + #expect(!Classify.isBreak(" ")) + #expect(!Classify.isBreak("x")) + } + + @Test("alpha is alnum plus underscore and hyphen") + func test_alpha() { + for scalar in ["0", "9", "A", "Z", "a", "z", "_", "-"] as [Unicode.Scalar] { + #expect(Classify.isAlpha(scalar)) + } + #expect(!Classify.isAlpha(".")) + #expect(!Classify.isAlpha(" ")) + #expect(Classify.isDigit("5")) + #expect(!Classify.isDigit("a")) + #expect(Classify.isHex("f")) + #expect(Classify.isHex("9")) + #expect(!Classify.isHex("g")) + } + + @Test("ascii and printable boundaries") + func test_asciiPrintable() { + #expect(Classify.isASCII("\u{7F}")) + #expect(!Classify.isASCII("\u{80}")) + #expect(Classify.isPrintable("\t")) + #expect(Classify.isPrintable("A")) + #expect(Classify.isPrintable("\u{85}")) + #expect(!Classify.isPrintable("\u{0}")) + #expect(!Classify.isPrintable("\u{1F}")) + } +}