From 70878898895ec72a0b704be8d11f9e70146a7706 Mon Sep 17 00:00:00 2001 From: deyanp <25847560+deyanp@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:23:28 +0100 Subject: [PATCH 1/3] feat: support escaped pipe (\|) in Gherkin table cells The Gherkin specification defines \| as an escape sequence for literal pipe characters within table cells. TickSpec previously split table rows on all | characters without handling escapes, making it impossible to include pipe characters in table data. Replace \| with a placeholder before splitting, then restore the literal | in the resulting cell values. This aligns TickSpec with the behavior of Cucumber, SpecFlow, and other Gherkin-based frameworks. Co-Authored-By: Claude Opus 4.6 (1M context) --- TickSpec.Tests/EscapedPipe.feature | 22 ++++++++++ TickSpec.Tests/FeatureParserTest.fs | 61 +++++++++++++++++++++++++++- TickSpec.Tests/TickSpec.Tests.fsproj | 1 + TickSpec/LineParser.fs | 6 ++- 4 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 TickSpec.Tests/EscapedPipe.feature diff --git a/TickSpec.Tests/EscapedPipe.feature b/TickSpec.Tests/EscapedPipe.feature new file mode 100644 index 0000000..647f926 --- /dev/null +++ b/TickSpec.Tests/EscapedPipe.feature @@ -0,0 +1,22 @@ +Feature: Escaped pipe in table cells + + Scenario Outline: Values with escaped pipes are parsed correctly + Given a value + Then the value is + + Examples: + | value | expected | + | no pipe | no pipe | + | before\|after | before\|after | + | \|leading | \|leading | + | trailing\| | trailing\| | + | a\|b\|c | a\|b\|c | + + Scenario: Step table with escaped pipes + Given a table with escaped pipes + | col1 | col2 | + | hello\|world | normal | + | a\|b\|c | x | + Then the table cell 0,0 is hello|world + And the table cell 0,1 is normal + And the table cell 1,0 is a|b|c diff --git a/TickSpec.Tests/FeatureParserTest.fs b/TickSpec.Tests/FeatureParserTest.fs index fc0f40c..ad07a33 100644 --- a/TickSpec.Tests/FeatureParserTest.fs +++ b/TickSpec.Tests/FeatureParserTest.fs @@ -604,4 +604,63 @@ let RuleKeyword_ParseBlocks () = let secondRule = featureBlock.Rules.[1] Assert.AreEqual("Second business rule", secondRule.Name) Assert.AreEqual(0, secondRule.Background.Length) // No rule-level background - Assert.AreEqual(1, secondRule.Scenarios.Length) // One scenario in second rule \ No newline at end of file + Assert.AreEqual(1, secondRule.Scenarios.Length) // One scenario in second rule + +[] +let EscapedPipe_ParseLines () = + "TickSpec.Tests.EscapedPipe.feature" + |> loadFeatureFile + |> verifyLineParsing <| + [ + FileStart + FeatureName "Escaped pipe in table cells" + Scenario "Scenario Outline: Values with escaped pipes are parsed correctly" + Step (GivenStep "a value ") + Step (ThenStep "the value is ") + Examples + Item (Examples, TableRow [ "value"; "expected" ]) + Item (Examples, TableRow [ "no pipe"; "no pipe" ]) + Item (Examples, TableRow [ "before|after"; "before|after" ]) + Item (Examples, TableRow [ "|leading"; "|leading" ]) + Item (Examples, TableRow [ "trailing|"; "trailing|" ]) + Item (Examples, TableRow [ "a|b|c"; "a|b|c" ]) + Scenario "Scenario: Step table with escaped pipes" + Step (GivenStep "a table with escaped pipes") + Item (Step (GivenStep "a table with escaped pipes"), TableRow [ "col1"; "col2" ]) + Item (Step (GivenStep "a table with escaped pipes"), TableRow [ "hello|world"; "normal" ]) + Item (Step (GivenStep "a table with escaped pipes"), TableRow [ "a|b|c"; "x" ]) + Step (ThenStep "the table cell 0,0 is hello|world") + Step (ThenStep "the table cell 0,1 is normal") + Step (ThenStep "the table cell 1,0 is a|b|c") + ] + +[] +let EscapedPipe_ParseFeature () = + let featureSource = + "TickSpec.Tests.EscapedPipe.feature" + |> loadFeatureFile + |> FeatureParser.parseFeature + + Assert.AreEqual("Escaped pipe in table cells", featureSource.Name) + Assert.AreEqual(6, featureSource.Scenarios.Length) + + // Verify the outline scenario with escaped pipes produces correct parameter values + let scenario3 = featureSource.Scenarios.[2] + let parameters = scenario3.Parameters |> dict + Assert.AreEqual("before|after", parameters.["value"]) + Assert.AreEqual("before|after", parameters.["expected"]) + + let scenario5 = featureSource.Scenarios.[4] + let parameters5 = scenario5.Parameters |> dict + Assert.AreEqual("a|b|c", parameters5.["value"]) + Assert.AreEqual("a|b|c", parameters5.["expected"]) + + // Verify step table with escaped pipes + let tableScenario = featureSource.Scenarios.[5] + let tableStep = tableScenario.Steps.[0] |> fst + Assert.AreEqual(GivenStep "a table with escaped pipes", tableStep) + let table = (tableScenario.Steps.[0] |> snd).Table.Value + Assert.AreEqual([| "col1"; "col2" |], table.Header) + Assert.AreEqual("hello|world", table.Rows.[0].[0]) + Assert.AreEqual("normal", table.Rows.[0].[1]) + Assert.AreEqual("a|b|c", table.Rows.[1].[0]) \ No newline at end of file diff --git a/TickSpec.Tests/TickSpec.Tests.fsproj b/TickSpec.Tests/TickSpec.Tests.fsproj index fccb25b..a7b5842 100644 --- a/TickSpec.Tests/TickSpec.Tests.fsproj +++ b/TickSpec.Tests/TickSpec.Tests.fsproj @@ -9,6 +9,7 @@ + diff --git a/TickSpec/LineParser.fs b/TickSpec/LineParser.fs index ba19324..d3db50a 100644 --- a/TickSpec/LineParser.fs +++ b/TickSpec/LineParser.fs @@ -84,8 +84,10 @@ let (|ButLine|_|) s = |> Option.map (function Trim t -> ButLine t) let (|TableRowLine|_|) (s:string) = if s.Trim().StartsWith("|") then - let columnsStrings = s.Trim().Split([|'|'|], System.StringSplitOptions.RemoveEmptyEntries) - let columns = [ for (Trim s) in columnsStrings -> s ] + let placeholder = "\u0000" + let escaped = s.Trim().Replace("\\|", placeholder) + let columnsStrings = escaped.Split([|'|'|], System.StringSplitOptions.RemoveEmptyEntries) + let columns = [ for (Trim s) in columnsStrings -> s.Replace(placeholder, "|") ] TableRowLine columns |> Some else None let (|Bullet|_|) (s:string) = From 7cde50f66685df68229b5a30ffbc46e8053d6b56 Mon Sep 17 00:00:00 2001 From: deyanp <25847560+deyanp@users.noreply.github.com> Date: Mon, 23 Mar 2026 00:28:52 +0100 Subject: [PATCH 2/3] fix: correct scenario indices in EscapedPipe_ParseFeature test Scenarios.[2] is the third Examples row ("|leading"), not "before|after". Fixed indices: 1 for "before|after", 4 for "a|b|c". Co-Authored-By: Claude Opus 4.6 (1M context) --- TickSpec.Tests/FeatureParserTest.fs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/TickSpec.Tests/FeatureParserTest.fs b/TickSpec.Tests/FeatureParserTest.fs index ad07a33..e9046d2 100644 --- a/TickSpec.Tests/FeatureParserTest.fs +++ b/TickSpec.Tests/FeatureParserTest.fs @@ -644,16 +644,17 @@ let EscapedPipe_ParseFeature () = Assert.AreEqual("Escaped pipe in table cells", featureSource.Name) Assert.AreEqual(6, featureSource.Scenarios.Length) - // Verify the outline scenario with escaped pipes produces correct parameter values - let scenario3 = featureSource.Scenarios.[2] - let parameters = scenario3.Parameters |> dict - Assert.AreEqual("before|after", parameters.["value"]) - Assert.AreEqual("before|after", parameters.["expected"]) - - let scenario5 = featureSource.Scenarios.[4] - let parameters5 = scenario5.Parameters |> dict - Assert.AreEqual("a|b|c", parameters5.["value"]) - Assert.AreEqual("a|b|c", parameters5.["expected"]) + // Verify the outline scenarios with escaped pipes produce correct parameter values + // Row order: 0=no pipe, 1=before|after, 2=|leading, 3=trailing|, 4=a|b|c + let scenario1 = featureSource.Scenarios.[1] + let params1 = scenario1.Parameters |> dict + Assert.AreEqual("before|after", params1.["value"]) + Assert.AreEqual("before|after", params1.["expected"]) + + let scenario4 = featureSource.Scenarios.[4] + let params4 = scenario4.Parameters |> dict + Assert.AreEqual("a|b|c", params4.["value"]) + Assert.AreEqual("a|b|c", params4.["expected"]) // Verify step table with escaped pipes let tableScenario = featureSource.Scenarios.[5] From 2dc58c44d70ddbac79b2d440d0697b08be456b78 Mon Sep 17 00:00:00 2001 From: Deyan Petrov <25847560+deyanp@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:22:48 +0100 Subject: [PATCH 3/3] feat: support full Gherkin table cell escaping (\\, \|, \n) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the complete escape logic per the Cucumber spec: - \\ → literal backslash - \| → literal pipe (not a cell delimiter) - \n → newline character Fix \\| being incorrectly treated as escaped pipe instead of escaped backslash followed by a cell delimiter. --- TickSpec.Tests/EscapedPipe.feature | 47 ++++++++++- TickSpec.Tests/FeatureParserTest.fs | 119 ++++++++++++++++++++++++---- TickSpec/LineParser.fs | 17 +++- 3 files changed, 163 insertions(+), 20 deletions(-) diff --git a/TickSpec.Tests/EscapedPipe.feature b/TickSpec.Tests/EscapedPipe.feature index 647f926..9b68237 100644 --- a/TickSpec.Tests/EscapedPipe.feature +++ b/TickSpec.Tests/EscapedPipe.feature @@ -1,4 +1,4 @@ -Feature: Escaped pipe in table cells +Feature: Escaped characters in table cells Scenario Outline: Values with escaped pipes are parsed correctly Given a value @@ -12,6 +12,22 @@ Feature: Escaped pipe in table cells | trailing\| | trailing\| | | a\|b\|c | a\|b\|c | + Scenario Outline: Values with escaped backslashes are parsed correctly + Given a value + Then the value is + + Examples: + | value | expected | + | hello\\world | hello\\world | + + Scenario Outline: Values with escaped newlines are parsed correctly + Given a value + Then the value is + + Examples: + | value | expected | + | line1\nline2 | line1\nline2 | + Scenario: Step table with escaped pipes Given a table with escaped pipes | col1 | col2 | @@ -20,3 +36,32 @@ Feature: Escaped pipe in table cells Then the table cell 0,0 is hello|world And the table cell 0,1 is normal And the table cell 1,0 is a|b|c + + Scenario: Step table with escaped backslashes + Given a table with escaped backslashes + | col1 | col2 | + | hello\\world | normal | + Then the table cell 0,0 is hello\world + And the table cell 0,1 is normal + + Scenario: Escaped backslash before pipe acts as cell delimiter + Given a table with escaped backslash before pipe + | col1 | col2 | col3 | + | before\\| after | end | + Then the table cell 0,0 is before\ + And the table cell 0,1 is after + And the table cell 0,2 is end + + Scenario: Triple backslash-pipe is escaped backslash then escaped pipe + Given a table with triple backslash-pipe + | col1 | col2 | + | a\\\|b | normal | + Then the table cell 0,0 is a\|b + And the table cell 0,1 is normal + + Scenario: Escaped backslash before n is literal backslash-n not newline + Given a table with escaped backslash before n + | col1 | col2 | + | test\\n | normal | + Then the table cell 0,0 is test\n + And the table cell 0,1 is normal diff --git a/TickSpec.Tests/FeatureParserTest.fs b/TickSpec.Tests/FeatureParserTest.fs index e9046d2..234e02f 100644 --- a/TickSpec.Tests/FeatureParserTest.fs +++ b/TickSpec.Tests/FeatureParserTest.fs @@ -607,13 +607,15 @@ let RuleKeyword_ParseBlocks () = Assert.AreEqual(1, secondRule.Scenarios.Length) // One scenario in second rule [] -let EscapedPipe_ParseLines () = +let EscapedCharacters_ParseLines () = "TickSpec.Tests.EscapedPipe.feature" |> loadFeatureFile |> verifyLineParsing <| [ FileStart - FeatureName "Escaped pipe in table cells" + FeatureName "Escaped characters in table cells" + + // Scenario Outline: escaped pipes Scenario "Scenario Outline: Values with escaped pipes are parsed correctly" Step (GivenStep "a value ") Step (ThenStep "the value is ") @@ -624,6 +626,24 @@ let EscapedPipe_ParseLines () = Item (Examples, TableRow [ "|leading"; "|leading" ]) Item (Examples, TableRow [ "trailing|"; "trailing|" ]) Item (Examples, TableRow [ "a|b|c"; "a|b|c" ]) + + // Scenario Outline: escaped backslashes + Scenario "Scenario Outline: Values with escaped backslashes are parsed correctly" + Step (GivenStep "a value ") + Step (ThenStep "the value is ") + Examples + Item (Examples, TableRow [ "value"; "expected" ]) + Item (Examples, TableRow [ "hello\\world"; "hello\\world" ]) + + // Scenario Outline: escaped newlines + Scenario "Scenario Outline: Values with escaped newlines are parsed correctly" + Step (GivenStep "a value ") + Step (ThenStep "the value is ") + Examples + Item (Examples, TableRow [ "value"; "expected" ]) + Item (Examples, TableRow [ "line1\nline2"; "line1\nline2" ]) + + // Scenario: step table with escaped pipes Scenario "Scenario: Step table with escaped pipes" Step (GivenStep "a table with escaped pipes") Item (Step (GivenStep "a table with escaped pipes"), TableRow [ "col1"; "col2" ]) @@ -632,20 +652,51 @@ let EscapedPipe_ParseLines () = Step (ThenStep "the table cell 0,0 is hello|world") Step (ThenStep "the table cell 0,1 is normal") Step (ThenStep "the table cell 1,0 is a|b|c") + + // Scenario: step table with escaped backslashes + Scenario "Scenario: Step table with escaped backslashes" + Step (GivenStep "a table with escaped backslashes") + Item (Step (GivenStep "a table with escaped backslashes"), TableRow [ "col1"; "col2" ]) + Item (Step (GivenStep "a table with escaped backslashes"), TableRow [ "hello\\world"; "normal" ]) + Step (ThenStep "the table cell 0,0 is hello\\world") + Step (ThenStep "the table cell 0,1 is normal") + + // Scenario: escaped backslash before pipe acts as delimiter + Scenario "Scenario: Escaped backslash before pipe acts as cell delimiter" + Step (GivenStep "a table with escaped backslash before pipe") + Item (Step (GivenStep "a table with escaped backslash before pipe"), TableRow [ "col1"; "col2"; "col3" ]) + Item (Step (GivenStep "a table with escaped backslash before pipe"), TableRow [ "before\\"; "after"; "end" ]) + Step (ThenStep "the table cell 0,0 is before\\") + Step (ThenStep "the table cell 0,1 is after") + Step (ThenStep "the table cell 0,2 is end") + + // Scenario: triple backslash-pipe (\\\| -> \|) + Scenario "Scenario: Triple backslash-pipe is escaped backslash then escaped pipe" + Step (GivenStep "a table with triple backslash-pipe") + Item (Step (GivenStep "a table with triple backslash-pipe"), TableRow [ "col1"; "col2" ]) + Item (Step (GivenStep "a table with triple backslash-pipe"), TableRow [ "a\\|b"; "normal" ]) + Step (ThenStep "the table cell 0,0 is a\\|b") + Step (ThenStep "the table cell 0,1 is normal") + + // Scenario: escaped backslash before n (\\n -> \n literal, not newline) + Scenario "Scenario: Escaped backslash before n is literal backslash-n not newline" + Step (GivenStep "a table with escaped backslash before n") + Item (Step (GivenStep "a table with escaped backslash before n"), TableRow [ "col1"; "col2" ]) + Item (Step (GivenStep "a table with escaped backslash before n"), TableRow [ "test\\n"; "normal" ]) + Step (ThenStep "the table cell 0,0 is test\\n") + Step (ThenStep "the table cell 0,1 is normal") ] [] -let EscapedPipe_ParseFeature () = +let EscapedCharacters_ParseFeature () = let featureSource = "TickSpec.Tests.EscapedPipe.feature" |> loadFeatureFile |> FeatureParser.parseFeature - Assert.AreEqual("Escaped pipe in table cells", featureSource.Name) - Assert.AreEqual(6, featureSource.Scenarios.Length) + Assert.AreEqual("Escaped characters in table cells", featureSource.Name) - // Verify the outline scenarios with escaped pipes produce correct parameter values - // Row order: 0=no pipe, 1=before|after, 2=|leading, 3=trailing|, 4=a|b|c + // Outline 1: escaped pipes (5 data rows -> scenarios 0-4) let scenario1 = featureSource.Scenarios.[1] let params1 = scenario1.Parameters |> dict Assert.AreEqual("before|after", params1.["value"]) @@ -656,12 +707,48 @@ let EscapedPipe_ParseFeature () = Assert.AreEqual("a|b|c", params4.["value"]) Assert.AreEqual("a|b|c", params4.["expected"]) - // Verify step table with escaped pipes - let tableScenario = featureSource.Scenarios.[5] - let tableStep = tableScenario.Steps.[0] |> fst - Assert.AreEqual(GivenStep "a table with escaped pipes", tableStep) - let table = (tableScenario.Steps.[0] |> snd).Table.Value - Assert.AreEqual([| "col1"; "col2" |], table.Header) - Assert.AreEqual("hello|world", table.Rows.[0].[0]) - Assert.AreEqual("normal", table.Rows.[0].[1]) - Assert.AreEqual("a|b|c", table.Rows.[1].[0]) \ No newline at end of file + // Outline 2: escaped backslashes (1 data row -> scenario 5) + let bsScenario = featureSource.Scenarios.[5] + let bsParams = bsScenario.Parameters |> dict + Assert.AreEqual("hello\\world", bsParams.["value"]) + Assert.AreEqual("hello\\world", bsParams.["expected"]) + + // Outline 3: escaped newlines (1 data row -> scenario 6) + let nlScenario = featureSource.Scenarios.[6] + let nlParams = nlScenario.Parameters |> dict + Assert.AreEqual("line1\nline2", nlParams.["value"]) + Assert.AreEqual("line1\nline2", nlParams.["expected"]) + + // Scenario: step table with escaped pipes (scenario 7) + let pipeTableScenario = featureSource.Scenarios.[7] + let pipeTable = (pipeTableScenario.Steps.[0] |> snd).Table.Value + Assert.AreEqual([| "col1"; "col2" |], pipeTable.Header) + Assert.AreEqual("hello|world", pipeTable.Rows.[0].[0]) + Assert.AreEqual("normal", pipeTable.Rows.[0].[1]) + Assert.AreEqual("a|b|c", pipeTable.Rows.[1].[0]) + + // Scenario: step table with escaped backslashes (scenario 8) + let bsTableScenario = featureSource.Scenarios.[8] + let bsTable = (bsTableScenario.Steps.[0] |> snd).Table.Value + Assert.AreEqual("hello\\world", bsTable.Rows.[0].[0]) + Assert.AreEqual("normal", bsTable.Rows.[0].[1]) + + // Scenario: escaped backslash before pipe = cell delimiter (scenario 9) + let delimScenario = featureSource.Scenarios.[9] + let delimTable = (delimScenario.Steps.[0] |> snd).Table.Value + Assert.AreEqual([| "col1"; "col2"; "col3" |], delimTable.Header) + Assert.AreEqual("before\\", delimTable.Rows.[0].[0]) + Assert.AreEqual("after", delimTable.Rows.[0].[1]) + Assert.AreEqual("end", delimTable.Rows.[0].[2]) + + // Scenario: triple backslash-pipe \\\| -> \| (scenario 10) + let tripleScenario = featureSource.Scenarios.[10] + let tripleTable = (tripleScenario.Steps.[0] |> snd).Table.Value + Assert.AreEqual("a\\|b", tripleTable.Rows.[0].[0]) + Assert.AreEqual("normal", tripleTable.Rows.[0].[1]) + + // Scenario: \\n -> literal \n, not newline (scenario 11) + let bsNScenario = featureSource.Scenarios.[11] + let bsNTable = (bsNScenario.Steps.[0] |> snd).Table.Value + Assert.AreEqual("test\\n", bsNTable.Rows.[0].[0]) + Assert.IsFalse(bsNTable.Rows.[0].[0].Contains("\n"), "Should not contain actual newline") \ No newline at end of file diff --git a/TickSpec/LineParser.fs b/TickSpec/LineParser.fs index d3db50a..be14d71 100644 --- a/TickSpec/LineParser.fs +++ b/TickSpec/LineParser.fs @@ -84,10 +84,21 @@ let (|ButLine|_|) s = |> Option.map (function Trim t -> ButLine t) let (|TableRowLine|_|) (s:string) = if s.Trim().StartsWith("|") then - let placeholder = "\u0000" - let escaped = s.Trim().Replace("\\|", placeholder) + let escapedBackslash = "\u0000" + let escapedPipe = "\u0001" + let escapedNewline = "\u0002" + let escaped = + s.Trim() + .Replace("\\\\", escapedBackslash) + .Replace("\\|", escapedPipe) + .Replace("\\n", escapedNewline) let columnsStrings = escaped.Split([|'|'|], System.StringSplitOptions.RemoveEmptyEntries) - let columns = [ for (Trim s) in columnsStrings -> s.Replace(placeholder, "|") ] + let columns = + [ for (Trim s) in columnsStrings -> + s + .Replace(escapedBackslash, "\\") + .Replace(escapedPipe, "|") + .Replace(escapedNewline, "\n") ] TableRowLine columns |> Some else None let (|Bullet|_|) (s:string) =