From 8177a3e00d9967e141b5b4909a2a5e91516c9f06 Mon Sep 17 00:00:00 2001 From: Hamish Knight Date: Fri, 28 Jan 2022 19:41:49 +0000 Subject: [PATCH] Parse global matching options Parse the PCRE global matching options e.g `(*CR)`, `(*UTF)`, and `(*LIMIT_DEPTH=d)`. Such options may only appear at the very start of the regex, and as such are stored on the top-level AST type. --- Sources/_MatchingEngine/Regex/AST/AST.swift | 19 ++- .../Regex/AST/MatchingOptions.swift | 79 ++++++++++++ .../Regex/Parse/Diagnostics.swift | 4 + .../Regex/Parse/LexicalAnalysis.swift | 116 ++++++++++++++++++ .../_MatchingEngine/Regex/Parse/Parse.swift | 11 +- .../Regex/Printing/DumpAST.swift | 21 +++- .../Regex/Printing/PrintAsCanonical.swift | 75 ++++++++--- .../Regex/Printing/PrintAsPattern.swift | 3 +- Sources/_StringProcessing/ASTBuilder.swift | 8 ++ Sources/_StringProcessing/Compiler.swift | 1 + Sources/_StringProcessing/RegexDSL/Core.swift | 2 +- Tests/RegexTests/ParseTests.swift | 80 ++++++++++-- 12 files changed, 389 insertions(+), 30 deletions(-) diff --git a/Sources/_MatchingEngine/Regex/AST/AST.swift b/Sources/_MatchingEngine/Regex/AST/AST.swift index 919eb4959..9e5cacd96 100644 --- a/Sources/_MatchingEngine/Regex/AST/AST.swift +++ b/Sources/_MatchingEngine/Regex/AST/AST.swift @@ -13,8 +13,11 @@ /// node. public struct AST: Hashable { public var root: AST.Node - public init(_ root: AST.Node) { + public var globalOptions: GlobalMatchingOptionSequence? + + public init(_ root: AST.Node, globalOptions: GlobalMatchingOptionSequence?) { self.root = root + self.globalOptions = globalOptions } } @@ -291,6 +294,20 @@ extension AST { /// a group. public var recursesWholePattern: Bool { kind == .recurseWholePattern } } + + /// A set of global matching options in a regular expression literal. + public struct GlobalMatchingOptionSequence: Hashable { + public var options: [AST.GlobalMatchingOption] + + public init?(_ options: [AST.GlobalMatchingOption]) { + guard !options.isEmpty else { return nil } + self.options = options + } + + public var location: SourceLocation { + options.first!.location.union(with: options.last!.location) + } + } } // FIXME: Get this out of here diff --git a/Sources/_MatchingEngine/Regex/AST/MatchingOptions.swift b/Sources/_MatchingEngine/Regex/AST/MatchingOptions.swift index 115a28af1..cd1c08e0f 100644 --- a/Sources/_MatchingEngine/Regex/AST/MatchingOptions.swift +++ b/Sources/_MatchingEngine/Regex/AST/MatchingOptions.swift @@ -105,3 +105,82 @@ extension AST.MatchingOptionSequence: _ASTPrintable { "adding: \(adding), removing: \(removing), hasCaret: \(caretLoc != nil)" } } + +extension AST { + /// Global matching option specifiers. Unlike `MatchingOptionSequence`, + /// these must appear at the start of the pattern, and apply globally. + public struct GlobalMatchingOption: _ASTNode, Hashable { + /// Determines the definition of a newline for the '.' character class. + public enum NewlineMatching: Hashable { + /// (*CR*) + case carriageReturnOnly + + /// (*LF) + case linefeedOnly + + /// (*CRLF) + case carriageAndLinefeedOnly + + /// (*ANYCRLF) + case anyCarriageReturnOrLinefeed + + /// (*ANY) + case anyUnicode + + /// (*NUL) + case nulCharacter + } + /// Determines what `\R` matches. + public enum NewlineSequenceMatching: Hashable { + /// (*BSR_ANYCRLF) + case anyCarriageReturnOrLinefeed + + /// (*BSR_UNICODE) + case anyUnicode + } + public enum Kind: Hashable { + /// (*LIMIT_DEPTH=d) + case limitDepth(Located) + + /// (*LIMIT_HEAP=d) + case limitHeap(Located) + + /// (*LIMIT_MATCH=d) + case limitMatch(Located) + + /// (*NOTEMPTY) + case notEmpty + + /// (*NOTEMPTY_ATSTART) + case notEmptyAtStart + + /// (*NO_AUTO_POSSESS) + case noAutoPossess + + /// (*NO_DOTSTAR_ANCHOR) + case noDotStarAnchor + + /// (*NO_JIT) + case noJIT + + /// (*NO_START_OPT) + case noStartOpt + + /// (*UTF) + case utfMode + + /// (*UCP) + case unicodeProperties + + case newlineMatching(NewlineMatching) + case newlineSequenceMatching(NewlineSequenceMatching) + } + public var kind: Kind + public var location: SourceLocation + + public init(_ kind: Kind, _ location: SourceLocation) { + self.kind = kind + self.location = location + } + } +} diff --git a/Sources/_MatchingEngine/Regex/Parse/Diagnostics.swift b/Sources/_MatchingEngine/Regex/Parse/Diagnostics.swift index 3f80cb7a3..add1ee188 100644 --- a/Sources/_MatchingEngine/Regex/Parse/Diagnostics.swift +++ b/Sources/_MatchingEngine/Regex/Parse/Diagnostics.swift @@ -33,6 +33,8 @@ enum ParseError: Error, Hashable { case tooManyAbsentExpressionChildren(Int) + case globalMatchingOptionNotAtStart(String) + case expectedASCII(Character) case expectedNonEmptyContents @@ -116,6 +118,8 @@ extension ParseError: CustomStringConvertible { return "\(str) cannot be used as condition" case let .tooManyAbsentExpressionChildren(i): return "expected 2 expressions in absent expression, have \(i)" + case let .globalMatchingOptionNotAtStart(opt): + return "matching option '\(opt)' may only appear at the start of the regex" case let .unknownGroupKind(str): return "unknown group kind '(\(str)'" case let .unknownCalloutKind(str): diff --git a/Sources/_MatchingEngine/Regex/Parse/LexicalAnalysis.swift b/Sources/_MatchingEngine/Regex/Parse/LexicalAnalysis.swift index 369b7fd5a..18b536005 100644 --- a/Sources/_MatchingEngine/Regex/Parse/LexicalAnalysis.swift +++ b/Sources/_MatchingEngine/Regex/Parse/LexicalAnalysis.swift @@ -1638,6 +1638,12 @@ extension Source { return .backtrackingDirective(b) } + // Global matching options can only appear at the very start. + if let opt = try src.lexGlobalMatchingOption() { + throw ParseError.globalMatchingOptionNotAtStart( + String(src[opt.location.range])) + } + // (?C) if let callout = try src.lexPCRECallout() { return .callout(callout) @@ -1743,5 +1749,115 @@ extension Source { } return (dash, end) } + + /// Try to consume a newline sequence matching option kind. + /// + /// NewlineSequenceKind -> 'BSR_ANYCRLF' | 'BSR_UNICODE' + /// + private mutating func lexNewlineSequenceMatchingOption( + ) throws -> AST.GlobalMatchingOption.NewlineSequenceMatching? { + if tryEat(sequence: "BSR_ANYCRLF") { return .anyCarriageReturnOrLinefeed } + if tryEat(sequence: "BSR_UNICODE") { return .anyUnicode } + return nil + } + + /// Try to consume a newline matching option kind. + /// + /// NewlineKind -> 'CRLF' | 'CR' | 'ANYCRLF' | 'ANY' | 'LF' | 'NUL' + /// + private mutating func lexNewlineMatchingOption( + ) throws -> AST.GlobalMatchingOption.NewlineMatching? { + // The ordering here is important: CRLF needs to precede CR, and ANYCRLF + // needs to precede ANY to ensure we don't short circuit on the wrong one. + if tryEat(sequence: "CRLF") { return .carriageAndLinefeedOnly } + if tryEat(sequence: "CR") { return .carriageReturnOnly } + if tryEat(sequence: "ANYCRLF") { return .anyCarriageReturnOrLinefeed } + if tryEat(sequence: "ANY") { return .anyUnicode } + + if tryEat(sequence: "LF") { return .linefeedOnly } + if tryEat(sequence: "NUL") { return .nulCharacter } + return nil + } + + /// Try to consume a global matching option kind, returning `nil` if + /// unsuccessful. + /// + /// GlobalMatchingOptionKind -> LimitOptionKind '=' + /// | NewlineKind | NewlineSequenceKind + /// | 'NOTEMPTY_ATSTART' | 'NOTEMPTY' + /// | 'NO_AUTO_POSSESS' | 'NO_DOTSTAR_ANCHOR' + /// | 'NO_JIT' | 'NO_START_OPT' | 'UTF' | 'UCP' + /// + /// LimitOptionKind -> 'LIMIT_DEPTH' | 'LIMIT_HEAP' + /// | 'LIMIT_MATCH' + /// + private mutating func lexGlobalMatchingOptionKind( + ) throws -> Located? { + try recordLoc { src in + if let opt = try src.lexNewlineSequenceMatchingOption() { + return .newlineSequenceMatching(opt) + } + if let opt = try src.lexNewlineMatchingOption() { + return .newlineMatching(opt) + } + if src.tryEat(sequence: "LIMIT_DEPTH") { + try src.expect("=") + return .limitDepth(try src.expectNumber()) + } + if src.tryEat(sequence: "LIMIT_HEAP") { + try src.expect("=") + return .limitHeap(try src.expectNumber()) + } + if src.tryEat(sequence: "LIMIT_MATCH") { + try src.expect("=") + return .limitMatch(try src.expectNumber()) + } + + // The ordering here is important: NOTEMPTY_ATSTART needs to precede + // NOTEMPTY to ensure we don't short circuit on the wrong one. + if src.tryEat(sequence: "NOTEMPTY_ATSTART") { return .notEmptyAtStart } + if src.tryEat(sequence: "NOTEMPTY") { return .notEmpty } + + if src.tryEat(sequence: "NO_AUTO_POSSESS") { return .noAutoPossess } + if src.tryEat(sequence: "NO_DOTSTAR_ANCHOR") { return .noDotStarAnchor } + if src.tryEat(sequence: "NO_JIT") { return .noJIT } + if src.tryEat(sequence: "NO_START_OPT") { return .noStartOpt } + if src.tryEat(sequence: "UTF") { return .utfMode } + if src.tryEat(sequence: "UCP") { return .unicodeProperties } + return nil + } + } + + /// Try to consume a global matching option, returning `nil` if unsuccessful. + /// + /// GlobalMatchingOption -> '(*' GlobalMatchingOptionKind ')' + /// + mutating func lexGlobalMatchingOption( + ) throws -> AST.GlobalMatchingOption? { + let kind = try recordLoc { src -> AST.GlobalMatchingOption.Kind? in + try src.tryEating { src in + guard src.tryEat(sequence: "(*"), + let kind = try src.lexGlobalMatchingOptionKind()?.value + else { return nil } + try src.expect(")") + return kind + } + } + guard let kind = kind else { return nil } + return .init(kind.value, kind.location) + } + + /// Try to consume a sequence of global matching options. + /// + /// GlobalMatchingOptionSequence -> GlobalMatchingOption+ + /// + mutating func lexGlobalMatchingOptionSequence( + ) throws -> AST.GlobalMatchingOptionSequence? { + var opts: [AST.GlobalMatchingOption] = [] + while let opt = try lexGlobalMatchingOption() { + opts.append(opt) + } + return .init(opts) + } } diff --git a/Sources/_MatchingEngine/Regex/Parse/Parse.swift b/Sources/_MatchingEngine/Regex/Parse/Parse.swift index 58a7f230c..08c8cf77e 100644 --- a/Sources/_MatchingEngine/Regex/Parse/Parse.swift +++ b/Sources/_MatchingEngine/Regex/Parse/Parse.swift @@ -117,17 +117,24 @@ extension Parser { /// Parse a top-level regular expression. Do not use for recursive calls, use /// `parseNode()` instead. /// - /// Regex -> RegexNode + /// Regex -> GlobalMatchingOptionSequence? RegexNode /// mutating func parse() throws -> AST { + // First parse any global matching options if present. + let opts = try source.lexGlobalMatchingOptionSequence() + + // Then parse the root AST node. let ast = try parseNode() guard source.isEmpty else { + // parseConcatenation() terminates on encountering a ')' to enable + // recursive parses of a group body. However for a top-level parse, this + // means we have an unmatched closing paren, so let's diagnose. if let loc = source.tryEatWithLoc(")") { throw Source.LocatedError(ParseError.unbalancedEndOfGroup, loc) } fatalError("Unhandled termination condition") } - return .init(ast) + return .init(ast, globalOptions: opts) } /// Parse a regular expression node. This should be used instead of `parse()` diff --git a/Sources/_MatchingEngine/Regex/Printing/DumpAST.swift b/Sources/_MatchingEngine/Regex/Printing/DumpAST.swift index 1eb607315..a130fb5a0 100644 --- a/Sources/_MatchingEngine/Regex/Printing/DumpAST.swift +++ b/Sources/_MatchingEngine/Regex/Printing/DumpAST.swift @@ -58,7 +58,12 @@ extension _ASTPrintable { extension AST: _ASTPrintable { public var _dumpBase: String { - root._dumpBase + var result = "" + if let opts = globalOptions { + result += "\(opts) " + } + result += root._dump() + return result } } @@ -341,3 +346,17 @@ extension AST.AbsentFunction { "absent function \(kind._dumpBase)" } } + +extension AST.GlobalMatchingOption.Kind: _ASTPrintable { + public var _dumpBase: String { _canonicalBase } +} + +extension AST.GlobalMatchingOption: _ASTPrintable { + public var _dumpBase: String { "\(kind._dumpBase)" } +} + +extension AST.GlobalMatchingOptionSequence: _ASTPrintable { + public var _dumpBase: String { + "GlobalMatchingOptionSequence<\(options)>" + } +} diff --git a/Sources/_MatchingEngine/Regex/Printing/PrintAsCanonical.swift b/Sources/_MatchingEngine/Regex/Printing/PrintAsCanonical.swift index df1c8b64b..b6f0759b2 100644 --- a/Sources/_MatchingEngine/Regex/Printing/PrintAsCanonical.swift +++ b/Sources/_MatchingEngine/Regex/Printing/PrintAsCanonical.swift @@ -32,12 +32,8 @@ extension AST.Node { showDelimiters delimiters: Bool = false, terminateLine: Bool = false ) -> String { - var printer = PrettyPrinter() - printer.printAsCanonical( - self, - delimiters: delimiters, - terminateLine: terminateLine) - return printer.finish() + AST(self, globalOptions: nil).renderAsCanonical( + showDelimiters: delimiters, terminateLine: terminateLine) } } @@ -48,20 +44,13 @@ extension PrettyPrinter { _ ast: AST, delimiters: Bool = false, terminateLine terminate: Bool = true - ) { - printAsCanonical(ast.root, delimiters: delimiters, terminateLine: terminate) - } - - /// Will output `ast` in canonical form, taking care to - /// also indent and terminate the line (updating internal state) - mutating func printAsCanonical( - _ ast: AST.Node, - delimiters: Bool = false, - terminateLine terminate: Bool = true ) { indent() if delimiters { output("'/") } - outputAsCanonical(ast) + if let opts = ast.globalOptions { + outputAsCanonical(opts) + } + outputAsCanonical(ast.root) if delimiters { output("/'") } if terminate { terminateLine() @@ -173,6 +162,12 @@ extension PrettyPrinter { } output(")") } + + mutating func outputAsCanonical(_ opts: AST.GlobalMatchingOptionSequence) { + for opt in opts.options { + output(opt._canonicalBase) + } + } } extension AST.Quote { @@ -274,3 +269,49 @@ extension AST.Group.BalancedCapture { "\(name?.value ?? "")-\(priorName.value)" } } + +extension AST.GlobalMatchingOption.NewlineMatching { + var _canonicalBase: String { + switch self { + case .carriageReturnOnly: return "CR" + case .linefeedOnly: return "LF" + case .carriageAndLinefeedOnly: return "CRLF" + case .anyCarriageReturnOrLinefeed: return "ANYCRLF" + case .anyUnicode: return "ANY" + case .nulCharacter: return "NUL" + } + } +} + +extension AST.GlobalMatchingOption.NewlineSequenceMatching { + var _canonicalBase: String { + switch self { + case .anyCarriageReturnOrLinefeed: return "BSR_ANYCRLF" + case .anyUnicode: return "BSR_UNICODE" + } + } +} + +extension AST.GlobalMatchingOption.Kind { + var _canonicalBase: String { + switch self { + case .limitDepth(let i): return "LIMIT_DEPTH=\(i.value)" + case .limitHeap(let i): return "LIMIT_HEAP=\(i.value)" + case .limitMatch(let i): return "LIMIT_MATCH=\(i.value)" + case .notEmpty: return "NOTEMPTY" + case .notEmptyAtStart: return "NOTEMPTY_ATSTART" + case .noAutoPossess: return "NO_AUTO_POSSESS" + case .noDotStarAnchor: return "NO_DOTSTAR_ANCHOR" + case .noJIT: return "NO_JIT" + case .noStartOpt: return "NO_START_OPT" + case .utfMode: return "UTF" + case .unicodeProperties: return "UCP" + case .newlineMatching(let m): return m._canonicalBase + case .newlineSequenceMatching(let m): return m._canonicalBase + } + } +} + +extension AST.GlobalMatchingOption { + var _canonicalBase: String { "(*\(kind._canonicalBase))"} +} diff --git a/Sources/_MatchingEngine/Regex/Printing/PrintAsPattern.swift b/Sources/_MatchingEngine/Regex/Printing/PrintAsPattern.swift index a48d2b7ce..8a6367af6 100644 --- a/Sources/_MatchingEngine/Regex/Printing/PrintAsPattern.swift +++ b/Sources/_MatchingEngine/Regex/Printing/PrintAsPattern.swift @@ -42,12 +42,13 @@ extension PrettyPrinter { } mutating func printAsPattern(_ ast: AST) { + // TODO: Global matching options? printAsPattern(ast.root) } mutating func printAsPattern(_ ast: AST.Node) { if patternBackoff(ast) { - printAsCanonical(ast, delimiters: true) + printAsCanonical(.init(ast, globalOptions: nil), delimiters: true) return } diff --git a/Sources/_StringProcessing/ASTBuilder.swift b/Sources/_StringProcessing/ASTBuilder.swift index df552e599..dda007ca6 100644 --- a/Sources/_StringProcessing/ASTBuilder.swift +++ b/Sources/_StringProcessing/ASTBuilder.swift @@ -47,6 +47,14 @@ func empty() -> AST.Node { .empty(.init(.fake)) } +func ast(_ root: AST.Node, opts: [AST.GlobalMatchingOption.Kind]) -> AST { + .init(root, globalOptions: .init(opts.map { .init($0, .fake) })) +} + +func ast(_ root: AST.Node, opts: AST.GlobalMatchingOption.Kind...) -> AST { + ast(root, opts: opts) +} + func group( _ kind: AST.Group.Kind, _ child: AST.Node ) -> AST.Node { diff --git a/Sources/_StringProcessing/Compiler.swift b/Sources/_StringProcessing/Compiler.swift index 791e302d8..d3c6846a2 100644 --- a/Sources/_StringProcessing/Compiler.swift +++ b/Sources/_StringProcessing/Compiler.swift @@ -28,6 +28,7 @@ class Compiler { } __consuming func emit() throws -> RegexProgram { + // TODO: Global matching options? try emit(ast.root) builder.buildAccept() let program = builder.assemble() diff --git a/Sources/_StringProcessing/RegexDSL/Core.swift b/Sources/_StringProcessing/RegexDSL/Core.swift index 65469e875..5de4c791f 100644 --- a/Sources/_StringProcessing/RegexDSL/Core.swift +++ b/Sources/_StringProcessing/RegexDSL/Core.swift @@ -79,7 +79,7 @@ public struct Regex: RegexProtocol { self.program = Program(ast: ast) } init(ast: AST.Node) { - self.program = Program(ast: .init(ast)) + self.program = Program(ast: .init(ast, globalOptions: nil)) } // Compiler interface. Do not change independently. diff --git a/Tests/RegexTests/ParseTests.swift b/Tests/RegexTests/ParseTests.swift index 6f0cc09ff..4c17bc5dc 100644 --- a/Tests/RegexTests/ParseTests.swift +++ b/Tests/RegexTests/ParseTests.swift @@ -42,20 +42,33 @@ func parseTest( captures expectedCaptures: CaptureStructure = .empty, file: StaticString = #file, line: UInt = #line +) { + parseTest( + input, .init(expectedAST, globalOptions: nil), syntax: syntax, + captures: expectedCaptures, file: file, line: line + ) +} + +func parseTest( + _ input: String, _ expectedAST: AST, + syntax: SyntaxOptions = .traditional, + captures expectedCaptures: CaptureStructure = .empty, + file: StaticString = #file, + line: UInt = #line ) { let ast = try! parse(input, syntax) - guard ast.root == expectedAST - || ast.root._dump() == expectedAST._dump() // EQ workaround + guard ast == expectedAST + || ast._dump() == expectedAST._dump() // EQ workaround else { XCTFail(""" Expected: \(expectedAST._dump()) - Found: \(ast.root._dump()) + Found: \(ast._dump()) """, file: file, line: line) return } - let captures = ast.root.captureStructure + let captures = ast.captureStructure guard captures == expectedCaptures else { XCTFail(""" @@ -125,13 +138,14 @@ func parseNotEqualTest( syntax: SyntaxOptions = .traditional, file: StaticString = #file, line: UInt = #line ) { - let lhsAST = try! parse(lhs, syntax).root - let rhsAST = try! parse(rhs, syntax).root + let lhsAST = try! parse(lhs, syntax) + let rhsAST = try! parse(rhs, syntax) if lhsAST == rhsAST || lhsAST._dump() == rhsAST._dump() { XCTFail(""" AST: \(lhsAST._dump()) Should not be equal to: \(rhsAST._dump()) - """) + """, + file: file, line: line) } } @@ -1221,6 +1235,43 @@ extension RegexTests { // Maybe we should diagnose it? parseTest("(?~|)+", oneOrMore(.eager, absentRangeClear())) + // MARK: Global matching options + + parseTest("(*CR)(*UTF)(*LIMIT_DEPTH=3)", ast( + empty(), opts: .newlineMatching(.carriageReturnOnly), .utfMode, + .limitDepth(.init(faking: 3)) + )) + + parseTest( + "(*BSR_UNICODE)3", ast("3", opts: .newlineSequenceMatching(.anyUnicode))) + parseTest( + "(*BSR_ANYCRLF)", ast( + empty(), opts: .newlineSequenceMatching(.anyCarriageReturnOrLinefeed))) + + // TODO: Diagnose on multiple line matching modes? + parseTest( + "(*CR)(*LF)(*CRLF)(*ANYCRLF)(*ANY)(*NUL)", + ast(empty(), opts: [ + .carriageReturnOnly, .linefeedOnly, .carriageAndLinefeedOnly, + .anyCarriageReturnOrLinefeed, .anyUnicode, .nulCharacter + ].map { .newlineMatching($0) })) + + parseTest( + """ + (*LIMIT_DEPTH=3)(*LIMIT_HEAP=1)(*LIMIT_MATCH=2)(*NOTEMPTY)\ + (*NOTEMPTY_ATSTART)(*NO_AUTO_POSSESS)(*NO_DOTSTAR_ANCHOR)(*NO_JIT)\ + (*NO_START_OPT)(*UTF)(*UCP)a + """, + ast("a", opts: + .limitDepth(.init(faking: 3)), .limitHeap(.init(faking: 1)), + .limitMatch(.init(faking: 2)), .notEmpty, .notEmptyAtStart, + .noAutoPossess, .noDotStarAnchor, .noJIT, .noStartOpt, .utfMode, + .unicodeProperties + ) + ) + + parseTest("[(*CR)]", charClass("(", "*", "C", "R", ")")) + // MARK: Parse with delimiters parseWithDelimitersTest("'/a b/'", concat("a", " ", "b")) @@ -1318,6 +1369,11 @@ extension RegexTests { parseNotEqualTest("(?~|a|b)", "(?~|a|c)") parseNotEqualTest("(?~)", "(?~|)") parseNotEqualTest("(?~a)", "(?~b)") + + parseNotEqualTest("(*CR)", "(*LF)") + parseNotEqualTest("(*LIMIT_DEPTH=3)", "(*LIMIT_DEPTH=1)") + parseNotEqualTest("(*UTF)", "(*LF)") + parseNotEqualTest("(*LF)", "(*BSR_ANYCRLF)") } func testParseSourceLocations() throws { @@ -1613,5 +1669,15 @@ extension RegexTests { diagnosticTest("(?~|", .expected(")")) diagnosticTest("(?~|a|b|c)", .tooManyAbsentExpressionChildren(3)) diagnosticTest("(?~||||)", .tooManyAbsentExpressionChildren(4)) + + // MARK: Global matching options + + diagnosticTest("a(*CR)", .globalMatchingOptionNotAtStart("(*CR)")) + diagnosticTest("(*CR)a(*LF)", .globalMatchingOptionNotAtStart("(*LF)")) + diagnosticTest("(*LIMIT_HEAP)", .expected("=")) + diagnosticTest("(*LIMIT_DEPTH=", .expectedNumber("", kind: .decimal)) + + // TODO: This diagnostic could be better. + diagnosticTest("(*LIMIT_DEPTH=-1", .expectedNumber("", kind: .decimal)) } }