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 @@ +[![NuGet Version](https://img.shields.io/nuget/v/Cucumber.TagExpressions)](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 Expectations() + { + var folder = TestFolderHelper.TestFolder; + var filePath = Path.Combine(folder, "errors.yml"); + var fileContent = File.ReadAllText(filePath); + var deserializer = new YamlDotNet.Serialization.Deserializer(); + var items = deserializer.Deserialize>(fileContent); + foreach (var item in items) + { + yield return new object?[] { item }; + } + } + + [TestMethod] + [DynamicData(nameof(Expectations), DynamicDataSourceType.Method)] + public void ParsedExpression_ShouldThrow(Expectation expectation) + { + var expression = expectation["expression"]; + var error = expectation["error"]; + var parser = new TagExpressionParser(); + var ex = Assert.ThrowsException(() => parser.Parse(expression)); + Assert.AreEqual(error, ex.Message); + } +} diff --git a/dotnet/TagExpressionsTest/EvaluationsTest.cs b/dotnet/TagExpressionsTest/EvaluationsTest.cs new file mode 100644 index 00000000..1b8e9419 --- /dev/null +++ b/dotnet/TagExpressionsTest/EvaluationsTest.cs @@ -0,0 +1,60 @@ +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Cucumber.TagExpressionsTest; + +[TestClass] +public class EvaluationsTest +{ + public static IEnumerable Expectations() + { + var folder = TestFolderHelper.TestFolder; + var filePath = Path.Combine(folder, "evaluations.yml"); + var fileContent = File.ReadAllText(filePath); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + var testCases = deserializer.Deserialize>(fileContent); + foreach (var item in testCases) + { + foreach (var test in item.Tests) + { + yield return new object?[] { new Expectation { Expression = item.Expression, Result = test.Result, Variables = test.Variables } }; + } + } + } + + [TestMethod] + [DynamicData(nameof(Expectations), DynamicDataSourceType.Method)] + public void EvaluatedExpression_MatchesExpectedResult(Expectation expectation) + { + var parser = new TagExpressions.TagExpressionParser(); + var expression = parser.Parse(expectation.Expression); + var result = expression.Evaluate(expectation.Variables); + Assert.AreEqual(expectation.Result, result, $"Expression: {expectation.Expression}"); + } +} + +public class TestCase +{ + public string Expression { get; set; } = string.Empty; + public List Tests { get; set; } = new List(); +} + +public class Test +{ + public List Variables { get; set; } = new List(); + public bool Result { get; set; } +} + +public class Expectation +{ + public string Expression { get; set; } = string.Empty; + public List Variables { get; set; } = new List(); + public bool Result { get; set; } + + public override string ToString() + { + return $"\"{Expression}\" with \"{String.Join(",", Variables)}\""; + } +} diff --git a/dotnet/TagExpressionsTest/MSTestSettings.cs b/dotnet/TagExpressionsTest/MSTestSettings.cs new file mode 100644 index 00000000..d983280e --- /dev/null +++ b/dotnet/TagExpressionsTest/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.ClassLevel)] diff --git a/dotnet/TagExpressionsTest/ParsingTest.cs b/dotnet/TagExpressionsTest/ParsingTest.cs new file mode 100644 index 00000000..44b7c5db --- /dev/null +++ b/dotnet/TagExpressionsTest/ParsingTest.cs @@ -0,0 +1,38 @@ +using Cucumber.TagExpressions; + +namespace Cucumber.TagExpressionsTest; + +[TestClass] +public sealed class ParsingTest +{ + public class Expectation : Dictionary + { + public override string ToString() + { + return $"{this["expression"]}: {this["formatted"]}"; + } + } + public static IEnumerable Expectations() + { + var folder = TestFolderHelper.TestFolder; + var filePath = Path.Combine(folder, "parsing.yml"); + var fileContent = File.ReadAllText(filePath); + var deserializer = new YamlDotNet.Serialization.Deserializer(); + var items = deserializer.Deserialize>(fileContent); + foreach (var item in items) + { + yield return new object?[] { item }; + } + } + + [TestMethod] + [DynamicData(nameof(Expectations), DynamicDataSourceType.Method)] + public void ParsedExpression_ToString_MatchesOriginalInput(Expectation expectation) + { + var expression = expectation["expression"]; + var formatted = expectation["formatted"]; + var parser = new TagExpressionParser(); + var parsed = parser.Parse(expression); + Assert.AreEqual(formatted, parsed.ToString()); + } +} diff --git a/dotnet/TagExpressionsTest/TagLexerTests.cs b/dotnet/TagExpressionsTest/TagLexerTests.cs new file mode 100644 index 00000000..a60911dd --- /dev/null +++ b/dotnet/TagExpressionsTest/TagLexerTests.cs @@ -0,0 +1,104 @@ +using Cucumber.TagExpressions; + +namespace Cucumber.TagExpressionsTest; + +[TestClass] +public class TagLexerTests +{ + [TestMethod] + public void TokenizesOperators_And() + { + var lexer = new TagLexer("AND"); + var token = lexer.NextToken(); + Assert.AreEqual(TagTokenType.And, token.Type); + Assert.AreEqual(TagTokenType.End, lexer.NextToken().Type); + } + + [TestMethod] + public void TokenizesOperators_Or() + { + var lexer = new TagLexer("OR"); + var token = lexer.NextToken(); + Assert.AreEqual(TagTokenType.Or, token.Type); + Assert.AreEqual(TagTokenType.End, lexer.NextToken().Type); + } + + [TestMethod] + public void TokenizesOperators_Not() + { + var lexer = new TagLexer("NOT"); + var token = lexer.NextToken(); + Assert.AreEqual(TagTokenType.Not, token.Type); + Assert.AreEqual(TagTokenType.End, lexer.NextToken().Type); + } + + [TestMethod] + public void TokenizesParentheses() + { + var lexer = new TagLexer("( )"); + Assert.AreEqual(TagTokenType.LParen, lexer.NextToken().Type); + Assert.AreEqual(TagTokenType.RParen, lexer.NextToken().Type); + Assert.AreEqual(TagTokenType.End, lexer.NextToken().Type); + } + + [TestMethod] + public void TokenizesIdentifier() + { + var lexer = new TagLexer("foo"); + var token = lexer.NextToken(); + Assert.AreEqual(TagTokenType.Identifier, token.Type); + Assert.AreEqual("foo", token.Value); + Assert.AreEqual(TagTokenType.End, lexer.NextToken().Type); + } + + [TestMethod] + public void TokenizesEscapedCharacters() + { + var lexer = new TagLexer("foo\\ bar"); + var token = lexer.NextToken(); + Assert.AreEqual(TagTokenType.Identifier, token.Type); + Assert.AreEqual("foo bar", token.Value); + Assert.AreEqual(TagTokenType.End, lexer.NextToken().Type); + } + + [TestMethod] + public void HandlesEndOfInput() + { + var lexer = new TagLexer(""); + var token = lexer.NextToken(); + Assert.AreEqual(TagTokenType.End, token.Type); + } + + [TestMethod] + public void HandlesNullInput() + { + var lexer = new TagLexer(null!); + var token = lexer.NextToken(); + Assert.AreEqual(TagTokenType.End, token.Type); + } + + [TestMethod] + public void ThrowsOnIllegalEscape() + { + var lexer = new TagLexer("foo\\x"); + try + { + lexer.NextToken(); + Assert.Fail("Expected exception for illegal escape"); + } + catch (TagExpressionException ex) + { + StringAssert.Contains(ex.Message, "Illegal escape"); + } + } + + [TestMethod] + public void PeekTokenDoesNotAdvancePosition() + { + var lexer = new TagLexer("foo bar"); + var peeked = lexer.PeekToken(); + var next = lexer.NextToken(); + Assert.AreEqual(peeked.Type, next.Type); + Assert.AreEqual(peeked.Value, next.Value); + } +} diff --git a/dotnet/TagExpressionsTest/TestFolderHelper.cs b/dotnet/TagExpressionsTest/TestFolderHelper.cs new file mode 100644 index 00000000..d82984e4 --- /dev/null +++ b/dotnet/TagExpressionsTest/TestFolderHelper.cs @@ -0,0 +1,55 @@ +namespace Cucumber.TagExpressionsTest; + +internal static class TestFolderHelper +{ + public static string TestFolder + { + get + { + var assemblyLocation = System.Reflection.Assembly.GetExecutingAssembly().Location; + var testFolder = PathHelper.FindSiblingFolder(assemblyLocation, "testdata"); + if (testFolder == null) + { + throw new InvalidOperationException("Could not find 'testdata' folder relative to assembly location."); + } + return testFolder; + } + } +} + +internal static class PathHelper +{ + /// + /// Finds a sibling folder relative to the assembly location by moving upward + /// through parent directories if the sibling does not exist at the initial level. + /// + /// The full path of the assembly (file or folder). + /// The name of the sibling folder to find. + /// The full path to the sibling folder if found; otherwise, null. + public static string? FindSiblingFolder(string assemblyLocation, string siblingFolderName) + { + // Start from the directory of the assembly location + var directory = Path.GetDirectoryName(assemblyLocation); + if (directory == null) + return null; + + var currentDir = new DirectoryInfo(directory); + + while (currentDir.Parent != null) + { + // Construct the sibling folder path at this level + string siblingPath = Path.Combine(currentDir.Parent.FullName, siblingFolderName); + + if (Directory.Exists(siblingPath)) + { + return siblingPath; // Found the sibling folder + } + + // Move one directory up to continue probing + currentDir = currentDir.Parent; + } + + // Reached root with no sibling folder found + return null; + } +}