diff --git a/Reqnroll.VisualStudio/Configuration/CSharpCodeGenerationConfiguration.cs b/Reqnroll.VisualStudio/Configuration/CSharpCodeGenerationConfiguration.cs new file mode 100644 index 0000000..8398b49 --- /dev/null +++ b/Reqnroll.VisualStudio/Configuration/CSharpCodeGenerationConfiguration.cs @@ -0,0 +1,18 @@ +namespace Reqnroll.VisualStudio.Configuration; + +public class CSharpCodeGenerationConfiguration +{ + /// + /// Specifies the namespace declaration style for generated C# code. + /// Uses file-scoped namespaces when set to "file_scoped", otherwise uses block-scoped namespaces. + /// + [EditorConfigSetting("csharp_style_namespace_declarations")] + public string NamespaceDeclarationStyle { get; set; } = "block_scoped"; + + /// + /// Determines if file-scoped namespaces should be used based on the EditorConfig setting. + /// + public bool UseFileScopedNamespaces => + NamespaceDeclarationStyle != null && + NamespaceDeclarationStyle.StartsWith("file_scoped", StringComparison.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/Reqnroll.VisualStudio/Editor/Commands/DefineStepsCommand.cs b/Reqnroll.VisualStudio/Editor/Commands/DefineStepsCommand.cs index 507d804..4906475 100644 --- a/Reqnroll.VisualStudio/Editor/Commands/DefineStepsCommand.cs +++ b/Reqnroll.VisualStudio/Editor/Commands/DefineStepsCommand.cs @@ -1,16 +1,23 @@ #nullable disable + +using System.Text; + 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[] @@ -101,7 +108,7 @@ public override bool PreExec(IWpfTextView textView, DeveroomEditorCommandTargetK switch (viewModel.Result) { case CreateStepDefinitionsDialogResult.Create: - SaveAsStepDefinitionClass(projectScope, combinedSnippet, viewModel.ClassName, indent, newLine); + SaveAsStepDefinitionClass(projectScope, combinedSnippet, viewModel.ClassName, indent, newLine, textView); break; case CreateStepDefinitionsDialogResult.CopyToClipboard: Logger.LogVerbose($"Copy to clipboard: {combinedSnippet}"); @@ -114,7 +121,7 @@ public override bool PreExec(IWpfTextView textView, DeveroomEditorCommandTargetK } private void SaveAsStepDefinitionClass(IProjectScope projectScope, string combinedSnippet, string className, - string indent, string newLine) + string indent, string newLine, IWpfTextView textView) { string targetFolder = projectScope.ProjectFolder; var projectSettings = projectScope.GetProjectSettings(); @@ -130,21 +137,54 @@ private void SaveAsStepDefinitionClass(IProjectScope projectScope, string combin 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); + + // 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; + + // Adjust combinedSnippet indentation based on namespace style + var adjustedSnippet = csharpConfig.UseFileScopedNamespaces + ? AdjustIndentationForFileScopedNamespace(combinedSnippet, indent, newLine) + : combinedSnippet; + // 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}{{"); + template.Append(adjustedSnippet); + template.AppendLine($"{classIndent}}}"); + + // Close namespace if block-scoped + if (!csharpConfig.UseFileScopedNamespaces) + { + template.AppendLine("}"); + } var targetFile = FileDetails .FromPath(targetFolder, className + ".cs") - .WithCSharpContent(template); + .WithCSharpContent(template.ToString()); if (IdeScope.FileSystem.File.Exists(targetFile.FullName)) if (IdeScope.Actions.ShowSyncQuestion("Overwrite file?", @@ -152,7 +192,7 @@ private void SaveAsStepDefinitionClass(IProjectScope projectScope, string combin defaultButton: MessageBoxResult.No) != MessageBoxResult.Yes) return; - projectScope.AddFile(targetFile, template); + projectScope.AddFile(targetFile, template.ToString()); projectScope.IdeScope.Actions.NavigateTo(new SourceLocation(targetFile, 9, 1)); IDiscoveryService discoveryService = projectScope.GetDiscoveryService(); @@ -168,4 +208,31 @@ await discoveryService.BindingRegistryCache Finished.Set(); } + + private static string AdjustIndentationForFileScopedNamespace(string snippet, string indent, string newLine) + { + if (string.IsNullOrEmpty(snippet)) + return snippet; + + // Split into lines and process each line + var lines = snippet.Split(new[] { newLine }, StringSplitOptions.None); + var adjustedLines = new string[lines.Length]; + + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + + // If line starts with double indentation, reduce it to single indentation + if (line.StartsWith(indent + indent)) + { + adjustedLines[i] = indent + line.Substring((indent + indent).Length); + } + else + { + adjustedLines[i] = line; + } + } + + return string.Join(newLine, adjustedLines); + } } diff --git a/Reqnroll.VisualStudio/Editor/Services/EditorConfig/EditorConfigOptionsExtensions.cs b/Reqnroll.VisualStudio/Editor/Services/EditorConfig/EditorConfigOptionsExtensions.cs index bac6206..7d1fe9a 100644 --- a/Reqnroll.VisualStudio/Editor/Services/EditorConfig/EditorConfigOptionsExtensions.cs +++ b/Reqnroll.VisualStudio/Editor/Services/EditorConfig/EditorConfigOptionsExtensions.cs @@ -26,16 +26,16 @@ public static void UpdateFromEditorConfig(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); } diff --git a/Reqnroll.VisualStudio/Editor/Services/EditorConfig/EditorConfigOptionsProvider.cs b/Reqnroll.VisualStudio/Editor/Services/EditorConfig/EditorConfigOptionsProvider.cs index 2045bde..0f0f1e4 100644 --- a/Reqnroll.VisualStudio/Editor/Services/EditorConfig/EditorConfigOptionsProvider.cs +++ b/Reqnroll.VisualStudio/Editor/Services/EditorConfig/EditorConfigOptionsProvider.cs @@ -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); @@ -35,10 +50,17 @@ private Document CreateAdHocDocument(IWpfTextView textView) var editorFilePath = GetPath(textView); if (editorFilePath == null) return null; + return CreateAdHocDocumentByPath(editorFilePath); + } + + private Document CreateAdHocDocumentByPath(string filePath) + { + if (string.IsNullOrEmpty(filePath)) + return null; var project = _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) diff --git a/Reqnroll.VisualStudio/Editor/Services/EditorConfig/IEditorConfigOptionsProvider.cs b/Reqnroll.VisualStudio/Editor/Services/EditorConfig/IEditorConfigOptionsProvider.cs index 22ba4b0..3d8e996 100644 --- a/Reqnroll.VisualStudio/Editor/Services/EditorConfig/IEditorConfigOptionsProvider.cs +++ b/Reqnroll.VisualStudio/Editor/Services/EditorConfig/IEditorConfigOptionsProvider.cs @@ -3,4 +3,5 @@ namespace Reqnroll.VisualStudio.Editor.Services.EditorConfig; public interface IEditorConfigOptionsProvider { IEditorConfigOptions GetEditorConfigOptions(IWpfTextView textView); + IEditorConfigOptions GetEditorConfigOptionsByPath(string filePath); } diff --git a/Tests/Reqnroll.VisualStudio.Specs/StepDefinitions/ProjectSystemSteps.cs b/Tests/Reqnroll.VisualStudio.Specs/StepDefinitions/ProjectSystemSteps.cs index 17bc2a8..219e3d0 100644 --- a/Tests/Reqnroll.VisualStudio.Specs/StepDefinitions/ProjectSystemSteps.cs +++ b/Tests/Reqnroll.VisualStudio.Specs/StepDefinitions/ProjectSystemSteps.cs @@ -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; } diff --git a/Tests/Reqnroll.VisualStudio.Tests/Configuration/CSharpCodeGenerationConfigurationTests.cs b/Tests/Reqnroll.VisualStudio.Tests/Configuration/CSharpCodeGenerationConfigurationTests.cs new file mode 100644 index 0000000..b29aeb2 --- /dev/null +++ b/Tests/Reqnroll.VisualStudio.Tests/Configuration/CSharpCodeGenerationConfigurationTests.cs @@ -0,0 +1,154 @@ +using Reqnroll.VisualStudio.Configuration; +using Reqnroll.VisualStudio.Editor.Services.EditorConfig; +using Xunit; + +namespace Reqnroll.VisualStudio.Tests.Configuration; + +public class CSharpCodeGenerationConfigurationTests +{ + [Fact] + public void UseFileScopedNamespaces_WhenFileScopedSet_ReturnsTrue() + { + // Arrange + var config = new CSharpCodeGenerationConfiguration + { + NamespaceDeclarationStyle = "file_scoped" + }; + + // Act & Assert + Assert.True(config.UseFileScopedNamespaces); + } + + [Fact] + public void UseFileScopedNamespaces_WhenFileScopedWithSeveritySet_ReturnsTrue() + { + // Arrange + var config = new CSharpCodeGenerationConfiguration + { + NamespaceDeclarationStyle = "file_scoped:warning" + }; + + // Act & Assert + Assert.True(config.UseFileScopedNamespaces); + } + + [Fact] + public void UseFileScopedNamespaces_WhenBlockScopedSet_ReturnsFalse() + { + // Arrange + var config = new CSharpCodeGenerationConfiguration + { + NamespaceDeclarationStyle = "block_scoped" + }; + + // Act & Assert + Assert.False(config.UseFileScopedNamespaces); + } + + [Fact] + public void UseFileScopedNamespaces_WhenDefaultValue_ReturnsFalse() + { + // Arrange + var config = new CSharpCodeGenerationConfiguration(); + + // Act & Assert + Assert.False(config.UseFileScopedNamespaces); + } + + [Fact] + public void UseFileScopedNamespaces_WhenNullValue_ReturnsFalse() + { + // Arrange + var config = new CSharpCodeGenerationConfiguration + { + NamespaceDeclarationStyle = null + }; + + // Act & Assert + Assert.False(config.UseFileScopedNamespaces); + } + + [Fact] + public void UseFileScopedNamespaces_WhenUnknownValue_ReturnsFalse() + { + // Arrange + var config = new CSharpCodeGenerationConfiguration + { + NamespaceDeclarationStyle = "unknown_style" + }; + + // Act & Assert + Assert.False(config.UseFileScopedNamespaces); + } + + [Fact] + public void UpdateFromEditorConfig_WhenFileScopedValue_SetsCorrectValue() + { + // Arrange + var config = new CSharpCodeGenerationConfiguration(); + var editorConfigOptions = new TestEditorConfigOptions("file_scoped:silent"); + + // Act + editorConfigOptions.UpdateFromEditorConfig(config); + + // Assert + Assert.Equal("file_scoped:silent", config.NamespaceDeclarationStyle); + Assert.True(config.UseFileScopedNamespaces); + } + + [Fact] + public void UpdateFromEditorConfig_WhenBlockScopedValue_SetsCorrectValue() + { + // Arrange + var config = new CSharpCodeGenerationConfiguration(); + var editorConfigOptions = new TestEditorConfigOptions("block_scoped"); + + // Act + editorConfigOptions.UpdateFromEditorConfig(config); + + // Assert + Assert.Equal("block_scoped", config.NamespaceDeclarationStyle); + Assert.False(config.UseFileScopedNamespaces); + } + + [Fact] + public void UpdateFromEditorConfig_WhenNoValue_KeepsDefault() + { + // Arrange + var config = new CSharpCodeGenerationConfiguration(); + var editorConfigOptions = new TestEditorConfigOptions(null); + + // Act + editorConfigOptions.UpdateFromEditorConfig(config); + + // Assert + Assert.Equal("block_scoped", config.NamespaceDeclarationStyle); // Should keep default + Assert.False(config.UseFileScopedNamespaces); + } +} + +// Test EditorConfig options provider that simulates reading specific values +public class TestEditorConfigOptions : IEditorConfigOptions +{ + private readonly string _namespaceStyle; + + public TestEditorConfigOptions(string namespaceStyle) + { + _namespaceStyle = namespaceStyle; + } + + public TResult GetOption(string editorConfigKey, TResult defaultValue) + { + if (editorConfigKey == "csharp_style_namespace_declarations" && _namespaceStyle != null) + { + return (TResult)(object)_namespaceStyle; + } + + return defaultValue; + } + + public bool GetBoolOption(string editorConfigKey, bool defaultValue) + { + return defaultValue; + } +} \ No newline at end of file diff --git a/Tests/Reqnroll.VisualStudio.Tests/Editor/Commands/DefineStepsCommandTests.cs b/Tests/Reqnroll.VisualStudio.Tests/Editor/Commands/DefineStepsCommandTests.cs index fba8031..95d2ec8 100644 --- a/Tests/Reqnroll.VisualStudio.Tests/Editor/Commands/DefineStepsCommandTests.cs +++ b/Tests/Reqnroll.VisualStudio.Tests/Editor/Commands/DefineStepsCommandTests.cs @@ -6,7 +6,8 @@ public class DefineStepsCommandTests : CommandTestBase { public DefineStepsCommandTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, (ps, tp) => - new DefineStepsCommand(ps.IdeScope, new StubBufferTagAggregatorFactoryService(tp), tp), + new DefineStepsCommand(ps.IdeScope, new StubBufferTagAggregatorFactoryService(tp), tp, + new StubEditorConfigOptionsProvider()), "ShowProblem: User Notification: ") { } diff --git a/Tests/Reqnroll.VisualStudio.VsxStubs/StubEditorConfigOptionsProvider.cs b/Tests/Reqnroll.VisualStudio.VsxStubs/StubEditorConfigOptionsProvider.cs index dd45a7b..d24b482 100644 --- a/Tests/Reqnroll.VisualStudio.VsxStubs/StubEditorConfigOptionsProvider.cs +++ b/Tests/Reqnroll.VisualStudio.VsxStubs/StubEditorConfigOptionsProvider.cs @@ -3,4 +3,5 @@ namespace Reqnroll.VisualStudio.VsxStubs; public class StubEditorConfigOptionsProvider : IEditorConfigOptionsProvider { public IEditorConfigOptions GetEditorConfigOptions(IWpfTextView textView) => new NullEditorConfigOptions(); + public IEditorConfigOptions GetEditorConfigOptionsByPath(string filePath) => new NullEditorConfigOptions(); }