Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

## Improvements:

* Improved handling of scope tag expressions, hook and scope errors (#150)
* Improved logging for binding discovery (#154)
* Support file scoped namespace declarations when generating code (#140)

## Bug fixes:

*Contributors of this release (in alphabetical order):* @clrudolphi, @gasparnagy
* Improved handling of scope tag expressions, hook and scope errors (#150)
* Improved logging for binding discovery (#154)

*Contributors of this release (in alphabetical order):* @304NotModified, @clrudolphi, @gasparnagy

# v2025.3.395 - 2025-12-17

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Reqnroll.VisualStudio.Configuration;

public class CSharpCodeGenerationConfiguration
{
/// <summary>
/// Specifies the namespace declaration style for generated C# code.
/// Uses file-scoped namespaces when set to "file_scoped", otherwise uses block-scoped namespaces.
/// </summary>
[EditorConfigSetting("csharp_style_namespace_declarations")]
public string? NamespaceDeclarationStyle { get; set; } = "block_scoped";

/// <summary>
/// Determines if file-scoped namespaces should be used based on the EditorConfig setting.
/// </summary>
public bool UseFileScopedNamespaces =>
NamespaceDeclarationStyle != null &&
NamespaceDeclarationStyle.StartsWith("file_scoped", StringComparison.OrdinalIgnoreCase);
}
127 changes: 108 additions & 19 deletions Reqnroll.VisualStudio/Editor/Commands/DefineStepsCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ namespace Reqnroll.VisualStudio.Editor.Commands;
[Export(typeof(IDeveroomFeatureEditorCommand))]
public class DefineStepsCommand : DeveroomEditorCommandBase, IDeveroomFeatureEditorCommand
{
private readonly IEditorConfigOptionsProvider _editorConfigOptionsProvider;

[ImportingConstructor]
public DefineStepsCommand(
IIdeScope ideScope,
IBufferTagAggregatorFactoryService aggregatorFactory,
IDeveroomTaggerProvider taggerProvider)
IDeveroomTaggerProvider taggerProvider,
IEditorConfigOptionsProvider editorConfigOptionsProvider)
: base(ideScope, aggregatorFactory, taggerProvider)
{
_editorConfigOptionsProvider = editorConfigOptionsProvider;
}

public override DeveroomEditorCommandTargetKey[] Targets => new[]
Expand Down Expand Up @@ -94,7 +98,7 @@ public override bool PreExec(IWpfTextView textView, DeveroomEditorCommandTargetK
}

var combinedSnippet = string.Join(newLine,
viewModel.Items.Where(i => i.IsSelected).Select(i => i.Snippet.Indent(indent + indent)));
viewModel.Items.Where(i => i.IsSelected).Select(i => i.Snippet.Indent(indent)));

MonitoringService.MonitorCommandDefineSteps(viewModel.Result, viewModel.Items.Count(i => i.IsSelected));

Expand Down Expand Up @@ -124,42 +128,104 @@ private void SaveAsStepDefinitionClass(IProjectScope projectScope, string combin
if (IdeScope.FileSystem.Directory.Exists(stepDefinitionsFolder))
{
targetFolder = stepDefinitionsFolder;
fileNamespace = fileNamespace + ".StepDefinitions";
fileNamespace += ".StepDefinitions";
}
var projectTraits = projectScope.GetProjectSettings().ReqnrollProjectTraits;
var isSpecFlow = projectTraits.HasFlag(ReqnrollProjectTraits.LegacySpecFlow) || projectTraits.HasFlag(ReqnrollProjectTraits.SpecFlowCompatibility);
var libraryNameSpace = isSpecFlow ? "SpecFlow" : "Reqnroll";

var template = "using System;" + newLine +
$"using {libraryNameSpace};" + newLine +
newLine +
$"namespace {fileNamespace}" + newLine +
"{" + newLine +
$"{indent}[Binding]" + newLine +
$"{indent}public class {className}" + newLine +
$"{indent}{{" + newLine +
combinedSnippet +
$"{indent}}}" + newLine +
"}" + newLine;
// Get C# code generation configuration from EditorConfig using target .cs file path
var targetFilePath = Path.Combine(targetFolder, className + ".cs");
var csharpConfig = new CSharpCodeGenerationConfiguration();
var editorConfigOptions = _editorConfigOptionsProvider.GetEditorConfigOptionsByPath(targetFilePath);
editorConfigOptions.UpdateFromEditorConfig(csharpConfig);

var projectTraits = projectScope.GetProjectSettings().ReqnrollProjectTraits;
var generatedContent = GenerateStepDefinitionClass(
combinedSnippet,
className,
fileNamespace,
projectTraits,
csharpConfig,
indent,
newLine);

var targetFile = FileDetails
.FromPath(targetFolder, className + ".cs")
.WithCSharpContent(template);
.WithCSharpContent(generatedContent);

if (IdeScope.FileSystem.File.Exists(targetFile.FullName))
if (IdeScope.Actions.ShowSyncQuestion("Overwrite file?",
$"The selected step definition file '{targetFile}' already exists. By overwriting the existing file you might lose work. {Environment.NewLine}Do you want to overwrite the file?",
defaultButton: MessageBoxResult.No) != MessageBoxResult.Yes)
return;

projectScope.AddFile(targetFile, template);
projectScope.AddFile(targetFile, generatedContent);
projectScope.IdeScope.Actions.NavigateTo(new SourceLocation(targetFile, 9, 1));
IDiscoveryService discoveryService = projectScope.GetDiscoveryService();

projectScope.IdeScope.FireAndForget(
() => RebuildBindingRegistry(discoveryService, targetFile), _ => { Finished.Set(); });
}

internal static string GenerateStepDefinitionClass(
string combinedSnippet,
string className,
string fileNamespace,
ReqnrollProjectTraits projectTraits,
CSharpCodeGenerationConfiguration csharpConfig,
string indent,
string newLine)
{
var isSpecFlow = projectTraits.HasFlag(ReqnrollProjectTraits.LegacySpecFlow) ||
projectTraits.HasFlag(ReqnrollProjectTraits.SpecFlowCompatibility);
var libraryNameSpace = isSpecFlow ? "SpecFlow" : "Reqnroll";

// Estimate template size for StringBuilder capacity
var estimatedSize = 200 + fileNamespace.Length + className.Length + combinedSnippet.Length;
var template = new StringBuilder(estimatedSize);
template.AppendLine("using System;");
template.AppendLine($"using {libraryNameSpace};");
template.AppendLine();

// Determine indentation level based on namespace style
var classIndent = csharpConfig.UseFileScopedNamespaces ? "" : indent;

// Add namespace declaration
if (csharpConfig.UseFileScopedNamespaces)
{
template.AppendLine($"namespace {fileNamespace};");
template.AppendLine();
}
else
{
template.AppendLine($"namespace {fileNamespace}");
template.AppendLine("{");
}

// Add class declaration (common structure with appropriate indentation)
template.AppendLine($"{classIndent}[Binding]");
template.AppendLine($"{classIndent}public class {className}");
template.AppendLine($"{classIndent}{{");

// Add snippet with appropriate indentation based on namespace style
if (csharpConfig.UseFileScopedNamespaces)
{
template.AppendLine(combinedSnippet);
}
else
{
AppendLinesWithIndent(template, combinedSnippet, indent, newLine);
}

template.AppendLine($"{classIndent}}}");

// Close namespace if block-scoped
if (!csharpConfig.UseFileScopedNamespaces)
{
template.AppendLine("}");
}

return template.ToString();
}

private async Task RebuildBindingRegistry(IDiscoveryService discoveryService,
CSharpStepDefinitionFile stepDefinitionFile)
{
Expand All @@ -168,4 +234,27 @@ await discoveryService.BindingRegistryCache

Finished.Set();
}

private static void AppendLinesWithIndent(StringBuilder builder, string content, string indent, string newLine)
{
if (string.IsNullOrEmpty(content))
return;

var lines = content.Split(new[] { newLine }, StringSplitOptions.None);

for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];

// Add indentation to non-empty lines
if (!string.IsNullOrWhiteSpace(line))
{
builder.Append(indent).AppendLine(line);
}
else
{
builder.AppendLine(line);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,16 @@ public static void UpdateFromEditorConfig<TConfig>(this IEditorConfigOptions edi
.Select(p => new
{
PropertyInfo = p,
((EditorConfigSettingAttribute) Attribute.GetCustomAttribute(p, typeof(EditorConfigSettingAttribute)))
EditorConfigKey = ((EditorConfigSettingAttribute) Attribute.GetCustomAttribute(p, typeof(EditorConfigSettingAttribute)))
?.EditorConfigSettingName
})
.Where(p => p.EditorConfigSettingName != null);
.Where(p => p.EditorConfigKey != null);

foreach (var property in propertiesWithEditorConfig)
{
var currentValue = property.PropertyInfo.GetValue(config);
var updatedValue = editorConfigOptions.GetOption(property.PropertyInfo.PropertyType,
property.EditorConfigSettingName, currentValue);
property.EditorConfigKey, currentValue);
if (!Equals(currentValue, updatedValue))
property.PropertyInfo.SetValue(config, updatedValue);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ public IEditorConfigOptions GetEditorConfigOptions(IWpfTextView textView)
return new EditorConfigOptions(options);
}

public IEditorConfigOptions GetEditorConfigOptionsByPath(string filePath)
{
if (string.IsNullOrEmpty(filePath))
return NullEditorConfigOptions.Instance;

var document = CreateAdHocDocumentByPath(filePath);
if (document == null)
return NullEditorConfigOptions.Instance;

var options =
ThreadHelper.JoinableTaskFactory.Run(() => document.GetOptionsAsync());

return new EditorConfigOptions(options);
}

private Document GetDocument(IWpfTextView textView) =>
textView.TextBuffer.GetRelatedDocuments().FirstOrDefault() ??
CreateAdHocDocument(textView);
Expand All @@ -35,10 +50,32 @@ private Document CreateAdHocDocument(IWpfTextView textView)
var editorFilePath = GetPath(textView);
if (editorFilePath == null)
return null;
var project = _visualStudioWorkspace.CurrentSolution.Projects.FirstOrDefault();
return CreateAdHocDocumentByPath(editorFilePath);
}

private Document CreateAdHocDocumentByPath(string filePath)
{
bool IsInProject(Project project)
{
if (project.FilePath == null)
return false;
var projectDir = Path.GetDirectoryName(project.FilePath);
if (projectDir == null) return false;
return Path.GetFullPath(filePath)
.StartsWith(projectDir + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}

if (string.IsNullOrEmpty(filePath))
return null;

// We try to create the ad-hoc document in the project that contains (or would contain) the file,
// because otherwise the editorconfig options may not be correctly resolved.
var project =
_visualStudioWorkspace.CurrentSolution.Projects.FirstOrDefault(IsInProject) ??
_visualStudioWorkspace.CurrentSolution.Projects.FirstOrDefault();
if (project == null)
return null;
return project.AddDocument(editorFilePath, string.Empty, filePath: editorFilePath);
return project.AddDocument(filePath, string.Empty, filePath: filePath);
}

public static string GetPath(IWpfTextView textView)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ namespace Reqnroll.VisualStudio.Editor.Services.EditorConfig;
public interface IEditorConfigOptionsProvider
{
IEditorConfigOptions GetEditorConfigOptions(IWpfTextView textView);
IEditorConfigOptions GetEditorConfigOptionsByPath(string filePath);
}
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,8 @@ private void PerformCommand(string commandName, string parameter = null,
}
case "Define Steps":
{
_invokedCommand = new DefineStepsCommand(_ideScope, aggregatorFactoryService, taggerProvider);
_invokedCommand = new DefineStepsCommand(_ideScope, aggregatorFactoryService, taggerProvider,
new StubEditorConfigOptionsProvider());
_invokedCommand.PreExec(_wpfTextView, _invokedCommand.Targets.First());
return;
}
Expand Down
Loading