mirror of
https://github.com/Smaug123/unofficial-nunit-runner
synced 2025-10-05 09:28:40 +00:00
Allow escaping in the filter language (#39)
This commit is contained in:
@@ -1 +1,10 @@
|
||||
# Toy NUnit test runner
|
||||
|
||||
## Filtering
|
||||
|
||||
To supply special characters in a string, XML-encode them and `"quote"` the string; if you give a quoted string, we will XML-decode the string.
|
||||
(In an unquoted string, we will just do our best; special characters may or may not result in parse failures and unexpected parses.)
|
||||
|
||||
We support at least the [documented `dotnet test` examples](https://learn.microsoft.com/en-us/dotnet/core/testing/selective-unit-tests).
|
||||
However, we would recommend phrasing some of them differently, for maximum peace of mind:
|
||||
* `FullyQualifiedName=MyNamespace.MyTestsClass<ParameterType1%2CParameterType2>.MyTestMethod`. This would be better phrased with quotes and escaping as `FullyQualifiedName="MyNamespace.MyTestsClass<ParameterType1%2CParameterType2>.MyTestMethod"`
|
||||
|
@@ -1,6 +1,7 @@
|
||||
namespace TestRunner
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open PrattParser
|
||||
|
||||
// Documentation:
|
||||
@@ -33,6 +34,15 @@ type internal TokenType =
|
||||
| Contains
|
||||
| NotContains
|
||||
| String
|
||||
| QuotedString
|
||||
|
||||
static member canTerminateUnquotedString (t : TokenType) : bool =
|
||||
// Here we essentially choose that unquoted strings can only appear on the RHS of an operation.
|
||||
match t with
|
||||
| TokenType.CloseParen
|
||||
| TokenType.And
|
||||
| TokenType.Or -> true
|
||||
| _ -> false
|
||||
|
||||
type internal Token =
|
||||
{
|
||||
@@ -67,15 +77,26 @@ module internal Token =
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module internal Lexer =
|
||||
type State =
|
||||
| UnquotedString of startPos : int
|
||||
| Awaiting
|
||||
| QuotedString of startPos : int
|
||||
|
||||
let lex (s : string) : Token seq =
|
||||
seq {
|
||||
let mutable i = 0
|
||||
let mutable stringAcc : int option = None
|
||||
let mutable state = State.Awaiting
|
||||
|
||||
while i < s.Length do
|
||||
match (i, s.[i]), stringAcc with
|
||||
match (i, s.[i]), state with
|
||||
| (endI, '"'), State.QuotedString startI ->
|
||||
yield Token.single TokenType.QuotedString startI (endI - startI)
|
||||
i <- i + 1
|
||||
state <- State.Awaiting
|
||||
| _, State.QuotedString _ -> i <- i + 1
|
||||
|
||||
// This one has to come before the check for prefix Not
|
||||
| (startI, '!'), None when i + 1 < s.Length ->
|
||||
| (startI, '!'), State.Awaiting when i + 1 < s.Length ->
|
||||
i <- i + 1
|
||||
|
||||
match s.[i] with
|
||||
@@ -88,44 +109,66 @@ module internal Lexer =
|
||||
| _ ->
|
||||
yield Token.single TokenType.Not startI 1
|
||||
i <- i + 1
|
||||
| Token.SingleChar token, None ->
|
||||
| Token.SingleChar token, State.Awaiting ->
|
||||
i <- i + 1
|
||||
yield token
|
||||
| Token.SingleChar _, Some stringStart ->
|
||||
yield Token.single TokenType.String stringStart (i - stringStart)
|
||||
stringAcc <- None // and we'll do the match again
|
||||
| (_, 'F'), None when
|
||||
| Token.SingleChar t, State.UnquotedString stringStart ->
|
||||
if TokenType.canTerminateUnquotedString t.Type then
|
||||
yield Token.single TokenType.String stringStart (i - stringStart)
|
||||
// don't increment `i`, we'll just do the match again
|
||||
state <- State.Awaiting
|
||||
else
|
||||
i <- i + 1
|
||||
| (_, 'F'), State.Awaiting when
|
||||
i + 1 < s.Length
|
||||
&& s.[i + 1 ..].StartsWith ("ullyQualifiedName", StringComparison.Ordinal)
|
||||
->
|
||||
yield Token.single TokenType.FullyQualifiedName i "FullyQualifiedName".Length
|
||||
i <- i + "FullyQualifiedName".Length
|
||||
| (_, 'N'), None when i + 1 < s.Length && s.[i + 1 ..].StartsWith ("ame", StringComparison.Ordinal) ->
|
||||
| (_, 'N'), State.Awaiting when
|
||||
i + 1 < s.Length && s.[i + 1 ..].StartsWith ("ame", StringComparison.Ordinal)
|
||||
->
|
||||
yield Token.single TokenType.Name i "Name".Length
|
||||
i <- i + "Name".Length
|
||||
| (_, 'T'), None when
|
||||
| (_, 'T'), State.Awaiting when
|
||||
i + 1 < s.Length
|
||||
&& s.[i + 1 ..].StartsWith ("estCategory", StringComparison.Ordinal)
|
||||
->
|
||||
yield Token.single TokenType.TestCategory i "TestCategory".Length
|
||||
i <- i + "TestCategory".Length
|
||||
| (_, ' '), None -> i <- i + 1
|
||||
| (_, _), None ->
|
||||
stringAcc <- Some i
|
||||
| (_, ' '), State.Awaiting -> i <- i + 1
|
||||
| (_, '"'), State.Awaiting ->
|
||||
state <- State.QuotedString i
|
||||
i <- i + 1
|
||||
| (_, _), Some _ -> i <- i + 1
|
||||
| (_, _), State.Awaiting ->
|
||||
state <- State.UnquotedString i
|
||||
i <- i + 1
|
||||
| (_, _), State.UnquotedString _ -> i <- i + 1
|
||||
|
||||
match stringAcc with
|
||||
| None -> ()
|
||||
| Some start -> yield Token.single TokenType.String start (s.Length - start)
|
||||
match state with
|
||||
| State.Awaiting -> ()
|
||||
| State.UnquotedString start -> yield Token.single TokenType.String start (s.Length - start)
|
||||
| State.QuotedString i ->
|
||||
failwith $"Parse failed: we never closed the string which started at position %i{i}"
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module internal ParsedFilter =
|
||||
let private unescape (s : string) : string =
|
||||
System.Xml.XmlReader
|
||||
.Create(new StringReader ("<r>" + s + "</r>"))
|
||||
.ReadElementString ()
|
||||
|
||||
let private atom (inputString : string) (token : Token) : ParsedFilter option =
|
||||
let start, len = token.Trivia
|
||||
|
||||
match token.Type with
|
||||
| TokenType.QuotedString ->
|
||||
// +1 and -1, because the trivia contains the initial and terminal quote mark
|
||||
inputString.Substring (start + 1, len - 1)
|
||||
|> unescape
|
||||
|> ParsedFilter.String
|
||||
|> Some
|
||||
| TokenType.String -> Some (ParsedFilter.String (inputString.Substring (start, len)))
|
||||
| TokenType.FullyQualifiedName -> Some ParsedFilter.FullyQualifiedName
|
||||
| TokenType.Name -> Some ParsedFilter.Name
|
||||
@@ -159,7 +202,8 @@ module internal ParsedFilter =
|
||||
}
|
||||
|
||||
let parse (s : string) : ParsedFilter =
|
||||
let parsed, remaining = Parser.execute parser s (Lexer.lex s |> Seq.toList)
|
||||
let tokens = Lexer.lex s |> Seq.toList
|
||||
let parsed, remaining = Parser.execute parser s tokens
|
||||
|
||||
if not remaining.IsEmpty then
|
||||
failwith $"Leftover tokens: %O{remaining}"
|
||||
@@ -194,10 +238,6 @@ type Filter =
|
||||
/// Methods for manipulating filters.
|
||||
[<RequireQualifiedAccess>]
|
||||
module Filter =
|
||||
let private unescape (s : string) : string =
|
||||
// TODO: XML escaping
|
||||
s
|
||||
|
||||
let rec internal makeParsed (fi : ParsedFilter) : Filter =
|
||||
match fi with
|
||||
| ParsedFilter.Not x -> Filter.Not (makeParsed x)
|
||||
@@ -209,7 +249,7 @@ module Filter =
|
||||
| ParsedFilter.Equal (key, value) ->
|
||||
let value =
|
||||
match value with
|
||||
| ParsedFilter.String s -> unescape s
|
||||
| ParsedFilter.String s -> s
|
||||
| _ -> failwith $"malformed filter: found non-string operand on RHS of equality, '%O{value}'"
|
||||
|
||||
match key with
|
||||
@@ -220,7 +260,7 @@ module Filter =
|
||||
| ParsedFilter.Contains (key, value) ->
|
||||
let value =
|
||||
match value with
|
||||
| ParsedFilter.String s -> unescape s
|
||||
| ParsedFilter.String s -> s
|
||||
| _ -> failwith $"malformed filter: found non-string operand on RHS of containment, '%O{value}'"
|
||||
|
||||
match key with
|
||||
|
@@ -52,6 +52,9 @@ module TestFilter =
|
||||
),
|
||||
ParsedFilter.Equal (ParsedFilter.TestCategory, ParsedFilter.String "1")
|
||||
)
|
||||
|
||||
"Name ~\"'hello" world^&foo|bar!><\"",
|
||||
ParsedFilter.Contains (ParsedFilter.Name, ParsedFilter.String """'hello" world^&foo|bar!><""")
|
||||
]
|
||||
|> List.map TestCaseData
|
||||
|
||||
@@ -69,10 +72,18 @@ module TestFilter =
|
||||
"FullyQualifiedName~xyz", Filter.FullyQualifiedName (Match.Contains "xyz")
|
||||
"FullyQualifiedName!~IntegrationTests",
|
||||
Filter.Not (Filter.FullyQualifiedName (Match.Contains "IntegrationTests"))
|
||||
|
||||
"FullyQualifiedName=MyNamespace.MyTestsClass<ParameterType1%2CParameterType2>.MyTestMethod",
|
||||
Filter.FullyQualifiedName (
|
||||
Match.Exact "MyNamespace.MyTestsClass<ParameterType1%2CParameterType2>.MyTestMethod"
|
||||
)
|
||||
|
||||
// This example has been modified: it's in quotes and XML-escaped.
|
||||
"FullyQualifiedName=\"MyNamespace.MyTestsClass<ParameterType1%2CParameterType2>.MyTestMethod\"",
|
||||
Filter.FullyQualifiedName (
|
||||
Match.Exact "MyNamespace.MyTestsClass<ParameterType1%2CParameterType2>.MyTestMethod"
|
||||
)
|
||||
|
||||
"Name~Method", Filter.Name (Match.Contains "Method")
|
||||
"FullyQualifiedName!=MSTestNamespace.UnitTest1.TestMethod1",
|
||||
Filter.Not (Filter.FullyQualifiedName (Match.Exact "MSTestNamespace.UnitTest1.TestMethod1"))
|
||||
@@ -101,3 +112,14 @@ module TestFilter =
|
||||
[<TestCaseSource(nameof docExamplesRefined)>]
|
||||
let ``Doc examples, refined`` (example : string, expected : Filter) =
|
||||
Filter.parse example |> shouldEqual expected
|
||||
|
||||
let xmlExamples =
|
||||
[
|
||||
"Name ~\"'hello" world^&foo|bar!><\"",
|
||||
Filter.Name (Match.Contains """'hello" world^&foo|bar!><""")
|
||||
]
|
||||
|> List.map TestCaseData
|
||||
|
||||
[<TestCaseSource(nameof xmlExamples)>]
|
||||
let ``XML examples`` (example : string, expected : Filter) =
|
||||
Filter.parse example |> shouldEqual expected
|
||||
|
Reference in New Issue
Block a user