diff --git a/Directory.Build.props b/Directory.Build.props
index 8705d91..8b9e4b4 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,6 +8,7 @@
+
diff --git a/README.md b/README.md
index 2c4dfbd..ee09507 100644
--- a/README.md
+++ b/README.md
@@ -202,6 +202,58 @@ While the typical recommended usage of TickSpec is to keep the step definitions
See the `CustomContainer` example project for usage examples - the example demonstrates wiring of Autofac including usage of lifetime scopes per scenario and usage of the [xUnit 2+ Shared Fixtures](https://xunit.github.io/docs/shared-context.html) to correctly manage the sharing/ifetime of the container where one runs xUnit Test Classes in parallel as part of a large test suite.
+# Build integration
+
+TickSpec.Build automatically integrates into the build process as "BeforeCompile" target and generates the "wiring" needed to execute
+the BDD scenarios. Currently only NUnit is supported as test framework.
+
+## HTML documentation
+
+TickSpec.Build additionally supports generating HTML documents for the feature files
+
+```bash
+TickSpec.Build doc ./src ./html
+```
+
+When generating the HTML files to the output location the F# project local folders are preserved.
+
+Using ``--toc html`` a HTML table of contents and with ``--toc json`` a Json table of contents can be generated.
+
+### Styling
+
+The generated HTML documents intentionally only contain HTML fragments of type "article" so that those
+articles can easily be integrated in an existing HTML documentation.
+
+These articles provide the following CSS classes for styling:
+
+- **gherkin-keyword** applies to the keywords like GIVEN, WHEN, THEN
+- **gherkin-scenario-body** applies to the body of a scenario
+- **gherkin-scenario** applies to a complete scenario
+- **gherkin-tags** applies to the tags attached to scenarios
+- **gherkin-description** applies to a comment provided above a scenario
+- **gherkin-scenario-title** applies to the title of a scenario
+- **gherkin-feature-title** applies to the feature title
+
+If you want to use the generated articles as a standalone documentation use ``--toc html`` to generate a
+standalone HTML document. Put a ``style.css`` next to the ``ToC.html`` to define the CSS classes listed above
+
+### MsBuild integration
+
+To integrate the HTML generation into your MsBuild based build process set the property ``FeatureFileHtmlOutput``
+to the location the HTML files should be generated too. By default, only the feature files local to this project
+are considered. You can change this by setting the property ``FeatureFileHtmlInput``.
+
+The format of the table of contents can be set using property ``TickSpecBuildTocFormat``.
+
+
+## Story behind this project
+
+The following articles tell the story behind this project:
+
+- [Lean BDD and Code Generation](http://www.plainionist.net/TickSpec-with-Code-Generation/)
+- [Lean BDD with even more Code Generation](http://www.plainionist.net/TickSpec-More-CodeGen/)
+
+
# Contributing
Contributions are welcome, particularly examples and documentation. If you'd like to chat about TickSpec, please use the [the gitter channel](https://gitter.im/fsprojects/TickSpec).
diff --git a/TickSpec.sln b/TickSpec.sln
index 8ae8841..43de646 100644
--- a/TickSpec.sln
+++ b/TickSpec.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 15
-VisualStudioVersion = 15.0.27130.2027
+# Visual Studio Version 17
+VisualStudioVersion = 17.3.32901.215
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{CD7A2701-42ED-47BD-8724-1E130B5C5071}"
EndProject
@@ -17,47 +17,53 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CommandLine", "CommandLine"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CSharp", "Examples\ByFramework\CommandLine\CSharp\CSharp.csproj", "{FBB69434-1AC8-46B9-8533-81F09B69B134}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharp", "Examples\ByFramework\CommandLine\FSharp\FSharp.fsproj", "{9A6D191D-73B3-48D9-A0C1-51581E8894A8}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp", "Examples\ByFramework\CommandLine\FSharp\FSharp.fsproj", "{9A6D191D-73B3-48D9-A0C1-51581E8894A8}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TicTacToe", "Examples\ByFramework\CommandLine\TicTacToe\TicTacToe.fsproj", "{3D58E3C3-1A7C-426D-BC9F-555735EA622E}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TicTacToe", "Examples\ByFramework\CommandLine\TicTacToe\TicTacToe.fsproj", "{3D58E3C3-1A7C-426D-BC9F-555735EA622E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NUnit.CSharp", "Examples\ByFramework\NUnit\CSharp.NUnit\NUnit.CSharp.csproj", "{B224A186-190C-43D8-B83C-3E2F0FD7BE39}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NUnit.FSharp", "Examples\ByFramework\NUnit\FSharp.NUnit\NUnit.FSharp.fsproj", "{5DFF73BD-8C5D-4EE2-B5DD-2D244EC06A61}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "NUnit.FSharp", "Examples\ByFramework\NUnit\FSharp.NUnit\NUnit.FSharp.fsproj", "{5DFF73BD-8C5D-4EE2-B5DD-2D244EC06A61}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Interactive", "Examples\ByStyle\Interactive\Interactive.fsproj", "{BA6F3A69-34F3-4D85-8804-DD354F8E17FA}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Interactive", "Examples\ByStyle\Interactive\Interactive.fsproj", "{BA6F3A69-34F3-4D85-8804-DD354F8E17FA}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Attributes", "Examples\ByStyle\Attributes\Attributes.fsproj", "{B3AA7D1B-E83A-4C9B-97CF-12293AF46E10}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Attributes", "Examples\ByStyle\Attributes\Attributes.fsproj", "{B3AA7D1B-E83A-4C9B-97CF-12293AF46E10}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Functional", "Examples\ByStyle\Functional\Functional.fsproj", "{1D2CA398-6F02-41B1-BAA5-E63DC15508A4}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Functional", "Examples\ByStyle\Functional\Functional.fsproj", "{1D2CA398-6F02-41B1-BAA5-E63DC15508A4}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Xunit.FSharp", "Examples\ByFramework\xUnit\FSharp.xUnit\Xunit.FSharp.fsproj", "{1207F9C8-3B84-4939-B6E2-DB96D06B73E2}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Xunit.FSharp", "Examples\ByFramework\xUnit\FSharp.xUnit\Xunit.FSharp.fsproj", "{1207F9C8-3B84-4939-B6E2-DB96D06B73E2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ByFeature", "ByFeature", "{EB1F6262-0913-4464-A034-4F4D01268E83}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TickSpec", "TickSpec\TickSpec.fsproj", "{DC4A4A1D-CCC5-42E7-8EE6-4F015E8A2E3E}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSpec", "TickSpec\TickSpec.fsproj", "{DC4A4A1D-CCC5-42E7-8EE6-4F015E8A2E3E}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "CustomContainer", "Examples\ByFeature\CustomContainer\CustomContainer.fsproj", "{4F45F8E2-F9FD-4C80-B4E0-A38B17A791D2}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "CustomContainer", "Examples\ByFeature\CustomContainer\CustomContainer.fsproj", "{4F45F8E2-F9FD-4C80-B4E0-A38B17A791D2}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "DependencyInjection", "Examples\ByFeature\DependencyInjection\DependencyInjection.fsproj", "{6DD7F109-6798-4CAF-BD13-7BA4689BD892}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "DependencyInjection", "Examples\ByFeature\DependencyInjection\DependencyInjection.fsproj", "{6DD7F109-6798-4CAF-BD13-7BA4689BD892}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FunctionalInjection", "Examples\ByFeature\FunctionalInjection\FunctionalInjection.fsproj", "{1CE6B475-C94F-438E-97D4-5E99FD0F04D4}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FunctionalInjection", "Examples\ByFeature\FunctionalInjection\FunctionalInjection.fsproj", "{1CE6B475-C94F-438E-97D4-5E99FD0F04D4}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TickSpec.Tests", "TickSpec.Tests\TickSpec.Tests.fsproj", "{CD2AA807-5E4F-4A2C-80F7-A7D7CA6B3C2D}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSpec.Tests", "TickSpec.Tests\TickSpec.Tests.fsproj", "{CD2AA807-5E4F-4A2C-80F7-A7D7CA6B3C2D}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TaggedExamples", "Examples\ByFeature\TaggedExamples\TaggedExamples.fsproj", "{39C63F8E-F3A5-48D8-851C-62BEB9C701C2}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TaggedExamples", "Examples\ByFeature\TaggedExamples\TaggedExamples.fsproj", "{39C63F8E-F3A5-48D8-851C-62BEB9C701C2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MSTest", "MSTest", "{694A8BD0-05B1-4000-BDD2-67CC4B3B8D90}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MSTest.FSharp", "Examples\ByFramework\MSTest\MSTest.FSharp\MSTest.FSharp.fsproj", "{6EAC46DB-177A-4482-970A-D43124DB2FB9}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MSTest.FSharp", "Examples\ByFramework\MSTest\MSTest.FSharp\MSTest.FSharp.fsproj", "{6EAC46DB-177A-4482-970A-D43124DB2FB9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Expecto", "Expecto", "{750A854A-FB45-4E51-94DF-D79B4F1E46DC}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Expecto.FSharp", "Examples\ByFramework\Expecto\FSharp.Expecto\Expecto.FSharp.fsproj", "{F9B139E4-0160-4150-B420-73DCCD57B6B8}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Expecto.FSharp", "Examples\ByFramework\Expecto\FSharp.Expecto\Expecto.FSharp.fsproj", "{F9B139E4-0160-4150-B420-73DCCD57B6B8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Wiring", "Wiring", "{373B654A-3A88-48CC-A0A3-0454514E61FF}"
EndProject
-Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TickSpec.Xunit", "Wiring\TickSpec.Xunit\TickSpec.Xunit.fsproj", "{DDB39875-AA8B-494E-B923-C8143930464F}"
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSpec.Xunit", "Wiring\TickSpec.Xunit\TickSpec.Xunit.fsproj", "{DDB39875-AA8B-494E-B923-C8143930464F}"
+EndProject
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSepc.Build.Tests", "Wiring\TickSepc.Build.Tests\TickSepc.Build.Tests.fsproj", "{8FE8AF85-C728-4DD7-BD16-3520707F97A1}"
+EndProject
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSpec.Build", "Wiring\TickSpec.Build\TickSpec.Build.fsproj", "{F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6}"
+EndProject
+Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSpec.CodeGen.NUnit", "Wiring\TickSpec.CodeGen.NUnit\TickSpec.CodeGen.NUnit.fsproj", "{3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -137,6 +143,18 @@ Global
{DDB39875-AA8B-494E-B923-C8143930464F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DDB39875-AA8B-494E-B923-C8143930464F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DDB39875-AA8B-494E-B923-C8143930464F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8FE8AF85-C728-4DD7-BD16-3520707F97A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8FE8AF85-C728-4DD7-BD16-3520707F97A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8FE8AF85-C728-4DD7-BD16-3520707F97A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8FE8AF85-C728-4DD7-BD16-3520707F97A1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -166,6 +184,9 @@ Global
{750A854A-FB45-4E51-94DF-D79B4F1E46DC} = {1D43105D-361B-4D27-8069-FBE067C8C114}
{F9B139E4-0160-4150-B420-73DCCD57B6B8} = {750A854A-FB45-4E51-94DF-D79B4F1E46DC}
{DDB39875-AA8B-494E-B923-C8143930464F} = {373B654A-3A88-48CC-A0A3-0454514E61FF}
+ {8FE8AF85-C728-4DD7-BD16-3520707F97A1} = {373B654A-3A88-48CC-A0A3-0454514E61FF}
+ {F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6} = {373B654A-3A88-48CC-A0A3-0454514E61FF}
+ {3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC} = {373B654A-3A88-48CC-A0A3-0454514E61FF}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8B381617-F1EE-4BFC-8EAE-CAB4DBB4B9A2}
diff --git a/Wiring/TickSepc.Build.Tests/Assertions.fs b/Wiring/TickSepc.Build.Tests/Assertions.fs
new file mode 100644
index 0000000..f9c0d63
--- /dev/null
+++ b/Wiring/TickSepc.Build.Tests/Assertions.fs
@@ -0,0 +1,21 @@
+[]
+module TickSepc.Build.Tests.Assertions
+
+open System.Text.RegularExpressions
+open NUnit.Framework.Constraints
+
+type SubStringConstraintIgnoreWhitespaces(expected:string) =
+ inherit Constraint()
+ let removeWhitespaces str = Regex.Replace(str, @"\s", "")
+ let expected' = expected |> removeWhitespaces
+ override this.ApplyTo(actual) =
+ let actual' = actual.ToString() |> removeWhitespaces
+ let isSuccess = actual'.Contains(expected')
+ ConstraintResult(this, actual, isSuccess)
+ override _.Description = expected
+
+let haveSubstringIgnoringWhitespaces = SubStringConstraintIgnoreWhitespaces
+
+let dump x =
+ printfn "%A" x
+ x
diff --git a/Wiring/TickSepc.Build.Tests/HtmlGenerationTests.fs b/Wiring/TickSepc.Build.Tests/HtmlGenerationTests.fs
new file mode 100644
index 0000000..aee3e5e
--- /dev/null
+++ b/Wiring/TickSepc.Build.Tests/HtmlGenerationTests.fs
@@ -0,0 +1,173 @@
+module TickSepc.Build.Tests.HtmlGenerationTests
+
+open NUnit.Framework
+open TickSpec.Build
+open FsUnit
+
+[]
+let ``Feature title is headline``() =
+ """
+ Feature: First feature
+
+ Scenario: One
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+ """
+ |> TestApi.GenerateHtmlDoc
+ |> should haveSubstringIgnoringWhitespaces """
First feature
"""
+
+[]
+let ``Scenario title is headline``() =
+ """
+ Feature: First feature
+
+ Scenario: One
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+ """
+ |> TestApi.GenerateHtmlDoc
+ |> should haveSubstringIgnoringWhitespaces """
One
"""
+
+[]
+let ``Scenario Outline title is headline``() =
+ """
+ Feature: First feature
+
+ Scenario Outline: One
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+ """
+ |> TestApi.GenerateHtmlDoc
+ |> should haveSubstringIgnoringWhitespaces """
One
"""
+
+[]
+let ``Steps rendered as ``() =
+ """
+ Feature: First feature
+
+ Scenario: One
+ GIVEN some environment
+ AND with following setting
+ WHEN some event happens
+ THEN the system should be in this state
+ AND behave like this
+ """
+ |> TestApi.GenerateHtmlDoc
+ |> should haveSubstringIgnoringWhitespaces """
+
One
+
Given some environment
+And with following setting
+When some event happens
+Then the system should be in this state
+And behave like this
+
"""
+
+[]
+let ``With Background``() =
+ """
+ Feature: First feature
+
+ Background:
+ GIVEN some additional environment
+
+ Scenario: One
+ GIVEN some environment
+ AND with following setting
+ WHEN some event happens
+ THEN the system should be in this state
+ AND behave like this
+ """
+ |> TestApi.GenerateHtmlDoc
+ |> should haveSubstringIgnoringWhitespaces """
+
+
First feature
+
+
Background
+
Given some additional environment
+
"""
+
+[]
+let ``Step with multi line string``() =
+ """
+ Feature: First feature
+
+ Scenario: One
+ GIVEN some environment
+ AND the following value
+ \"\"\"
+ line 1
+ line 2
+ \"\"\"
+ WHEN some event happens
+ THEN the system should be in this state
+ """
+ |> TestApi.GenerateHtmlDoc
+ |> should haveSubstringIgnoringWhitespaces """
+
One
+
Given some environment
+And the following value
+ \"\"\"
+ line 1
+ line 2
+ \"\"\"
+When some event happens
+Then the system should be in this state
+
"""
+
+[]
+let ``Tags``() =
+ """
+ Feature: First feature
+
+ @some-tag @one-more-tag
+ Scenario: One
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+ """
+ |> TestApi.GenerateHtmlDoc
+ |> should haveSubstringIgnoringWhitespaces """
+
+
One
+
Tags:some-tag, one-more-tag
+
"""
+
+[]
+let ``Comments``() =
+ """
+ Feature: First feature
+
+ Scenario: One
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+
+ # this is a comment
+ # over multiple lines
+ @some-tag @one-more-tag
+ Scenario: Two
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+ """
+ |> TestApi.GenerateHtmlDoc
+ |> should haveSubstringIgnoringWhitespaces """
+
+
One
+
Given some environment
+When some event happens
+Then the system should be in this state
+
+
+
+
Two
+
Tags:some-tag, one-more-tag
+
this is a comment over multiple lines
+
Given some environment
+When some event happens
+Then the system should be in this state
+
"""
+
diff --git a/Wiring/TickSepc.Build.Tests/TestFixtureGenerationTests.fs b/Wiring/TickSepc.Build.Tests/TestFixtureGenerationTests.fs
new file mode 100644
index 0000000..4188196
--- /dev/null
+++ b/Wiring/TickSepc.Build.Tests/TestFixtureGenerationTests.fs
@@ -0,0 +1,217 @@
+module TickSepc.Build.Tests.TestFixtureGenerationTests
+
+open NUnit.Framework
+open TickSpec.Build
+open FsUnit
+
+[]
+let ``Single scenario``() =
+ [
+ """
+ Feature: First feature
+
+ Scenario: One
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+ """
+ ]
+ |> TestApi.GenerateTestFixtures "Dummy.feature"
+ |> should haveSubstringIgnoringWhitespaces """
+ namespace Specification
+
+ open System.Reflection
+ open NUnit.Framework
+ open TickSpec.CodeGen
+
+ []
+ type ``First feature``() =
+ inherit AbstractFeature()
+
+ let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "Dummy.feature")
+
+ []
+ member this.``One``() =
+ #line 5 "Dummy.feature"
+ this.RunScenario(scenarios, "Scenario: One")
+ """
+
+[]
+let ``Feature file in sub folder``() =
+ [
+ """
+ Feature: First feature
+
+ Scenario: One
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+ """
+ ]
+ |> TestApi.GenerateTestFixtures "SubFeature/Dummy.feature"
+ |> should haveSubstringIgnoringWhitespaces """
+ namespace Specification
+
+ open System.Reflection
+ open NUnit.Framework
+ open TickSpec.CodeGen
+
+ []
+ type ``First feature``() =
+ inherit AbstractFeature()
+
+ let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "SubFeature.Dummy.feature")
+
+ []
+ member this.``One``() =
+ #line 5 "SubFeature/Dummy.feature"
+ this.RunScenario(scenarios, "Scenario: One")
+ """
+
+
+
+[]
+let ``With background``() =
+ [
+ """
+ Feature: First feature
+
+ Background:
+ GIVEN some additional environment
+
+ Scenario: One
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+ """
+ ]
+ |> TestApi.GenerateTestFixtures "Dummy.feature"
+ |> should haveSubstringIgnoringWhitespaces """
+ namespace Specification
+
+ open System.Reflection
+ open NUnit.Framework
+ open TickSpec.CodeGen
+
+ []
+ type ``First feature``() =
+ inherit AbstractFeature()
+
+ let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "Dummy.feature")
+
+ []
+ member this.``One``() =
+ #line 8 "Dummy.feature"
+ this.RunScenario(scenarios, "Scenario: One")
+ """
+
+[]
+let ``Multiple features with multipe scenario``() =
+ [
+ """
+ Feature: First feature
+
+ Scenario: One
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+
+ Scenario: Two
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+ """
+
+ """
+ Feature: Second feature
+
+ Scenario: Three
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+
+ Scenario: Four
+ GIVEN some environment
+ WHEN some event happens
+ THEN the system should be in this state
+ """
+ ]
+ |> TestApi.GenerateTestFixtures "Dummy.feature"
+ |> should haveSubstringIgnoringWhitespaces """
+ namespace Specification
+
+ open System.Reflection
+ open NUnit.Framework
+ open TickSpec.CodeGen
+
+ []
+ type ``First feature``() =
+ inherit AbstractFeature()
+
+ let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "Dummy.feature")
+
+ []
+ member this.``One``() =
+ #line 5 "Dummy.feature"
+ this.RunScenario(scenarios, "Scenario: One")
+
+ []
+ member this.``Two``() =
+ #line 10 "Dummy.feature"
+ this.RunScenario(scenarios, "Scenario: Two")
+
+ []
+ type ``Second feature``() =
+ inherit AbstractFeature()
+
+ let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "Dummy.feature")
+
+ []
+ member this.``Three``() =
+ #line 5 "Dummy.feature"
+ this.RunScenario(scenarios, "Scenario: Three")
+
+ []
+ member this.``Four``() =
+ #line 10 "Dummy.feature"
+ this.RunScenario(scenarios, "Scenario: Four")
+ """
+
+[]
+let ``Scenario outline``() =
+ [
+ """
+ Feature: First feature
+
+ Scenario Outline: Computing the state
+ GIVEN a work item
+ AND with "Concept Needed" set to ''
+ WHEN parsing the work item
+ THEN the computed concept state is ''
+
+ Examples:
+ | ConceptNeeded | ConceptState |
+ | | Unset |
+ | yes | Needed |
+ | no | NotNeeded |
+ """
+ ]
+ |> TestApi.GenerateTestFixtures "Dummy.feature"
+ |> should haveSubstringIgnoringWhitespaces """
+ namespace Specification
+
+ open System.Reflection
+ open NUnit.Framework
+ open TickSpec.CodeGen
+
+ []
+ type ``First feature``() =
+ inherit AbstractFeature()
+
+ let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "Dummy.feature")
+
+ []
+ member this.``Computing the state``() =
+ #line 5 "Dummy.feature"
+ this.RunScenario(scenarios, "Scenario Outline: Computing the state")
+ """
diff --git a/Wiring/TickSepc.Build.Tests/TickSepc.Build.Tests.fsproj b/Wiring/TickSepc.Build.Tests/TickSepc.Build.Tests.fsproj
new file mode 100644
index 0000000..d8b35d3
--- /dev/null
+++ b/Wiring/TickSepc.Build.Tests/TickSepc.Build.Tests.fsproj
@@ -0,0 +1,34 @@
+
+
+
+ net6.0
+ true
+ 1591
+ 618,672
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wiring/TickSpec.Build/Entities.fs b/Wiring/TickSpec.Build/Entities.fs
new file mode 100644
index 0000000..c4584e9
--- /dev/null
+++ b/Wiring/TickSpec.Build/Entities.fs
@@ -0,0 +1,23 @@
+namespace TickSpec.Build
+
+type Scenario = {
+ Name : string
+ Title : string
+ Description : string
+ Tags : string list
+ Body : string list
+ StartsAtLine : int
+}
+
+type Location = {
+ Filename : string
+ /// project local folders
+ Folders : string list
+}
+
+type Feature = {
+ Name : string
+ Location : Location
+ Background : string list
+ Scenarios : Scenario list
+}
diff --git a/Wiring/TickSpec.Build/GherkinParser.fs b/Wiring/TickSpec.Build/GherkinParser.fs
new file mode 100644
index 0000000..fbb365b
--- /dev/null
+++ b/Wiring/TickSpec.Build/GherkinParser.fs
@@ -0,0 +1,179 @@
+module TickSpec.Build.GherkinParser
+
+open System
+open System.IO
+
+[]
+module private Impl =
+ let trimLine numSpaces (line:string) =
+ if line |> String.IsNullOrWhiteSpace then
+ String.Empty
+ else
+ line.Substring(numSpaces).TrimEnd()
+
+ // indent which exists for all non-empty lines
+ let detectGlobalIndent lines =
+ lines
+ |> Seq.filter (String.IsNullOrWhiteSpace >> not)
+ |> Seq.map(fun x -> x |> Seq.takeWhile Char.IsWhiteSpace |> Seq.length)
+ |> Seq.min
+
+ // Line numbers start at 1 as in any editor
+ let parseLines (text:string)=
+ let lines = text.Split(Environment.NewLine)
+
+ let globalIndent = lines |> detectGlobalIndent
+
+ lines
+ |> Seq.mapi(fun i l -> i + 1, l |> trimLine globalIndent)
+ |> List.ofSeq
+
+ let (|Title|_|) (keyword:string) (line:string) =
+ let prefix = keyword + ":"
+ if line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) then
+ line.Substring(prefix.Length).Trim() |> Some
+ else
+ None
+
+ let (|Tags|_|) (line:string) =
+ if line.Trim().StartsWith("@") then
+ line.Split(' ', StringSplitOptions.RemoveEmptyEntries)
+ |> Seq.map(fun x -> x.Trim().TrimStart('@'))
+ |> List.ofSeq
+ |> Some
+ else
+ None
+
+ let (|Comment|_|) (line:string) =
+ if line.Trim().StartsWith("#") then
+ line.TrimStart().TrimStart('#').Trim() |> Some
+ else
+ None
+
+ let trimEmptyLines =
+ Seq.skipWhile String.IsNullOrWhiteSpace
+ >> Seq.rev
+ >> Seq.skipWhile String.IsNullOrWhiteSpace
+ >> Seq.rev
+
+ let parseTags (lines:(int*string) list) lineNo =
+ if lineNo > 0 then
+ match lines |> Seq.find(fun (x,_) -> x = lineNo - 1) |> snd with
+ | Tags x -> x
+ | _ -> []
+ else
+ []
+
+ let parseComment (lines:(int*string) list) lineNo =
+ if lineNo > 0 then
+ lines
+ |> Seq.takeWhile(fun (x,_) -> x < lineNo)
+ |> Seq.rev
+ |> Seq.takeWhile(fun (_,x) -> String.IsNullOrWhiteSpace(x) |> not)
+ |> Seq.map(fun (_,x) -> x.Trim())
+ |> Seq.choose(function | Comment x -> x |> Some | _ -> None)
+ |> Seq.rev
+ |> String.concat " "
+ else
+ ""
+
+ let getProjectLocalFolders (file:string) =
+ let rec find dir =
+ seq {
+ if Directory.GetFiles(dir, "*.fsproj").Length > 0 then
+ yield! []
+ else
+ yield! dir |> Path.GetFileName |> List.singleton
+ yield! dir |> Path.GetDirectoryName |> find
+ }
+
+
+ file |> Path.GetDirectoryName |> find |> List.ofSeq
+
+let Parse location (feature:string) =
+ let linesWithLineNo = feature |> parseLines
+
+ let featureName =
+ linesWithLineNo
+ |> Seq.map snd
+ |> Seq.choose(function | Title "Feature" x -> x |> Some | _ -> None)
+ |> Seq.exactlyOne
+
+ let scenarios =
+ linesWithLineNo
+ |> Seq.filter(snd >> function | Tags _ -> false | Comment _ -> false | _ -> true)
+ |> Seq.mapFold(fun scenario (lineNo, line) ->
+ match scenario, line with
+ | _, Title "Scenario" x
+ | _, Title "Scenario Outline" x ->
+ let newScenario =
+ {
+ Name = line
+ Title = x
+ StartsAtLine = lineNo + 1 // skip scenario title
+ Body = []
+ Tags = parseTags linesWithLineNo lineNo
+ Description = parseComment linesWithLineNo lineNo
+ }
+ scenario, newScenario |> Some
+ | Some scenario, _ -> None, { scenario with Body = line::scenario.Body } |> Some
+ | None, _ -> None, None // ignore lines outside scenario
+ ) None
+ |> fun (scenarios, scenario) -> scenario |> List.singleton |> Seq.append scenarios
+ |> Seq.choose id
+ |> Seq.map(fun x ->
+ let globalIndent = x.Body |> detectGlobalIndent
+ { x with Body = x.Body |> Seq.map (trimLine globalIndent) |> trimEmptyLines |> Seq.rev |> List.ofSeq } )
+ |> List.ofSeq
+
+ let background =
+ linesWithLineNo
+ |> Seq.map snd
+ |> Seq.filter(function | Tags _ -> false | Comment _ -> false | _ -> true)
+ |> Seq.skipWhile ((function | Title "Background" _ -> true | _ -> false) >> not)
+ |> Seq.takeWhile ((function | Title "Scenario" _ -> true | Title "Scenario Outline" _ -> true | _ -> false) >> not)
+ |> trimEmptyLines
+ |> List.ofSeq
+ |> function
+ | [] -> []
+ | h::t ->
+ let globalIndent = t |> detectGlobalIndent
+ t |> List.map (trimLine globalIndent)
+
+ {
+ Name = featureName
+ Background = background
+ Location = location
+ Scenarios = scenarios
+ }
+
+let Read (file:string) =
+ let location =
+ {
+ Filename = file |> Path.GetFileName
+ Folders = file |> getProjectLocalFolders
+ }
+
+ file |> File.ReadAllText |> Parse location
+
+let FindAllFeatureFiles folder =
+ let subDirectoriesToSkip =
+ [
+ "node_modules"
+ "obj"
+ "bin"
+ "dist"
+ ]
+
+ let rec getAllFiles dir =
+ seq {
+ yield! Directory.EnumerateFiles(dir, "*.feature")
+ for subDir in Directory.EnumerateDirectories(dir) do
+ let dirName = Path.GetFileName(subDir)
+ if subDirectoriesToSkip |> Seq.exists(fun x -> x.Equals(dirName, StringComparison.OrdinalIgnoreCase)) |> not then
+ yield! getAllFiles subDir
+ }
+
+ folder
+ |> getAllFiles
+ |> List.ofSeq
diff --git a/Wiring/TickSpec.Build/HtmlGenerator.fs b/Wiring/TickSpec.Build/HtmlGenerator.fs
new file mode 100644
index 0000000..a574edb
--- /dev/null
+++ b/Wiring/TickSpec.Build/HtmlGenerator.fs
@@ -0,0 +1,182 @@
+module TickSpec.Build.HtmlGenerator
+
+open System
+open System.IO
+open System.Xml.Linq
+open System.Xml
+
+type TocFormat =
+ | Html
+ | Json
+
+type TocEntry = {
+ Title: string
+ Folders : string list
+ Filename: string
+}
+
+[]
+module private Impl =
+ open System.Text.Json
+
+ let (|Keyword|_|) (keyword:string) (line:string) =
+ if line.TrimStart().StartsWith(keyword + " ", StringComparison.OrdinalIgnoreCase) then
+ let indent = line |> Seq.takeWhile Char.IsWhiteSpace |> Seq.length
+ (keyword.PadLeft(indent, ' '), line.Substring(keyword.Length + indent)) |> Some
+ else
+ None
+
+ let generateStep (line:string) =
+ match line with
+ | Keyword "Given" (k,l)
+ | Keyword "When" (k,l)
+ | Keyword "Then" (k,l)
+ | Keyword "And" (k,l)
+ | Keyword "But" (k,l) ->
+ [
+ new XElement("span", new XAttribute("class", "gherkin-keyword"), k) :> obj
+ l
+ Environment.NewLine
+ ]
+ | _ ->
+ [
+ line
+ Environment.NewLine
+ ]
+
+ let generateScenarioBody (lines:string list) =
+ new XElement("pre",
+ new XAttribute("class", "gherkin-scenario-body"),
+ new XElement("code",
+ lines
+ |> Seq.map generateStep))
+
+ let generateScenario (scenario:Scenario) =
+ let doc = new XElement("div", new XAttribute("class", "gherkin-scenario"))
+
+ doc.Add(new XElement("h3", [|
+ new XAttribute("class", "gherkin-scenario-title") :> obj
+ scenario.Title :> obj |]))
+
+ match scenario.Tags with
+ | [] -> ()
+ | tags ->
+ new XElement("div",
+ new XElement("span", new XAttribute("class", "gherkin-tags"), "Tags:"),
+ String.Join(", ", tags))
+ |> doc.Add
+
+ match scenario.Description with
+ | "" -> ()
+ | text ->
+ doc.Add(new XElement("div", new XAttribute("class", "gherkin-description"), text))
+
+ doc.Add(generateScenarioBody scenario.Body)
+
+ doc
+
+ let generateBackground (lines:string list) =
+ let doc = new XElement("div", new XAttribute("class", "gherkin-scenario"))
+
+ doc.Add(new XElement("h3", [|
+ new XAttribute("class", "gherkin-scenario-title") :> obj
+ "Background" :> obj |]))
+
+ doc.Add(generateScenarioBody lines)
+
+ doc
+
+ let generateFeature stylesheet (feature:Feature) =
+ let doc = new XElement("article")
+
+ stylesheet
+ |> Option.iter(fun (x:string) ->
+ doc.Add(new XElement("link",
+ new XAttribute("rel", "stylesheet"),
+ new XAttribute("href", x))))
+
+ doc.Add(new XElement("h2", [|
+ new XAttribute("class", "gherkin-feature-title") :> obj
+ feature.Name |]))
+
+ match feature.Background with
+ | [] -> ()
+ | x -> doc.Add(generateBackground x)
+
+ feature.Scenarios
+ |> Seq.map generateScenario
+ |> Seq.iter doc.Add
+
+ doc
+
+ let write (writer:TextWriter) (doc:XElement) =
+ let settings = new XmlWriterSettings()
+ // explicitly disable so that formatting is kept
+ settings.Indent <- false
+
+ use xmlWriter = XmlWriter.Create(writer, settings)
+ doc.WriteTo(xmlWriter)
+
+ let generateHtmlToc (features:Feature list) =
+ let head = new XElement("head",
+ new XElement("link",
+ new XAttribute("rel", "stylesheet"),
+ new XAttribute("href", "style.css")))
+
+ let body =
+ let doc = new XElement("body")
+ doc.Add(new XElement("h2", "Table of contents"))
+
+ features
+ |> Seq.map(fun x -> [x.Name + ".html"] |> List.append x.Location.Folders |> String.concat "/",x)
+ |> Seq.sortBy fst
+ |> Seq.map(fun (file,x) ->
+ new XElement("li",
+ new XElement("a",[|
+ new XAttribute("href", file) :> obj
+ new XAttribute("target", "article") :> obj
+ x.Name |])))
+ |> fun x -> doc.Add(new XElement("ul", x))
+
+ doc.Add(new XElement("iframe",
+ new XAttribute("id", "article"),
+ new XAttribute("name", "article"),
+ new XAttribute("width", "100%"),
+ new XAttribute("height", "80%"),
+ new XElement("div")))
+
+ doc
+
+ new XElement("html", head, body)
+
+ let generateJsonToc (writer:TextWriter) (features:Feature list) =
+ let entries =
+ features
+ |> Seq.map(fun x ->
+ {
+ Title = x.Name
+ Folders = x.Location.Folders
+ Filename = x.Name + ".html"
+ })
+ |> List.ofSeq
+
+ let options = new JsonSerializerOptions()
+ options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
+ options.WriteIndented <- true
+
+ let json = JsonSerializer.Serialize(entries, options)
+ writer.WriteLine(json)
+
+let GenerateArticle (writer:TextWriter) stylesheet (feature:Feature) =
+ feature
+ |> generateFeature stylesheet
+ |> write writer
+
+let GenerateToC tocFormat (features:Feature list) (output:string) =
+ match tocFormat with
+ | Html ->
+ use writer = new StreamWriter(Path.Combine(output, "toc.html"))
+ generateHtmlToc features |> write writer
+ | Json ->
+ use writer = new StreamWriter(Path.Combine(output, "toc.json"))
+ generateJsonToc writer features
diff --git a/Wiring/TickSpec.Build/Program.fs b/Wiring/TickSpec.Build/Program.fs
new file mode 100644
index 0000000..61133d1
--- /dev/null
+++ b/Wiring/TickSpec.Build/Program.fs
@@ -0,0 +1,80 @@
+module TickSpec.Build.Program
+
+open System
+open CommandLine
+
+module Unions =
+ open System.Reflection
+ open Microsoft.FSharp.Reflection
+
+ let private bindingFlags = BindingFlags.Public ||| BindingFlags.NonPublic
+
+ let fromString<'a> (s:string) =
+ try
+ match FSharpType.GetUnionCases(typeof<'a>, bindingFlags) |> Array.filter (fun case -> case.Name.Equals(s,StringComparison.OrdinalIgnoreCase)) with
+ |[|case|] -> Some(FSharpValue.MakeUnion(case,[||]) :?> 'a)
+ |_ -> None
+ with
+ | _ -> None
+
+[]
+type FixturesOptions = {
+ [] File : string
+}
+
+[]
+type DocOptions = {
+ [] Input : string
+ [] Output : string
+ [