Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Find Unused Step Definitions command #8

Merged
merged 7 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
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);

if (status != DeveroomEditorCommandStatus.NotSupported)
// very basic heuristic: if the word "Reqnroll" is in the content of the file, it might be a binding class
status = textView.TextBuffer.CurrentSnapshot.GetText().Contains("Reqnroll")
? 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 fileName = GetEditorDocumentPath(textBuffer);
var triggerPoint = textView.Caret.Position.BufferPosition;

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 = IdeScope.GetProjectsWithFeatureFiles()
.Where(p => p.GetProjectSettings().IsReqnrollTestProject)
.ToArray();

if (reqnrollTestProjects.Length == 0)
{
IdeScope.Actions.ShowProblem(
"Unable to find step definition usages: could not find any Reqnroll project with feature files.");
return true;
}

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

private async Task FindUnusedStepDefinitionsInProjectsAsync(IProjectScope[] reqnrollTestProjects, string fileName,
SnapshotPoint triggerPoint, IAsyncContextMenu asyncContextMenu, CancellationToken cancellationToken)
{
var summary = new UnusedStepDefinitionSummary();

try
{
await FindUsagesInternalAsync(reqnrollTestProjects, fileName, triggerPoint, 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.UnusedStepDefinitions == 0)
asyncContextMenu.AddItems(
new ContextMenuItem("Could not find any unused step definitions at the current position"));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the message should be "Could not find any unused step definitions in the current project"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: the messaging when there are no Unbound StepDefs. This feature scans all the projects in the solution, including external binding assemblies. At this point in the execution, all the work is done (all projects and assemblies scanned).
I would suggest:
"There are no unused step definitions" or perhaps "There are no unused step definitions in the solution"

Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not exactly. It takes the binding registry from the current project. It scans all projects for feature files, but not for the step definitions. At least I think it is like that.

//TODO: determine if this required/needed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would include the not found message (with a different text of course)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

//else if (summary.UsagesFound == 0) asyncContextMenu.AddItems(new ContextMenuItem("Could not find any usage"));

//TODO: modify monitoring to include finding unused step definitions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would definitely include the monitoring, so that we can see how users will use this feature. You can make a new telemetry event similar to the find usages.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added MonitorCommandFindUnusedSStepDefinitions to IMonitoringService and its two implementations (MonitoringService and NullVsIdeScope.cs)

//MonitoringService.MonitorCommandFindStepDefinitionUsages(summary.UsagesFound,
// cancellationToken.IsCancellationRequested);
if (cancellationToken.IsCancellationRequested)
Logger.LogVerbose("Finding unused step definitions cancelled");
else
Logger.LogInfo($"Found {summary.UnusedStepDefinitions} unused step definitions in {summary.ScannedFeatureFiles} feature files");
asyncContextMenu.Complete();
Finished.Set();
}
private async Task FindUsagesInternalAsync(IProjectScope[] reqnrollTestProjects, string fileName,
SnapshotPoint triggerPoint, IAsyncContextMenu asyncContextMenu, CancellationToken cancellationToken,
UnusedStepDefinitionSummary summary)
{
foreach (var project in reqnrollTestProjects)
{
if (cancellationToken.IsCancellationRequested)
break;

var stepDefinitions = await GetStepDefinitionsAsync(project, fileName, triggerPoint);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not need to collect step definitions per file, because we can use the step definitions for the project (see comment below). So this few lines and the related methods (GetStepDefinitionsAsync, GetStepDefinitions) are not needed. Consequently you will not need the fileName parameter either.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored per your suggestion.

//summary.FoundStepDefinitions += stepDefinitions.Length;
if (stepDefinitions.Length == 0)
continue;

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

//await Task.Delay(500);

//TODO: create menu item - what type of menu item?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SourceLocationContextMenuItem you have used is fine, you can remove this comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

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

summary.ScannedFeatureFiles += featureFiles.Length;
}
}

private async Task<ProjectStepDefinitionBinding[]> GetStepDefinitionsAsync(IProjectScope project, string fileName,
SnapshotPoint triggerPoint)
{
var discoveryService = project.GetDiscoveryService();
var bindingRegistry = await discoveryService.BindingRegistryCache.GetLatest();
if (bindingRegistry == ProjectBindingRegistry.Invalid)
Logger.LogWarning(
$"Unable to get step definitions from project '{project.ProjectName}', usages will not be found for this project.");
return GetStepDefinitions(fileName, triggerPoint, bindingRegistry);
}

//TODO: since this Command doesn't need to filter the set of StepDefinitions, this can be merged with the method above
internal static ProjectStepDefinitionBinding[] GetStepDefinitions(string fileName, SnapshotPoint triggerPoint,
ProjectBindingRegistry bindingRegistry)
{
return bindingRegistry.StepDefinitions
// .Where(sd => sd.Implementation?.SourceLocation != null &&
// sd.Implementation.SourceLocation.SourceFile == fileName &&
//
.ToArray();
}

//TODO: near duplicate of similar method in FindStepDefinitionUsagesCommand
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. Maybe try to create a JumpListEditorCommand abstract base class for this two commands?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After looking at it further, decided against it for now. The other is dealing with creating a menu item and Jump for feature files, not C# files. I found that the base class for these Editor Commands already has an implementation for PerformJump for C# code. So I refactored the FindUnusedStepDefinitionsCommand to use the base class behavior. The implementation that I was previously looking at, in FindStepDefinitionUsages, shadows the base class implementation because jumping to a feature file location is somewhat different.

Let me know if you'd like me to further refactor this such that both classes use a common implementation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. All good.

private ContextMenuItem CreateMenuItem(ProjectStepDefinitionBinding stepDefinition, IProjectScope project)
{
return new SourceLocationContextMenuItem(
stepDefinition.Implementation.SourceLocation, project.ProjectFolder,
GetUsageLabel(stepDefinition), _ => { PerformJump(stepDefinition); }, null);
}

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

//TODO: near duplicate of similar method in FindStepDefinitionUsagesCommand
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like above, maybe an JumpListEditorCommand abstract base would help.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

private void PerformJump(ProjectStepDefinitionBinding binding)
{
var sourceLocation = binding.Implementation.SourceLocation;
if (sourceLocation == null)
{
Logger.LogWarning($"Cannot jump to {binding.Implementation.Method}: no source location");
IdeScope.Actions.ShowProblem("Unable to jump to the step. No source location detected.");
return;
}

Logger.LogInfo($"Jumping to {binding.Implementation.Method} at {sourceLocation}");
if (!IdeScope.Actions.NavigateTo(sourceLocation))
{
Logger.LogWarning($"Cannot jump to {binding.Implementation.Method}: invalid source file or position");
IdeScope.Actions.ShowProblem(
$"Unable to jump to the step. Invalid source file or file position.{Environment.NewLine}{sourceLocation}");
}
}
private class UnusedStepDefinitionSummary
{
public int UnusedStepDefinitions { get; set; }
public int ScannedFeatureFiles { 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");
}
127 changes: 127 additions & 0 deletions Reqnroll.VisualStudio/Editor/Services/UnusedStepDefinitionsFinder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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
{
private readonly IIdeScope _ideScope;

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

public IEnumerable<ProjectStepDefinitionBinding> FindUnused(ProjectStepDefinitionBinding[] stepDefinitions,
string[] featureFiles, DeveroomConfiguration configuration)
{
return featureFiles.SelectMany(ff => FindUnused(stepDefinitions, ff, configuration));
}

public IEnumerable<ProjectStepDefinitionBinding> FindUnused(ProjectStepDefinitionBinding[] stepDefinitions,
string featureFilePath, DeveroomConfiguration configuration) =>
LoadContent(featureFilePath, out string featureFileContent)
? FindUsagesFromContent(stepDefinitions, featureFileContent, featureFilePath, configuration)
: Enumerable.Empty<ProjectStepDefinitionBinding>();

private IEnumerable<ProjectStepDefinitionBinding> FindUsagesFromContent(ProjectStepDefinitionBinding[] stepDefinitions, 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 dummyRegistry = ProjectBindingRegistry.FromBindings(stepDefinitions);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of this you should get an instance of the IDiscoveryService from the project scope (like in DefineStepsCommand here) in the command and pass it to the finder class.

In the finder class, you can get the binding registry with

var bindingRegistry = _discoveryService.BindingRegistryCache.Value;

this way you will not need the stepDefinitions param all the way up.


//this set keeps track of which step definitions have not been used. We start with ALL step definitions, and will subtract those that match steps in the feature file
HashSet<ProjectStepDefinitionBinding> stepDefinitionsSet = new(stepDefinitions);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following the comment above, here you can use the bindingRegistry.StepDefinitions instead of the stepDefinitions


var featureContext = new UnusedFinderContext(featureNode);

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

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

var usedSteps = matchResult.Items.Where(m => m.Type == MatchResultType.Defined || m.Type == MatchResultType.Ambiguous)
.Select(i => i.MatchedStepDefinition)
.ToHashSet<ProjectStepDefinitionBinding>();
stepDefinitionsSet.ExceptWith(usedSteps);
}
}

return stepDefinitionsSet.ToList();

}

//TODO: duplicate of what is in StepDefinitionUsageFinder; consider refactoring to shared implementation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. Maybe a FeatureFileVisitor or FeatureFileProcessor abstract base class?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a base class for both Finder services, called it StepFinderBase.cs. IT contains the common logic for loading feature file content.

private 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;
}

//TODO: duplicate of what is in StepDefinitionUsageFinder; consider refactoring to shared implementation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, see comment above.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created StepFinderContext class, embedded in the StepFinderBase.cs base class file.

private class UnusedFinderContext : IGherkinDocumentContext
{
public UnusedFinderContext(object node, IGherkinDocumentContext parent = null)
{
Node = node;
Parent = parent;
}

public IGherkinDocumentContext Parent { get; }
public object Node { get; }
}
}
Loading
Loading