diff --git a/CHANGELOG.md b/CHANGELOG.md index cb0c0152..c2ae69fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,11 @@ ## Improvements: +* Improved handling of scope tag expressions, hook and scope errors (#150) + ## Bug fixes: -*Contributors of this release (in alphabetical order):* +*Contributors of this release (in alphabetical order):* @clrudolphi, @gasparnagy # v2025.3.395 - 2025-12-17 diff --git a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/Discovery/ReqnrollDiscoverer.cs b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/Discovery/ReqnrollDiscoverer.cs index d627b0d2..f313ae1b 100644 --- a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/Discovery/ReqnrollDiscoverer.cs +++ b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/Discovery/ReqnrollDiscoverer.cs @@ -93,7 +93,8 @@ private Hook CreateHook(HookBindingAdapter sdb, HookOrder = sdb.HookOrder, Method = sdb.Method.ToString(), Scope = GetScope(sdb), - SourceLocation = sourceLocation.Reduce((string)null!) + SourceLocation = sourceLocation.Reduce((string)null!), + Error = sdb.Error }; return stepDefinition; @@ -137,7 +138,8 @@ private string GetParamType(string parameterTypeName, Func getKe { Tag = scopedBinding.BindingScopeTag, FeatureTitle = scopedBinding.BindingScopeFeatureTitle, - ScenarioTitle = scopedBinding.BindingScopeScenarioTitle + ScenarioTitle = scopedBinding.BindingScopeScenarioTitle, + Error = scopedBinding.BindingScopeError }; } diff --git a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/Data/BindingScopeData.cs b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/Data/BindingScopeData.cs index c04e2854..3e3de76e 100644 --- a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/Data/BindingScopeData.cs +++ b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/Data/BindingScopeData.cs @@ -6,4 +6,5 @@ public class BindingScopeData public string Tag { get; set; } // contains leading '@', e.g. '@mytag' public string FeatureTitle { get; set; } public string ScenarioTitle { get; set; } + public string Error { get; set; } } diff --git a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/Data/HookData.cs b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/Data/HookData.cs index c2fd72f4..d8e538cf 100644 --- a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/Data/HookData.cs +++ b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/Data/HookData.cs @@ -6,4 +6,5 @@ public class HookData public BindingScopeData Scope { get; set; } public string Type { get; set; } public int? HookOrder { get; set; } + public string Error { get; set; } } diff --git a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/HookBindingAdapter.cs b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/HookBindingAdapter.cs index 1bba025c..bb8d4350 100644 --- a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/HookBindingAdapter.cs +++ b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/HookBindingAdapter.cs @@ -10,5 +10,7 @@ public record HookBindingAdapter(HookData Adaptee) : IScopedBindingAdapter public string? BindingScopeTag => Adaptee.Scope?.Tag; public string? BindingScopeFeatureTitle => Adaptee.Scope?.FeatureTitle; public string? BindingScopeScenarioTitle => Adaptee.Scope?.ScenarioTitle; + public string? BindingScopeError => Adaptee.Scope?.Error; public int? HookOrder => Adaptee.HookOrder; + public string? Error => Adaptee.Error; } \ No newline at end of file diff --git a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/StepDefinitionBindingAdapter.cs b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/StepDefinitionBindingAdapter.cs index 30f8b7e9..7545af43 100644 --- a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/StepDefinitionBindingAdapter.cs +++ b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Generic/ReqnrollProxies/StepDefinitionBindingAdapter.cs @@ -8,6 +8,7 @@ public interface IScopedBindingAdapter string? BindingScopeTag { get; } string? BindingScopeFeatureTitle { get; } string? BindingScopeScenarioTitle { get; } + string? BindingScopeError { get; } } public record StepDefinitionBindingAdapter(StepDefinitionData Adaptee) : IScopedBindingAdapter @@ -22,6 +23,7 @@ public record StepDefinitionBindingAdapter(StepDefinitionData Adaptee) : IScoped public string? BindingScopeTag => Adaptee.Scope?.Tag; public string? BindingScopeFeatureTitle => Adaptee.Scope?.FeatureTitle; public string? BindingScopeScenarioTitle => Adaptee.Scope?.ScenarioTitle; + public string? BindingScopeError => Adaptee.Scope?.Error; public virtual Option GetProperty(string propertyName) { return Adaptee.ReflectionHasProperty(propertyName) ? Adaptee.ReflectionGetProperty(propertyName) : None.Value; diff --git a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Models/Hook.cs b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Models/Hook.cs index d682489f..0e231948 100644 --- a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Models/Hook.cs +++ b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Models/Hook.cs @@ -9,13 +9,15 @@ public class Hook //public string ParamTypes { get; set; } public StepScope Scope { get; set; } + public string Error { get; set; } + public string SourceLocation { get; set; } #region Equality protected bool Equals(Hook other) { - return Type == other.Type && HookOrder == other.HookOrder && Method == other.Method && Equals(Scope, other.Scope) && SourceLocation == other.SourceLocation; + return Type == other.Type && HookOrder == other.HookOrder && Method == other.Method && Equals(Scope, other.Scope) && Error == other.Error && SourceLocation == other.SourceLocation; } public override bool Equals(object obj) diff --git a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Models/StepScope.cs b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Models/StepScope.cs index e3753acb..b7c22e0f 100644 --- a/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Models/StepScope.cs +++ b/Connectors/Reqnroll.VisualStudio.ReqnrollConnector.Models/StepScope.cs @@ -6,12 +6,14 @@ public class StepScope public string Tag { get; set; } public string FeatureTitle { get; set; } public string ScenarioTitle { get; set; } + public string Error { get; set; } #region Equality protected bool Equals(StepScope other) => string.Equals(Tag, other.Tag) && string.Equals(FeatureTitle, other.FeatureTitle) && - string.Equals(ScenarioTitle, other.ScenarioTitle); + string.Equals(ScenarioTitle, other.ScenarioTitle) && + string.Equals(Error, other.Error); public override bool Equals(object obj) { diff --git a/Connectors/prebuilt/Reqnroll-Generic-net10.0/Reqnroll.VisualStudio.ReqnrollConnector.Models.dll b/Connectors/prebuilt/Reqnroll-Generic-net10.0/Reqnroll.VisualStudio.ReqnrollConnector.Models.dll index 0dfb2537..6fe5a6c9 100644 Binary files a/Connectors/prebuilt/Reqnroll-Generic-net10.0/Reqnroll.VisualStudio.ReqnrollConnector.Models.dll and b/Connectors/prebuilt/Reqnroll-Generic-net10.0/Reqnroll.VisualStudio.ReqnrollConnector.Models.dll differ diff --git a/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.deps.json b/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.deps.json index afc0410e..00a982c9 100644 --- a/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.deps.json +++ b/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.deps.json @@ -6,10 +6,10 @@ "compilationOptions": {}, "targets": { ".NETCoreApp,Version=v10.0": { - "reqnroll-vs/2025.2.99999-local": { + "reqnroll-vs/2025.4.99999-local": { "dependencies": { "Microsoft.Extensions.DependencyModel": "8.0.1", - "Reqnroll.VisualStudio.ReqnrollConnector.Models": "2025.2.99999-local", + "Reqnroll.VisualStudio.ReqnrollConnector.Models": "2025.4.99999-local", "dnlib": "4.4.0" }, "runtime": { @@ -32,18 +32,18 @@ } } }, - "Reqnroll.VisualStudio.ReqnrollConnector.Models/2025.2.99999-local": { + "Reqnroll.VisualStudio.ReqnrollConnector.Models/2025.4.99999-local": { "runtime": { "Reqnroll.VisualStudio.ReqnrollConnector.Models.dll": { - "assemblyVersion": "2025.2.0.0", - "fileVersion": "2025.2.0.0" + "assemblyVersion": "2025.4.0.0", + "fileVersion": "2025.4.0.0" } } } } }, "libraries": { - "reqnroll-vs/2025.2.99999-local": { + "reqnroll-vs/2025.4.99999-local": { "type": "project", "serviceable": false, "sha512": "" @@ -62,7 +62,7 @@ "path": "microsoft.extensions.dependencymodel/8.0.1", "hashPath": "microsoft.extensions.dependencymodel.8.0.1.nupkg.sha512" }, - "Reqnroll.VisualStudio.ReqnrollConnector.Models/2025.2.99999-local": { + "Reqnroll.VisualStudio.ReqnrollConnector.Models/2025.4.99999-local": { "type": "project", "serviceable": false, "sha512": "" diff --git a/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.dll b/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.dll index 261d763b..9f5a8e25 100644 Binary files a/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.dll and b/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.dll differ diff --git a/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.exe b/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.exe index 0e3a0bd0..214e9946 100644 Binary files a/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.exe and b/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.exe differ diff --git a/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.runtimeconfig.json b/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.runtimeconfig.json index 397bcde1..f730443c 100644 --- a/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.runtimeconfig.json +++ b/Connectors/prebuilt/Reqnroll-Generic-net10.0/reqnroll-vs.runtimeconfig.json @@ -3,7 +3,7 @@ "tfm": "net10.0", "framework": { "name": "Microsoft.NETCore.App", - "version": "10.0.0-rc.2.25502.107" + "version": "10.0.0" }, "configProperties": { "System.Reflection.Metadata.MetadataUpdater.IsSupported": false, diff --git a/Reqnroll.VisualStudio.Package/Reqnroll.VisualStudio.Package.csproj b/Reqnroll.VisualStudio.Package/Reqnroll.VisualStudio.Package.csproj index 3b6ae380..8c6aea40 100644 --- a/Reqnroll.VisualStudio.Package/Reqnroll.VisualStudio.Package.csproj +++ b/Reqnroll.VisualStudio.Package/Reqnroll.VisualStudio.Package.csproj @@ -91,10 +91,11 @@ + - - + + diff --git a/Reqnroll.VisualStudio/Discovery/BindingImporter.cs b/Reqnroll.VisualStudio/Discovery/BindingImporter.cs index 6b2e03b2..c716f793 100644 --- a/Reqnroll.VisualStudio/Discovery/BindingImporter.cs +++ b/Reqnroll.VisualStudio/Discovery/BindingImporter.cs @@ -7,15 +7,15 @@ namespace Reqnroll.VisualStudio.Discovery; public class BindingImporter { private static readonly string[] EmptyParameterTypes = new string[0]; - private static readonly string[] SingleStringParameterTypes = {TypeShortcuts.StringType}; - private static readonly string[] DoubleStringParameterTypes = {TypeShortcuts.StringType, TypeShortcuts.StringType}; - private static readonly string[] SingleIntParameterTypes = {TypeShortcuts.Int32Type}; - private static readonly string[] SingleDataTableParameterTypes = {TypeShortcuts.ReqnrollTableType}; + private static readonly string[] SingleStringParameterTypes = { TypeShortcuts.StringType }; + private static readonly string[] DoubleStringParameterTypes = { TypeShortcuts.StringType, TypeShortcuts.StringType }; + private static readonly string[] SingleIntParameterTypes = { TypeShortcuts.Int32Type }; + private static readonly string[] SingleDataTableParameterTypes = { TypeShortcuts.ReqnrollTableType }; private readonly Dictionary _implementations = new(); private readonly IDeveroomLogger _logger; private readonly Dictionary _sourceFiles; - private readonly TagExpressionParser _tagExpressionParser = new(); + private readonly ReqnrollTagExpressionParser _tagExpressionParser = new(); private readonly Dictionary _typeNames; public BindingImporter(Dictionary sourceFiles, Dictionary typeNames, @@ -46,7 +46,7 @@ public ProjectStepDefinitionBinding ImportStepDefinition(StepDefinition stepDefi } return new ProjectStepDefinitionBinding(stepDefinitionType, regex, scope, implementation, - stepDefinition.Expression, stepDefinition.Error); + stepDefinition.Expression, GetBindingError(stepDefinition.Error, scope, "step definition")); } catch (Exception ex) { @@ -72,7 +72,7 @@ public ProjectHookBinding ImportHook(Hook hook) _implementations.Add(hook.Method, implementation); } - return new ProjectHookBinding(implementation, scope, hookType, hook.HookOrder); + return new ProjectHookBinding(implementation, scope, hookType, hook.HookOrder, GetBindingError(hook.Error, scope, "hook")); } catch (Exception ex) { @@ -81,6 +81,15 @@ public ProjectHookBinding ImportHook(Hook hook) } } + private string GetBindingError(string error, Scope scope, string bindingType) + { + if (!string.IsNullOrWhiteSpace(error)) + return $"Invalid {bindingType}: {error}"; + if (!string.IsNullOrWhiteSpace(scope?.Error)) + return $"Invalid scope for {bindingType}: {scope.Error}"; + return null; + } + private static Regex ParseRegex(StepDefinition stepDefinition) => string.IsNullOrEmpty(stepDefinition.Regex) ? null @@ -149,13 +158,27 @@ private Scope ParseScope(StepScope bindingScope) if (bindingScope == null) return null; + var tagExpression = _tagExpressionParser.Parse(bindingScope.Tag); + + if (tagExpression is InvalidTagExpression ite) + { + _logger.LogVerbose($"Invalid tag expression '{bindingScope.Tag}': {ite.Message}"); + return new Scope + { + FeatureTitle = bindingScope.FeatureTitle, + ScenarioTitle = bindingScope.ScenarioTitle, + Tag = null, + Error = $"Invalid tag expression '{bindingScope.Tag}': {ite.Message}" + }; + } return new Scope { FeatureTitle = bindingScope.FeatureTitle, ScenarioTitle = bindingScope.ScenarioTitle, Tag = string.IsNullOrWhiteSpace(bindingScope.Tag) - ? null - : _tagExpressionParser.Parse(bindingScope.Tag) + ? null + : tagExpression, + Error = bindingScope.Error }; } } diff --git a/Reqnroll.VisualStudio/Discovery/DiscoveryInvoker.cs b/Reqnroll.VisualStudio/Discovery/DiscoveryInvoker.cs index 379663bc..b4e16ded 100644 --- a/Reqnroll.VisualStudio/Discovery/DiscoveryInvoker.cs +++ b/Reqnroll.VisualStudio/Discovery/DiscoveryInvoker.cs @@ -143,6 +143,7 @@ public IDiscovery ThenImportBindings(string projectName) $"{_stepDefinitions.Length} step definitions and {_hooks.Length} hooks discovered for project {projectName}"); ReportInvalidStepDefinitions(); + ReportInvalidHooks(); return this; } @@ -193,6 +194,30 @@ private void ReportInvalidStepDefinitions() }) ); } + + + private void ReportInvalidHooks() + { + if (!_hooks.Any(h => !h.IsValid)) + return; + + _logger.LogWarning($"Invalid hooks found: {Environment.NewLine}" + + string.Join(Environment.NewLine, _hooks + .Where(h => !h.IsValid) + .Select(h => + $" {h}: {h.Error} at {h.Implementation?.SourceLocation}"))); + + _errorListServices.AddErrors( + _hooks.Where(h => !h.IsValid) + .Select(h => new DeveroomUserError + { + Category = DeveroomUserErrorCategory.Discovery, + Message = h.Error, + SourceLocation = h.Implementation?.SourceLocation, + Type = TaskErrorCategory.Error + }) + ); + } } private class FailedDiscovery : IDiscovery diff --git a/Reqnroll.VisualStudio/Discovery/ProjectBinding.cs b/Reqnroll.VisualStudio/Discovery/ProjectBinding.cs index d7135726..c95c59e1 100644 --- a/Reqnroll.VisualStudio/Discovery/ProjectBinding.cs +++ b/Reqnroll.VisualStudio/Discovery/ProjectBinding.cs @@ -5,11 +5,14 @@ public class ProjectBinding { public Scope Scope { get; } public ProjectBindingImplementation Implementation { get; } + public virtual bool IsValid => Error == null && Scope?.IsValid != false; + public string Error { get; } - public ProjectBinding(ProjectBindingImplementation implementation, Scope scope) + public ProjectBinding(ProjectBindingImplementation implementation, Scope scope, string error = null) { Implementation = implementation; Scope = scope; + Error = error; } protected bool MatchScope(IGherkinDocumentContext context) diff --git a/Reqnroll.VisualStudio/Discovery/ProjectBindingRegistry.cs b/Reqnroll.VisualStudio/Discovery/ProjectBindingRegistry.cs index 103413a4..0657a3d5 100644 --- a/Reqnroll.VisualStudio/Discovery/ProjectBindingRegistry.cs +++ b/Reqnroll.VisualStudio/Discovery/ProjectBindingRegistry.cs @@ -34,7 +34,7 @@ public ProjectBindingRegistry(IEnumerable stepDefi public HookMatchResult MatchScenarioToHooks(Scenario scenario, IGherkinDocumentContext context) { var hookMatches = Hooks - .Where(h => h.Match(scenario, context)) + .Where(h => h.IsValid && h.Match(scenario, context)) .OrderBy(h => h.HookType) .ThenBy(h => h.HookOrder) .ToArray(); diff --git a/Reqnroll.VisualStudio/Discovery/ProjectHookBinding.cs b/Reqnroll.VisualStudio/Discovery/ProjectHookBinding.cs index fc3ab57d..7ca43b0c 100644 --- a/Reqnroll.VisualStudio/Discovery/ProjectHookBinding.cs +++ b/Reqnroll.VisualStudio/Discovery/ProjectHookBinding.cs @@ -8,8 +8,8 @@ public class ProjectHookBinding : ProjectBinding public HookType HookType { get; } public int HookOrder { get; } - public ProjectHookBinding(ProjectBindingImplementation implementation, Scope scope, HookType hookType, int? hookOrder) - : base(implementation, scope) + public ProjectHookBinding(ProjectBindingImplementation implementation, Scope scope, HookType hookType, int? hookOrder, string error) + : base(implementation, scope, error) { HookType = hookType; HookOrder = hookOrder ?? DefaultHookOrder; diff --git a/Reqnroll.VisualStudio/Discovery/ProjectStepDefinitionBinding.cs b/Reqnroll.VisualStudio/Discovery/ProjectStepDefinitionBinding.cs index 2774b6b9..bb0e0621 100644 --- a/Reqnroll.VisualStudio/Discovery/ProjectStepDefinitionBinding.cs +++ b/Reqnroll.VisualStudio/Discovery/ProjectStepDefinitionBinding.cs @@ -6,16 +6,14 @@ public class ProjectStepDefinitionBinding : ProjectBinding { public ProjectStepDefinitionBinding(ScenarioBlock stepDefinitionType, Regex regex, Scope scope, ProjectBindingImplementation implementation, string specifiedExpression = null, string error = null) - : base(implementation, scope) + : base(implementation, scope, error) { StepDefinitionType = stepDefinitionType; Regex = regex; SpecifiedExpression = specifiedExpression; - Error = error; } - public bool IsValid => Regex != null && Error == null; - public string Error { get; } + public override bool IsValid => Regex != null && base.IsValid; public ScenarioBlock StepDefinitionType { get; } public string SpecifiedExpression { get; } public Regex Regex { get; } diff --git a/Reqnroll.VisualStudio/Discovery/Scope.cs b/Reqnroll.VisualStudio/Discovery/Scope.cs index cac8b351..5e194a6a 100644 --- a/Reqnroll.VisualStudio/Discovery/Scope.cs +++ b/Reqnroll.VisualStudio/Discovery/Scope.cs @@ -1,4 +1,4 @@ -using Reqnroll.VisualStudio.Discovery.TagExpressions; +using Cucumber.TagExpressions; namespace Reqnroll.VisualStudio.Discovery; @@ -7,6 +7,9 @@ public class Scope public ITagExpression? Tag { get; set; } public string? FeatureTitle { get; set; } public string? ScenarioTitle { get; set; } + public string? Error { get; set; } + + public bool IsValid => Error == null; public override string ToString() { @@ -21,6 +24,11 @@ public override string ToString() result = result.Length > 0 ? result + ", " : result; result = $"{result}Scenario='{ScenarioTitle}'"; } + if (Error != null) + { + result = result.Length > 0 ? result + ", " : result; + result = $"{result}Error='{Error}'"; + } return result; } } diff --git a/Reqnroll.VisualStudio/Discovery/TagExpressions/IReqnrollTagExpressionParser.cs b/Reqnroll.VisualStudio/Discovery/TagExpressions/IReqnrollTagExpressionParser.cs new file mode 100644 index 00000000..ef37478c --- /dev/null +++ b/Reqnroll.VisualStudio/Discovery/TagExpressions/IReqnrollTagExpressionParser.cs @@ -0,0 +1,16 @@ +using Cucumber.TagExpressions; + +namespace Reqnroll.VisualStudio.Discovery.TagExpressions; + +/// +/// Defines a parser for tag expressions. +/// +public interface IReqnrollTagExpressionParser +{ + /// + /// Parses the specified text into an . + /// + /// The tag expression string to parse. + /// An representing the parsed expression. + ITagExpression Parse(string text); +} \ No newline at end of file diff --git a/Reqnroll.VisualStudio/Discovery/TagExpressions/ITagExpression.cs b/Reqnroll.VisualStudio/Discovery/TagExpressions/ITagExpression.cs deleted file mode 100644 index 24485c9b..00000000 --- a/Reqnroll.VisualStudio/Discovery/TagExpressions/ITagExpression.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Reqnroll.VisualStudio.Discovery.TagExpressions; - -public interface ITagExpression -{ - bool Evaluate(IEnumerable variables); -} diff --git a/Reqnroll.VisualStudio/Discovery/TagExpressions/InvalidTagExpression.cs b/Reqnroll.VisualStudio/Discovery/TagExpressions/InvalidTagExpression.cs new file mode 100644 index 00000000..b2aa7deb --- /dev/null +++ b/Reqnroll.VisualStudio/Discovery/TagExpressions/InvalidTagExpression.cs @@ -0,0 +1,20 @@ +using Cucumber.TagExpressions; +using System; + +namespace Reqnroll.VisualStudio.Discovery.TagExpressions; +public class InvalidTagExpression : ReqnrollTagExpression, ITagExpression +{ + public string Message { get; } + public InvalidTagExpression(ITagExpression expression, string originalTagExpression, string message) : base(expression, originalTagExpression) + { + Message = message; + } + public override bool Evaluate(System.Collections.Generic.IEnumerable tags) + { + throw new InvalidOperationException("Cannot evaluate an invalid tag expression: " + Message); + } + public override string ToString() + { + return "Invalid Tag Expression: " + Message; + } +} diff --git a/Reqnroll.VisualStudio/Discovery/TagExpressions/ReqnrollTagExpression.cs b/Reqnroll.VisualStudio/Discovery/TagExpressions/ReqnrollTagExpression.cs new file mode 100644 index 00000000..c97c757e --- /dev/null +++ b/Reqnroll.VisualStudio/Discovery/TagExpressions/ReqnrollTagExpression.cs @@ -0,0 +1,25 @@ +using Cucumber.TagExpressions; + +namespace Reqnroll.VisualStudio.Discovery.TagExpressions; + +public class ReqnrollTagExpression : ITagExpression +{ + public ReqnrollTagExpression(ITagExpression inner, string tagExpressionText) + { + TagExpressionText = tagExpressionText; + _inner = inner; + } + public string TagExpressionText { get; } + + private ITagExpression _inner; + + public override string ToString() + { + return _inner.ToString(); + } + + public virtual bool Evaluate(IEnumerable inputs) + { + return _inner.Evaluate(inputs); + } +} diff --git a/Reqnroll.VisualStudio/Discovery/TagExpressions/ReqnrollTagExpressionParser.cs b/Reqnroll.VisualStudio/Discovery/TagExpressions/ReqnrollTagExpressionParser.cs new file mode 100644 index 00000000..3aa57da3 --- /dev/null +++ b/Reqnroll.VisualStudio/Discovery/TagExpressions/ReqnrollTagExpressionParser.cs @@ -0,0 +1,66 @@ +using Cucumber.TagExpressions; + +namespace Reqnroll.VisualStudio.Discovery.TagExpressions; + +public class ReqnrollTagExpressionParser : IReqnrollTagExpressionParser +{ + public static ITagExpression CreateTagLiteral(string tag) => new LiteralNode(tag); + + public ITagExpression Parse(string tagExpression) + { + var tagExpressionParser = new TagExpressionParser(); + ITagExpression result = null; + try { + 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 (string.IsNullOrEmpty(literalNode.Name) || literalNode.Name.StartsWith("@")) + return literalNode; + + return new LiteralNode("@" + literalNode.Name); + } +} diff --git a/Reqnroll.VisualStudio/Discovery/TagExpressions/TagExpressionException.cs b/Reqnroll.VisualStudio/Discovery/TagExpressions/TagExpressionException.cs deleted file mode 100644 index bcab811f..00000000 --- a/Reqnroll.VisualStudio/Discovery/TagExpressions/TagExpressionException.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace Reqnroll.VisualStudio.Discovery.TagExpressions; - -public class TagExpressionException : Exception -{ - public TagExpressionException(string message) : base(message) - { - } - - public TagExpressionException(string message, Exception inner) : base(message, inner) - { - } -} diff --git a/Reqnroll.VisualStudio/Discovery/TagExpressions/TagExpressionExtensions.cs b/Reqnroll.VisualStudio/Discovery/TagExpressions/TagExpressionExtensions.cs index 58890d05..0cc42eb1 100644 --- a/Reqnroll.VisualStudio/Discovery/TagExpressions/TagExpressionExtensions.cs +++ b/Reqnroll.VisualStudio/Discovery/TagExpressions/TagExpressionExtensions.cs @@ -1,3 +1,4 @@ +using Cucumber.TagExpressions; using System.Linq; namespace Reqnroll.VisualStudio.Discovery.TagExpressions; diff --git a/Reqnroll.VisualStudio/Discovery/TagExpressions/TagExpressionParser.cs b/Reqnroll.VisualStudio/Discovery/TagExpressions/TagExpressionParser.cs deleted file mode 100644 index e4fac94a..00000000 --- a/Reqnroll.VisualStudio/Discovery/TagExpressions/TagExpressionParser.cs +++ /dev/null @@ -1,278 +0,0 @@ -#nullable disable - -namespace Reqnroll.VisualStudio.Discovery.TagExpressions; - -public class TagExpressionParser -{ - private const char ESCAPING_CHAR = '\\'; - - private static readonly IDictionary ASSOC = new Dictionary - { - {"or", Assoc.LEFT}, - {"and", Assoc.LEFT}, - {"not", Assoc.RIGHT} - }; - - private static readonly IDictionary PREC = new Dictionary - { - {"(", -2}, - {")", -1}, - {"or", 0}, - {"and", 1}, - {"not", 2} - }; - - public virtual ITagExpression Parse(string infix) - { - var tokens = Tokenize(infix); - if (!tokens.Any()) return new True(); - - var operators = new Stack(); - var expressions = new Stack(); - TokenType expectedTokenType = TokenType.OPERAND; - foreach (string token in tokens) - if (IsUnary(token)) - { - Check(expectedTokenType, TokenType.OPERAND); - operators.Push(token); - expectedTokenType = TokenType.OPERAND; - } - else if (IsBinary(token)) - { - Check(expectedTokenType, TokenType.OPERATOR); - while (operators.Count > 0 && IsOperator(operators.Peek()) && ( - ASSOC[token] == Assoc.LEFT && PREC[token] <= PREC[operators.Peek()] - || - ASSOC[token] == Assoc.RIGHT && PREC[token] < PREC[operators.Peek()]) - ) - PushExpr(Pop(operators), expressions); - operators.Push(token); - expectedTokenType = TokenType.OPERAND; - } - else if ("(".Equals(token)) - { - Check(expectedTokenType, TokenType.OPERAND); - operators.Push(token); - expectedTokenType = TokenType.OPERAND; - } - else if (")".Equals(token)) - { - Check(expectedTokenType, TokenType.OPERATOR); - while (operators.Count > 0 && !"(".Equals(operators.Peek())) PushExpr(Pop(operators), expressions); - if (operators.Count == 0) throw new TagExpressionException("Syntax error. Unmatched )"); - if ("(".Equals(operators.Peek())) Pop(operators); - expectedTokenType = TokenType.OPERATOR; - } - else - { - Check(expectedTokenType, TokenType.OPERAND); - PushExpr(token, expressions); - expectedTokenType = TokenType.OPERATOR; - } - - while (operators.Count > 0) - { - if ("(".Equals(operators.Peek())) throw new TagExpressionException("Syntax error. Unmatched ("); - PushExpr(Pop(operators), expressions); - } - - return expressions.Pop(); - } - - private static List Tokenize(string expr) - { - var tokens = new List(); - - bool isEscaped = false; - StringBuilder token = null; - for (int i = 0; i < expr.Length; i++) - { - char c = expr[i]; - if (ESCAPING_CHAR == c) - { - isEscaped = true; - } - else - { - if (char.IsWhiteSpace(c)) - { - // skip - if (null != token) - { - // end of token - tokens.Add(token.ToString()); - token = null; - } - } - else - { - switch (c) - { - case '(': - case ')': - if (!isEscaped) - { - if (null != token) - { - // end of token - tokens.Add(token.ToString()); - token = null; - } - - tokens.Add(c.ToString()); - } - - break; - default: - if (null == token) - // start of token - token = new StringBuilder(); - token.Append(c); - break; - } - } - - isEscaped = false; - } - } - - if (null != token) - // end of token - tokens.Add(token.ToString()); - return tokens; - } - - private void Check(TokenType expectedTokenType, TokenType tokenType) - { - if (expectedTokenType != tokenType) - throw new TagExpressionException( - $"Syntax error. Expected {expectedTokenType.ToString().ToLowerInvariant()}"); - } - - private T Pop(Stack stack) - { - if (!stack.Any()) throw new TagExpressionException("empty stack"); - return stack.Pop(); - } - - private void PushExpr(string token, Stack stack) - { - switch (token) - { - case "and": - ITagExpression rightAndExpr = Pop(stack); - stack.Push(new And(Pop(stack), rightAndExpr)); - break; - case "or": - ITagExpression rightOrExpr = Pop(stack); - stack.Push(new Or(Pop(stack), rightOrExpr)); - break; - case "not": - stack.Push(new Not(Pop(stack))); - break; - default: - stack.Push(new Literal(token)); - break; - } - } - - private bool IsUnary(string token) => "not".Equals(token); - - private bool IsBinary(string token) => "or".Equals(token) || "and".Equals(token); - - private bool IsOperator(string token) => ASSOC.ContainsKey(token); - - public static ITagExpression CreateTagLiteral(string tag) => new Literal(tag); - - private static string[] EnsureArray(IEnumerable variables) - { - if (variables is string[] variablesArray) - return variablesArray; - return variables.ToArray(); - } - - private enum TokenType - { - OPERAND, - OPERATOR - } - - private enum Assoc - { - LEFT, - RIGHT - } - - private class Literal : ITagExpression - { - private readonly string value; - - public Literal(string value) - { - this.value = value; - } - - public bool Evaluate(IEnumerable variables) => variables.Contains(value); - - public override string ToString() => value.Replace("\\(", "\\\\(").Replace("\\)", "\\\\)"); - } - - private class Or : ITagExpression - { - private readonly ITagExpression left; - private readonly ITagExpression right; - - public Or(ITagExpression left, ITagExpression right) - { - this.left = left; - this.right = right; - } - - public bool Evaluate(IEnumerable variables) - { - var variablesArray = EnsureArray(variables); - return left.Evaluate(variablesArray) || right.Evaluate(variablesArray); - } - - public override string ToString() => "( " + left + " or " + right + " )"; - } - - private class And : ITagExpression - { - private readonly ITagExpression left; - private readonly ITagExpression right; - - public And(ITagExpression left, ITagExpression right) - { - this.left = left; - this.right = right; - } - - public bool Evaluate(IEnumerable variables) - { - var variablesArray = EnsureArray(variables); - return left.Evaluate(variablesArray) && right.Evaluate(variablesArray); - } - - public override string ToString() => "( " + left + " and " + right + " )"; - } - - private class Not : ITagExpression - { - private readonly ITagExpression expr; - - public Not(ITagExpression expr) - { - this.expr = expr; - } - - public bool Evaluate(IEnumerable variables) => !expr.Evaluate(variables); - - public override string ToString() => "not ( " + expr + " )"; - } - - private class True : ITagExpression - { - public bool Evaluate(IEnumerable variables) => true; - } -} diff --git a/Reqnroll.VisualStudio/Reqnroll.VisualStudio.csproj b/Reqnroll.VisualStudio/Reqnroll.VisualStudio.csproj index d7ff0e86..a2d3bd67 100644 --- a/Reqnroll.VisualStudio/Reqnroll.VisualStudio.csproj +++ b/Reqnroll.VisualStudio/Reqnroll.VisualStudio.csproj @@ -13,6 +13,7 @@ + diff --git a/Tests/Reqnroll.VisualStudio.Tests/Discovery/BindingImporterTests.cs b/Tests/Reqnroll.VisualStudio.Tests/Discovery/BindingImporterTests.cs index 0c8eed19..3d7457da 100644 --- a/Tests/Reqnroll.VisualStudio.Tests/Discovery/BindingImporterTests.cs +++ b/Tests/Reqnroll.VisualStudio.Tests/Discovery/BindingImporterTests.cs @@ -142,7 +142,7 @@ public void Imports_expression_and_error() expression: "my step", error: "this is an error")); result.SpecifiedExpression.Should().Be("my step"); - result.Error.Should().Be("this is an error"); + result.Error.Should().Be("Invalid step definition: this is an error"); result.IsValid.Should().BeFalse(); } @@ -155,7 +155,7 @@ public void Imports_invalid_step_definition_with_null_regex() var result = sut.ImportStepDefinition(stepDefinition); result.Regex.Should().BeNull(); - result.Error.Should().Be("this is an error"); + result.Error.Should().Be("Invalid step definition: this is an error"); result.IsValid.Should().BeFalse(); } @@ -264,4 +264,74 @@ public void Merges_implementations() result1.Implementation.Should().BeSameAs(result2.Implementation); } + + [Fact] + public void Parses_step_definition_with_invalid_tag_scope() + { + var sut = CreateSut(); + var result = sut.ImportStepDefinition(CreateStepDefinition(scope: new StepScope { Tag = "@foo ( wrong" })); + + result.Should().NotBeNull(); + result.IsValid.Should().BeFalse(); + result.Scope.Should().NotBeNull(); + result.Scope.IsValid.Should().BeFalse(); + result.Scope.Error.Should().Contain("Invalid tag expression"); + result.Error.Should().Contain("Invalid tag expression"); + } + + [Fact] + public void Parses_hook_with_invalid_tag_scope() + { + var sut = CreateSut(); + var result = sut.ImportHook(CreateHook(scope: new StepScope { Tag = "@foo ( wrong" })); + + result.Should().NotBeNull(); + result.IsValid.Should().BeFalse(); + result.Scope.Should().NotBeNull(); + result.Scope.IsValid.Should().BeFalse(); + result.Scope.Error.Should().Contain("Invalid tag expression"); + result.Error.Should().Contain("Invalid tag expression"); + } + + [Fact] + public void Parses_step_definition_with_invalid_tag_scope_and_source_error() + { + var sut = CreateSut(); + var result = sut.ImportStepDefinition(CreateStepDefinition( + scope: new StepScope { Tag = "@foo ( wrong" }, + error: "source error")); + + result.Should().NotBeNull(); + result.IsValid.Should().BeFalse(); + result.Error.Should().Be("Invalid step definition: source error"); + result.Scope.Should().NotBeNull(); + result.Scope.IsValid.Should().BeFalse(); + result.Scope.Error.Should().Contain("Invalid tag expression"); + } + + [Fact] + public void Parses_hook_with_invalid_tag_scope_and_source_error() + { + var sut = CreateSut(); + var result = sut.ImportHook(CreateHook(scope: new StepScope { Tag = "@foo ( wrong" }, error: "source error")); + + result.Should().NotBeNull(); + result.IsValid.Should().BeFalse(); + result.Error.Should().Be("Invalid hook: source error"); + result.Scope.Should().NotBeNull(); + result.Scope.IsValid.Should().BeFalse(); + result.Scope.Error.Should().Contain("Invalid tag expression"); + } + + private Hook CreateHook(string type = null, string sourceLocation = null, + StepScope scope = null, string method = null, int? hookOrder = null, string error = null) => + new() + { + Method = method ?? "M1", + Type = type ?? "BeforeScenario", + SourceLocation = sourceLocation, + Scope = scope, + HookOrder = hookOrder, + Error = error + }; } diff --git a/Tests/Reqnroll.VisualStudio.Tests/Discovery/ProjectBindingRegistryAmbiguousTests.cs b/Tests/Reqnroll.VisualStudio.Tests/Discovery/ProjectBindingRegistryAmbiguousTests.cs index 81ae58f3..173bc035 100644 --- a/Tests/Reqnroll.VisualStudio.Tests/Discovery/ProjectBindingRegistryAmbiguousTests.cs +++ b/Tests/Reqnroll.VisualStudio.Tests/Discovery/ProjectBindingRegistryAmbiguousTests.cs @@ -94,7 +94,7 @@ public void Matches_disambiguates_single_stepDef_with_multiple_matching_Scopes() private ProjectStepDefinitionBinding CreateStepDefinitionBindingWithScope(string stepRegex, string scopeText, string methodName) { - var scope = new Scope() { Tag = new TagExpressionParser().Parse(scopeText) }; + var scope = new Scope() { Tag = new ReqnrollTagExpressionParser().Parse(scopeText) }; return CreateStepDefinitionBinding(stepRegex, ScenarioBlock.Given, scope, null, methodName); } } diff --git a/Tests/Reqnroll.VisualStudio.Tests/Discovery/ProjectBindingRegistryTestsBase.cs b/Tests/Reqnroll.VisualStudio.Tests/Discovery/ProjectBindingRegistryTestsBase.cs index 18789e39..54b6dff7 100644 --- a/Tests/Reqnroll.VisualStudio.Tests/Discovery/ProjectBindingRegistryTestsBase.cs +++ b/Tests/Reqnroll.VisualStudio.Tests/Discovery/ProjectBindingRegistryTestsBase.cs @@ -45,7 +45,7 @@ protected static DataTable CreateDataTable() }); } - protected Scope CreateTagScope(string tagName) => new() {Tag = TagExpressionParser.CreateTagLiteral(tagName)}; + protected Scope CreateTagScope(string tagName) => new() {Tag = ReqnrollTagExpressionParser.CreateTagLiteral(tagName)}; private DeveroomTag CreateFeatureStructure(string[] featureTags, string[] scenarioTags, string[] scenarioOutlineTags = null, string[] soHeaders = null, string[][] soCells = null,