diff --git a/.github/workflows/release-nuget.yaml b/.github/workflows/release-nuget.yaml
new file mode 100644
index 00000000..6c9ccbc4
--- /dev/null
+++ b/.github/workflows/release-nuget.yaml
@@ -0,0 +1,22 @@
+name: Release NuGet
+
+on:
+ push:
+ branches: [ release/* ]
+
+jobs:
+ publish-nuget:
+ name: Publish package to NuGet.org
+ # Failing on `ubuntu-24.04` (https://github.com/cucumber/gherkin/issues/349)
+ runs-on: ubuntu-22.04
+ environment: Release
+ steps:
+ - uses: actions/checkout@v5
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 9.0.x
+ - uses: cucumber/action-publish-nuget@v1.0.0
+ with:
+ nuget-api-key: ${{ secrets.NUGET_API_KEY }}
+ working-directory: "dotnet"
\ No newline at end of file
diff --git a/.github/workflows/test-dotnet.yml b/.github/workflows/test-dotnet.yml
new file mode 100644
index 00000000..e07b6284
--- /dev/null
+++ b/.github/workflows/test-dotnet.yml
@@ -0,0 +1,32 @@
+name: test-dotnet
+
+on:
+ push:
+ branches:
+ - main
+ - renovate/**
+ paths:
+ - dotnet/**
+ - testdata/**
+ - .github/**
+ pull_request:
+ branches:
+ - main
+ paths:
+ - dotnet/**
+ - testdata/**
+ - .github/**
+ workflow_call:
+
+jobs:
+ test-dotnet:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: |
+ 8.0.x
+ 9.0.x
+ - run: dotnet test
+ working-directory: dotnet
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1701d8e7..fdb6018d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Render the empty tag expression as an empty string ([#222](https://github.com/cucumber/tag-expressions/pull/222))
- Improve error message for missing operands ([#221](https://github.com/cucumber/tag-expressions/pull/221))
+- [.NET] Add a .NET implementation
+
## [8.0.0] - 2025-10-14
### Fixed
- [Perl] Fix building release artifacts ([#214](https://github.com/cucumber/tag-expressions/pull/214))
diff --git a/dotnet/.editorconfig b/dotnet/.editorconfig
new file mode 100644
index 00000000..d7b8a2e0
--- /dev/null
+++ b/dotnet/.editorconfig
@@ -0,0 +1,13 @@
+root=true
+
+[*]
+indent_style=space
+end_of_line=crlf
+charset=utf-8
+
+[*.{csproj,props}]
+indent_size=2
+
+[*.cs]
+indent_size=4
+insert_final_newline = true
\ No newline at end of file
diff --git a/dotnet/.gitignore b/dotnet/.gitignore
new file mode 100644
index 00000000..9a2acfb3
--- /dev/null
+++ b/dotnet/.gitignore
@@ -0,0 +1,178 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+
+# User-specific files
+*.suo
+*.user
+*.sln.docstates
+*.ide
+
+# Build results
+
+[Dd]ebug/
+[Rr]elease/
+x64/
+build/
+[Oo]bj/
+*/**/bin
+
+# Enable "build/" folder in the NuGet Packages folder since NuGet packages use it for MSBuild targets
+!packages/*/build/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+*_i.c
+*_p.c
+*.ilk
+*.meta
+*.obj
+*.pch
+*.pdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.log
+*.scc
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opensdf
+*.sdf
+*.cachefile
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# NCrunch
+*.ncrunch*
+.*crunch*.local.xml
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.Publish.xml
+
+# NuGet Packages Directory
+## TODO: If you have NuGet Package Restore enabled, uncomment the next line
+#packages/
+
+# Windows Azure Build Output
+csx
+*.build.csdef
+
+# Windows Store app package directory
+AppPackages/
+
+# Others
+sql/
+*.Cache
+ClientBin/
+[Ss]tyle[Cc]op.*
+~$*
+*~
+*.dbmdl
+*.[Pp]ublish.xml
+*.pfx
+*.publishsettings
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file to a newer
+# Visual Studio version. Backup files are not needed, because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+
+# SQL Server files
+App_Data/*.mdf
+App_Data/*.ldf
+
+
+#LightSwitch generated files
+GeneratedArtifacts/
+_Pvt_Extensions/
+ModelManifest.xml
+
+# =========================
+# Windows detritus
+# =========================
+
+# Windows image file caches
+Thumbs.db
+ehthumbs.db
+
+# Folder config file
+Desktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Mac desktop service store files
+.DS_Store
+
+packages/
+acceptance/
+output/
+.built
+.compared
+.sln_built_debug
+*.userprefs
+*.nupkg
+Gherkin.NuGetPackages/bin/
+.build*
+.built*
+.vscode
+.run_tests
+.generated
+.packed
+.tested
+.fixprotoc
+.vs/
+
+# ========================
diff --git a/dotnet/Cucumber.TagExpressions.sln b/dotnet/Cucumber.TagExpressions.sln
new file mode 100644
index 00000000..878e6ced
--- /dev/null
+++ b/dotnet/Cucumber.TagExpressions.sln
@@ -0,0 +1,31 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.36616.10
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cucumber.TagExpressions", "TagExpressions\Cucumber.TagExpressions.csproj", "{0BDD57B5-52F9-4866-8031-C84E41138453}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cucumber.TagExpressionsTest", "TagExpressionsTest\Cucumber.TagExpressionsTest.csproj", "{EBEAC0FF-9EA0-4065-B39E-B9C4C29EE48A}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {0BDD57B5-52F9-4866-8031-C84E41138453}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0BDD57B5-52F9-4866-8031-C84E41138453}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0BDD57B5-52F9-4866-8031-C84E41138453}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0BDD57B5-52F9-4866-8031-C84E41138453}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EBEAC0FF-9EA0-4065-B39E-B9C4C29EE48A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EBEAC0FF-9EA0-4065-B39E-B9C4C29EE48A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EBEAC0FF-9EA0-4065-B39E-B9C4C29EE48A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EBEAC0FF-9EA0-4065-B39E-B9C4C29EE48A}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {0789D1F0-C759-4F1D-BED0-F55A1E05100B}
+ EndGlobalSection
+EndGlobal
diff --git a/dotnet/Cucumber.TagExpressions.snk b/dotnet/Cucumber.TagExpressions.snk
new file mode 100644
index 00000000..0d41503a
Binary files /dev/null and b/dotnet/Cucumber.TagExpressions.snk differ
diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props
new file mode 100644
index 00000000..5fa33879
--- /dev/null
+++ b/dotnet/Directory.Build.props
@@ -0,0 +1,10 @@
+
+
+
+ 13
+ enable
+ enable
+ true
+
+
+
\ No newline at end of file
diff --git a/dotnet/LICENSE b/dotnet/LICENSE
new file mode 100644
index 00000000..1c022048
--- /dev/null
+++ b/dotnet/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) Cucumber Ltd
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/dotnet/README.md b/dotnet/README.md
new file mode 100644
index 00000000..fb54d8f8
--- /dev/null
+++ b/dotnet/README.md
@@ -0,0 +1,5 @@
+[](https://www.nuget.org/packages/Cucumber.TagExpressions)
+
+# Cucumber Tag Expressions for .NET
+
+[The docs are here](https://cucumber.io/docs/cucumber/api/#tag-expressions).
diff --git a/dotnet/TagExpressions/Cucumber.TagExpressions.csproj b/dotnet/TagExpressions/Cucumber.TagExpressions.csproj
new file mode 100644
index 00000000..3b2563b4
--- /dev/null
+++ b/dotnet/TagExpressions/Cucumber.TagExpressions.csproj
@@ -0,0 +1,41 @@
+
+
+
+ netstandard2.0
+ 1591
+ true
+ ..\Cucumber.TagExpressions.snk
+
+
+
+ 8.0.0
+ $(VersionNumber)-$(SnapshotSuffix)
+ $(VersionNumber)
+
+
+
+
+ True
+ .
+ true
+
+
+
+
+ Cucumber.TagExpressions
+ Cucumber.TagExpressions
+ Cucumber Ltd, Chris Rudolphi
+ Copyright © Cucumber Ltd, Chris Rudolphi
+ Tag Expressions is a simple query language for Cucumber tags.
+ reqnroll gherkin cucumber
+ https://github.com/cucumber/tag-expressions
+ https://github.com/cucumber/tag-expressions
+ git
+ cucumber-mark-green-128.png
+ MIT
+
+ true
+ bin/$(Configuration)/NuGet
+
+
+
diff --git a/dotnet/TagExpressions/ITagExpression.cs b/dotnet/TagExpressions/ITagExpression.cs
new file mode 100644
index 00000000..289b024d
--- /dev/null
+++ b/dotnet/TagExpressions/ITagExpression.cs
@@ -0,0 +1,20 @@
+namespace Cucumber.TagExpressions;
+
+///
+/// Represents a tag expression that can be evaluated against a set of input tags.
+///
+public interface ITagExpression
+{
+ ///
+ /// Evaluates the tag expression against the provided input tags.
+ ///
+ /// A collection of input tags to evaluate against.
+ /// true if the expression matches the inputs; otherwise, false.
+ bool Evaluate(IEnumerable inputs);
+
+ ///
+ /// Returns the string representation of the tag expression.
+ ///
+ /// A string that represents the tag expression.
+ string ToString();
+}
diff --git a/dotnet/TagExpressions/ITagExpressionParser.cs b/dotnet/TagExpressions/ITagExpressionParser.cs
new file mode 100644
index 00000000..8c0e5d25
--- /dev/null
+++ b/dotnet/TagExpressions/ITagExpressionParser.cs
@@ -0,0 +1,14 @@
+namespace Cucumber.TagExpressions;
+
+///
+/// Defines a parser for tag expressions.
+///
+public interface ITagExpressionParser
+{
+ ///
+ /// Parses the specified text into an .
+ ///
+ /// The tag expression string to parse.
+ /// An representing the parsed expression.
+ ITagExpression Parse(string text);
+}
diff --git a/dotnet/TagExpressions/InternalsVisibleTo.TagExpressionsTest.cs b/dotnet/TagExpressions/InternalsVisibleTo.TagExpressionsTest.cs
new file mode 100644
index 00000000..d4616e9b
--- /dev/null
+++ b/dotnet/TagExpressions/InternalsVisibleTo.TagExpressionsTest.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Cucumber.TagExpressionsTest, PublicKey=002400000480000094000000060200000024000052534131000400000100010065f38950b50c56e5f4902d259bdc7b1b0b5f8064ead6d41c6e3eb0ed078cdaf8e26e93e408ccd068ba1281fa3793d543cdbdf9bebd9804b33c35fca67febaafdccac647ec810a10c14c281bad866ef47a46f5d59aedf0914bd3915bfbb07bbf996bd37a98c3d2f5cabd548b2b0cef548b3d9bc272360adefa06fb5c24dd447b3")]
diff --git a/dotnet/TagExpressions/Resources/cucumber-mark-green-128.png b/dotnet/TagExpressions/Resources/cucumber-mark-green-128.png
new file mode 100644
index 00000000..3dacd756
Binary files /dev/null and b/dotnet/TagExpressions/Resources/cucumber-mark-green-128.png differ
diff --git a/dotnet/TagExpressions/TagExpression.cs b/dotnet/TagExpressions/TagExpression.cs
new file mode 100644
index 00000000..418026f0
--- /dev/null
+++ b/dotnet/TagExpressions/TagExpression.cs
@@ -0,0 +1,166 @@
+using System.Text;
+
+namespace Cucumber.TagExpressions;
+
+///
+/// Provides an abstract base for tag expressions that can be evaluated against a set of input tags.
+///
+public abstract class TagExpression : ITagExpression
+{
+ ///
+ /// Evaluates the tag expression against the provided input tags.
+ ///
+ /// A collection of input tags to evaluate against.
+ /// true if the expression matches the inputs; otherwise, false.
+ public bool Evaluate(IEnumerable inputs)
+ {
+ var inputSet = new HashSet(inputs);
+ return EvaluateInternal(inputSet);
+ }
+
+ ///
+ /// Returns the string representation of the tag expression.
+ ///
+ /// A string that represents the tag expression.
+ public abstract override string ToString();
+
+ ///
+ /// Recursively evaluates the tag expression against the provided set of input tags.
+ ///
+ /// A set of input tags.
+ /// true if the expression matches the inputs; otherwise, false.
+ internal abstract bool EvaluateInternal(HashSet inputs);
+}
+
+///
+/// Represents an expression that always evaluates to .
+///
+///
+/// This expression is used as a fallback condition when the expression input is empty.
+/// By convention, an empty tag expression is considered equivalent to regardless of input.
+///
+public class NullExpression : TagExpression
+{
+ ///
+ public override string ToString() => "";
+
+ ///
+ internal override bool EvaluateInternal(HashSet inputs) => true;
+}
+
+///
+/// Represents a leaf node for a tag expression, corresponding to a literal tag name.
+///
+public class LiteralNode : TagExpression
+{
+ ///
+ /// Gets the name of the literal tag.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the tag.
+ public LiteralNode(string name) => Name = name;
+
+ ///
+ public override string ToString()
+ {
+ var sb = new StringBuilder();
+ foreach (char c in Name)
+ {
+ if (c == '\\' || c == ' ' || c == '(' || c == ')')
+ sb.Append('\\');
+ sb.Append(c);
+ }
+ return sb.ToString();
+ }
+
+ ///
+ internal override bool EvaluateInternal(HashSet inputs) => inputs.Contains(Name);
+}
+
+///
+/// Represents a unary NOT operation in a tag expression.
+///
+public class NotNode : TagExpression
+{
+ ///
+ /// Gets the operand of the NOT operation.
+ ///
+ public ITagExpression Operand { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The operand expression to negate.
+ public NotNode(ITagExpression operand) => Operand = operand;
+
+ ///
+ public override string ToString()
+ {
+ string operandStr = Operand is not BinaryOpNode ? $"( {Operand} )" : Operand.ToString();
+ return $"not {operandStr}";
+ }
+
+ ///
+ internal override bool EvaluateInternal(HashSet inputs) => !(((TagExpression)Operand).EvaluateInternal(inputs));
+}
+
+///
+/// Represents a binary operator node for AND and OR operations in a tag expression.
+///
+public class BinaryOpNode : TagExpression
+{
+ ///
+ /// Gets the left operand of the binary operation.
+ ///
+ public ITagExpression Left { get; }
+
+ ///
+ /// Gets the right operand of the binary operation.
+ ///
+ public ITagExpression Right { get; }
+
+ ///
+ /// Gets the operator for the binary operation ("AND" or "OR").
+ ///
+ public string Op { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The operator ("AND" or "OR").
+ /// The left operand.
+ /// The right operand.
+ public BinaryOpNode(string op, ITagExpression left, ITagExpression right)
+ {
+ Op = op;
+ Left = left;
+ Right = right;
+ }
+
+ ///
+ public override string ToString()
+ {
+ string leftStr = Left is BinaryOpNode leftBin && Precedence(leftBin.Op) < Precedence(Op)
+ ? $"( {Left} )" : Left.ToString();
+ string rightStr = Right is BinaryOpNode rightBin && Precedence(rightBin.Op) < Precedence(Op)
+ ? $"( {Right} )" : Right.ToString();
+ return $"( {leftStr} {Op} {rightStr} )";
+ }
+
+ private int Precedence(string op) =>
+ op == "not" ? 3 :
+ op == "and" ? 2 :
+ op == "or" ? 1 : 0;
+
+ ///
+ internal override bool EvaluateInternal(HashSet inputs)
+ {
+ bool leftVal = ((TagExpression)Left).EvaluateInternal(inputs);
+ bool rightVal = ((TagExpression)Right).EvaluateInternal(inputs);
+ return Op == "and" ? (leftVal && rightVal) : (leftVal || rightVal);
+ }
+}
diff --git a/dotnet/TagExpressions/TagExpressionException.cs b/dotnet/TagExpressions/TagExpressionException.cs
new file mode 100644
index 00000000..21d93f12
--- /dev/null
+++ b/dotnet/TagExpressions/TagExpressionException.cs
@@ -0,0 +1,11 @@
+namespace Cucumber.TagExpressions;
+
+public class TagExpressionException : ApplicationException
+{
+ public TagToken? TagToken { get; private set; }
+
+ public TagExpressionException(string message, TagToken? tagToken = null) : base(message)
+ {
+ TagToken = tagToken;
+ }
+}
diff --git a/dotnet/TagExpressions/TagExpressionParser.cs b/dotnet/TagExpressions/TagExpressionParser.cs
new file mode 100644
index 00000000..f0c7e620
--- /dev/null
+++ b/dotnet/TagExpressions/TagExpressionParser.cs
@@ -0,0 +1,152 @@
+namespace Cucumber.TagExpressions;
+
+///
+/// Provides a recursive descent parser for logical tag expressions.
+///
+public class TagExpressionParser : ITagExpressionParser
+{
+ private string? _text;
+ private TagLexer? _lexer;
+ private TagToken? _current;
+ private int _openParens;
+
+ ///
+ /// Parses the specified tag expression string into an .
+ ///
+ /// The tag expression string to parse.
+ /// An representing the parsed expression.
+ /// Thrown when a syntax error is encountered in the tag expression.
+ public ITagExpression Parse(string text)
+ {
+ _text = text;
+ _openParens = 0;
+ _lexer = new TagLexer(text);
+ Next();
+ if (_current!.Type == TagTokenType.End)
+ return new NullExpression();
+
+ var expr = ParseExpression();
+
+ while (_current.Type != TagTokenType.End)
+ {
+ Next();
+ }
+ if (_openParens != 0)
+ ThrowSyntaxError("Unmatched (", _current);
+ return expr;
+ }
+
+ ///
+ /// Advances to the next token in the input stream.
+ ///
+ private void Next()
+ {
+ _current = _lexer!.NextToken();
+ if (_current.Type == TagTokenType.LParen)
+ _openParens++;
+ else if (_current.Type == TagTokenType.RParen)
+ {
+ _openParens--;
+ if (_openParens < 0)
+ ThrowSyntaxError("Unmatched )", _current);
+ }
+ }
+
+ ///
+ /// Throws a syntax error exception with the specified message.
+ ///
+ /// The error message.
+ /// Always thrown to indicate a syntax error.
+ private void ThrowSyntaxError(string message, TagToken? tagToken)
+ {
+ throw new TagExpressionException($"Tag expression \"{_text}\" could not be parsed because of syntax error: {message}.", tagToken);
+ }
+
+ ///
+ /// Parses an expression consisting of terms separated by the OR operator.
+ ///
+ /// The parsed .
+ private ITagExpression ParseExpression()
+ {
+ var left = ParseTerm();
+
+ if (_current!.Type != TagTokenType.Or &&
+ _current.Type != TagTokenType.RParen &&
+ _current.Type != TagTokenType.End)
+ {
+ ThrowSyntaxError("Expected operator", _current);
+ }
+
+ while (_current.Type == TagTokenType.Or)
+ {
+ Next();
+ var right = ParseTerm();
+ left = new BinaryOpNode("or", left, right);
+ }
+ return left;
+ }
+
+ ///
+ /// Parses a term consisting of factors separated by the AND operator.
+ ///
+ /// The parsed .
+ private ITagExpression ParseTerm()
+ {
+ var left = ParseFactor();
+
+ if (_current!.Type != TagTokenType.Or &&
+ _current.Type != TagTokenType.And &&
+ _current.Type != TagTokenType.RParen &&
+ _current.Type != TagTokenType.End)
+ {
+ ThrowSyntaxError("Expected operator", _current);
+ }
+
+ while (_current.Type == TagTokenType.And)
+ {
+ Next();
+ var right = ParseFactor();
+ left = new BinaryOpNode("and", left!, right!);
+ }
+ return left!;
+ }
+
+ ///
+ /// Parses a factor, which can be a NOT operation, a parenthesized expression, or an identifier.
+ ///
+ /// The parsed .
+ private ITagExpression? ParseFactor()
+ {
+ switch (_current!.Type)
+ {
+ case TagTokenType.Not:
+ Next();
+ // Only NOT, (, or Identifier are valid after NOT
+ if (_current.Type != TagTokenType.Not &&
+ _current.Type != TagTokenType.LParen &&
+ _current.Type != TagTokenType.Identifier)
+ {
+ ThrowSyntaxError("Expected operand", _current);
+ }
+ var operand = ParseFactor();
+ return new NotNode(operand!);
+
+ case TagTokenType.LParen:
+ Next();
+ var expr = ParseExpression();
+ if (_current.Type != TagTokenType.RParen)
+ ThrowSyntaxError("Unmatched (", _current);
+ Next();
+ return expr;
+
+ case TagTokenType.Identifier:
+ var ident = _current.Value;
+ Next();
+ return new LiteralNode(ident!);
+
+ default:
+ ThrowSyntaxError("Expected operand", _current);
+ return null; // unreachable
+ }
+ }
+}
diff --git a/dotnet/TagExpressions/TagLexer.cs b/dotnet/TagExpressions/TagLexer.cs
new file mode 100644
index 00000000..997235b1
--- /dev/null
+++ b/dotnet/TagExpressions/TagLexer.cs
@@ -0,0 +1,127 @@
+using System.Text;
+
+namespace Cucumber.TagExpressions;
+
+internal class TagLexer
+{
+ private readonly string _text;
+ private int _pos;
+ private int _peekPos = -1;
+ private TagToken? _peekedToken = null;
+ private static readonly HashSet Operators = new() { "AND", "OR", "NOT" };
+ private static readonly char[] Escapable = new[] { '\\', ' ', '(', ')' };
+
+ public TagLexer(string text)
+ {
+ _text = String.IsNullOrEmpty(text) ? "" : text;
+ _pos = 0;
+ }
+
+ private void ThrowSyntaxError(string message, TagToken? tagToken)
+ {
+ throw new TagExpressionException($"Tag expression \"{_text}\" could not be parsed because of syntax error: {message}.", tagToken);
+ }
+
+ public TagToken NextToken()
+ {
+ if (_peekedToken != null)
+ {
+ var token = _peekedToken;
+ _pos = _peekPos;
+ _peekedToken = null;
+ _peekPos = -1;
+ return token;
+ }
+
+ return ReadToken(ref _pos);
+ }
+
+ public TagToken PeekToken()
+ {
+ if (_peekedToken != null)
+ return _peekedToken;
+
+ int tempPos = _pos;
+ var token = ReadToken(ref tempPos);
+ _peekedToken = token;
+ _peekPos = tempPos;
+ return token;
+ }
+
+ private TagToken ReadToken(ref int pos)
+ {
+ SkipWhitespace(ref pos);
+ if (pos >= _text.Length)
+ return new TagToken(TagTokenType.End, null, pos);
+
+ char c = _text[pos];
+
+ // Parentheses
+ if (c == '(')
+ {
+ pos++;
+ return new TagToken(TagTokenType.LParen, c.ToString(), pos - 1);
+ }
+ if (c == ')')
+ {
+ pos++;
+ return new TagToken(TagTokenType.RParen, c.ToString(), pos - 1);
+ }
+
+ // Operators
+ var location = pos;
+ var op = Operators
+ .FirstOrDefault(o => _text.Substring(location).StartsWith(o, StringComparison.OrdinalIgnoreCase));
+ if (op != null)
+ {
+ pos += op.Length;
+ return new TagToken(op switch
+ {
+ "AND" => TagTokenType.And,
+ "OR" => TagTokenType.Or,
+ "NOT" => TagTokenType.Not,
+ _ => throw new Exception("Unknown operator")
+ },
+ op,
+ location);
+ }
+
+ // Identifier (with escapes)
+ var sb = new StringBuilder();
+ var startPos = pos;
+ while (pos < _text.Length)
+ {
+ c = _text[pos];
+ if (char.IsWhiteSpace(c) || c == '(' || c == ')')
+ break;
+
+ if (c == '\\')
+ {
+ if (pos + 1 < _text.Length && Escapable.Contains(_text[pos + 1]))
+ {
+ pos++;
+ sb.Append(_text[pos]);
+ pos++;
+ continue;
+ }
+ else
+ {
+ ThrowSyntaxError($"Illegal escape before \"{_text[pos + 1]}\"", new TagToken(TagTokenType.Identifier, sb.ToString(), startPos));
+ }
+ }
+
+ sb.Append(c);
+ pos++;
+ }
+ if (sb.Length > 0)
+ return new TagToken(TagTokenType.Identifier, sb.ToString(), startPos);
+
+ throw new TagExpressionException($"Unexpected character '{c}' at position {pos}");
+ }
+
+ private void SkipWhitespace(ref int pos)
+ {
+ while (pos < _text.Length && char.IsWhiteSpace(_text[pos]))
+ pos++;
+ }
+}
diff --git a/dotnet/TagExpressions/TagToken.cs b/dotnet/TagExpressions/TagToken.cs
new file mode 100644
index 00000000..f0ae2e06
--- /dev/null
+++ b/dotnet/TagExpressions/TagToken.cs
@@ -0,0 +1,27 @@
+namespace Cucumber.TagExpressions;
+
+public enum TagTokenType
+{
+ Identifier,
+ And,
+ Or,
+ Not,
+ LParen,
+ RParen,
+ End
+}
+
+public class TagToken
+{
+ public TagTokenType Type { get; }
+ public string? Value { get; }
+
+ public int Position { get; set; }
+
+ public TagToken(TagTokenType type, string? value = null, int position = 0)
+ {
+ Type = type;
+ Value = value;
+ Position = position;
+ }
+}
diff --git a/dotnet/TagExpressionsTest/Cucumber.TagExpressionsTest.csproj b/dotnet/TagExpressionsTest/Cucumber.TagExpressionsTest.csproj
new file mode 100644
index 00000000..9eabc049
--- /dev/null
+++ b/dotnet/TagExpressionsTest/Cucumber.TagExpressionsTest.csproj
@@ -0,0 +1,36 @@
+
+
+
+ net8.0
+ latest
+ enable
+ enable
+ true
+ Exe
+ true
+
+ true
+ True
+ ../Cucumber.TagExpressions.snk
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/TagExpressionsTest/ErrorsTest.cs b/dotnet/TagExpressionsTest/ErrorsTest.cs
new file mode 100644
index 00000000..fe78f9df
--- /dev/null
+++ b/dotnet/TagExpressionsTest/ErrorsTest.cs
@@ -0,0 +1,39 @@
+using Cucumber.TagExpressions;
+
+namespace Cucumber.TagExpressionsTest;
+
+[TestClass]
+public class ErrorsTest
+{
+ public class Expectation : Dictionary
+ {
+ public override string ToString()
+ {
+ return $"{this["expression"]}: {this["error"]}";
+ }
+ }
+
+ public static IEnumerable