Skip to content

Commit

Permalink
Find Unused Step Definitions command (#8)
Browse files Browse the repository at this point in the history
* Initial Commit of FEAT FindUnusedStepDefinitionsCommand.
This commit duplicates code from FindStepDefinitionUsageCommand.
Not fully tested, but basically working.

* Added testing via Specs.
Renamed StepDefinitionsUnusedFinder to UnusedStepDefinitionsFinder.cs

* Cleanup per review comments.

* Fixed bug in UnusedStepDefFinder that was counting as unused any StepDef in the project that didn't appear in the FF currently being examined.
Minor modification to the logging to report back the number of Unused and the number of Projects (rather than the number of Features).

* Restrict analysis and results to ONLY the current Project open in the editor.

* Restricted FindUnusedStepDefinitions to find only those within the current project (the project of the current editor).
Added same modification as in GH#7 so that this command works within SpecFlow projects.
Added CHANGELOG entry for this new feature.
  • Loading branch information
clrudolphi authored Mar 12, 2024
1 parent 9f95c1e commit 6d5f803
Show file tree
Hide file tree
Showing 14 changed files with 521 additions and 2 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# [vNext]
* FEATURE: FindUnused Step Definitions Command - from within a binding class a new context menu command, "FindUsusedStepDefinitions", is available.
* This will list any Step Definition methods that are not matched by one or more Feature steps in the current project.

* FIX for Create Step Definition Snippets Generates Reqnroll Using Statements for SpecFlow Projects #6
* Fix for GH7 - "Find step definitions usages command not visible for SpecFlow projects
Expand Down
14 changes: 14 additions & 0 deletions Reqnroll.VisualStudio.Package/Commands/ReqnrollVsPackage.vsct
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,19 @@
<ToolTipText>Finds usages of the step definition in feature files.</ToolTipText>
</Strings>
</Button>
<Button guid="guidReqnrollPackageCmdSet" id="FindUnusedStepDefinitionsCommandId" priority="0x0103" type="Button">
<Parent guid="guidReqnrollPackageCmdSet" id="EditorContextMenuGroup" />
<Icon guid="guidImages" id="bmpReqnroll" />
<CommandFlag>DynamicVisibility</CommandFlag>
<CommandFlag>DefaultInvisible</CommandFlag>
<Strings>
<ButtonText>Find Unused Step Definitions</ButtonText>
<CommandName>Reqnroll - Find Unused Step Definitions</CommandName>
<CanonicalName>Reqnroll.FindUnusedStepDefinitions</CanonicalName>
<LocCanonicalName>Reqnroll.FindUnusedStepDefinitions</LocCanonicalName>
<ToolTipText>Finds step definitions that are not used in feature files.</ToolTipText>
</Strings>
</Button>
</Buttons>

<!--The bitmaps section is used to define the bitmaps that are used for the commands.-->
Expand Down Expand Up @@ -140,6 +153,7 @@
<IDSymbol name="FindStepDefinitionUsagesCommandId" value="0x0101" />
<IDSymbol name="RenameStepCommandId" value="0x0103" />
<IDSymbol name="GoToHookCommandId" value="0x0104" />
<IDSymbol name="FindUnusedStepDefinitionsCommandId" value="0x0102" />
</GuidSymbol>

<GuidSymbol name="guidImages" value="{027c70d2-dbf1-49ee-ba67-c4796aa26a87}" >
Expand Down
5 changes: 5 additions & 0 deletions Reqnroll.VisualStudio.Package/ProjectSystem/NullVsIdeScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ public void MonitorCommandFindStepDefinitionUsages(int usagesCount, bool isCance
{
}

public void MonitorCommandFindUnusedStepDefinitions(int unusedStepDefinitions, int scannedFeatureFiles, bool isCancellationRequested)
{
}

public void MonitorCommandGoToStepDefinition(bool generateSnippet)
{
}
Expand Down Expand Up @@ -232,5 +236,6 @@ public void MonitorWelcomeDialogDismissed(Dictionary<string, object> additionalP
public void TransmitEvent(IAnalyticsEvent runtimeEvent)
{
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public string[] GetProjectFiles(string extension)
{
return VsUtils.GetPhysicalFileProjectItems(_project)
.Select(VsUtils.GetFilePath)
.Where(fp => FileSystemHelper.IsOfType(fp, ".feature"))
.Where(fp => FileSystemHelper.IsOfType(fp, extension))
.ToArray();
}
catch (Exception e)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
namespace Reqnroll.VisualStudio.Editor.Commands
{
[Export(typeof(IDeveroomCodeEditorCommand))]

public class FindUnusedStepDefinitionsCommand : DeveroomEditorCommandBase, IDeveroomCodeEditorCommand
{
private const string PopupHeader = "Unused Step Definitions";

private readonly UnusedStepDefinitionsFinder _stepDefinitionsUnusedFinder;

[ImportingConstructor]
public FindUnusedStepDefinitionsCommand(
IIdeScope ideScope,
IBufferTagAggregatorFactoryService aggregatorFactory,
IDeveroomTaggerProvider taggerProvider)
: base(ideScope, aggregatorFactory, taggerProvider)
{
_stepDefinitionsUnusedFinder = new UnusedStepDefinitionsFinder(ideScope);
}


public override DeveroomEditorCommandTargetKey[] Targets => new[]
{
new DeveroomEditorCommandTargetKey(ReqnrollVsCommands.DefaultCommandSet,
ReqnrollVsCommands.FindUnusedStepDefinitionsCommandId)
};

public override DeveroomEditorCommandStatus QueryStatus(IWpfTextView textView,
DeveroomEditorCommandTargetKey commandKey)
{
var status = base.QueryStatus(textView, commandKey);

var heuristicTest = textView.TextBuffer.CurrentSnapshot.GetText().Contains("Reqnroll") || textView.TextBuffer.CurrentSnapshot.GetText().Contains("SpecFlow");

if (status != DeveroomEditorCommandStatus.NotSupported)
// very basic heuristic: if the word "Reqnroll" or "SpecFlow" is in the content of the file, it might be a binding class
status = heuristicTest
? DeveroomEditorCommandStatus.Supported
: DeveroomEditorCommandStatus.NotSupported;

return status;
}


public override bool PreExec(IWpfTextView textView, DeveroomEditorCommandTargetKey commandKey, IntPtr inArgs = default)
{
Logger.LogVerbose("Find Unused Step Definitions");

var textBuffer = textView.TextBuffer;

var project = IdeScope.GetProject(textBuffer);
if (project == null || !project.GetProjectSettings().IsReqnrollProject)
{
IdeScope.Actions.ShowProblem(
"Unable to find step definition usages: the project is not detected to be a Reqnroll project or it is not initialized yet.");
return true;
}

var reqnrollTestProjects = new IProjectScope[] { project };

var asyncContextMenu = IdeScope.Actions.ShowAsyncContextMenu(PopupHeader);
Task.Run(
() => FindUnusedStepDefinitionsInProjectsAsync(reqnrollTestProjects, asyncContextMenu,
asyncContextMenu.CancellationToken), asyncContextMenu.CancellationToken);
return true;
}

private async Task FindUnusedStepDefinitionsInProjectsAsync(IProjectScope[] reqnrollTestProjects, IAsyncContextMenu asyncContextMenu, CancellationToken cancellationToken)
{
var summary = new UnusedStepDefinitionSummary();
summary.ScannedProjects = reqnrollTestProjects.Length;
try
{
await FindUsagesInternalAsync(reqnrollTestProjects, asyncContextMenu, cancellationToken, summary);
}
catch (Exception ex)
{
Logger.LogException(MonitoringService, ex);
summary.WasError = true;
}

if (summary.WasError)
asyncContextMenu.AddItems(new ContextMenuItem("Could not complete find operation because of an error"));
else if (summary.FoundStepDefinitions == 0)
asyncContextMenu.AddItems(
new ContextMenuItem("Could not find any step definitions in the current solution"));
else if (summary.UnusedStepDefinitions == 0)
asyncContextMenu.AddItems(
new ContextMenuItem("There are no unused step definitions"));

MonitoringService.MonitorCommandFindUnusedStepDefinitions(summary.UnusedStepDefinitions, summary.ScannedFeatureFiles,
cancellationToken.IsCancellationRequested);
if (cancellationToken.IsCancellationRequested)
Logger.LogVerbose("Finding unused step definitions cancelled");
else
Logger.LogInfo($"Found {summary.UnusedStepDefinitions} unused step definitions in {summary.ScannedProjects} Projects");
asyncContextMenu.Complete();
Finished.Set();
}
private async Task FindUsagesInternalAsync(IProjectScope[] reqnrollTestProjects, IAsyncContextMenu asyncContextMenu,

Check warning on line 100 in Reqnroll.VisualStudio/Editor/Commands/FindUnusedStepDefinitionsCommand.cs

View workflow job for this annotation

GitHub Actions / build

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
CancellationToken cancellationToken, UnusedStepDefinitionSummary summary)
{
foreach (var project in reqnrollTestProjects)
{
if (cancellationToken.IsCancellationRequested)
break;

var bindingRegistry = project.GetDiscoveryService().BindingRegistryCache.Value;
if (bindingRegistry == ProjectBindingRegistry.Invalid)
{
Logger.LogWarning(
$"Unable to get step definitions from project '{project.ProjectName}', usages will not be found for this project.");
continue;
}

// At this point, the binding registry contains StepDefinitions from the current project and any referenced assemblies that contain StepDefinitions.
// We need to filter out any step definitions that are not from the current project.

var projectCodeFiles = project.GetProjectFiles(".cs");
var projectScopedBindingRegistry = bindingRegistry.Where(sd => projectCodeFiles.Contains(sd.Implementation?.SourceLocation?.SourceFile));

var stepDefinitionCount = projectScopedBindingRegistry.StepDefinitions.Length;

summary.FoundStepDefinitions += stepDefinitionCount;
if (stepDefinitionCount == 0)
continue;

var featureFiles = project.GetProjectFiles(".feature");
var configuration = project.GetDeveroomConfiguration();
var projectUnusedStepDefinitions = _stepDefinitionsUnusedFinder.FindUnused(projectScopedBindingRegistry, featureFiles, configuration);
foreach (var unused in projectUnusedStepDefinitions)
{
if (cancellationToken.IsCancellationRequested)
break;

//await Task.Delay(500);

asyncContextMenu.AddItems(CreateMenuItem(unused, project, GetUsageLabel(unused), unused.Implementation.Method));
summary.UnusedStepDefinitions++;
}

summary.ScannedFeatureFiles += featureFiles.Length;
}
}

private static string GetUsageLabel(ProjectStepDefinitionBinding stepDefinition)
{
return $"[{stepDefinition.StepDefinitionType}(\"{stepDefinition.Expression}\")] {stepDefinition.Implementation.Method}";
}
private string GetIcon() => null;

Check warning on line 150 in Reqnroll.VisualStudio/Editor/Commands/FindUnusedStepDefinitionsCommand.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference return.

private ContextMenuItem CreateMenuItem(ProjectStepDefinitionBinding stepDefinition, IProjectScope project, string menuLabel, string shortDescription)
{
return new SourceLocationContextMenuItem(
stepDefinition.Implementation.SourceLocation, project.ProjectFolder,
menuLabel, _ => { PerformJump<string>(shortDescription, "", stepDefinition.Implementation, _ => { } ); }, GetIcon());
}

private class UnusedStepDefinitionSummary
{
public int FoundStepDefinitions { get; set; }
public int UnusedStepDefinitions { get; set; }
public int ScannedFeatureFiles { get; set; }
public int ScannedProjects { get; set; }
public bool WasError { get; set; }

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public DeveroomCodeEditorCommandBroker(IVsEditorAdaptersFactoryService adaptersF
[ImportMany] IEnumerable<IDeveroomCodeEditorCommand> commands, IDeveroomLogger logger)
: base(adaptersFactory, commands, logger)
{
Debug.Assert(_commands.Count == 2, "There have to be 2 code file editor Reqnroll commands");
Debug.Assert(_commands.Count == 3, "There have to be 3 code file editor Reqnroll commands");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ public static class ReqnrollVsCommands
public const int FindStepDefinitionUsagesCommandId = 0x0101;
public const int RenameStepCommandId = 0x0103;
public const int GoToHookCommandId = 0x0104;
public const int FindUnusedStepDefinitionsCommandId = 0x0102;
public static readonly Guid DefaultCommandSet = new("7fd3ed5d-2cf1-4200-b28b-cf1cc6b00c5a");
}
71 changes: 71 additions & 0 deletions Reqnroll.VisualStudio/Editor/Services/StepFinderBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using Reqnroll.VisualStudio.ProjectSystem;

namespace Reqnroll.VisualStudio.Editor.Services
{
public abstract class StepFinderBase
{
private readonly IIdeScope _ideScope;

protected StepFinderBase(IIdeScope ideScope)
{
_ideScope = ideScope;
}
protected bool LoadContent(string featureFilePath, out string content)
{
if (LoadAlreadyOpenedContent(featureFilePath, out string openedContent))
{
content = openedContent;
return true;
}

if (LoadContentFromFile(featureFilePath, out string fileContent))
{
content = fileContent;
return true;
}

content = string.Empty;
return false;
}

private bool LoadContentFromFile(string featureFilePath, out string content)
{
try
{
content = _ideScope.FileSystem.File.ReadAllText(featureFilePath);
return true;
}
catch (Exception ex)
{
_ideScope.Logger.LogDebugException(ex);
content = string.Empty;
return false;
}
}

private bool LoadAlreadyOpenedContent(string featureFilePath, out string content)
{
var sl = new SourceLocation(featureFilePath, 1, 1);
if (!_ideScope.GetTextBuffer(sl, out ITextBuffer tb))
{
content = string.Empty;
return false;
}

content = tb.CurrentSnapshot.GetText();
return true;
}

protected class StepFinderContext : IGherkinDocumentContext
{
public StepFinderContext(object node, IGherkinDocumentContext parent = null)

Check warning on line 61 in Reqnroll.VisualStudio/Editor/Services/StepFinderBase.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.
{
Node = node;
Parent = parent;
}

public IGherkinDocumentContext Parent { get; }
public object Node { get; }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Reqnroll.VisualStudio.ProjectSystem;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

#nullable disable

namespace Reqnroll.VisualStudio.Editor.Services;

public class UnusedStepDefinitionsFinder : StepFinderBase
{
private readonly IIdeScope _ideScope;

public UnusedStepDefinitionsFinder(IIdeScope ideScope) : base(ideScope)
{
_ideScope = ideScope;
}

public IEnumerable<ProjectStepDefinitionBinding> FindUnused(ProjectBindingRegistry bindingRegistry,
string[] featureFiles, DeveroomConfiguration configuration)
{
var stepDefUsageCounts = bindingRegistry.StepDefinitions.ToDictionary(stepDef => stepDef, _ => 0);
foreach (var ff in featureFiles)
{
var usedSteps = FindUsed(bindingRegistry, ff, configuration);
foreach (var step in usedSteps) stepDefUsageCounts[step]++;
}

return stepDefUsageCounts.Where(x => x.Value == 0).Select(x => x.Key);
}

protected IEnumerable<ProjectStepDefinitionBinding> FindUsed(ProjectBindingRegistry bindingRegistry,
string featureFilePath, DeveroomConfiguration configuration) =>
LoadContent(featureFilePath, out string featureFileContent)
? FindUsagesFromContent(bindingRegistry, featureFileContent, featureFilePath, configuration)
: Enumerable.Empty<ProjectStepDefinitionBinding>();

private IEnumerable<ProjectStepDefinitionBinding> FindUsagesFromContent(ProjectBindingRegistry bindingRegistry, string featureFileContent, string featureFilePath, DeveroomConfiguration configuration)
{
var dialectProvider = ReqnrollGherkinDialectProvider.Get(configuration.DefaultFeatureLanguage);
var parser = new DeveroomGherkinParser(dialectProvider, _ideScope.MonitoringService);
parser.ParseAndCollectErrors(featureFileContent, _ideScope.Logger,
out var gherkinDocument, out _);

var featureNode = gherkinDocument?.Feature;
if (featureNode == null)
return Enumerable.Empty<ProjectStepDefinitionBinding>();

var featureContext = new StepFinderContext(featureNode);
var usedSteps = new List<ProjectStepDefinitionBinding>();

foreach (var scenarioDefinition in featureNode.FlattenStepsContainers())
{
var context = new StepFinderContext(scenarioDefinition, featureContext);

foreach (var step in scenarioDefinition.Steps)
{
var matchResult = bindingRegistry.MatchStep(step, context);

usedSteps.AddRange(matchResult.Items.Where(m => m.Type == MatchResultType.Defined || m.Type == MatchResultType.Ambiguous)
.Select(i => i.MatchedStepDefinition));
}
}

return usedSteps;
}
}
1 change: 1 addition & 0 deletions Reqnroll.VisualStudio/Monitoring/IMonitoringService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public interface IMonitoringService
void MonitorCommandCommentUncomment();
void MonitorCommandDefineSteps(CreateStepDefinitionsDialogResult action, int snippetCount);
void MonitorCommandFindStepDefinitionUsages(int usagesCount, bool isCancelled);
void MonitorCommandFindUnusedStepDefinitions(int unusedStepDefinitions, int scannedFeatureFiles, bool isCancellationRequested);
void MonitorCommandGoToStepDefinition(bool generateSnippet);
void MonitorCommandGoToHook();
void MonitorCommandAutoFormatTable();
Expand Down
Loading

0 comments on commit 6d5f803

Please sign in to comment.