Merge epic/30-regex: XML Schema regular-expression engine
This commit is contained in:
commit
2bc7dd2410
@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Regular-expression engine (#30, the XML Schema regex flavor, gating XSD). The
|
||||
new `PureXML.Regex` namespace compiles a pattern (literals, `.`, character
|
||||
classes with ranges and negation, the escapes `\d \D \w \W \s \S \i \I \c \C`,
|
||||
single-character escapes, grouping, alternation, and the quantifiers `?`, `*`,
|
||||
`+`, `{n}`, `{n,}`, `{n,m}`) to a Thompson NFA and matches the whole string, as
|
||||
`xs:pattern` facets require. Matching is linear in the input with no
|
||||
backtracking blow-up. `\p{...}` Unicode category escapes are not yet supported.
|
||||
- Canonical XML (#26, the libxml2 `c14n.h` model). The new `PureXML.Canonical`
|
||||
namespace renders the C14N form of a node: namespace declarations sorted by
|
||||
prefix (default first) and attributes by namespace URI then local name, empty
|
||||
|
||||
@ -90,7 +90,7 @@ flowchart TB
|
||||
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
|
||||
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
|
||||
Querying["Querying: #21 XPath, #22 xmlPattern, #23 XPointer (shipped)"]:::done
|
||||
Regexp["#30 Regular expression engine"]:::epic
|
||||
Regexp["#30 Regular expression engine"]:::done
|
||||
Xsd["#2 XSD / RELAX NG validation"]:::epic
|
||||
Schematron["#25 Schematron validation"]:::done
|
||||
Regexp --> Xsd
|
||||
|
||||
@ -39,4 +39,7 @@ public enum PureXML {
|
||||
|
||||
/// Canonical XML (C14N) serialization types.
|
||||
public enum Canonical {}
|
||||
|
||||
/// Regular-expression types (the XML Schema regex flavor).
|
||||
public enum Regex {}
|
||||
}
|
||||
|
||||
173
Sources/Regex/RegexAutomaton.swift
Normal file
173
Sources/Regex/RegexAutomaton.swift
Normal file
@ -0,0 +1,173 @@
|
||||
extension PureXML.Regex {
|
||||
/// One NFA state: epsilon transitions and at most one character-class
|
||||
/// transition (Thompson construction).
|
||||
struct NFAState: Sendable {
|
||||
var epsilon: [Int] = []
|
||||
var transition: CharTransition?
|
||||
}
|
||||
|
||||
/// A character-consuming transition to another state.
|
||||
struct CharTransition: Sendable {
|
||||
let charClass: CharClass
|
||||
let target: Int
|
||||
}
|
||||
|
||||
/// A compiled non-deterministic finite automaton.
|
||||
struct NFA: Sendable {
|
||||
var states: [NFAState]
|
||||
var start: Int
|
||||
var accept: Int
|
||||
|
||||
/// Whether the automaton matches the whole string (XSD patterns are
|
||||
/// implicitly anchored at both ends).
|
||||
func matchesWhole(_ string: String) -> Bool {
|
||||
var current = closure([start])
|
||||
for character in string {
|
||||
var next: Set<Int> = []
|
||||
for state in current {
|
||||
if let transition = states[state].transition, transition.charClass.matches(character) {
|
||||
next.insert(transition.target)
|
||||
}
|
||||
}
|
||||
if next.isEmpty { return false }
|
||||
current = closure(next)
|
||||
}
|
||||
return current.contains(accept)
|
||||
}
|
||||
|
||||
private func closure(_ seed: Set<Int>) -> Set<Int> {
|
||||
var seen = seed
|
||||
var stack = Array(seed)
|
||||
while let state = stack.popLast() {
|
||||
for next in states[state].epsilon where seen.insert(next).inserted {
|
||||
stack.append(next)
|
||||
}
|
||||
}
|
||||
return seen
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an ``NFA`` from a ``Node`` by Thompson construction.
|
||||
struct NFABuilder {
|
||||
private var states: [NFAState] = []
|
||||
|
||||
static func build(_ node: Node) -> NFA {
|
||||
var builder = NFABuilder()
|
||||
let fragment = builder.fragment(for: node)
|
||||
return NFA(states: builder.states, start: fragment.start, accept: fragment.accept)
|
||||
}
|
||||
|
||||
private mutating func addState() -> Int {
|
||||
states.append(NFAState())
|
||||
return states.count - 1
|
||||
}
|
||||
|
||||
private mutating func fragment(for node: Node) -> (start: Int, accept: Int) {
|
||||
switch node {
|
||||
case .empty:
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
states[start].epsilon.append(accept)
|
||||
return (start, accept)
|
||||
case let .characters(charClass):
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
states[start].transition = CharTransition(charClass: charClass, target: accept)
|
||||
return (start, accept)
|
||||
case let .concat(nodes):
|
||||
return concatenate(nodes)
|
||||
case let .alternate(nodes):
|
||||
return alternate(nodes)
|
||||
case let .repeated(inner, minimum, maximum):
|
||||
return repeatedFragment(inner, minimum: minimum, maximum: maximum)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func concatenate(_ nodes: [Node]) -> (start: Int, accept: Int) {
|
||||
guard !nodes.isEmpty else { return fragment(for: .empty) }
|
||||
var first: Int?
|
||||
var last: Int?
|
||||
for node in nodes {
|
||||
let part = fragment(for: node)
|
||||
if let previous = last { states[previous].epsilon.append(part.start) } else { first = part.start }
|
||||
last = part.accept
|
||||
}
|
||||
return (first ?? addState(), last ?? addState())
|
||||
}
|
||||
|
||||
private mutating func alternate(_ nodes: [Node]) -> (start: Int, accept: Int) {
|
||||
let start = addState()
|
||||
let accept = addState()
|
||||
for node in nodes {
|
||||
let part = fragment(for: node)
|
||||
states[start].epsilon.append(part.start)
|
||||
states[part.accept].epsilon.append(accept)
|
||||
}
|
||||
return (start, accept)
|
||||
}
|
||||
|
||||
private mutating func repeatedFragment(_ node: Node, minimum: Int, maximum: Int?) -> (start: Int, accept: Int) {
|
||||
let start = addState()
|
||||
var current = start
|
||||
for _ in 0 ..< minimum {
|
||||
let part = fragment(for: node)
|
||||
states[current].epsilon.append(part.start)
|
||||
current = part.accept
|
||||
}
|
||||
let accept = addState()
|
||||
if let maximum {
|
||||
appendOptional(node, count: maximum - minimum, from: current, to: accept)
|
||||
} else {
|
||||
appendStar(node, from: current, to: accept)
|
||||
}
|
||||
return (start, accept)
|
||||
}
|
||||
|
||||
private mutating func appendOptional(_ node: Node, count: Int, from: Int, to accept: Int) {
|
||||
var current = from
|
||||
for _ in 0 ..< max(0, count) {
|
||||
let part = fragment(for: node)
|
||||
states[current].epsilon.append(part.start)
|
||||
states[current].epsilon.append(accept)
|
||||
current = part.accept
|
||||
}
|
||||
states[current].epsilon.append(accept)
|
||||
}
|
||||
|
||||
private mutating func appendStar(_ node: Node, from current: Int, to accept: Int) {
|
||||
let part = fragment(for: node)
|
||||
states[current].epsilon.append(part.start)
|
||||
states[current].epsilon.append(accept)
|
||||
states[part.accept].epsilon.append(part.start)
|
||||
states[part.accept].epsilon.append(accept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension PureXML.Regex {
|
||||
/// A compiled regular expression in the XML Schema flavor: literals, `.`, the
|
||||
/// character-class escapes (`\d \D \w \W \s \S \i \I \c \C`), single-character
|
||||
/// escapes, character classes with ranges and negation, grouping, alternation
|
||||
/// (`|`), and the quantifiers `?`, `*`, `+`, `{n}`, `{n,}`, `{n,m}`. Matching
|
||||
/// is whole-string (anchored), as XSD `pattern` facets require. Built on a
|
||||
/// Thompson NFA, so it runs in time linear in the input with no backtracking
|
||||
/// blow-up. Unicode category escapes (`\p{...}`) are not yet supported.
|
||||
struct Pattern: Sendable {
|
||||
private let nfa: NFA
|
||||
|
||||
/// Compiles a pattern.
|
||||
public init(_ pattern: String) throws {
|
||||
nfa = try NFABuilder.build(RegexParser.parse(pattern))
|
||||
}
|
||||
|
||||
/// Whether `string` matches the pattern in full.
|
||||
public func matches(_ string: String) -> Bool {
|
||||
nfa.matchesWhole(string)
|
||||
}
|
||||
}
|
||||
|
||||
/// Compiles and matches in one step.
|
||||
static func matches(_ pattern: String, _ string: String) throws -> Bool {
|
||||
try Pattern(pattern).matches(string)
|
||||
}
|
||||
}
|
||||
228
Sources/Regex/RegexParser.swift
Normal file
228
Sources/Regex/RegexParser.swift
Normal file
@ -0,0 +1,228 @@
|
||||
/// One member of a character class: a single scalar (a possible range endpoint)
|
||||
/// or a named sub-class. File-scope and private.
|
||||
private enum ClassMember {
|
||||
case scalar(Unicode.Scalar)
|
||||
case named(PureXML.Regex.NamedClass)
|
||||
case negatedNamed(PureXML.Regex.NamedClass)
|
||||
}
|
||||
|
||||
extension PureXML.Regex {
|
||||
/// Recursive-descent parser for the XML Schema regular-expression grammar,
|
||||
/// producing a ``Node`` tree. Anchoring is implicit (the whole string is
|
||||
/// matched), so `^` and `$` are ordinary characters.
|
||||
struct RegexParser {
|
||||
private let chars: [Character]
|
||||
private var index = 0
|
||||
|
||||
private init(_ pattern: String) {
|
||||
chars = Array(pattern)
|
||||
}
|
||||
|
||||
static func parse(_ pattern: String) throws -> Node {
|
||||
guard !pattern.isEmpty else { throw RegexError.empty }
|
||||
var parser = RegexParser(pattern)
|
||||
let node = try parser.parseAlternation()
|
||||
guard parser.isAtEnd else { throw RegexError.unbalanced }
|
||||
return node
|
||||
}
|
||||
|
||||
private mutating func parseAlternation() throws -> Node {
|
||||
var branches = try [parseBranch()]
|
||||
while peek() == "|" {
|
||||
advance()
|
||||
try branches.append(parseBranch())
|
||||
}
|
||||
return branches.count == 1 ? branches[0] : .alternate(branches)
|
||||
}
|
||||
|
||||
private mutating func parseBranch() throws -> Node {
|
||||
var pieces: [Node] = []
|
||||
while let character = peek(), character != "|", character != ")" {
|
||||
try pieces.append(parsePiece())
|
||||
}
|
||||
if pieces.isEmpty { return .empty }
|
||||
return pieces.count == 1 ? pieces[0] : .concat(pieces)
|
||||
}
|
||||
|
||||
private mutating func parsePiece() throws -> Node {
|
||||
if let character = peek(), "?*+".contains(character) || character == "{" {
|
||||
throw RegexError.danglingQuantifier
|
||||
}
|
||||
let atom = try parseAtom()
|
||||
return try applyQuantifier(to: atom)
|
||||
}
|
||||
|
||||
private mutating func applyQuantifier(to atom: Node) throws -> Node {
|
||||
switch peek() {
|
||||
case "?": advance()
|
||||
return .repeated(atom, min: 0, max: 1)
|
||||
case "*": advance()
|
||||
return .repeated(atom, min: 0, max: nil)
|
||||
case "+": advance()
|
||||
return .repeated(atom, min: 1, max: nil)
|
||||
case "{": return try parseBoundedQuantifier(atom)
|
||||
default: return atom
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func parseBoundedQuantifier(_ atom: Node) throws -> Node {
|
||||
advance()
|
||||
let minimum = parseInteger()
|
||||
var maximum: Int? = minimum
|
||||
if peek() == "," {
|
||||
advance()
|
||||
maximum = peek() == "}" ? nil : parseInteger()
|
||||
}
|
||||
guard peek() == "}", let minimum else { throw RegexError.badQuantifier }
|
||||
advance()
|
||||
return .repeated(atom, min: minimum, max: maximum)
|
||||
}
|
||||
|
||||
private mutating func parseAtom() throws -> Node {
|
||||
guard let character = peek() else { throw RegexError.danglingQuantifier }
|
||||
switch character {
|
||||
case "(":
|
||||
advance()
|
||||
let inner = try parseAlternation()
|
||||
guard peek() == ")" else { throw RegexError.unbalanced }
|
||||
advance()
|
||||
return inner
|
||||
case "[":
|
||||
return try .characters(parseClass())
|
||||
case ".":
|
||||
advance()
|
||||
return .characters(.named(.anyButLineBreak))
|
||||
case "\\":
|
||||
advance()
|
||||
return try .characters(parseEscape())
|
||||
default:
|
||||
advance()
|
||||
return .characters(.single(scalar(of: character)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Escapes
|
||||
|
||||
private mutating func parseEscape() throws -> CharClass {
|
||||
guard let character = peek() else { throw RegexError.badEscape("") }
|
||||
advance()
|
||||
if let single = Self.singleEscape(character) {
|
||||
return .single(single)
|
||||
}
|
||||
if let member = Self.classEscape(character) {
|
||||
return classFrom(member)
|
||||
}
|
||||
throw RegexError.badEscape(String(character))
|
||||
}
|
||||
|
||||
private func classFrom(_ member: ClassMember) -> CharClass {
|
||||
switch member {
|
||||
case let .scalar(value): .single(value)
|
||||
case let .named(named): CharClass(named: [named])
|
||||
case let .negatedNamed(named): CharClass(negatedNamed: [named])
|
||||
}
|
||||
}
|
||||
|
||||
private static func singleEscape(_ character: Character) -> Unicode.Scalar? {
|
||||
switch character {
|
||||
case "n": "\n"
|
||||
case "r": "\r"
|
||||
case "t": "\t"
|
||||
case "\\", ".", "|", "-", "^", "?", "*", "+", "{", "}", "(", ")", "[", "]":
|
||||
character.unicodeScalars.first
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
private static let classEscapes: [Character: ClassMember] = [
|
||||
"d": .named(.digit), "D": .negatedNamed(.digit),
|
||||
"w": .named(.word), "W": .negatedNamed(.word),
|
||||
"s": .named(.space), "S": .negatedNamed(.space),
|
||||
"i": .named(.nameStart), "I": .negatedNamed(.nameStart),
|
||||
"c": .named(.nameChar), "C": .negatedNamed(.nameChar),
|
||||
]
|
||||
|
||||
private static func classEscape(_ character: Character) -> ClassMember? {
|
||||
classEscapes[character]
|
||||
}
|
||||
|
||||
// MARK: Character classes
|
||||
|
||||
private mutating func parseClass() throws -> CharClass {
|
||||
advance()
|
||||
var cls = CharClass()
|
||||
if peek() == "^" {
|
||||
advance()
|
||||
cls.negated = true
|
||||
}
|
||||
while let character = peek(), character != "]" {
|
||||
try parseClassMember(into: &cls)
|
||||
}
|
||||
guard peek() == "]" else { throw RegexError.badClass }
|
||||
advance()
|
||||
return cls
|
||||
}
|
||||
|
||||
private mutating func parseClassMember(into cls: inout CharClass) throws {
|
||||
let member = try nextClassMember()
|
||||
guard case let .scalar(low) = member else {
|
||||
add(member, to: &cls)
|
||||
return
|
||||
}
|
||||
if peek() == "-", peek(1) != "]" {
|
||||
advance()
|
||||
guard case let .scalar(high) = try nextClassMember() else { throw RegexError.badClass }
|
||||
cls.ranges.append(low ... high)
|
||||
} else {
|
||||
cls.ranges.append(low ... low)
|
||||
}
|
||||
}
|
||||
|
||||
private func add(_ member: ClassMember, to cls: inout CharClass) {
|
||||
switch member {
|
||||
case let .scalar(value): cls.ranges.append(value ... value)
|
||||
case let .named(named): cls.named.append(named)
|
||||
case let .negatedNamed(named): cls.negatedNamed.append(named)
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func nextClassMember() throws -> ClassMember {
|
||||
guard let character = peek() else { throw RegexError.badClass }
|
||||
advance()
|
||||
guard character == "\\" else { return .scalar(scalar(of: character)) }
|
||||
guard let escaped = peek() else { throw RegexError.badClass }
|
||||
advance()
|
||||
if let single = Self.singleEscape(escaped) { return .scalar(single) }
|
||||
if let member = Self.classEscape(escaped) { return member }
|
||||
throw RegexError.badEscape(String(escaped))
|
||||
}
|
||||
|
||||
// MARK: Cursor
|
||||
|
||||
private func scalar(of character: Character) -> Unicode.Scalar {
|
||||
character.unicodeScalars.first ?? Unicode.Scalar(0)
|
||||
}
|
||||
|
||||
private mutating func parseInteger() -> Int? {
|
||||
var digits = ""
|
||||
while let character = peek(), character.isNumber {
|
||||
digits.append(character)
|
||||
advance()
|
||||
}
|
||||
return Int(digits)
|
||||
}
|
||||
|
||||
private var isAtEnd: Bool {
|
||||
index >= chars.count
|
||||
}
|
||||
|
||||
private func peek(_ ahead: Int = 0) -> Character? {
|
||||
let target = index + ahead
|
||||
return target < chars.count ? chars[target] : nil
|
||||
}
|
||||
|
||||
private mutating func advance() {
|
||||
if index < chars.count { index += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
87
Sources/Regex/RegexSyntax.swift
Normal file
87
Sources/Regex/RegexSyntax.swift
Normal file
@ -0,0 +1,87 @@
|
||||
extension PureXML.Regex {
|
||||
/// An error compiling a regular expression.
|
||||
enum RegexError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
|
||||
case empty
|
||||
case unbalanced
|
||||
case danglingQuantifier
|
||||
case badEscape(String)
|
||||
case badClass
|
||||
case badQuantifier
|
||||
case unsupported(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .empty: "the pattern is empty"
|
||||
case .unbalanced: "unbalanced parentheses"
|
||||
case .danglingQuantifier: "a quantifier has nothing to repeat"
|
||||
case let .badEscape(detail): "invalid escape '\\\(detail)'"
|
||||
case .badClass: "malformed character class"
|
||||
case .badQuantifier: "malformed quantifier"
|
||||
case let .unsupported(detail): "unsupported construct: \(detail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named character class (`\d`, `\w`, `\s`, `\i`, `\c`) or the `.` wildcard.
|
||||
enum NamedClass: Equatable, Sendable {
|
||||
case digit
|
||||
case word
|
||||
case space
|
||||
case nameStart
|
||||
case nameChar
|
||||
case anyButLineBreak
|
||||
|
||||
func contains(_ scalar: Unicode.Scalar) -> Bool {
|
||||
switch self {
|
||||
case .digit: scalar.properties.numericType == .decimal
|
||||
case .space: scalar == " " || scalar == "\t" || scalar == "\n" || scalar == "\r"
|
||||
case .word: scalar.properties.isAlphabetic || scalar.properties.numericType == .decimal || scalar == "_"
|
||||
case .nameStart: PureXML.Parsing.XMLCharacter.isNameStart(scalar)
|
||||
case .nameChar: PureXML.Parsing.XMLCharacter.isNameChar(scalar)
|
||||
case .anyButLineBreak: scalar != "\n" && scalar != "\r"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A character class: ranges, named sub-classes, and an optional negation.
|
||||
struct CharClass: Equatable, Sendable {
|
||||
var negated = false
|
||||
var ranges: [ClosedRange<Unicode.Scalar>] = []
|
||||
var named: [NamedClass] = []
|
||||
var negatedNamed: [NamedClass] = []
|
||||
|
||||
func matches(_ character: Character) -> Bool {
|
||||
guard character.unicodeScalars.count == 1, let scalar = character.unicodeScalars.first else {
|
||||
return false
|
||||
}
|
||||
let hit = ranges.contains { $0.contains(scalar) }
|
||||
|| named.contains { $0.contains(scalar) }
|
||||
|| negatedNamed.contains { !$0.contains(scalar) }
|
||||
return negated ? !hit : hit
|
||||
}
|
||||
|
||||
static func single(_ scalar: Unicode.Scalar) -> CharClass {
|
||||
CharClass(ranges: [scalar ... scalar])
|
||||
}
|
||||
|
||||
static func named(_ named: NamedClass) -> CharClass {
|
||||
CharClass(named: [named])
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed regular-expression tree. Groups are non-capturing; `?`, `*`,
|
||||
/// `+`, and `{n,m}` all become ``repeated``.
|
||||
indirect enum Node: Equatable, Sendable {
|
||||
case empty
|
||||
case characters(CharClass)
|
||||
case concat([Node])
|
||||
case alternate([Node])
|
||||
case repeated(Node, min: Int, max: Int?)
|
||||
}
|
||||
}
|
||||
|
||||
extension Unicode.Scalar: @retroactive Comparable {
|
||||
public static func < (lhs: Unicode.Scalar, rhs: Unicode.Scalar) -> Bool {
|
||||
lhs.value < rhs.value
|
||||
}
|
||||
}
|
||||
86
Tests/RegexTests.swift
Normal file
86
Tests/RegexTests.swift
Normal file
@ -0,0 +1,86 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("Regex")
|
||||
struct RegexTests {
|
||||
private func matches(_ pattern: String, _ string: String) throws -> Bool {
|
||||
try PureXML.Regex.matches(pattern, string)
|
||||
}
|
||||
|
||||
@Test("Literal patterns match the whole string")
|
||||
func test_literal() throws {
|
||||
#expect(try matches("abc", "abc"))
|
||||
#expect(try !matches("abc", "abcd"))
|
||||
#expect(try !matches("abc", "ab"))
|
||||
}
|
||||
|
||||
@Test("The dot matches any character except line breaks")
|
||||
func test_dot() throws {
|
||||
#expect(try matches("a.c", "axc"))
|
||||
#expect(try !matches("a.c", "a\nc"))
|
||||
}
|
||||
|
||||
@Test("Quantifiers ? * + bound repetition")
|
||||
func test_quantifiers() throws {
|
||||
#expect(try matches("ab?c", "ac"))
|
||||
#expect(try matches("ab?c", "abc"))
|
||||
#expect(try matches("ab*c", "abbbc"))
|
||||
#expect(try matches("ab+c", "abc"))
|
||||
#expect(try !matches("ab+c", "ac"))
|
||||
}
|
||||
|
||||
@Test("Bounded quantifiers {n}, {n,}, {n,m}")
|
||||
func test_boundedQuantifiers() throws {
|
||||
#expect(try matches("a{3}", "aaa"))
|
||||
#expect(try !matches("a{3}", "aa"))
|
||||
#expect(try matches("a{2,}", "aaaa"))
|
||||
#expect(try matches("a{2,4}", "aaa"))
|
||||
#expect(try !matches("a{2,4}", "aaaaa"))
|
||||
}
|
||||
|
||||
@Test("Alternation and grouping")
|
||||
func test_alternationGrouping() throws {
|
||||
#expect(try matches("cat|dog", "dog"))
|
||||
#expect(try matches("(ab)+", "ababab"))
|
||||
#expect(try matches("gr(a|e)y", "grey"))
|
||||
#expect(try !matches("gr(a|e)y", "groy"))
|
||||
}
|
||||
|
||||
@Test("Character classes with ranges and negation")
|
||||
func test_charClasses() throws {
|
||||
#expect(try matches("[a-z]+", "hello"))
|
||||
#expect(try !matches("[a-z]+", "Hello"))
|
||||
#expect(try matches("[^0-9]+", "abc"))
|
||||
#expect(try matches("[abc]{2}", "ca"))
|
||||
}
|
||||
|
||||
@Test("Class escapes \\d \\w \\s and their negations")
|
||||
func test_classEscapes() throws {
|
||||
#expect(try matches("\\d{4}", "2026"))
|
||||
#expect(try !matches("\\d{4}", "20x6"))
|
||||
#expect(try matches("\\w+", "a_1"))
|
||||
#expect(try matches("a\\sb", "a b"))
|
||||
#expect(try matches("\\D+", "abc"))
|
||||
}
|
||||
|
||||
@Test("Escaped metacharacters are literal")
|
||||
func test_escapes() throws {
|
||||
#expect(try matches("a\\.b", "a.b"))
|
||||
#expect(try !matches("a\\.b", "axb"))
|
||||
#expect(try matches("\\(\\)", "()"))
|
||||
}
|
||||
|
||||
@Test("A realistic ISBN-like pattern")
|
||||
func test_realistic() throws {
|
||||
let pattern = "\\d{3}-\\d{1,5}-\\d{1,7}-\\d{1,6}-[0-9X]"
|
||||
#expect(try matches(pattern, "978-0-13-468599-1"))
|
||||
#expect(try !matches(pattern, "abc-0-13-468599-1"))
|
||||
}
|
||||
|
||||
@Test("Malformed patterns are rejected")
|
||||
func test_errors() {
|
||||
#expect(throws: PureXML.Regex.RegexError.self) { _ = try PureXML.Regex.Pattern("a(b") }
|
||||
#expect(throws: PureXML.Regex.RegexError.self) { _ = try PureXML.Regex.Pattern("*ab") }
|
||||
#expect(throws: PureXML.Regex.RegexError.self) { _ = try PureXML.Regex.Pattern("") }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user