Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
44fd54a
Initial commit. This adds support for tag exprssions on Scopes assign…
clrudolphi Nov 4, 2025
416be89
Fix an issue in which the BindingProviderService (as invoked OOP and …
clrudolphi Nov 5, 2025
bf7d969
Merge branch 'main' into Exploratory_TagExpression_Support
clrudolphi Nov 9, 2025
20b0f08
Draft update to the documentation on the topic of tag expressions
clrudolphi Nov 9, 2025
71e6fcc
File scope namespace for the ReqnrollTagExpressionParser.
clrudolphi Nov 10, 2025
cf3a071
Merge branch 'Documentation_for_Tag_Expressions' into Exploratory_Tag…
clrudolphi Nov 26, 2025
467c8da
Merge branch 'main' into Exploratory_TagExpression_Support
clrudolphi Nov 26, 2025
7033a85
Added nuget package reference to Cucumber.TagExpressions; dropping th…
clrudolphi Nov 26, 2025
193f22c
Merge branch 'main' into Exploratory_TagExpression_Support
clrudolphi Nov 26, 2025
d6cbd0a
Update CHANGELOG.md
clrudolphi Nov 26, 2025
67103d0
Merge branch 'main' into Exploratory_TagExpression_Support
304NotModified Dec 15, 2025
259e77b
Update CHANGELOG.md
304NotModified Dec 15, 2025
8e44510
code cleanup
gasparnagy Dec 17, 2025
4d45031
Merge remote-tracking branch 'origin/main' into Exploratory_TagExpres…
gasparnagy Dec 17, 2025
d827d4d
Changes per review comments:
clrudolphi Jan 2, 2026
98946cf
Update scoped-bindings.md
clrudolphi Jan 2, 2026
7246af2
code cleanup
gasparnagy Jan 7, 2026
80b5986
fix BindingProviderService to work with empty Tag
gasparnagy Jan 7, 2026
f7dd139
Wrapping tag parsing errors in a new type of ITagExpression which dri…
clrudolphi Jan 7, 2026
1627faa
Merge branch 'main' into Exploratory_TagExpression_Support
clrudolphi Jan 8, 2026
5d060d7
Updated tests.
clrudolphi Jan 9, 2026
4403a3e
Added properties to surface tag expression errors via BindingSourcePr…
clrudolphi Jan 14, 2026
ec0263a
Refactored tag expression support with addition of a ReqnollTagExpres…
clrudolphi Jan 14, 2026
fa433c4
Fix ReqnrollTagExpression missing ToString() override
clrudolphi Jan 14, 2026
d472a88
Merge branch 'main' into Exploratory_TagExpression_Support
clrudolphi Jan 14, 2026
813d9c2
small fixes
gasparnagy Jan 16, 2026
74dfbc3
Merge remote-tracking branch 'origin/main' into Exploratory_TagExpres…
gasparnagy Jan 17, 2026
e9566b4
Adjusted acceptance test file for tag-expressions Formatters test sce…
clrudolphi Jan 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# [vNext]

## Improvements:
* Tag Expressions: step definition scopes and hooks may now use tag expressions (such as `@db and not @slow`) (#911)

## Bug fixes:

*Contributors of this release (in alphabetical order):*
@clrudolphi

# v3.3.2 - 2026-01-14

Expand Down
4 changes: 2 additions & 2 deletions Reqnroll/Bindings/BindingFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ public class BindingFactory(
: IBindingFactory
{
public IHookBinding CreateHookBinding(IBindingMethod bindingMethod, HookType hookType, BindingScope bindingScope,
int hookOrder)
int hookOrder, string errorMessage = null)
{
return new HookBinding(bindingMethod, hookType, bindingScope, hookOrder);
return new HookBinding(bindingMethod, hookType, bindingScope, hookOrder, errorMessage);
}

public IStepDefinitionBindingBuilder CreateStepDefinitionBindingBuilder(StepDefinitionType stepDefinitionType, IBindingMethod bindingMethod, BindingScope bindingScope,
Expand Down
39 changes: 16 additions & 23 deletions Reqnroll/Bindings/BindingScope.cs
Original file line number Diff line number Diff line change
@@ -1,38 +1,30 @@
using System;
using System.Linq;
using Cucumber.TagExpressions;
using Reqnroll.Bindings.Discovery;

namespace Reqnroll.Bindings
{
public class BindingScope
public class BindingScope(ITagExpression tagExpression, string featureTitle, string scenarioTitle)
{
public string Tag { get; private set; }
public string FeatureTitle { get; private set; }
public string ScenarioTitle { get; private set; }
public string Tag => tagExpression is ReqnrollTagExpression reqnrollTagExpression ? reqnrollTagExpression.TagExpressionText : tagExpression.ToString();
public ITagExpression TagExpression => tagExpression;

public BindingScope(string tag, string featureTitle, string scenarioTitle)
{
Tag = RemoveLeadingAt(tag);
FeatureTitle = featureTitle;
ScenarioTitle = scenarioTitle;
}
public string FeatureTitle { get; } = featureTitle;

private string RemoveLeadingAt(string tag)
{
if (tag == null || !tag.StartsWith("@"))
return tag;

return tag.Substring(1); // remove leading "@"
}
public string ScenarioTitle { get; } = scenarioTitle;
public bool IsValid => ErrorMessage == null;
public string ErrorMessage => tagExpression is InvalidTagExpression ? tagExpression.ToString() : null;

public bool Match(StepContext stepContext, out int scopeMatches)
{
scopeMatches = 0;

var tags = stepContext.Tags;

if (Tag != null)
if (tagExpression is not NullExpression)
{
if (!tags.Contains(Tag))
var tags = stepContext.Tags.Select(t => "@" + t).ToList();

if (!tagExpression.Evaluate(tags))
return false;

scopeMatches++;
Expand All @@ -57,14 +49,14 @@ public bool Match(StepContext stepContext, out int scopeMatches)

protected bool Equals(BindingScope other)
{
return string.Equals(Tag, other.Tag) && string.Equals(FeatureTitle, other.FeatureTitle) && string.Equals(ScenarioTitle, other.ScenarioTitle);
return string.Equals(Tag, other.Tag) && string.Equals(FeatureTitle, other.FeatureTitle) && string.Equals(ScenarioTitle, other.ScenarioTitle) && string.Equals(ErrorMessage, other.ErrorMessage);
}

public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != this.GetType()) return false;
if (obj.GetType() != GetType()) return false;
return Equals((BindingScope) obj);
}

Expand All @@ -75,6 +67,7 @@ public override int GetHashCode()
var hashCode = (Tag != null ? Tag.GetHashCode() : 0);
hashCode = (hashCode*397) ^ (FeatureTitle != null ? FeatureTitle.GetHashCode() : 0);
hashCode = (hashCode*397) ^ (ScenarioTitle != null ? ScenarioTitle.GetHashCode() : 0);
hashCode = (hashCode*397) ^ (ErrorMessage != null ? ErrorMessage.GetHashCode() : 0);
return hashCode;
}
}
Expand Down
28 changes: 23 additions & 5 deletions Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,23 @@
using System.Linq;
using Reqnroll.Bindings.Reflection;
using Reqnroll.PlatformCompatibility;
using Cucumber.TagExpressions;

namespace Reqnroll.Bindings.Discovery
{
public abstract class BindingSourceProcessor : IBindingSourceProcessor
{
private readonly IBindingFactory _bindingFactory;
private readonly IReqnrollTagExpressionParser _tagExpressionParser;

private BindingSourceType _currentBindingSourceType = null;
private BindingScope[] _typeScopes = null;
private readonly List<IStepDefinitionBindingBuilder> _stepDefinitionBindingBuilders = new();

protected BindingSourceProcessor(IBindingFactory bindingFactory)
protected BindingSourceProcessor(IBindingFactory bindingFactory, IReqnrollTagExpressionParser tagExpressionParser)
{
_bindingFactory = bindingFactory;
_tagExpressionParser = tagExpressionParser;
}

public bool CanProcessTypeAttribute(string attributeTypeName)
Expand Down Expand Up @@ -75,7 +78,7 @@ public virtual void BuildingCompleted()
private IEnumerable<BindingScope> GetScopes(IEnumerable<BindingSourceAttribute> attributes)
{
return attributes.Where(attr => attr.AttributeType.TypeEquals(typeof(ScopeAttribute)))
.Select(attr => new BindingScope(attr.TryGetAttributeValue<string>("Tag"), attr.TryGetAttributeValue<string>("Feature"), attr.TryGetAttributeValue<string>("Scenario")));
.Select(attr => new BindingScope(_tagExpressionParser.Parse(attr.TryGetAttributeValue<string>("Tag")), attr.TryGetAttributeValue<string>("Feature"), attr.TryGetAttributeValue<string>("Scenario")));
}

private bool IsBindingType(BindingSourceType bindingSourceType)
Expand Down Expand Up @@ -156,7 +159,7 @@ private void ProcessHookAttribute(BindingSourceMethod bindingSourceMethod, Bindi

string[] tags = GetTagsDefinedOnBindingAttribute(hookAttribute);
if (tags != null)
scopes = scopes.Concat(tags.Select(t => new BindingScope(t, null, null)));
scopes = scopes.Concat(tags.Select(t => new BindingScope(_tagExpressionParser.Parse(t), null, null)));


ApplyForScope(scopes.ToArray(), scope => ProcessHookAttribute(bindingSourceMethod, hookAttribute, scope));
Expand All @@ -179,13 +182,15 @@ private void ProcessHookAttribute(BindingSourceMethod bindingSourceMethod, Bindi
int order = GetHookOrder(hookAttribute);

var validationResult = ValidateHook(bindingSourceMethod, hookAttribute, hookType);
var scopeValidationResult = ValidateBindingScope(scope);
validationResult += scopeValidationResult;
if (!validationResult.IsValid)
{
OnValidationError(validationResult, true);
return;
}

var hookBinding = _bindingFactory.CreateHookBinding(bindingSourceMethod.BindingMethod, hookType, scope, order);
var hookBinding = _bindingFactory.CreateHookBinding(bindingSourceMethod.BindingMethod, hookType, scope, order,
scopeValidationResult.IsValid ? null : scopeValidationResult.CombinedErrorMessages);

ProcessHookBinding(hookBinding);
}
Expand Down Expand Up @@ -250,6 +255,8 @@ private void ProcessStepDefinitionAttribute(BindingSourceMethod bindingSourceMet
var expressionType = stepDefinitionAttribute.TryGetAttributeValue<ExpressionType>(nameof(StepDefinitionBaseAttribute.ExpressionType));

var validationResult = ValidateStepDefinition(bindingSourceMethod, stepDefinitionAttribute);
validationResult += ValidateBindingScope(scope);

if (!validationResult.IsValid)
{
OnValidationError(validationResult, false);
Expand Down Expand Up @@ -352,6 +359,17 @@ protected virtual BindingValidationResult ValidateHook(BindingSourceMethod bindi
return result;
}

protected virtual BindingValidationResult ValidateBindingScope(BindingScope bindingScope)
{
var result = BindingValidationResult.Valid;

if (bindingScope is { TagExpression: InvalidTagExpression invalidTagExpression })
{
result += BindingValidationResult.Error($"Invalid scope: {invalidTagExpression}");
}
return result;
}

protected bool IsScenarioSpecificHook(HookType hookType)
{
return
Expand Down
16 changes: 16 additions & 0 deletions Reqnroll/Bindings/Discovery/IReqnrollTagExpressionParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Cucumber.TagExpressions;

namespace Reqnroll.Bindings.Discovery;

/// <summary>
/// Defines a parser for tag expressions.
/// </summary>
public interface IReqnrollTagExpressionParser
{
/// <summary>
/// Parses the specified text into an <see cref="ITagExpression"/>.
/// </summary>
/// <param name="text">The tag expression string to parse.</param>
/// <returns>An <see cref="ITagExpression"/> representing the parsed expression.</returns>
ITagExpression Parse(string text);
}
17 changes: 17 additions & 0 deletions Reqnroll/Bindings/Discovery/InvalidTagExpression.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Cucumber.TagExpressions;
using System;

namespace Reqnroll.Bindings.Discovery;
public class InvalidTagExpression(ITagExpression expression, string originalTagExpression, string message) : ReqnrollTagExpression(expression, originalTagExpression)
{
public string Message { get; } = message;

public override bool Evaluate(System.Collections.Generic.IEnumerable<string> tags)
{
throw new InvalidOperationException("Cannot evaluate an invalid tag expression: " + Message);
}
public override string ToString()
{
return "Invalid tag expression: " + Message;
}
}
19 changes: 19 additions & 0 deletions Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Cucumber.TagExpressions;
using System.Collections.Generic;

namespace Reqnroll.Bindings.Discovery;

public class ReqnrollTagExpression(ITagExpression inner, string tagExpressionText) : ITagExpression
{
public string TagExpressionText { get; } = tagExpressionText;

public override string ToString()
{
return inner.ToString();
}

public virtual bool Evaluate(IEnumerable<string> inputs)
{
return inner.Evaluate(inputs);
}
}
65 changes: 65 additions & 0 deletions Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Cucumber.TagExpressions;
using System;

namespace Reqnroll.Bindings.Discovery;

public class ReqnrollTagExpressionParser : IReqnrollTagExpressionParser
{
public ITagExpression Parse(string tagExpression)
{
var tagExpressionParser = new TagExpressionParser();
try
{
var result = tagExpressionParser.Parse(tagExpression);
result = Rewrite(result);
return new ReqnrollTagExpression(result, tagExpression);
}
catch (TagExpressionException ex)
{
var msg = ex.Message;
if (ex.TagToken != null)
{
msg += $" (at offset {ex.TagToken.Position})";
}
return new InvalidTagExpression(null, tagExpression, msg);
}
}

// iff the expression is a literal node, prefix it with '@' if not already present
private ITagExpression Rewrite(ITagExpression expression)
{
if (expression is LiteralNode)
{
return PrefixLiteralNode(expression);
}
if (ConfirmExpressionHasAtPrefixes(expression))
return expression;
throw new TagExpressionException("In multi-term tag expressions, all tag names must start with '@'.");
}

private bool ConfirmExpressionHasAtPrefixes(ITagExpression expression)
{
switch (expression)
{
case NullExpression:
return true;
case BinaryOpNode binaryNode:
return ConfirmExpressionHasAtPrefixes(binaryNode.Left) && ConfirmExpressionHasAtPrefixes(binaryNode.Right);
case NotNode notNode:
return ConfirmExpressionHasAtPrefixes(notNode.Operand);
case LiteralNode literalNode:
return literalNode.Name.StartsWith("@");
default:
throw new InvalidOperationException($"Unknown tag expression node type: {expression.GetType().FullName}");
}
}

private ITagExpression PrefixLiteralNode(ITagExpression expression)
{
var literalNode = (LiteralNode)expression;
if (literalNode.Name.IsNullOrEmpty() || literalNode.Name.StartsWith("@"))
return literalNode;

return new LiteralNode("@" + literalNode.Name);
}
}
3 changes: 2 additions & 1 deletion Reqnroll/Bindings/Discovery/RuntimeBindingSourceProcessor.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Reqnroll.Tracing;
using Cucumber.TagExpressions;

namespace Reqnroll.Bindings.Discovery
{
Expand All @@ -12,7 +13,7 @@ public class RuntimeBindingSourceProcessor : BindingSourceProcessor, IRuntimeBin
private readonly IBindingRegistry _bindingRegistry;
private readonly ITestTracer _testTracer;

public RuntimeBindingSourceProcessor(IBindingFactory bindingFactory, IBindingRegistry bindingRegistry, ITestTracer testTracer) : base(bindingFactory)
public RuntimeBindingSourceProcessor(IBindingFactory bindingFactory, IBindingRegistry bindingRegistry, ITestTracer testTracer, IReqnrollTagExpressionParser tagExpressionParser) : base(bindingFactory, tagExpressionParser)
{
_bindingRegistry = bindingRegistry;
_testTracer = testTracer;
Expand Down
9 changes: 7 additions & 2 deletions Reqnroll/Bindings/HookBinding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@ public class HookBinding : MethodBinding, IHookBinding
public BindingScope BindingScope { get; private set; }
public bool IsScoped { get { return BindingScope != null; } }

public HookBinding(IBindingMethod bindingMethod, HookType hookType, BindingScope bindingScope, int hookOrder) : base(bindingMethod)
public bool IsValid => ErrorMessage == null;
public string ErrorMessage { get; }

public HookBinding(IBindingMethod bindingMethod, HookType hookType, BindingScope bindingScope, int hookOrder, string errorMessage = null) : base(bindingMethod)
{
HookOrder = hookOrder;
HookType = hookType;
BindingScope = bindingScope;
ErrorMessage = errorMessage;
}

protected bool Equals(HookBinding other)
{
return HookType == other.HookType && HookOrder == other.HookOrder && Equals(BindingScope, other.BindingScope) && base.Equals(other);
return HookType == other.HookType && HookOrder == other.HookOrder && Equals(BindingScope, other.BindingScope) && string.Equals(ErrorMessage, other.ErrorMessage) && base.Equals(other);
}

public override bool Equals(object obj)
Expand All @@ -36,6 +40,7 @@ public override int GetHashCode()
var hashCode = (int) HookType;
hashCode = (hashCode*397) ^ HookOrder;
hashCode = (hashCode*397) ^ (BindingScope != null ? BindingScope.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (ErrorMessage != null ? ErrorMessage.GetHashCode() : 0);
return hashCode;
}
}
Expand Down
2 changes: 1 addition & 1 deletion Reqnroll/Bindings/IBindingFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Reqnroll.Bindings
public interface IBindingFactory
{
IHookBinding CreateHookBinding(IBindingMethod bindingMethod, HookType hookType, BindingScope bindingScope,
int hookOrder);
int hookOrder, string errorMessage = null);

IStepDefinitionBindingBuilder CreateStepDefinitionBindingBuilder(StepDefinitionType stepDefinitionType, IBindingMethod bindingMethod, BindingScope bindingScope,
string expressionString, ExpressionType expressionType);
Expand Down
2 changes: 2 additions & 0 deletions Reqnroll/Bindings/IHookBinding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ public interface IHookBinding : IScopedBinding, IBinding
{
HookType HookType { get; }
int HookOrder { get; }
bool IsValid { get; }
string ErrorMessage { get; }
}
}
Loading
Loading