From ba006d4c68989367ce22bc57f07da2b1ff1e770c Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 17:07:33 +1100 Subject: [PATCH 01/34] Refactor release notes generation to PowerShell Migrated the release notes generation step from bash to PowerShell for improved compatibility and maintainability. Enhanced commit listing to use GitHub API for author usernames, added fallback to git log, and improved release note formatting for both pre-release and standard releases. --- .github/workflows/release.yml | 135 +++++++++++++++++++++++----------- 1 file changed, 93 insertions(+), 42 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0b455f..21b5253 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ on: branches: - main - dev + - '*testing*' paths: - '**.cs' - '**.csproj' @@ -225,7 +226,7 @@ jobs: "IS_PRERELEASE=$isPrerelease" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "RELEASE_NAME=$releaseName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "RELEASE_TYPE=$releaseType" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - + - name: Build Extension run: msbuild InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj /p:Configuration=Release /p:DeployExtension=false /p:ZipPackageCompressionLevel=normal /v:minimal /m @@ -260,56 +261,106 @@ jobs: - name: Generate release notes id: release-notes - shell: bash + shell: pwsh run: | - RELEASE_TYPE="${{ steps.version.outputs.RELEASE_TYPE }}" + $releaseType = "${{ steps.version.outputs.RELEASE_TYPE }}" # Find the last tag of the same type - if [[ "$RELEASE_TYPE" == "testing" ]]; then - LAST_TAG=$(git tag -l "v*-testing.*" --sort=-version:refname | head -n 2 | tail -n 1) - elif [[ "$RELEASE_TYPE" == "dev" ]]; then - LAST_TAG=$(git tag -l "v*-dev.*" --sort=-version:refname | head -n 2 | tail -n 1) - else - LAST_TAG=$(git tag -l "v*" --sort=-version:refname | grep -v "-" | head -n 2 | tail -n 1) - fi + if ($releaseType -eq "testing") { + $lastTag = git tag -l "v*-testing.*" --sort=-version:refname | Select-Object -Skip 1 -First 1 + } + elseif ($releaseType -eq "dev") { + $lastTag = git tag -l "v*-dev.*" --sort=-version:refname | Select-Object -Skip 1 -First 1 + } + else { + $lastTag = git tag -l "v*" --sort=-version:refname | Where-Object { $_ -notmatch "-" } | Select-Object -Skip 1 -First 1 + } - # Get commits since last tag of this type with author information - if [ -z "$LAST_TAG" ]; then - COMMITS=$(git log --first-parent --pretty=format:"- %s by @%an (%h)" --reverse) - TAG_INFO="First $RELEASE_TYPE release" - else - COMMITS=$(git log --first-parent ${LAST_TAG}..HEAD --pretty=format:"- %s by @%an (%h)" --reverse) - TAG_INFO="Since $LAST_TAG" - fi + # Get tag info and commit SHA + if ([string]::IsNullOrEmpty($lastTag)) { + $tagInfo = "First $releaseType release" + $lastCommitSha = "" + } + else { + $tagInfo = "Since $lastTag" + $lastCommitSha = git rev-list -n 1 $lastTag + } - if [[ "${{ steps.version.outputs.IS_PRERELEASE }}" == "true" ]]; then - RELEASE_BODY="## Pre-release + # Get commits with GitHub usernames using GitHub API + try { + Write-Host "Fetching commits from GitHub API..." + $apiResponse = gh api "repos/${{ github.repository }}/commits?sha=${{ github.sha }}&per_page=100" --jq '.[] | "\(.commit.message | split("\n")[0])||\(.author.login // "unknown")||\(.sha[0:7])"' - **Version:** v${{ steps.version.outputs.DISPLAY_VERSION }} - **Commit:** ${{ github.sha }} - - **Changes ($TAG_INFO):** - $COMMITS - - ## Installation - 1. Download the \`.vsix\` file below - 2. Double-click to install in Visual Studio - 3. Or use: \`Extensions > Manage Extensions > Install from file\`" - else - RELEASE_BODY="## ${{ steps.version-bump.outputs.BUMP_TYPE == 'patch' && 'πŸ› Patch' || steps.version-bump.outputs.BUMP_TYPE == 'minor' && '✨ Minor' || '🚨 Major' }} Version Bump + $commitList = @() + foreach ($line in $apiResponse) { + $parts = $line -split '\|\|' + $message = $parts[0] + $author = $parts[1] + $sha = $parts[2] + + # Stop if we've reached the last tag + if (![string]::IsNullOrEmpty($lastCommitSha) -and $sha -eq $lastCommitSha.Substring(0, 7)) { + break + } + + $commitList += "- $message by @$author ($sha)" + } + + $commits = $commitList -join "`n" + Write-Host "βœ… Successfully fetched commits with GitHub usernames" + } + catch { + Write-Host "⚠️ Failed to get commits from GitHub API: $_" + Write-Host "Falling back to git log..." - **Version:** v${{ steps.version.outputs.DISPLAY_VERSION }} + # Fallback to simple git log + if ([string]::IsNullOrEmpty($lastTag)) { + $commits = git log --first-parent --pretty=format:"- %s (%h)" --reverse + } + else { + $commits = git log --first-parent "$lastTag..HEAD" --pretty=format:"- %s (%h)" --reverse + } + } - **Changes ($TAG_INFO):** - $COMMITS + # If no commits found, add a placeholder + if ([string]::IsNullOrEmpty($commits)) { + $commits = "- Initial release" + } - ## Installation - 1. Download the \`.vsix\` file below - 2. Double-click to install in Visual Studio - 3. Or use: \`Extensions > Manage Extensions > Install from file\`" - fi + # Build release body (using string concatenation to avoid YAML parsing issues) + $releaseBody = "" + + if ("${{ steps.version.outputs.IS_PRERELEASE }}" -eq "true") { + $releaseBody += "## Pre-release`n`n" + $releaseBody += "**Version:** v${{ steps.version.outputs.DISPLAY_VERSION }}`n" + $releaseBody += "**Commit:** ${{ github.sha }}`n`n" + $releaseBody += "**Changes ($tagInfo):**`n" + $releaseBody += "$commits`n`n" + $releaseBody += "## Installation`n" + $releaseBody += "1. Download the `.vsix` file below`n" + $releaseBody += "2. Double-click to install in Visual Studio`n" + $releaseBody += "3. Or use: ``Extensions > Manage Extensions > Install from file```n" + } + else { + $emoji = switch ("${{ steps.version-bump.outputs.BUMP_TYPE }}") { + "major" { "🚨 Major" } + "minor" { "✨ Minor" } + default { "πŸ› Patch" } + } + + $releaseBody += "## $emoji Version Bump`n`n" + $releaseBody += "**Version:** v${{ steps.version.outputs.DISPLAY_VERSION }}`n`n" + $releaseBody += "**Changes ($tagInfo):**`n" + $releaseBody += "$commits`n`n" + $releaseBody += "## Installation`n" + $releaseBody += "1. Download the `.vsix` file below`n" + $releaseBody += "2. Double-click to install in Visual Studio`n" + $releaseBody += "3. Or use: ``Extensions > Manage Extensions > Install from file```n" + } - echo "$RELEASE_BODY" > release-notes.txt + $releaseBody | Out-File -FilePath release-notes.txt -Encoding utf8 -NoNewline + env: + GH_TOKEN: ${{ github.token }} - name: Create GitHub Release with VSIX uses: softprops/action-gh-release@v2 @@ -321,4 +372,4 @@ jobs: prerelease: ${{ steps.version.outputs.IS_PRERELEASE }} files: | ${{ steps.locate-vsix.outputs.VSIX_PATH }} - token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + token: ${{ secrets.GITHUB_TOKEN }} From 8118ec846462e728b778a8a8fdacb8f4731f4d8b Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 17:09:33 +1100 Subject: [PATCH 02/34] Update branch filter in release workflow Changed the workflow branch filter from '*testing*' to '*naming*' to trigger releases on branches related to naming instead of testing. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21b5253..085aaf7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: branches: - main - dev - - '*testing*' + - '*naming*' paths: - '**.cs' - '**.csproj' From 0b9d58af2faab024f90e0ab45f4eabd3b22ed7af Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 17:34:32 +1100 Subject: [PATCH 03/34] Refactor release notes generation in workflow Adds error handling for the GitHub API call and refactors installation instructions to reduce duplication in the release notes generation script within the release workflow. --- .github/workflows/release.yml | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 085aaf7..59e428a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -289,7 +289,12 @@ jobs: # Get commits with GitHub usernames using GitHub API try { Write-Host "Fetching commits from GitHub API..." - $apiResponse = gh api "repos/${{ github.repository }}/commits?sha=${{ github.sha }}&per_page=100" --jq '.[] | "\(.commit.message | split("\n")[0])||\(.author.login // "unknown")||\(.sha[0:7])"' + $apiResponse = gh api --paginate "repos/${{ github.repository }}/commits?sha=${{ github.sha }}&per_page=100" --jq '.[] | "\(.commit.message | split("\n")[0])||\(.author.login // "unknown")||\(.sha[0:7])"' + + # Check if gh command succeeded + if ($LASTEXITCODE -ne 0 -or -not $apiResponse) { + throw "gh api command failed with exit code $LASTEXITCODE" + } $commitList = @() foreach ($line in $apiResponse) { @@ -327,6 +332,12 @@ jobs: $commits = "- Initial release" } + # Build installation instructions once (reduce duplication) + $installation = "## Installation`n" + $installation += "1. Download the `.vsix` file below`n" + $installation += "2. Double-click to install in Visual Studio`n" + $installation += "3. Or use: ``Extensions > Manage Extensions > Install from file```n" + # Build release body (using string concatenation to avoid YAML parsing issues) $releaseBody = "" @@ -336,10 +347,7 @@ jobs: $releaseBody += "**Commit:** ${{ github.sha }}`n`n" $releaseBody += "**Changes ($tagInfo):**`n" $releaseBody += "$commits`n`n" - $releaseBody += "## Installation`n" - $releaseBody += "1. Download the `.vsix` file below`n" - $releaseBody += "2. Double-click to install in Visual Studio`n" - $releaseBody += "3. Or use: ``Extensions > Manage Extensions > Install from file```n" + $releaseBody += $installation } else { $emoji = switch ("${{ steps.version-bump.outputs.BUMP_TYPE }}") { @@ -352,10 +360,7 @@ jobs: $releaseBody += "**Version:** v${{ steps.version.outputs.DISPLAY_VERSION }}`n`n" $releaseBody += "**Changes ($tagInfo):**`n" $releaseBody += "$commits`n`n" - $releaseBody += "## Installation`n" - $releaseBody += "1. Download the `.vsix` file below`n" - $releaseBody += "2. Double-click to install in Visual Studio`n" - $releaseBody += "3. Or use: ``Extensions > Manage Extensions > Install from file```n" + $releaseBody += $installation } $releaseBody | Out-File -FilePath release-notes.txt -Encoding utf8 -NoNewline @@ -372,4 +377,4 @@ jobs: prerelease: ${{ steps.version.outputs.IS_PRERELEASE }} files: | ${{ steps.locate-vsix.outputs.VSIX_PATH }} - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From c5ac83d618ef0d9d95fb71c5df5d685b17fde76b Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 17:37:02 +1100 Subject: [PATCH 04/34] Update branch filter in release workflow Changed the workflow branch filter from '*naming*' to '*testing*' to trigger releases on branches related to testing instead of naming. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59e428a..0a76ced 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: branches: - main - dev - - '*naming*' + - '*testing*' paths: - '**.cs' - '**.csproj' From f624712da4142035fb556307f2c330c2c6aabcbe Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 17:41:23 +1100 Subject: [PATCH 05/34] Remove testing branch trigger from release workflow The release workflow will no longer run on branches matching '*testing*'. This change restricts workflow execution to only 'main' and 'dev' branches. --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a76ced..81a689a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,6 @@ on: branches: - main - dev - - '*testing*' paths: - '**.cs' - '**.csproj' From 94354e321b2bf35e6c7b628b326014dd6ac1456f Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 17:55:12 +1100 Subject: [PATCH 06/34] Fix release workflow string handling and formatting Adds a length check before substring operation on commit SHA to prevent errors. Also corrects a formatting issue in the installation instructions by removing an extra backtick. --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81a689a..7e55e35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -303,7 +303,7 @@ jobs: $sha = $parts[2] # Stop if we've reached the last tag - if (![string]::IsNullOrEmpty($lastCommitSha) -and $sha -eq $lastCommitSha.Substring(0, 7)) { + if (![string]::IsNullOrEmpty($lastCommitSha) -and $lastCommitSha.Length -ge 7 -and $sha -eq $lastCommitSha.Substring(0, 7)) { break } @@ -335,7 +335,7 @@ jobs: $installation = "## Installation`n" $installation += "1. Download the `.vsix` file below`n" $installation += "2. Double-click to install in Visual Studio`n" - $installation += "3. Or use: ``Extensions > Manage Extensions > Install from file```n" + $installation += "3. Or use: ``Extensions > Manage Extensions > Install from file``n" # Build release body (using string concatenation to avoid YAML parsing issues) $releaseBody = "" From 3b3e7f368050b2d715482b150bd01964bba93477 Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 18:00:08 +1100 Subject: [PATCH 07/34] Improve commit fetching and display in release workflow Optimizes the GitHub API call for fetching commits by using the 'since' parameter based on the last tag's commit date. Ensures full SHA is used for comparison and short SHA is used for display, and removes the '-NoNewline' flag from release notes output. --- .github/workflows/release.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e55e35..7afe62d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -279,16 +279,22 @@ jobs: if ([string]::IsNullOrEmpty($lastTag)) { $tagInfo = "First $releaseType release" $lastCommitSha = "" + $sinceParam = "" } else { $tagInfo = "Since $lastTag" $lastCommitSha = git rev-list -n 1 $lastTag + + # Get the commit date for the 'since' parameter to optimize API call + $lastCommitDate = git log -1 --format=%cI $lastTag + $sinceParam = "&since=$lastCommitDate" } # Get commits with GitHub usernames using GitHub API try { Write-Host "Fetching commits from GitHub API..." - $apiResponse = gh api --paginate "repos/${{ github.repository }}/commits?sha=${{ github.sha }}&per_page=100" --jq '.[] | "\(.commit.message | split("\n")[0])||\(.author.login // "unknown")||\(.sha[0:7])"' + + $apiResponse = gh api --paginate "repos/${{ github.repository }}/commits?sha=${{ github.sha }}&per_page=100$sinceParam" --jq '.[] | "\(.commit.message | split("\n")[0])||\(.author.login // "unknown")||\(.sha)"' # Check if gh command succeeded if ($LASTEXITCODE -ne 0 -or -not $apiResponse) { @@ -302,12 +308,13 @@ jobs: $author = $parts[1] $sha = $parts[2] - # Stop if we've reached the last tag - if (![string]::IsNullOrEmpty($lastCommitSha) -and $lastCommitSha.Length -ge 7 -and $sha -eq $lastCommitSha.Substring(0, 7)) { + if (![string]::IsNullOrEmpty($lastCommitSha) -and $sha -eq $lastCommitSha) { break } - $commitList += "- $message by @$author ($sha)" + # Use short SHA (7 chars) for display only + $shortSha = $sha.Substring(0, 7) + $commitList += "- $message by @$author ($shortSha)" } $commits = $commitList -join "`n" @@ -362,7 +369,7 @@ jobs: $releaseBody += $installation } - $releaseBody | Out-File -FilePath release-notes.txt -Encoding utf8 -NoNewline + $releaseBody | Out-File -FilePath release-notes.txt -Encoding utf8 env: GH_TOKEN: ${{ github.token }} From 6711ab41a534e18f9488f5004e83aee2733a2a69 Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:24:10 +1100 Subject: [PATCH 08/34] Add options system and operator overload support Introduces a configurable options system for the extension, including a Visual Studio options page and an ExtractorOptions model. Interface extraction now supports operator overloads and conversion operators when enabled. Interface generation and class update logic now respect user options for folder names, prefixes, namespace suffixes, file headers, member sorting/grouping, and separator lines. Adds comprehensive tests for options integration and operator overload extraction. --- .../Commands/ExtractInterfaceCommand.cs | 88 ++- .../InterfaceExtractor.Extension.csproj | 2 + .../InterfaceExtractorPackage.cs | 15 +- .../Options/OptionsPage.cs | 213 ++++++++ .../Services/InterfaceExtractorService.cs | 216 ++++++-- .../UI/ExtractInterfaceDialog.xaml.cs | 23 +- .../Integration/IntegrationTests.cs | 16 +- .../InterfaceExtractorServiceTests.cs | 30 +- .../OperatorOverloadGenerationTests.cs | 95 ++++ .../Services/OptionsIntegrationTests.cs | 501 ++++++++++++++++++ README.md | 262 ++++++--- 11 files changed, 1249 insertions(+), 212 deletions(-) create mode 100644 InterfaceExtractor.Extension/Options/OptionsPage.cs create mode 100644 InterfaceExtractor.Tests/Services/OperatorOverloadGenerationTests.cs create mode 100644 InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs diff --git a/InterfaceExtractor.Extension/Commands/ExtractInterfaceCommand.cs b/InterfaceExtractor.Extension/Commands/ExtractInterfaceCommand.cs index 2c208b5..8aebbd8 100644 --- a/InterfaceExtractor.Extension/Commands/ExtractInterfaceCommand.cs +++ b/InterfaceExtractor.Extension/Commands/ExtractInterfaceCommand.cs @@ -2,6 +2,7 @@ using EnvDTE80; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; +using InterfaceExtractor.Options; using System; using System.ComponentModel.Design; using System.IO; @@ -18,6 +19,7 @@ internal sealed class ExtractInterfaceCommand private readonly AsyncPackage package; private readonly Services.InterfaceExtractorService extractorService; private readonly DTE2 dte; + private readonly ExtractorOptions options; private IVsOutputWindowPane outputPane; private ExtractInterfaceCommand(AsyncPackage package, OleMenuCommandService commandService, DTE2 dte) @@ -26,7 +28,9 @@ private ExtractInterfaceCommand(AsyncPackage package, OleMenuCommandService comm this.dte = dte ?? throw new ArgumentNullException(nameof(dte)); commandService = commandService ?? throw new ArgumentNullException(nameof(commandService)); - extractorService = new Services.InterfaceExtractorService(); + // Get options from the package + options = OptionsProvider.GetOptions(package); + extractorService = new Services.InterfaceExtractorService(options); var menuCommandID = new CommandID(CommandSet, CommandId); var menuItem = new OleMenuCommand(this.Execute, menuCommandID); @@ -40,13 +44,11 @@ public static async Task InitializeAsync(AsyncPackage package) { await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken); - // Get services asynchronously var commandService = await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService; var dte = await package.GetServiceAsync(typeof(DTE)) as DTE2; Instance = new ExtractInterfaceCommand(package, commandService, dte); - // Initialize output pane await Instance.InitializeOutputPaneAsync(); } @@ -73,7 +75,6 @@ private void OnBeforeQueryStatus(object sender, EventArgs e) { ThreadHelper.ThrowIfNotOnUIThread(); - // Pattern matching (C# 7.3 compatible) if (!(sender is OleMenuCommand command)) return; command.Visible = false; @@ -98,7 +99,6 @@ private void OnBeforeQueryStatus(object sender, EventArgs e) private void Execute(object sender, EventArgs e) { - // Use the package's JoinableTaskFactory for proper async execution this.package.JoinableTaskFactory.RunAsync(async () => { try @@ -120,6 +120,8 @@ private async Task ExecuteAsync() await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); LogMessage("Starting interface extraction..."); + LogMessage($"Options: Folder={options.InterfacesFolderName}, Prefix={options.InterfacePrefix}, " + + $"AutoUpdate={options.AutoUpdateClass}, IncludeOperators={options.IncludeOperatorOverloads}"); if (dte?.SelectedItems == null) { @@ -153,7 +155,6 @@ private async Task ExecuteAsync() int failCount = 0; int skippedCount = 0; - // Track overwrite preference across all files OverwriteChoice overwriteChoice = OverwriteChoice.Ask; foreach (var filePath in selectedFiles) @@ -162,7 +163,6 @@ private async Task ExecuteAsync() try { - // Analyze the class(es) var classInfos = await extractorService.AnalyzeClassesAsync(filePath); if (!classInfos.Any()) @@ -172,7 +172,6 @@ private async Task ExecuteAsync() continue; } - // If multiple classes, let user choose or process all foreach (var classInfo in classInfos) { LogMessage($" Found class: {classInfo.ClassName} with {classInfo.Members.Count} public member(s)"); @@ -183,7 +182,6 @@ private async Task ExecuteAsync() continue; } - // Convert to selection items var selectionItems = classInfo.Members.Select(m => new UI.MemberSelectionItem { DisplayText = m.Signature, @@ -193,8 +191,7 @@ private async Task ExecuteAsync() IsSelected = true }).ToList(); - // Show dialog - var dialog = new UI.ExtractInterfaceDialog(classInfo.ClassName, selectionItems); + var dialog = new UI.ExtractInterfaceDialog(classInfo.ClassName, selectionItems, options); var dialogResult = dialog.ShowDialog(); if (dialogResult != true) @@ -204,7 +201,6 @@ private async Task ExecuteAsync() continue; } - // Validate interface name if (!IsValidInterfaceName(dialog.InterfaceName, out string validationError)) { ShowMessage($"Invalid interface name: {validationError}"); @@ -212,7 +208,6 @@ private async Task ExecuteAsync() continue; } - // Get selected members var selectedMembers = classInfo.Members .Where((m, i) => selectionItems[i].IsSelected) .ToList(); @@ -226,19 +221,16 @@ private async Task ExecuteAsync() LogMessage($" Generating interface {dialog.InterfaceName} with {selectedMembers.Count} member(s)"); - // Generate interface code - var interfaceCode = Services.InterfaceExtractorService.GenerateInterface( + var interfaceCode = extractorService.GenerateInterface( dialog.InterfaceName, classInfo, selectedMembers); - // Save interface file - var interfacesFolder = Path.Combine(Path.GetDirectoryName(filePath), Constants.InterfacesFolderName); + var interfacesFolder = Path.Combine(Path.GetDirectoryName(filePath), options.InterfacesFolderName); Directory.CreateDirectory(interfacesFolder); var interfaceFilePath = Path.Combine(interfacesFolder, $"{dialog.InterfaceName}{Constants.CSharpExtension}"); - // Check if file exists if (File.Exists(interfaceFilePath)) { bool shouldOverwrite = false; @@ -295,33 +287,37 @@ private async Task ExecuteAsync() File.WriteAllText(interfaceFilePath, interfaceCode); LogMessage($" Created: {interfaceFilePath}"); - // Update the original class to implement the interface - try + if (options.AutoUpdateClass) { - var originalCode = File.ReadAllText(filePath); - var updatedCode = Services.InterfaceExtractorService.AppendInterfaceToClass( - originalCode, - classInfo.ClassName, - dialog.InterfaceName, - $"{classInfo.Namespace}{Constants.InterfacesNamespaceSuffix}"); - - if (updatedCode != originalCode) + try { - File.WriteAllText(filePath, updatedCode); - LogMessage($" Updated class to implement {dialog.InterfaceName}"); + var originalCode = File.ReadAllText(filePath); + var updatedCode = extractorService.AppendInterfaceToClass( + originalCode, + classInfo.ClassName, + dialog.InterfaceName, + $"{classInfo.Namespace}{options.InterfacesNamespaceSuffix}"); + + if (updatedCode != originalCode) + { + File.WriteAllText(filePath, updatedCode); + LogMessage($" Updated class to implement {dialog.InterfaceName}"); + } + else + { + LogMessage($" Class already implements {dialog.InterfaceName}"); + } } - else + catch (Exception ex) { - LogMessage($" Class already implements {dialog.InterfaceName}"); + LogMessage($" Warning: Could not update class to implement interface: {ex.Message}"); } } - catch (Exception ex) + else { - LogMessage($" Warning: Could not update class to implement interface: {ex.Message}"); - // Continue - interface was still created successfully + LogMessage($" Skipped class update (disabled in options)"); } - // Add to project var projectItem = dte.Solution.FindProjectItem(filePath); if (projectItem?.ContainingProject != null) { @@ -332,10 +328,9 @@ private async Task ExecuteAsync() .FirstOrDefault(pi => { ThreadHelper.ThrowIfNotOnUIThread(); - return pi.Name == Constants.InterfacesFolderName; - }) ?? projectItems.AddFolder(Constants.InterfacesFolderName); + return pi.Name == options.InterfacesFolderName; + }) ?? projectItems.AddFolder(options.InterfacesFolderName); - // Check if already in project var existingItem = interfacesFolderItem?.ProjectItems.Cast() .FirstOrDefault(pi => { @@ -356,7 +351,6 @@ private async Task ExecuteAsync() catch (Exception ex) { LogMessage($" Warning: Could not add file to project: {ex.Message}"); - // File created successfully, just couldn't add to project } } @@ -401,14 +395,12 @@ private static bool IsValidInterfaceName(string name, out string error) return false; } - // Check if valid C# identifier if (!Microsoft.CodeAnalysis.CSharp.SyntaxFacts.IsValidIdentifier(name)) { error = "Interface name is not a valid C# identifier."; return false; } - // Check if it's a reserved keyword if (Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetKeywordKind(name) != Microsoft.CodeAnalysis.CSharp.SyntaxKind.None) { error = "Interface name cannot be a C# keyword."; @@ -441,9 +433,6 @@ private static UI.OverwriteChoice ShowOverwriteConfirmation(string fileName) } } - /// - /// Represents the user's choice for overwriting files (internal tracking) - /// internal enum OverwriteChoice { Ask, @@ -451,24 +440,15 @@ internal enum OverwriteChoice NoToAll } - /// - /// Extension methods for JoinableTask fire-and-forget operations - /// internal static class JoinableTaskExtensions { - /// - /// Allows fire-and-forget for JoinableTask while ensuring proper exception handling - /// public static void FileAndForget(this Microsoft.VisualStudio.Threading.JoinableTask joinableTask, string context) { - // JoinableTask already handles the async operation properly - // Just need to observe it to prevent unobserved task exceptions _ = joinableTask.Task.ContinueWith( t => { if (t.IsFaulted && t.Exception != null) { - // Log to activity log ActivityLog.LogError(context, $"Unhandled exception: {t.Exception.InnerException?.Message ?? t.Exception.Message}"); } }, diff --git a/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj b/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj index 806bfe8..ee55ad6 100644 --- a/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj +++ b/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj @@ -3,6 +3,7 @@ 17.0 $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + win @@ -48,6 +49,7 @@ + diff --git a/InterfaceExtractor.Extension/InterfaceExtractorPackage.cs b/InterfaceExtractor.Extension/InterfaceExtractorPackage.cs index efcf80b..d292526 100644 --- a/InterfaceExtractor.Extension/InterfaceExtractorPackage.cs +++ b/InterfaceExtractor.Extension/InterfaceExtractorPackage.cs @@ -9,23 +9,10 @@ namespace InterfaceExtractor /// /// This is the class that implements the package exposed by this assembly. /// - /// - /// - /// The minimum requirement for a class to be considered a valid package for Visual Studio - /// is to implement the IVsPackage interface and register itself with the shell. - /// This package uses the helper classes defined inside the Managed Package Framework (MPF) - /// to do it: it derives from the Package class that provides the implementation of the - /// IVsPackage interface and uses the registration attributes defined in the framework to - /// register itself and its components with the shell. These attributes tell the pkgdef creation - /// utility what data to put into .pkgdef file. - /// - /// - /// To get loaded into VS, the package must be referred by <Asset Type="Microsoft.VisualStudio.VsPackage" ...> in .vsixmanifest file. - /// - /// [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] [Guid(InterfaceExtractorPackage.PackageGuidString)] [ProvideMenuResource("Menus.ctmenu", 1)] + [ProvideOptionPage(typeof(Options.GeneralOptionsPage), "Interface Extractor", "General", 0, 0, true)] public sealed class InterfaceExtractorPackage : AsyncPackage { /// diff --git a/InterfaceExtractor.Extension/Options/OptionsPage.cs b/InterfaceExtractor.Extension/Options/OptionsPage.cs new file mode 100644 index 0000000..7ff6f78 --- /dev/null +++ b/InterfaceExtractor.Extension/Options/OptionsPage.cs @@ -0,0 +1,213 @@ +ο»Ώusing Microsoft.VisualStudio.Shell; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace InterfaceExtractor.Options +{ + /// + /// Options data model - can be used without Visual Studio dependencies + /// + public class ExtractorOptions + { + public string InterfacesFolderName { get; set; } = "Interfaces"; + public string InterfacePrefix { get; set; } = "I"; + public string InterfacesNamespaceSuffix { get; set; } = ".Interfaces"; + public bool AutoUpdateClass { get; set; } = true; + public bool AddUsingDirective { get; set; } = true; + public bool WarnIfNoIPrefix { get; set; } = true; + public bool IncludeOperatorOverloads { get; set; } = false; + public bool IncludeFileHeader { get; set; } = false; + public string FileHeaderTemplate { get; set; } = "// Generated by Interface Extractor on {Date} at {Time}\n// File: {FileName}"; + public int MemberSeparatorLines { get; set; } = 1; + public bool SortMembers { get; set; } = false; + public bool GroupByMemberType { get; set; } = false; + } + + /// + /// Options page for Interface Extractor settings (Visual Studio UI) + /// + [ComVisible(true)] + [Guid("A7B3C2D1-E4F5-6789-ABCD-EF0123456789")] + public class GeneralOptionsPage : DialogPage + { + private readonly ExtractorOptions _options = new ExtractorOptions(); + + [Category("General")] + [DisplayName("Interface Folder Name")] + [Description("The name of the folder where interface files will be created.")] + [DefaultValue("Interfaces")] + public string InterfacesFolderName + { + get => _options.InterfacesFolderName; + set => _options.InterfacesFolderName = value; + } + + [Category("General")] + [DisplayName("Interface Prefix")] + [Description("The prefix to use when suggesting interface names (typically 'I').")] + [DefaultValue("I")] + public string InterfacePrefix + { + get => _options.InterfacePrefix; + set => _options.InterfacePrefix = value; + } + + [Category("General")] + [DisplayName("Namespace Suffix")] + [Description("The suffix to append to the original namespace for interface files.")] + [DefaultValue(".Interfaces")] + public string InterfacesNamespaceSuffix + { + get => _options.InterfacesNamespaceSuffix; + set => _options.InterfacesNamespaceSuffix = value; + } + + [Category("Behavior")] + [DisplayName("Automatically Update Class")] + [Description("Automatically add the interface to the class declaration after generation.")] + [DefaultValue(true)] + public bool AutoUpdateClass + { + get => _options.AutoUpdateClass; + set => _options.AutoUpdateClass = value; + } + + [Category("Behavior")] + [DisplayName("Add Using Directive")] + [Description("Automatically add using directive for the interface namespace when updating the class.")] + [DefaultValue(true)] + public bool AddUsingDirective + { + get => _options.AddUsingDirective; + set => _options.AddUsingDirective = value; + } + + [Category("Behavior")] + [DisplayName("Warn If No 'I' Prefix")] + [Description("Show a warning dialog if the interface name doesn't start with 'I'.")] + [DefaultValue(true)] + public bool WarnIfNoIPrefix + { + get => _options.WarnIfNoIPrefix; + set => _options.WarnIfNoIPrefix = value; + } + + [Category("Behavior")] + [DisplayName("Include Operator Overloads")] + [Description("Include operator overloads (==, !=, +, -, etc.) when extracting interfaces.")] + [DefaultValue(false)] + public bool IncludeOperatorOverloads + { + get => _options.IncludeOperatorOverloads; + set => _options.IncludeOperatorOverloads = value; + } + + [Category("Templates")] + [DisplayName("Include File Header")] + [Description("Include a header comment at the top of generated interface files.")] + [DefaultValue(false)] + public bool IncludeFileHeader + { + get => _options.IncludeFileHeader; + set => _options.IncludeFileHeader = value; + } + + [Category("Templates")] + [DisplayName("File Header Template")] + [Description("The template for file headers. Use {FileName}, {Date}, {Time} placeholders.")] + [DefaultValue("// Generated by Interface Extractor on {Date} at {Time}\n// File: {FileName}")] + public string FileHeaderTemplate + { + get => _options.FileHeaderTemplate; + set => _options.FileHeaderTemplate = value; + } + + [Category("Templates")] + [DisplayName("Member Separator Lines")] + [Description("Number of blank lines between interface members (0-3).")] + [DefaultValue(1)] + public int MemberSeparatorLines + { + get => _options.MemberSeparatorLines; + set => _options.MemberSeparatorLines = value; + } + + [Category("Templates")] + [DisplayName("Sort Members")] + [Description("Sort interface members alphabetically by type and name.")] + [DefaultValue(false)] + public bool SortMembers + { + get => _options.SortMembers; + set => _options.SortMembers = value; + } + + [Category("Templates")] + [DisplayName("Group By Member Type")] + [Description("Group interface members by type (Properties, Methods, Events, Indexers).")] + [DefaultValue(false)] + public bool GroupByMemberType + { + get => _options.GroupByMemberType; + set => _options.GroupByMemberType = value; + } + + /// + /// Gets the underlying options data model + /// + public ExtractorOptions GetOptions() + { + return _options; + } + + /// + /// Validates settings when applied + /// + protected override void OnApply(PageApplyEventArgs e) + { + // Validate folder name + if (string.IsNullOrWhiteSpace(InterfacesFolderName)) + { + e.ApplyBehavior = ApplyKind.CancelNoNavigate; + return; + } + + // Validate prefix + if (InterfacePrefix != null && InterfacePrefix.Length > 5) + { + e.ApplyBehavior = ApplyKind.CancelNoNavigate; + return; + } + + // Validate separator lines + if (MemberSeparatorLines < 0 || MemberSeparatorLines > 3) + { + MemberSeparatorLines = 1; + } + + base.OnApply(e); + } + } + + /// + /// Static accessor for options + /// + public static class OptionsProvider + { + private static GeneralOptionsPage _optionsPage; + + public static ExtractorOptions GetOptions(Package package) + { + if (_optionsPage == null && package != null) + { + _optionsPage = (GeneralOptionsPage)package.GetDialogPage(typeof(GeneralOptionsPage)); + } + return _optionsPage?.GetOptions() ?? new ExtractorOptions(); + } + + public static void ClearCache() + { + _optionsPage = null; + } + } +} \ No newline at end of file diff --git a/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs b/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs index 40ff19f..20d45e2 100644 --- a/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs +++ b/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs @@ -1,6 +1,7 @@ ο»Ώusing Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using InterfaceExtractor.Options; using System; using System.Collections.Generic; using System.IO; @@ -12,6 +13,13 @@ namespace InterfaceExtractor.Services { public class InterfaceExtractorService { + private readonly ExtractorOptions _options; + + public InterfaceExtractorService(ExtractorOptions options = null) + { + _options = options ?? new ExtractorOptions(); + } + public async Task> AnalyzeClassesAsync(string filePath) { return await Task.Run(() => AnalyzeClasses(filePath)); @@ -33,7 +41,7 @@ private List AnalyzeClasses(string filePath) if (!classDeclarations.Any()) { - return new List(); // Return empty collection instead of null + return new List(); } var results = new List(); @@ -49,7 +57,7 @@ private List AnalyzeClasses(string filePath) { var className = classDeclaration.Identifier.Text; - // Extract namespace - simplified condition + // Extract namespace var namespaceDeclaration = classDeclaration.Ancestors() .OfType() .FirstOrDefault(); @@ -80,10 +88,23 @@ private List AnalyzeClasses(string filePath) } } - public static string GenerateInterface(string interfaceName, ExtractedClassInfo classInfo, List selectedMembers) + public string GenerateInterface(string interfaceName, ExtractedClassInfo classInfo, List selectedMembers) { var sb = new StringBuilder(); + // Add file header if enabled + if (_options.IncludeFileHeader && !string.IsNullOrWhiteSpace(_options.FileHeaderTemplate)) + { + var header = _options.FileHeaderTemplate + .Replace("{FileName}", $"{interfaceName}.cs") + .Replace("{Date}", DateTime.Now.ToString("yyyy-MM-dd")) + .Replace("{Time}", DateTime.Now.ToString("HH:mm:ss")) + .Replace("\\n", "\n"); + + sb.AppendLine(header); + sb.AppendLine(); + } + // Add usings foreach (var usingDirective in classInfo.Usings) { @@ -96,22 +117,51 @@ public static string GenerateInterface(string interfaceName, ExtractedClassInfo } // Start namespace - sb.AppendLine($"namespace {classInfo.Namespace}{Constants.InterfacesNamespaceSuffix}"); + sb.AppendLine($"namespace {classInfo.Namespace}{_options.InterfacesNamespaceSuffix}"); sb.AppendLine("{"); // Start interface sb.AppendLine($" public interface {interfaceName}"); sb.AppendLine(" {"); - // Add selected members - for (int i = 0; i < selectedMembers.Count; i++) + // Sort and group members if requested + var membersToGenerate = selectedMembers.ToList(); + + if (_options.SortMembers) + { + membersToGenerate = membersToGenerate + .OrderBy(m => m.Type) + .ThenBy(m => m.Name) + .ToList(); + } + + if (_options.GroupByMemberType) + { + membersToGenerate = membersToGenerate + .OrderBy(m => GetMemberTypeOrder(m.Type)) + .ThenBy(m => m.Name) + .ToList(); + } + + // Add members + for (int i = 0; i < membersToGenerate.Count; i++) { - var member = selectedMembers[i]; + var member = membersToGenerate[i]; - // Add blank line between members except for first one + // Add separator lines between members (except before first) if (i > 0) { - sb.AppendLine(); + for (int j = 0; j < _options.MemberSeparatorLines; j++) + { + sb.AppendLine(); + } + } + + // Add group comment if grouping is enabled + if (_options.GroupByMemberType && + (i == 0 || membersToGenerate[i - 1].Type != member.Type)) + { + sb.AppendLine($" // {GetMemberTypeGroupName(member.Type)}"); } // Add XML documentation comment if available @@ -122,7 +172,6 @@ public static string GenerateInterface(string interfaceName, ExtractedClassInfo var trimmedLine = line.Trim(); if (!string.IsNullOrWhiteSpace(trimmedLine)) { - // Ensure the line starts with /// if (trimmedLine.StartsWith("///")) { sb.AppendLine($" {trimmedLine}"); @@ -143,10 +192,7 @@ public static string GenerateInterface(string interfaceName, ExtractedClassInfo } else { - // Only add semicolon if the signature doesn't end with } - // Properties/indexers with accessor blocks end with }, methods/events don't var needsSemicolon = !member.Signature.TrimEnd().EndsWith("}"); - if (needsSemicolon) { sb.AppendLine($" {member.Signature};"); @@ -169,7 +215,7 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar { var members = new List(); - // Extract public methods - simplified with Where clause + // Extract public methods var methods = classDeclaration.Members .OfType() .Where(m => m.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword)) && @@ -195,7 +241,7 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar }); } - // Extract public properties - simplified with Where clause + // Extract public properties var properties = classDeclaration.Members .OfType() .Where(p => p.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword)) && @@ -207,7 +253,6 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar var propName = property.Identifier.Text; var documentation = ExtractDocumentation(property); - // Simplified accessor logic using LINQ Where var accessors = new List(); if (property.AccessorList != null) { @@ -218,13 +263,12 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar } else if (property.ExpressionBody != null) { - // Expression-bodied property (read-only) accessors.Add("get"); } var accessorList = accessors.Any() ? $" {{ {string.Join("; ", accessors)}; }}" - : " { get; }"; // Default to read-only + : " { get; }"; members.Add(new MemberInfo { @@ -236,7 +280,7 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar }); } - // Extract public events - simplified with Where clause + // Extract public events var events = classDeclaration.Members .OfType() .Where(e => e.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword)) && @@ -262,7 +306,7 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar } } - // Extract public indexers - simplified with Where clause + // Extract public indexers var indexers = classDeclaration.Members .OfType() .Where(i => i.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword)) && @@ -274,7 +318,6 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar var parameters = indexer.ParameterList.ToString(); var documentation = ExtractDocumentation(indexer); - // Simplified accessor logic using LINQ Where var accessors = new List(); if (indexer.AccessorList != null) { @@ -298,6 +341,53 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar }); } + // Extract operator overloads (if enabled in options) + if (_options.IncludeOperatorOverloads) + { + var operators = classDeclaration.Members + .OfType() + .Where(o => o.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword))); + + foreach (var op in operators) + { + var returnType = op.ReturnType.ToString(); + var operatorToken = op.OperatorToken.Text; + var parameters = op.ParameterList.ToString(); + var documentation = ExtractDocumentation(op); + + members.Add(new MemberInfo + { + Type = MemberType.Operator, + Signature = $"{returnType} operator {operatorToken}{parameters}", + Name = $"operator {operatorToken}", + ReturnType = returnType, + Documentation = documentation + }); + } + + // Extract conversion operators + var conversions = classDeclaration.Members + .OfType() + .Where(c => c.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword))); + + foreach (var conversion in conversions) + { + var conversionType = conversion.Type.ToString(); + var implicitOrExplicit = conversion.ImplicitOrExplicitKeyword.Text; + var parameters = conversion.ParameterList.ToString(); + var documentation = ExtractDocumentation(conversion); + + members.Add(new MemberInfo + { + Type = MemberType.Operator, + Signature = $"{implicitOrExplicit} operator {conversionType}{parameters}", + Name = $"{implicitOrExplicit} operator {conversionType}", + ReturnType = conversionType, + Documentation = documentation + }); + } + } + return members; } @@ -315,46 +405,41 @@ private static string ExtractDocumentation(MemberDeclarationSyntax member) return string.Empty; } - /// - /// Appends the interface to the class declaration - /// - public static string AppendInterfaceToClass(string sourceCode, string className, string interfaceName, string interfaceNamespace) + public string AppendInterfaceToClass(string sourceCode, string className, string interfaceName, string interfaceNamespace) { + if (!_options.AutoUpdateClass) + { + return sourceCode; // Don't update if disabled + } + var tree = CSharpSyntaxTree.ParseText(sourceCode); var root = (CompilationUnitSyntax)tree.GetRoot(); - // Find the target class var classDeclaration = root.DescendantNodes() .OfType() .FirstOrDefault(c => c.Identifier.Text == className); if (classDeclaration == null) { - return sourceCode; // Class not found, return original + return sourceCode; } - // Get the class's namespace var classNamespace = classDeclaration.Ancestors() .OfType() .FirstOrDefault(); var classNamespaceName = classNamespace?.Name.ToString() ?? ""; - // Determine if we need to use fully qualified name string interfaceToAdd; - if (classNamespaceName == interfaceNamespace || - string.IsNullOrEmpty(interfaceNamespace)) + if (classNamespaceName == interfaceNamespace || string.IsNullOrEmpty(interfaceNamespace)) { - // Same namespace, use simple name interfaceToAdd = interfaceName; } else { - // Different namespace, use fully qualified name interfaceToAdd = $"{interfaceNamespace}.{interfaceName}"; } - // Check if interface is already implemented if (classDeclaration.BaseList != null) { var existingBases = classDeclaration.BaseList.Types @@ -363,16 +448,14 @@ public static string AppendInterfaceToClass(string sourceCode, string className, if (existingBases.Any(b => b.Contains(interfaceName))) { - return sourceCode; // Already implements this interface + return sourceCode; } } - // Add the interface to the base list ClassDeclarationSyntax newClassDeclaration; if (classDeclaration.BaseList == null) { - // No base list, create one var baseType = SyntaxFactory.SimpleBaseType( SyntaxFactory.ParseTypeName(interfaceToAdd)); @@ -383,7 +466,6 @@ public static string AppendInterfaceToClass(string sourceCode, string className, } else { - // Add to existing base list var baseType = SyntaxFactory.SimpleBaseType( SyntaxFactory.ParseTypeName(interfaceToAdd)); @@ -391,17 +473,15 @@ public static string AppendInterfaceToClass(string sourceCode, string className, newClassDeclaration = classDeclaration.WithBaseList(newBaseList); } - // Replace the old class with the new one var newRoot = root.ReplaceNode(classDeclaration, newClassDeclaration); - // Add using directive if needed (for different namespace) - if (classNamespaceName != interfaceNamespace && + if (_options.AddUsingDirective && + classNamespaceName != interfaceNamespace && !string.IsNullOrEmpty(interfaceNamespace)) { var usingDirective = SyntaxFactory.UsingDirective( SyntaxFactory.ParseName(interfaceNamespace)); - // Check if using already exists var existingUsings = newRoot.Usings .Select(u => u.Name.ToString()) .ToList(); @@ -412,9 +492,56 @@ public static string AppendInterfaceToClass(string sourceCode, string className, } } - // Add NormalizeWhitespace() to properly format the output return newRoot.NormalizeWhitespace().ToFullString(); } + + private static int GetMemberTypeOrder(MemberType type) + { + switch (type) + { + case MemberType.Property: + return 1; + + case MemberType.Method: + return 2; + + case MemberType.Event: + return 3; + + case MemberType.Indexer: + return 4; + + case MemberType.Operator: + return 5; + + default: + return 99; + } + } + + private static string GetMemberTypeGroupName(MemberType type) + { + switch (type) + { + case MemberType.Property: + return "Properties"; + + case MemberType.Method: + return "Methods"; + + case MemberType.Event: + return "Events"; + + case MemberType.Indexer: + return "Indexers"; + + case MemberType.Operator: + return "Operators"; + + default: + return "Members"; + } + } } public class ExtractedClassInfo @@ -441,6 +568,7 @@ public enum MemberType Method, Property, Event, - Indexer + Indexer, + Operator // NEW in v1.1 } } \ No newline at end of file diff --git a/InterfaceExtractor.Extension/UI/ExtractInterfaceDialog.xaml.cs b/InterfaceExtractor.Extension/UI/ExtractInterfaceDialog.xaml.cs index ff6a55f..adc62a5 100644 --- a/InterfaceExtractor.Extension/UI/ExtractInterfaceDialog.xaml.cs +++ b/InterfaceExtractor.Extension/UI/ExtractInterfaceDialog.xaml.cs @@ -1,4 +1,5 @@ ο»Ώusing Microsoft.CodeAnalysis.CSharp; +using InterfaceExtractor.Options; using System.Collections.Generic; using System.Linq; using System.Windows; @@ -7,15 +8,19 @@ namespace InterfaceExtractor.UI { public partial class ExtractInterfaceDialog : Window { + private readonly ExtractorOptions options; + public string InterfaceName { get; private set; } public List Members { get; private set; } - public ExtractInterfaceDialog(string className, List members) + public ExtractInterfaceDialog(string className, List members, ExtractorOptions options = null) { InitializeComponent(); + this.options = options ?? new ExtractorOptions(); + ClassNameText.Text = className; - InterfaceNameTextBox.Text = $"{Constants.InterfacePrefix}{className}"; + InterfaceNameTextBox.Text = $"{this.options.InterfacePrefix}{className}"; Members = members; MembersListBox.ItemsSource = Members; @@ -26,10 +31,9 @@ public ExtractInterfaceDialog(string className, List member member.IsSelected = true; } - // Update select all checkbox state UpdateSelectAllCheckBox(); - // Subscribe to property changes to update select all checkbox + // Subscribe to property changes foreach (var member in Members) { member.PropertyChanged += (s, e) => @@ -59,7 +63,7 @@ private void UpdateSelectAllCheckBox() } else { - SelectAllCheckBox.IsChecked = null; // Indeterminate state + SelectAllCheckBox.IsChecked = null; } } @@ -106,11 +110,13 @@ private void OK_Click(object sender, RoutedEventArgs e) return; } - // Warn if doesn't start with 'I' - if (!InterfaceName.StartsWith(Constants.InterfacePrefix) || InterfaceName.Length < 2) + // Warn if doesn't start with prefix (respecting options) + if (options.WarnIfNoIPrefix && + !string.IsNullOrEmpty(options.InterfacePrefix) && + (!InterfaceName.StartsWith(options.InterfacePrefix) || InterfaceName.Length < options.InterfacePrefix.Length + 1)) { var result = MessageBox.Show( - $"Interface names typically start with '{Constants.InterfacePrefix}'. Do you want to continue?", + $"Interface names typically start with '{options.InterfacePrefix}'. Do you want to continue?", Constants.ExtensionName, MessageBoxButton.YesNo, MessageBoxImage.Question); @@ -162,7 +168,6 @@ private void DeselectAll_Click(object sender, RoutedEventArgs e) private void SelectAllCheckBox_Changed(object sender, RoutedEventArgs e) { - // Prevent recursion if (SelectAllCheckBox.IsChecked == null) return; diff --git a/InterfaceExtractor.Tests/Integration/IntegrationTests.cs b/InterfaceExtractor.Tests/Integration/IntegrationTests.cs index d0b8f0d..42a8519 100644 --- a/InterfaceExtractor.Tests/Integration/IntegrationTests.cs +++ b/InterfaceExtractor.Tests/Integration/IntegrationTests.cs @@ -68,8 +68,8 @@ public async Task CompleteFlow_SimpleClass_GeneratesCorrectInterfaceAsync() classInfos[0].Namespace.Should().Be("TestNamespace"); classInfos[0].Members.Should().HaveCount(3); // Name, Age, DoSomething - // Act - Generate - var interfaceCode = InterfaceExtractorService.GenerateInterface( + // Act - Generate (using instance method) + var interfaceCode = _service.GenerateInterface( "ISimpleClass", classInfos[0], classInfos[0].Members); @@ -92,7 +92,7 @@ public async Task CompleteFlow_ClassWithDocumentation_PreservesDocumentationAsyn // Act var classInfos = await _service.AnalyzeClassesAsync(filePath); - var interfaceCode = InterfaceExtractorService.GenerateInterface( + var interfaceCode = _service.GenerateInterface( "IDocumentedClass", classInfos[0], classInfos[0].Members); @@ -115,7 +115,7 @@ public async Task CompleteFlow_GenericClass_HandlesConstraintsAsync() // Act var classInfos = await _service.AnalyzeClassesAsync(filePath); - var interfaceCode = InterfaceExtractorService.GenerateInterface( + var interfaceCode = _service.GenerateInterface( "IGenericRepository", classInfos[0], classInfos[0].Members); @@ -209,8 +209,8 @@ public void CompleteFlow_AppendInterface_UpdatesClassAsync() // Arrange var sourceCode = TestHelpers.SampleCode.SimpleClass; - // Act - Append interface to class - var updatedCode = InterfaceExtractorService.AppendInterfaceToClass( + // Act - Append interface to class (using instance method) + var updatedCode = _service.AppendInterfaceToClass( sourceCode, "SimpleClass", "ISimpleClass", @@ -237,7 +237,7 @@ public async Task CompleteFlow_SelectiveMembers_GeneratesPartialInterfaceAsync() .Where(m => m.Name == "Name") .ToList(); - var interfaceCode = InterfaceExtractorService.GenerateInterface( + var interfaceCode = _service.GenerateInterface( "ISimpleClass", classInfos[0], selectedMembers); @@ -305,7 +305,7 @@ public async Task CompleteFlow_SaveToFile_CreatesValidCSharpFileAsync() // Act var classInfos = await _service.AnalyzeClassesAsync(filePath); - var interfaceCode = InterfaceExtractorService.GenerateInterface( + var interfaceCode = _service.GenerateInterface( "ISimpleClass", classInfos[0], classInfos[0].Members); diff --git a/InterfaceExtractor.Tests/Services/InterfaceExtractorServiceTests.cs b/InterfaceExtractor.Tests/Services/InterfaceExtractorServiceTests.cs index eba75e6..6bcacb6 100644 --- a/InterfaceExtractor.Tests/Services/InterfaceExtractorServiceTests.cs +++ b/InterfaceExtractor.Tests/Services/InterfaceExtractorServiceTests.cs @@ -396,8 +396,8 @@ public void GenerateInterface_WithBasicClass_GeneratesCorrectInterface() ] }; - // Act - var result = InterfaceExtractorService.GenerateInterface( + // Act - using instance method + var result = _service.GenerateInterface( "ITestClass", classInfo, classInfo.Members); @@ -432,7 +432,7 @@ public void GenerateInterface_WithXmlDocumentation_IncludesDocumentation() }; // Act - var result = InterfaceExtractorService.GenerateInterface( + var result = _service.GenerateInterface( "ITestClass", classInfo, classInfo.Members); @@ -465,7 +465,7 @@ public void GenerateInterface_WithGenericConstraints_FormatsConstraintsCorrectly }; // Act - var result = InterfaceExtractorService.GenerateInterface( + var result = _service.GenerateInterface( "ITestClass", classInfo, classInfo.Members); @@ -487,17 +487,17 @@ public void GenerateInterface_WithMultipleMembers_SeparatesWithBlankLines() Members = [ new MemberInfo { Type = MemberType.Property, Signature = "string First { get; set; }", Name = "First" }, - new MemberInfo { Type = MemberType.Property, Signature = "string Second { get; set; }", Name = "Second" } + new MemberInfo { Type = MemberType.Property, Signature = "string Second { get; set; }", Name = "Second" } ] }; // Act - var result = InterfaceExtractorService.GenerateInterface( + var result = _service.GenerateInterface( "ITestClass", classInfo, classInfo.Members); - var lines = result.Split(new[] { '\r', '\n' }, StringSplitOptions.None); + var lines = result.Split(['\r', '\n'], StringSplitOptions.None); var firstIndex = Array.FindIndex(lines, l => l.Contains("string First")); var secondIndex = Array.FindIndex(lines, l => l.Contains("string Second")); @@ -527,8 +527,8 @@ public class TestClass } }"; - // Act - var result = InterfaceExtractorService.AppendInterfaceToClass( + // Act - using instance method + var result = _service.AppendInterfaceToClass( sourceCode, "TestClass", "ITestClass", @@ -552,7 +552,7 @@ public class TestClass : BaseClass }"; // Act - var result = InterfaceExtractorService.AppendInterfaceToClass( + var result = _service.AppendInterfaceToClass( sourceCode, "TestClass", "ITestClass", @@ -576,7 +576,7 @@ public class TestClass : ITestClass }"; // Act - var result = InterfaceExtractorService.AppendInterfaceToClass( + var result = _service.AppendInterfaceToClass( sourceCode, "TestClass", "ITestClass", @@ -600,7 +600,7 @@ public class TestClass }"; // Act - var result = InterfaceExtractorService.AppendInterfaceToClass( + var result = _service.AppendInterfaceToClass( sourceCode, "TestClass", "ITestClass", @@ -627,7 +627,7 @@ public class TestClass }"; // Act - var result = InterfaceExtractorService.AppendInterfaceToClass( + var result = _service.AppendInterfaceToClass( sourceCode, "TestClass", "ITestClass", @@ -654,7 +654,7 @@ public class TestClass }"; // Act - var result = InterfaceExtractorService.AppendInterfaceToClass( + var result = _service.AppendInterfaceToClass( sourceCode, "TestClass", "ITestClass", @@ -679,7 +679,7 @@ public class OtherClass }"; // Act - var result = InterfaceExtractorService.AppendInterfaceToClass( + var result = _service.AppendInterfaceToClass( sourceCode, "TestClass", "ITestClass", diff --git a/InterfaceExtractor.Tests/Services/OperatorOverloadGenerationTests.cs b/InterfaceExtractor.Tests/Services/OperatorOverloadGenerationTests.cs new file mode 100644 index 0000000..2bf44f2 --- /dev/null +++ b/InterfaceExtractor.Tests/Services/OperatorOverloadGenerationTests.cs @@ -0,0 +1,95 @@ +ο»Ώusing FluentAssertions; +using InterfaceExtractor.Options; +using InterfaceExtractor.Services; +using Xunit; + +namespace InterfaceExtractor.Tests.Services +{ + public class OperatorOverloadGenerationTests + { + [Fact] + public void GenerateInterface_WithOperators_FormatsCorrectly() + { + // Arrange + var options = new ExtractorOptions + { + IncludeOperatorOverloads = true + }; + var service = new InterfaceExtractorService(options); + + var classInfo = new ExtractedClassInfo + { + ClassName = "Vector", + Namespace = "Math", + Usings = new System.Collections.Generic.List { "using System;" }, + Members = new System.Collections.Generic.List + { + new MemberInfo + { + Type = MemberType.Property, + Signature = "double X { get; set; }", + Name = "X" + }, + new MemberInfo + { + Type = MemberType.Operator, + Signature = "Vector operator +(Vector a, Vector b)", + Name = "operator +", + ReturnType = "Vector" + } + } + }; + + // Act + var result = service.GenerateInterface("IVector", classInfo, classInfo.Members); + + // Assert + result.Should().Contain("namespace Math.Interfaces"); + result.Should().Contain("public interface IVector"); + result.Should().Contain("double X { get; set; }"); + result.Should().Contain("Vector operator +(Vector a, Vector b);"); + } + + [Fact] + public void GenerateInterface_WithConversionOperators_FormatsCorrectly() + { + // Arrange + var options = new ExtractorOptions + { + IncludeOperatorOverloads = true + }; + var service = new InterfaceExtractorService(options); + + var classInfo = new ExtractedClassInfo + { + ClassName = "Money", + Namespace = "Finance", + Usings = new System.Collections.Generic.List(), + Members = new System.Collections.Generic.List + { + new MemberInfo + { + Type = MemberType.Operator, + Signature = "implicit operator double(Money m)", + Name = "implicit operator double", + ReturnType = "double" + }, + new MemberInfo + { + Type = MemberType.Operator, + Signature = "explicit operator string(Money m)", + Name = "explicit operator string", + ReturnType = "string" + } + } + }; + + // Act + var result = service.GenerateInterface("IMoney", classInfo, classInfo.Members); + + // Assert + result.Should().Contain("implicit operator double(Money m);"); + result.Should().Contain("explicit operator string(Money m);"); + } + } +} \ No newline at end of file diff --git a/InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs b/InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs new file mode 100644 index 0000000..c3f7db8 --- /dev/null +++ b/InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs @@ -0,0 +1,501 @@ +ο»Ώusing FluentAssertions; +using InterfaceExtractor.Options; +using InterfaceExtractor.Services; +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace InterfaceExtractor.Tests.Services +{ + public class OptionsIntegrationTests : IDisposable + { + private readonly string _tempDirectory; + private bool _disposed; + + public OptionsIntegrationTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), $"OptionsTests_{Guid.NewGuid()}"); + Directory.CreateDirectory(_tempDirectory); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing && Directory.Exists(_tempDirectory)) + { + try { Directory.Delete(_tempDirectory, true); } + catch { /* Ignore cleanup errors */ } + } + _disposed = true; + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + #region Options Tests + + [Fact] + public void ExtractorOptions_HasCorrectDefaults() + { + // Arrange & Act + var options = new ExtractorOptions(); + + // Assert + options.InterfacesFolderName.Should().Be("Interfaces"); + options.InterfacePrefix.Should().Be("I"); + options.InterfacesNamespaceSuffix.Should().Be(".Interfaces"); + options.AutoUpdateClass.Should().BeTrue(); + options.AddUsingDirective.Should().BeTrue(); + options.WarnIfNoIPrefix.Should().BeTrue(); + options.IncludeOperatorOverloads.Should().BeFalse(); + options.IncludeFileHeader.Should().BeFalse(); + options.MemberSeparatorLines.Should().Be(1); + options.SortMembers.Should().BeFalse(); + options.GroupByMemberType.Should().BeFalse(); + } + + [Fact] + public void ExtractorOptions_CanModifySettings() + { + // Arrange + var options = new ExtractorOptions(); + + // Act + options.InterfacesFolderName = "Contracts"; + options.InterfacePrefix = "X"; + options.InterfacesNamespaceSuffix = ".Contracts"; + options.AutoUpdateClass = false; + options.IncludeOperatorOverloads = true; + + // Assert + options.InterfacesFolderName.Should().Be("Contracts"); + options.InterfacePrefix.Should().Be("X"); + options.InterfacesNamespaceSuffix.Should().Be(".Contracts"); + options.AutoUpdateClass.Should().BeFalse(); + options.IncludeOperatorOverloads.Should().BeTrue(); + } + + [Fact] + public async Task Service_RespectsCustomFolderName() + { + // Arrange + var options = new ExtractorOptions + { + InterfacesFolderName = "CustomFolder" + }; + var service = new InterfaceExtractorService(options); + + var sourceCode = @" +namespace Test +{ + public class TestClass + { + public string Name { get; set; } + } +}"; + var filePath = CreateTempFile(sourceCode); + var classInfos = await service.AnalyzeClassesAsync(filePath); + + // Act + var interfaceCode = service.GenerateInterface("ITestClass", classInfos[0], classInfos[0].Members); + + // Assert - The namespace should use the custom suffix + interfaceCode.Should().Contain("namespace Test.Interfaces"); // Still uses .Interfaces suffix + } + + [Fact] + public async Task Service_RespectsCustomNamespaceSuffix() + { + // Arrange + var options = new ExtractorOptions + { + InterfacesNamespaceSuffix = ".Contracts" + }; + var service = new InterfaceExtractorService(options); + + var sourceCode = @" +namespace MyApp.Services +{ + public class UserService + { + public string GetUser() { return null; } + } +}"; + var filePath = CreateTempFile(sourceCode); + var classInfos = await service.AnalyzeClassesAsync(filePath); + + // Act + var interfaceCode = service.GenerateInterface("IUserService", classInfos[0], classInfos[0].Members); + + // Assert + interfaceCode.Should().Contain("namespace MyApp.Services.Contracts"); + } + + [Fact] + public void Service_WithAutoUpdateDisabled_DoesNotUpdateClass() + { + // Arrange + var options = new ExtractorOptions + { + AutoUpdateClass = false + }; + var service = new InterfaceExtractorService(options); + + var sourceCode = @" +namespace Test +{ + public class TestClass + { + public string Name { get; set; } + } +}"; + + // Act + var result = service.AppendInterfaceToClass(sourceCode, "TestClass", "ITestClass", "Test.Interfaces"); + + // Assert + result.Should().Be(sourceCode); // Unchanged + } + + #endregion Options Tests + + #region Operator Overload Tests + + [Fact] + public async Task Service_WithOperatorsDisabled_ExcludesOperators() + { + // Arrange + var options = new ExtractorOptions + { + IncludeOperatorOverloads = false // Default + }; + var service = new InterfaceExtractorService(options); + + var sourceCode = @" +namespace Test +{ + public class Vector + { + public double X { get; set; } + + public static Vector operator +(Vector a, Vector b) + { + return null; + } + } +}"; + var filePath = CreateTempFile(sourceCode); + + // Act + var classInfos = await service.AnalyzeClassesAsync(filePath); + + // Assert + classInfos[0].Members.Should().HaveCount(1); // Only property, no operator + classInfos[0].Members[0].Type.Should().Be(MemberType.Property); + } + + [Fact] + public async Task Service_WithOperatorsEnabled_IncludesOperators() + { + // Arrange + var options = new ExtractorOptions + { + IncludeOperatorOverloads = true + }; + var service = new InterfaceExtractorService(options); + + var sourceCode = @" +namespace Test +{ + public class Vector + { + public double X { get; set; } + + public static Vector operator +(Vector a, Vector b) + { + return null; + } + + public static Vector operator -(Vector a, Vector b) + { + return null; + } + } +}"; + var filePath = CreateTempFile(sourceCode); + + // Act + var classInfos = await service.AnalyzeClassesAsync(filePath); + + // Assert + classInfos[0].Members.Should().HaveCount(3); // Property + 2 operators + classInfos[0].Members.Should().Contain(m => m.Type == MemberType.Operator && m.Name.Contains("+")); + classInfos[0].Members.Should().Contain(m => m.Type == MemberType.Operator && m.Name.Contains("-")); + } + + [Fact] + public async Task Service_ExtractsAllOperatorTypes() + { + // Arrange + var options = new ExtractorOptions + { + IncludeOperatorOverloads = true + }; + var service = new InterfaceExtractorService(options); + + var sourceCode = @" +namespace Test +{ + public class ComplexNumber + { + public double Real { get; set; } + public double Imaginary { get; set; } + + // Binary operators + public static ComplexNumber operator +(ComplexNumber a, ComplexNumber b) { return null; } + public static ComplexNumber operator -(ComplexNumber a, ComplexNumber b) { return null; } + public static ComplexNumber operator *(ComplexNumber a, ComplexNumber b) { return null; } + public static ComplexNumber operator /(ComplexNumber a, ComplexNumber b) { return null; } + + // Comparison operators + public static bool operator ==(ComplexNumber a, ComplexNumber b) { return true; } + public static bool operator !=(ComplexNumber a, ComplexNumber b) { return false; } + + // Unary operators + public static ComplexNumber operator -(ComplexNumber a) { return null; } + public static ComplexNumber operator !(ComplexNumber a) { return null; } + + // Conversion operators + public static implicit operator string(ComplexNumber c) { return null; } + public static explicit operator double(ComplexNumber c) { return 0; } + } +}"; + var filePath = CreateTempFile(sourceCode); + + // Act + var classInfos = await service.AnalyzeClassesAsync(filePath); + + // Assert + var operators = classInfos[0].Members.Where(m => m.Type == MemberType.Operator).ToList(); + operators.Should().HaveCount(10); + operators.Should().Contain(o => o.Signature.Contains("operator +")); + operators.Should().Contain(o => o.Signature.Contains("operator ==")); + operators.Should().Contain(o => o.Signature.Contains("implicit operator")); + operators.Should().Contain(o => o.Signature.Contains("explicit operator")); + } + + #endregion Operator Overload Tests + + #region Template Tests + + [Fact] + public async Task Service_WithFileHeader_IncludesHeader() + { + // Arrange + var options = new ExtractorOptions + { + IncludeFileHeader = true, + FileHeaderTemplate = "// Custom Header\n// File: {FileName}" + }; + var service = new InterfaceExtractorService(options); + + var sourceCode = @" +namespace Test +{ + public class TestClass + { + public string Name { get; set; } + } +}"; + var filePath = CreateTempFile(sourceCode); + var classInfos = await service.AnalyzeClassesAsync(filePath); + + // Act + var interfaceCode = service.GenerateInterface("ITestClass", classInfos[0], classInfos[0].Members); + + // Assert + interfaceCode.Should().StartWith("// Custom Header"); + interfaceCode.Should().Contain("// File: ITestClass.cs"); + } + + [Fact] + public async Task Service_WithGrouping_GroupsMembersByType() + { + // Arrange + var options = new ExtractorOptions + { + GroupByMemberType = true + }; + var service = new InterfaceExtractorService(options); + + var sourceCode = @" +using System; + +namespace Test +{ + public class TestClass + { + public void Method1() { } + public string Property1 { get; set; } + public void Method2() { } + public int Property2 { get; set; } + public event EventHandler Event1; + } +}"; + var filePath = CreateTempFile(sourceCode); + var classInfos = await service.AnalyzeClassesAsync(filePath); + + // Act + var interfaceCode = service.GenerateInterface("ITestClass", classInfos[0], classInfos[0].Members); + + // Assert + interfaceCode.Should().Contain("// Properties"); + interfaceCode.Should().Contain("// Methods"); + interfaceCode.Should().Contain("// Events"); + + // Properties should come before methods (based on GetMemberTypeOrder) + var propertiesIndex = interfaceCode.IndexOf("// Properties"); + var methodsIndex = interfaceCode.IndexOf("// Methods"); + propertiesIndex.Should().BeLessThan(methodsIndex); + } + + [Fact] + public async Task Service_WithSorting_SortsMembersAlphabetically() + { + // Arrange + var options = new ExtractorOptions + { + SortMembers = true + }; + var service = new InterfaceExtractorService(options); + + var sourceCode = @" +namespace Test +{ + public class TestClass + { + public string Zebra { get; set; } + public string Alpha { get; set; } + public string Beta { get; set; } + } +}"; + var filePath = CreateTempFile(sourceCode); + var classInfos = await service.AnalyzeClassesAsync(filePath); + + // Act + var interfaceCode = service.GenerateInterface("ITestClass", classInfos[0], classInfos[0].Members); + + // Assert + var alphaIndex = interfaceCode.IndexOf("string Alpha"); + var betaIndex = interfaceCode.IndexOf("string Beta"); + var zebraIndex = interfaceCode.IndexOf("string Zebra"); + + alphaIndex.Should().BeLessThan(betaIndex); + betaIndex.Should().BeLessThan(zebraIndex); + } + + [Fact] + public async Task Service_WithZeroSeparatorLines_HasNoBlankLinesBetweenMembers() + { + // Arrange + var options = new ExtractorOptions { MemberSeparatorLines = 0 }; + var service = new InterfaceExtractorService(options); + + var sourceCode = "namespace Test { public class TestClass { public string A { get; set; } public string B { get; set; } } }"; + var filePath = CreateTempFile(sourceCode); + var classInfos = await service.AnalyzeClassesAsync(filePath); + + // Act + var interfaceCode = service.GenerateInterface("ITestClass", classInfos[0], classInfos[0].Members); + + // Assert - with 0 separator lines, properties should be consecutive (no blank line between) + var lines = interfaceCode.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + var aIndex = Array.FindIndex(lines, l => l.Contains("string A")); + var bIndex = Array.FindIndex(lines, l => l.Contains("string B")); + + // Count blank lines between them + var blankLines = 0; + for (int i = aIndex + 1; i < bIndex; i++) + { + if (string.IsNullOrWhiteSpace(lines[i])) + { + blankLines++; + } + } + + blankLines.Should().Be(0, "with 0 separator lines, there should be no blank lines between members"); + } + + [Fact] + public async Task Service_WithMultipleSeparatorLines_AddsBlankLinesBetweenMembers() + { + // Arrange + var optionsWithOne = new ExtractorOptions { MemberSeparatorLines = 1 }; + var optionsWithThree = new ExtractorOptions { MemberSeparatorLines = 3 }; + + var serviceOne = new InterfaceExtractorService(optionsWithOne); + var serviceThree = new InterfaceExtractorService(optionsWithThree); + + var sourceCode = "namespace Test { public class TestClass { public string A { get; set; } public string B { get; set; } } }"; + var filePath = CreateTempFile(sourceCode); + var classInfos = await serviceOne.AnalyzeClassesAsync(filePath); + + // Act + var codeWithOne = serviceOne.GenerateInterface("ITestClass", classInfos[0], classInfos[0].Members); + var codeWithThree = serviceThree.GenerateInterface("ITestClass", classInfos[0], classInfos[0].Members); + + // Assert - Count blank lines between the properties + var linesOne = codeWithOne.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + var linesThree = codeWithThree.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + + var aIndexOne = Array.FindIndex(linesOne, l => l.Contains("string A")); + var bIndexOne = Array.FindIndex(linesOne, l => l.Contains("string B")); + + var aIndexThree = Array.FindIndex(linesThree, l => l.Contains("string A")); + var bIndexThree = Array.FindIndex(linesThree, l => l.Contains("string B")); + + // Count blank lines between them + var blankLinesOne = 0; + for (int i = aIndexOne + 1; i < bIndexOne; i++) + { + if (string.IsNullOrWhiteSpace(linesOne[i])) + { + blankLinesOne++; + } + } + + var blankLinesThree = 0; + for (int i = aIndexThree + 1; i < bIndexThree; i++) + { + if (string.IsNullOrWhiteSpace(linesThree[i])) + { + blankLinesThree++; + } + } + + blankLinesOne.Should().Be(1, "with MemberSeparatorLines=1, there should be 1 blank line"); + blankLinesThree.Should().Be(3, "with MemberSeparatorLines=3, there should be 3 blank lines"); + } + + #endregion Template Tests + + #region Helper Methods + + private string CreateTempFile(string content) + { + var fileName = $"Test_{Guid.NewGuid()}.cs"; + var filePath = Path.Combine(_tempDirectory, fileName); + File.WriteAllText(filePath, content); + return filePath; + } + + #endregion Helper Methods + } +} \ No newline at end of file diff --git a/README.md b/README.md index 60d5838..03aeb05 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,21 @@ ο»Ώ# Interface Extractor -A Visual Studio 2022 extension that extracts interfaces from C# classes with interactive member selection. +A Visual Studio 2022 extension that extracts interfaces from C# classes with interactive member selection and customizable options. ## Features ✨ **Right-click to extract** - Works directly from Solution Explorer context menu ✨ **Interactive selection** - Choose which methods, properties, events, and indexers to include -✨ **Smart defaults** - Auto-suggests interface names with "I" prefix +✨ **Smart defaults** - Auto-suggests interface names with configurable prefix ✨ **Batch processing** - Handle multiple files at once ✨ **XML documentation** - Preserves XML comments from original members ✨ **Generic support** - Correctly handles generic methods with constraints ✨ **Read-only properties** - Properly detects `{ get; }` vs `{ get; set; }` ✨ **Overwrite protection** - Prompts before replacing existing files -✨ **Detailed logging** - Progress tracking in Output Window +✨ **Detailed logging** - Progress tracking in Output Window +✨ **Customizable options** - Configure behavior through Tools β†’ Options +✨ **Operator overloads** - Optional support for operator methods +✨ **Template system** - Custom headers, sorting, and grouping ### Supported Members @@ -20,6 +23,8 @@ A Visual Studio 2022 extension that extracts interfaces from C# classes with int - Public properties (with correct accessor detection) - Public events - Public indexers +- Public operator overloads (optional - disabled by default) +- Conversion operators (implicit/explicit) **Note:** Static members and private members are excluded by design. @@ -48,17 +53,56 @@ A Visual Studio 2022 extension that extracts interfaces from C# classes with int 4. Follow the installation wizard 5. Restart Visual Studio +## Configuration + +Access settings through **Tools β†’ Options β†’ Interface Extractor β†’ General** + +### General Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| Interface Folder Name | `Interfaces` | Folder for generated interface files | +| Interface Prefix | `I` | Suggested prefix for interface names | +| Namespace Suffix | `.Interfaces` | Appended to original namespace | + +### Behavior Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| Automatically Update Class | `true` | Add interface to class declaration | +| Add Using Directive | `true` | Include using for interface namespace | +| Warn If No 'I' Prefix | `true` | Show warning if name lacks 'I' prefix | +| Include Operator Overloads | `false` | Extract operator overloads | + +### Template Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| Include File Header | `false` | Add header comment to files | +| File Header Template | See below | Template for file headers | +| Member Separator Lines | `1` | Blank lines between members (0-3) | +| Sort Members | `false` | Sort alphabetically by type/name | +| Group By Member Type | `false` | Group by Properties, Methods, etc. | + +**Default File Header Template:** +``` +// Generated by Interface Extractor on {Date} at {Time} +// File: {FileName} +``` + +Placeholders: `{FileName}`, `{Date}`, `{Time}` + ## Usage ### Quick Start 1. Right-click any `.cs` file in Solution Explorer 2. Select **Extract Interface...** -3. Review/edit the interface name (defaults to `I{ClassName}`) +3. Review/edit the interface name (defaults based on your prefix setting) 4. Select members to include 5. Click **OK** -The extension creates an `Interfaces` folder, generates the interface file, and adds it to your project. +The extension creates an interface file in your configured folder and optionally updates the class declaration. ### Member Selection Dialog @@ -107,7 +151,7 @@ namespace MyProject.Data } ``` -**Output (Interfaces/IBookingData.cs):** +**Output (Interfaces/IBookingData.cs) - Default Settings:** ```csharp namespace MyProject.Data.Interfaces @@ -135,74 +179,126 @@ namespace MyProject.Data.Interfaces } ``` -### Generic Methods with Constraints +**Output with File Header and Grouping Enabled:** + +```csharp +// Generated by Interface Extractor on 2025-10-18 at 14:30:00 +// File: IBookingData.cs + +namespace MyProject.Data.Interfaces +{ + /// + /// Manages booking operations + /// + public interface IBookingData + { + // Properties + /// + /// Gets or sets the booking name + /// + string Name { get; set; } + + /// + /// Gets the booking date (read-only) + /// + DateTime BookingDate { get; } + + // Methods + /// + /// Gets a booking by ID + /// + Task GetBookingAsync(Guid id); + } +} +``` + +### Operator Overloads (New in v1.1) **Input:** ```csharp -public class Repository +public class Vector { - public T GetById(int id) where T : class, IEntity, new() { } - - public List GetAll() where T : IEntity { } + public double X { get; set; } + public double Y { get; set; } + + public static Vector operator +(Vector a, Vector b) + { + return new Vector { X = a.X + b.X, Y = a.Y + b.Y }; + } + + public static Vector operator -(Vector a, Vector b) + { + return new Vector { X = a.X - b.X, Y = a.Y - b.Y }; + } + + public static bool operator ==(Vector a, Vector b) + { + return a.X == b.X && a.Y == b.Y; + } + + public static bool operator !=(Vector a, Vector b) + { + return !(a == b); + } + + public static implicit operator string(Vector v) + { + return $"({v.X}, {v.Y})"; + } } ``` -**Output:** +**Output (with Include Operator Overloads = true):** ```csharp -public interface IRepository +namespace MyProject.Interfaces { - T GetById(int id) - where T : class, IEntity, new(); + public interface IVector + { + double X { get; set; } - List GetAll() - where T : IEntity; + double Y { get; set; } + + Vector operator +(Vector a, Vector b); + + Vector operator -(Vector a, Vector b); + + bool operator ==(Vector a, Vector b); + + bool operator !=(Vector a, Vector b); + + implicit operator string(Vector v); + } } ``` -### Events and Indexers +### Generic Methods with Constraints **Input:** ```csharp -public class DataCollection +public class Repository { - public event EventHandler DataChanged; - - public string this[int index] - { - get { return items[index]; } - set { items[index] = value; } - } + public T GetById(int id) where T : class, IEntity, new() { } - public int Count { get; } + public List GetAll() where T : IEntity { } } ``` **Output:** ```csharp -public interface IDataCollection +public interface IRepository { - event EventHandler DataChanged; - - string this[int index] { get; set; } + T GetById(int id) + where T : class, IEntity, new(); - int Count { get; } + List GetAll() + where T : IEntity; } ``` -## Configuration - -Default settings (modifiable in `Constants.cs`): - -| Setting | Default Value | Description | -|---------|---------------|-------------| -| Interface Folder | `Interfaces` | Where interface files are created | -| Interface Prefix | `I` | Suggested prefix for interface names | -| Namespace Suffix | `.Interfaces` | Added to original namespace | - ## Troubleshooting ### Extension doesn't appear in context menu @@ -224,12 +320,16 @@ Default settings (modifiable in `Constants.cs`): - Verify the file contains valid C# syntax - Check that at least one public member exists -### Validation errors in dialog +### Options not taking effect + +- Close and reopen Visual Studio after changing options +- Check Tools β†’ Options β†’ Interface Extractor β†’ General +- Verify settings were saved (they should persist between sessions) -- Interface names must be valid C# identifiers -- Cannot use reserved keywords (`class`, `interface`, `void`, etc.) -- At least one member must be selected -- Names should start with a letter or underscore +### Operator overloads not appearing + +- Enable "Include Operator Overloads" in Tools β†’ Options β†’ Interface Extractor β†’ General β†’ Behavior +- Note: This is disabled by default as operators in interfaces are uncommon ## Project Structure @@ -239,6 +339,8 @@ InterfaceExtractor/ β”‚ └── ExtractInterfaceCommand.cs # Command handler and orchestration β”œβ”€β”€ Services/ β”‚ └── InterfaceExtractorService.cs # Roslyn-based extraction logic +β”œβ”€β”€ Options/ +β”‚ └── OptionsPage.cs # Settings/configuration page (NEW v1.1) β”œβ”€β”€ UI/ β”‚ β”œβ”€β”€ ExtractInterfaceDialog.xaml # Member selection dialog β”‚ β”œβ”€β”€ ExtractInterfaceDialog.xaml.cs @@ -270,46 +372,67 @@ InterfaceExtractor/ ## Known Limitations - Only processes public, non-static members -- Does not support operator overloads -- Partial classes: only analyzes the current file - Nested classes: only processes top-level classes -- No configuration UI (requires source modification) +- Partial classes: only analyzes the current file +- Operator overloads in interfaces are valid C# but rarely used in practice ## Roadmap -### Planned for v1.1 - -- [ ] Options/settings page -- [ ] Support for operator overloads -- [ ] Custom interface templates -- [ ] Automatic class implementation updates - -### Planned for v2.0 +### Planned for Future Releases - [ ] Multi-file partial class support - [ ] Interface preview before saving - [ ] Integration with VS refactoring tools - [ ] Support for internal members (optional) +- [ ] Bulk extraction wizard +- [ ] Custom template files (.tt or .scriban) +- [ ] Extract interface from multiple classes at once +- [ ] Refactor existing code to use new interface +- [ ] Generate implementation stubs +- [ ] Support for record types +- [ ] Integration with dependency injection containers ## Version History -See [CHANGELOG.md](CHANGELOG.md) for detailed version history. +**v1.1.0** (2025-10-18) +- Added comprehensive options page for customization +- Support for operator overloads (optional) +- Custom file header templates with placeholders +- Member sorting and grouping options +- Enhanced auto-update with configurable behavior +- Improved logging and error reporting + +**v1.0.0** (2025-10-18) +- Initial release with core feature set +- Extract interfaces from C# classes +- Interactive member selection +- XML documentation preservation +- Generic method support +- Automatic class updates -**v1.0.0** (2025-10-18) - Initial release with full feature set +See [CHANGELOG.md](CHANGELOG.md) for detailed version history. ## License MIT License +Copyright (c) 2025 Developer Chizaruu + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ## Contributing Contributions welcome! Areas for improvement: -- Configuration UI -- Additional member type support (operators) -- Advanced namespace handling -- Template customization system -- Better integration with VS refactoring +- Multi-file partial class analysis +- Additional template customization options +- Integration with refactoring tools +- Performance optimizations +- Accessibility improvements ## Support @@ -317,13 +440,16 @@ For issues or questions: 1. Check the **Output Window** (View β†’ Output β†’ "Interface Extractor") for detailed error messages 2. Review the **Troubleshooting** section above -3. Verify Visual Studio 2022 and .NET Framework 4.8 compatibility -4. Create an issue with: +3. Check **Tools β†’ Options β†’ Interface Extractor** for configuration options +4. Verify Visual Studio 2022 and .NET Framework 4.8 compatibility +5. Create an issue with: - Visual Studio version + - Extension version - Steps to reproduce - Output Window logs - Sample code (if applicable) + - Current option settings --- -**Made with ❀️ for Visual Studio developers** +**Made with ❀️ for Visual Studio developers** \ No newline at end of file From 50d3150c6a35423472b9b1d6d7b6e6d0bf5e49eb Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:47:16 +1100 Subject: [PATCH 09/34] fix: resolve AddUsingDirective redundancy and update documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - πŸ› Fix `AddUsingDirective` logic to use simple names when adding using statements - πŸ“ Update `WarnIfNoIPrefix` description for configurable prefix - πŸ“ Add "Operators" to `GroupByMemberType` description - πŸ“ Add C# 11+ clarification for operator interfaces in README - βœ… Add 3 new tests for AddUsingDirective behavior - βœ… Update 6 existing tests to match corrected logic --- .../InterfaceExtractor.Extension.csproj | 4 +- .../Options/OptionsPage.cs | 42 +++++----- .../Services/InterfaceExtractorService.cs | 16 +++- .../Integration/IntegrationTests.cs | 4 +- .../InterfaceExtractorServiceTests.cs | 36 +++++++- .../OperatorOverloadGenerationTests.cs | 16 ++-- .../Services/OptionsIntegrationTests.cs | 82 ++++++++++++++++--- README.md | 10 ++- 8 files changed, 158 insertions(+), 52 deletions(-) diff --git a/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj b/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj index ee55ad6..c2a1172 100644 --- a/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj +++ b/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj @@ -49,7 +49,9 @@ - + + Component + diff --git a/InterfaceExtractor.Extension/Options/OptionsPage.cs b/InterfaceExtractor.Extension/Options/OptionsPage.cs index 7ff6f78..ff8eb74 100644 --- a/InterfaceExtractor.Extension/Options/OptionsPage.cs +++ b/InterfaceExtractor.Extension/Options/OptionsPage.cs @@ -4,25 +4,6 @@ namespace InterfaceExtractor.Options { - /// - /// Options data model - can be used without Visual Studio dependencies - /// - public class ExtractorOptions - { - public string InterfacesFolderName { get; set; } = "Interfaces"; - public string InterfacePrefix { get; set; } = "I"; - public string InterfacesNamespaceSuffix { get; set; } = ".Interfaces"; - public bool AutoUpdateClass { get; set; } = true; - public bool AddUsingDirective { get; set; } = true; - public bool WarnIfNoIPrefix { get; set; } = true; - public bool IncludeOperatorOverloads { get; set; } = false; - public bool IncludeFileHeader { get; set; } = false; - public string FileHeaderTemplate { get; set; } = "// Generated by Interface Extractor on {Date} at {Time}\n// File: {FileName}"; - public int MemberSeparatorLines { get; set; } = 1; - public bool SortMembers { get; set; } = false; - public bool GroupByMemberType { get; set; } = false; - } - /// /// Options page for Interface Extractor settings (Visual Studio UI) /// @@ -84,7 +65,7 @@ public bool AddUsingDirective [Category("Behavior")] [DisplayName("Warn If No 'I' Prefix")] - [Description("Show a warning dialog if the interface name doesn't start with 'I'.")] + [Description("Show a warning dialog if the interface name doesn't start with the configured prefix.")] [DefaultValue(true)] public bool WarnIfNoIPrefix { @@ -144,7 +125,7 @@ public bool SortMembers [Category("Templates")] [DisplayName("Group By Member Type")] - [Description("Group interface members by type (Properties, Methods, Events, Indexers).")] + [Description("Group interface members by type (Properties, Methods, Events, Indexers, Operators).")] [DefaultValue(false)] public bool GroupByMemberType { @@ -189,6 +170,25 @@ protected override void OnApply(PageApplyEventArgs e) } } + /// + /// Options data model - can be used without Visual Studio dependencies + /// + public class ExtractorOptions + { + public string InterfacesFolderName { get; set; } = "Interfaces"; + public string InterfacePrefix { get; set; } = "I"; + public string InterfacesNamespaceSuffix { get; set; } = ".Interfaces"; + public bool AutoUpdateClass { get; set; } = true; + public bool AddUsingDirective { get; set; } = true; + public bool WarnIfNoIPrefix { get; set; } = true; + public bool IncludeOperatorOverloads { get; set; } = false; + public bool IncludeFileHeader { get; set; } = false; + public string FileHeaderTemplate { get; set; } = "// Generated by Interface Extractor on {Date} at {Time}\n// File: {FileName}"; + public int MemberSeparatorLines { get; set; } = 1; + public bool SortMembers { get; set; } = false; + public bool GroupByMemberType { get; set; } = false; + } + /// /// Static accessor for options /// diff --git a/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs b/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs index 20d45e2..f448a49 100644 --- a/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs +++ b/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs @@ -430,13 +430,23 @@ public string AppendInterfaceToClass(string sourceCode, string className, string var classNamespaceName = classNamespace?.Name.ToString() ?? ""; + // Determine interface name to use string interfaceToAdd; - if (classNamespaceName == interfaceNamespace || string.IsNullOrEmpty(interfaceNamespace)) + bool sameNamespace = classNamespaceName == interfaceNamespace || string.IsNullOrEmpty(interfaceNamespace); + + if (sameNamespace) + { + // Same namespace, use simple name + interfaceToAdd = interfaceName; + } + else if (_options.AddUsingDirective) { + // Different namespace but adding using directive, use simple name interfaceToAdd = interfaceName; } else { + // Different namespace and not adding using, use fully qualified name interfaceToAdd = $"{interfaceNamespace}.{interfaceName}"; } @@ -476,7 +486,7 @@ public string AppendInterfaceToClass(string sourceCode, string className, string var newRoot = root.ReplaceNode(classDeclaration, newClassDeclaration); if (_options.AddUsingDirective && - classNamespaceName != interfaceNamespace && + !sameNamespace && !string.IsNullOrEmpty(interfaceNamespace)) { var usingDirective = SyntaxFactory.UsingDirective( @@ -569,6 +579,6 @@ public enum MemberType Property, Event, Indexer, - Operator // NEW in v1.1 + Operator } } \ No newline at end of file diff --git a/InterfaceExtractor.Tests/Integration/IntegrationTests.cs b/InterfaceExtractor.Tests/Integration/IntegrationTests.cs index 42a8519..6bf91d5 100644 --- a/InterfaceExtractor.Tests/Integration/IntegrationTests.cs +++ b/InterfaceExtractor.Tests/Integration/IntegrationTests.cs @@ -216,8 +216,8 @@ public void CompleteFlow_AppendInterface_UpdatesClassAsync() "ISimpleClass", "TestNamespace.Interfaces"); - // Assert - updatedCode.Should().Contain("public class SimpleClass : TestNamespace.Interfaces.ISimpleClass"); + // Assert - With default options (AddUsingDirective=true), should use simple name + updatedCode.Should().Contain("public class SimpleClass : ISimpleClass"); updatedCode.Should().Contain("using TestNamespace.Interfaces;"); } diff --git a/InterfaceExtractor.Tests/Services/InterfaceExtractorServiceTests.cs b/InterfaceExtractor.Tests/Services/InterfaceExtractorServiceTests.cs index 6bcacb6..214616b 100644 --- a/InterfaceExtractor.Tests/Services/InterfaceExtractorServiceTests.cs +++ b/InterfaceExtractor.Tests/Services/InterfaceExtractorServiceTests.cs @@ -534,8 +534,9 @@ public class TestClass "ITestClass", "TestNamespace.Interfaces"); - // Assert - result.Should().Contain("public class TestClass : TestNamespace.Interfaces.ITestClass"); + // Assert - Default options have AddUsingDirective=true, so uses simple name + result.Should().Contain("public class TestClass : ITestClass"); + result.Should().Contain("using TestNamespace.Interfaces;"); } [Fact] @@ -558,8 +559,9 @@ public class TestClass : BaseClass "ITestClass", "TestNamespace.Interfaces"); - // Assert - result.Should().Contain("public class TestClass : BaseClass, TestNamespace.Interfaces.ITestClass"); + // Assert - Default options have AddUsingDirective=true, so uses simple name + result.Should().Contain("public class TestClass : BaseClass, ITestClass"); + result.Should().Contain("using TestNamespace.Interfaces;"); } [Fact] @@ -637,6 +639,32 @@ public class TestClass result.Should().Contain("using TestNamespace.Interfaces;"); } + [Fact] + public void AppendInterfaceToClass_WithAddUsingDirective_UsesSimpleName() + { + // Arrange + var sourceCode = @" +namespace TestNamespace +{ + public class TestClass + { + public string Name { get; set; } + } +}"; + + // Act + var result = _service.AppendInterfaceToClass( + sourceCode, + "TestClass", + "ITestClass", + "TestNamespace.Interfaces"); + + // Assert - Should use simple name since using directive is added + result.Should().Contain("public class TestClass : ITestClass"); + result.Should().NotContain("TestNamespace.Interfaces.ITestClass"); + result.Should().Contain("using TestNamespace.Interfaces;"); + } + [Fact] public void AppendInterfaceToClass_DoesNotAddDuplicateUsing() { diff --git a/InterfaceExtractor.Tests/Services/OperatorOverloadGenerationTests.cs b/InterfaceExtractor.Tests/Services/OperatorOverloadGenerationTests.cs index 2bf44f2..314d068 100644 --- a/InterfaceExtractor.Tests/Services/OperatorOverloadGenerationTests.cs +++ b/InterfaceExtractor.Tests/Services/OperatorOverloadGenerationTests.cs @@ -21,9 +21,9 @@ public void GenerateInterface_WithOperators_FormatsCorrectly() { ClassName = "Vector", Namespace = "Math", - Usings = new System.Collections.Generic.List { "using System;" }, - Members = new System.Collections.Generic.List - { + Usings = ["using System;"], + Members = + [ new MemberInfo { Type = MemberType.Property, @@ -37,7 +37,7 @@ public void GenerateInterface_WithOperators_FormatsCorrectly() Name = "operator +", ReturnType = "Vector" } - } + ] }; // Act @@ -64,9 +64,9 @@ public void GenerateInterface_WithConversionOperators_FormatsCorrectly() { ClassName = "Money", Namespace = "Finance", - Usings = new System.Collections.Generic.List(), - Members = new System.Collections.Generic.List - { + Usings = [], + Members = + [ new MemberInfo { Type = MemberType.Operator, @@ -81,7 +81,7 @@ public void GenerateInterface_WithConversionOperators_FormatsCorrectly() Name = "explicit operator string", ReturnType = "string" } - } + ] }; // Act diff --git a/InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs b/InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs index c3f7db8..f1e6d46 100644 --- a/InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs +++ b/InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs @@ -65,14 +65,15 @@ public void ExtractorOptions_HasCorrectDefaults() public void ExtractorOptions_CanModifySettings() { // Arrange - var options = new ExtractorOptions(); - - // Act - options.InterfacesFolderName = "Contracts"; - options.InterfacePrefix = "X"; - options.InterfacesNamespaceSuffix = ".Contracts"; - options.AutoUpdateClass = false; - options.IncludeOperatorOverloads = true; + var options = new ExtractorOptions + { + // Act + InterfacesFolderName = "Contracts", + InterfacePrefix = "X", + InterfacesNamespaceSuffix = ".Contracts", + AutoUpdateClass = false, + IncludeOperatorOverloads = true + }; // Assert options.InterfacesFolderName.Should().Be("Contracts"); @@ -106,7 +107,7 @@ public class TestClass // Act var interfaceCode = service.GenerateInterface("ITestClass", classInfos[0], classInfos[0].Members); - // Assert - The namespace should use the custom suffix + // Assert - Folder name doesn't affect namespace, only the namespace suffix does interfaceCode.Should().Contain("namespace Test.Interfaces"); // Still uses .Interfaces suffix } @@ -164,6 +165,63 @@ public class TestClass result.Should().Be(sourceCode); // Unchanged } + [Fact] + public void Service_WithAddUsingDirectiveDisabled_UsesFullyQualifiedName() + { + // Arrange + var options = new ExtractorOptions + { + AutoUpdateClass = true, + AddUsingDirective = false + }; + var service = new InterfaceExtractorService(options); + + var sourceCode = @" +namespace Test +{ + public class TestClass + { + public string Name { get; set; } + } +}"; + + // Act + var result = service.AppendInterfaceToClass(sourceCode, "TestClass", "ITestClass", "Test.Interfaces"); + + // Assert - Should use fully qualified name since no using directive + result.Should().Contain("public class TestClass : Test.Interfaces.ITestClass"); + result.Should().NotContain("using Test.Interfaces;"); + } + + [Fact] + public void Service_WithAddUsingDirectiveEnabled_UsesSimpleNameAndAddsUsing() + { + // Arrange + var options = new ExtractorOptions + { + AutoUpdateClass = true, + AddUsingDirective = true + }; + var service = new InterfaceExtractorService(options); + + var sourceCode = @" +namespace Test +{ + public class TestClass + { + public string Name { get; set; } + } +}"; + + // Act + var result = service.AppendInterfaceToClass(sourceCode, "TestClass", "ITestClass", "Test.Interfaces"); + + // Assert - Should use simple name and add using directive + result.Should().Contain("public class TestClass : ITestClass"); + result.Should().NotContain("Test.Interfaces.ITestClass"); + result.Should().Contain("using Test.Interfaces;"); + } + #endregion Options Tests #region Operator Overload Tests @@ -416,7 +474,7 @@ public async Task Service_WithZeroSeparatorLines_HasNoBlankLinesBetweenMembers() var interfaceCode = service.GenerateInterface("ITestClass", classInfos[0], classInfos[0].Members); // Assert - with 0 separator lines, properties should be consecutive (no blank line between) - var lines = interfaceCode.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + var lines = interfaceCode.Split(["\r\n", "\n"], StringSplitOptions.None); var aIndex = Array.FindIndex(lines, l => l.Contains("string A")); var bIndex = Array.FindIndex(lines, l => l.Contains("string B")); @@ -452,8 +510,8 @@ public async Task Service_WithMultipleSeparatorLines_AddsBlankLinesBetweenMember var codeWithThree = serviceThree.GenerateInterface("ITestClass", classInfos[0], classInfos[0].Members); // Assert - Count blank lines between the properties - var linesOne = codeWithOne.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); - var linesThree = codeWithThree.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + var linesOne = codeWithOne.Split(["\r\n", "\n"], StringSplitOptions.None); + var linesThree = codeWithThree.Split(["\r\n", "\n"], StringSplitOptions.None); var aIndexOne = Array.FindIndex(linesOne, l => l.Contains("string A")); var bIndexOne = Array.FindIndex(linesOne, l => l.Contains("string B")); diff --git a/README.md b/README.md index 03aeb05..b636077 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,8 @@ namespace MyProject.Data.Interfaces ### Operator Overloads (New in v1.1) +**Note:** Interface operator members shown below are conceptual. In C# 11+, operators in interfaces require `static abstract` keywords. This extension generates the signatures as shown for reference purposes. + **Input:** ```csharp @@ -273,6 +275,12 @@ namespace MyProject.Interfaces } ``` +**For C# 11+ compatibility, you would manually add `static abstract` keywords:** +```csharp +static abstract Vector operator +(Vector a, Vector b); +static abstract implicit operator string(Vector v); +``` + ### Generic Methods with Constraints **Input:** @@ -374,7 +382,7 @@ InterfaceExtractor/ - Only processes public, non-static members - Nested classes: only processes top-level classes - Partial classes: only analyzes the current file -- Operator overloads in interfaces are valid C# but rarely used in practice +- Operator overloads in interfaces: Generated signatures are conceptual and require manual addition of `static abstract` keywords for C# 11+ compatibility ## Roadmap From 4ff2faba2f28da9c024d265b0333e0375727fe52 Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 19:56:39 +1100 Subject: [PATCH 10/34] fix: resolve using directive issues in interface generation - Fix AddUsingDirective to use simple names with using statements - Filter out self-referencing using statements in generated interfaces - Update documentation and option descriptions - Add comprehensive tests for using directive behavior --- .../Services/InterfaceExtractorService.cs | 14 ++++- .../Services/OptionsIntegrationTests.cs | 55 ++++++++++++++++--- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs b/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs index f448a49..ab5cd90 100644 --- a/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs +++ b/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs @@ -105,13 +105,21 @@ public string GenerateInterface(string interfaceName, ExtractedClassInfo classIn sb.AppendLine(); } - // Add usings - foreach (var usingDirective in classInfo.Usings) + // Calculate target namespace + var targetNamespace = $"{classInfo.Namespace}{_options.InterfacesNamespaceSuffix}"; + + // Filter out usings that match the target namespace (avoid self-referencing) + var filteredUsings = classInfo.Usings + .Where(u => !u.Contains($"using {targetNamespace};")) + .ToList(); + + // Add filtered usings + foreach (var usingDirective in filteredUsings) { sb.AppendLine(usingDirective); } - if (classInfo.Usings.Any()) + if (filteredUsings.Any()) { sb.AppendLine(); } diff --git a/InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs b/InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs index f1e6d46..3726ee7 100644 --- a/InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs +++ b/InterfaceExtractor.Tests/Services/OptionsIntegrationTests.cs @@ -65,15 +65,14 @@ public void ExtractorOptions_HasCorrectDefaults() public void ExtractorOptions_CanModifySettings() { // Arrange - var options = new ExtractorOptions - { - // Act - InterfacesFolderName = "Contracts", - InterfacePrefix = "X", - InterfacesNamespaceSuffix = ".Contracts", - AutoUpdateClass = false, - IncludeOperatorOverloads = true - }; + var options = new ExtractorOptions(); + + // Act + options.InterfacesFolderName = "Contracts"; + options.InterfacePrefix = "X"; + options.InterfacesNamespaceSuffix = ".Contracts"; + options.AutoUpdateClass = false; + options.IncludeOperatorOverloads = true; // Assert options.InterfacesFolderName.Should().Be("Contracts"); @@ -222,6 +221,44 @@ public class TestClass result.Should().Contain("using Test.Interfaces;"); } + [Fact] + public void Service_FiltersOutSelfReferencingUsings() + { + // Arrange + var service = new InterfaceExtractorService(); + + var classInfo = new ExtractedClassInfo + { + ClassName = "TestClass", + Namespace = "MyApp.Data", + Usings = + [ + "using System;", + "using MyApp.Models;", + "using MyApp.Data.Interfaces;" // This should be filtered out! + ], + Members = + [ + new MemberInfo + { + Type = MemberType.Property, + Signature = "string Name { get; set; }", + Name = "Name" + } + ] + }; + + // Act + var interfaceCode = service.GenerateInterface("ITestClass", classInfo, classInfo.Members); + + // Assert - Should NOT include the self-referencing using + interfaceCode.Should().Contain("using System;"); + interfaceCode.Should().Contain("using MyApp.Models;"); + interfaceCode.Should().NotContain("using MyApp.Data.Interfaces;", + "interface should not include using statement for its own namespace"); + interfaceCode.Should().Contain("namespace MyApp.Data.Interfaces"); + } + #endregion Options Tests #region Operator Overload Tests From 20fafa37cf7238cb65cc8e3a00bd5f23738106be Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:08:19 +1100 Subject: [PATCH 11/34] Automate version bump and improve release notes Adds a workflow step to automatically commit and push version bumps for the extension manifest. Improves release note generation by filtering out version bump commits, handling GitHub API errors more robustly, and refining installation instructions. Updates extension manifest to version 1.1.2 and changes publisher name. --- .github/workflows/release.yml | 109 +++++++++++++----- .../source.extension.vsixmanifest | 52 ++++----- 2 files changed, 105 insertions(+), 56 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7afe62d..e83a4de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -226,6 +226,23 @@ jobs: "RELEASE_NAME=$releaseName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "RELEASE_TYPE=$releaseType" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + - name: Commit and push version bump + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Check if there are changes to commit + if git diff --quiet InterfaceExtractor.Extension/source.extension.vsixmanifest; then + echo "ℹ️ No version changes to commit (version already up to date)" + else + echo "πŸ“ Committing version bump to ${{ steps.version.outputs.VERSION }}" + git add InterfaceExtractor.Extension/source.extension.vsixmanifest + git commit -m "chore: bump version to ${{ steps.version.outputs.VERSION }} [skip ci]" + git push + echo "βœ… Version bump committed and pushed" + fi + - name: Build Extension run: msbuild InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj /p:Configuration=Release /p:DeployExtension=false /p:ZipPackageCompressionLevel=normal /v:minimal /m @@ -263,13 +280,17 @@ jobs: shell: pwsh run: | $releaseType = "${{ steps.version.outputs.RELEASE_TYPE }}" + $displayVersion = "${{ steps.version.outputs.DISPLAY_VERSION }}" + + # Extract base version (e.g., "1.0.1" from "1.0.1-dev.02") + $baseVersion = $displayVersion -replace '-.*$', '' - # Find the last tag of the same type + # Find the last tag of the same type and base version if ($releaseType -eq "testing") { - $lastTag = git tag -l "v*-testing.*" --sort=-version:refname | Select-Object -Skip 1 -First 1 + $lastTag = git tag -l "v$baseVersion-testing.*" --sort=-version:refname | Select-Object -Skip 1 -First 1 } elseif ($releaseType -eq "dev") { - $lastTag = git tag -l "v*-dev.*" --sort=-version:refname | Select-Object -Skip 1 -First 1 + $lastTag = git tag -l "v$baseVersion-dev.*" --sort=-version:refname | Select-Object -Skip 1 -First 1 } else { $lastTag = git tag -l "v*" --sort=-version:refname | Where-Object { $_ -notmatch "-" } | Select-Object -Skip 1 -First 1 @@ -291,45 +312,73 @@ jobs: } # Get commits with GitHub usernames using GitHub API + $commits = "" try { Write-Host "Fetching commits from GitHub API..." - $apiResponse = gh api --paginate "repos/${{ github.repository }}/commits?sha=${{ github.sha }}&per_page=100$sinceParam" --jq '.[] | "\(.commit.message | split("\n")[0])||\(.author.login // "unknown")||\(.sha)"' + $apiResponse = gh api --paginate "repos/${{ github.repository }}/commits?sha=${{ github.sha }}&per_page=100$sinceParam" --jq '.[] | "\(.commit.message | split("\n")[0])||\(.author.login // "unknown")||\(.sha)"' 2>&1 - # Check if gh command succeeded - if ($LASTEXITCODE -ne 0 -or -not $apiResponse) { + # Check if gh command actually failed (non-zero exit code) + if ($LASTEXITCODE -ne 0) { throw "gh api command failed with exit code $LASTEXITCODE" } + # Process response even if empty $commitList = @() - foreach ($line in $apiResponse) { - $parts = $line -split '\|\|' - $message = $parts[0] - $author = $parts[1] - $sha = $parts[2] - - if (![string]::IsNullOrEmpty($lastCommitSha) -and $sha -eq $lastCommitSha) { - break + if ($apiResponse) { + foreach ($line in $apiResponse) { + # Skip error messages that might have been captured + if ($line -match '^\s*$' -or $line -like '*error*' -or $line -like '*warning*') { + continue + } + + $parts = $line -split '\|\|' + if ($parts.Count -lt 3) { + continue + } + + $message = $parts[0] + $author = $parts[1] + $sha = $parts[2] + + # Skip the version bump commit itself + if ($message -match '^\s*chore:\s*bump version') { + continue + } + + if (![string]::IsNullOrEmpty($lastCommitSha) -and $sha -eq $lastCommitSha) { + break + } + + # Use short SHA (7 chars) for display only + $shortSha = $sha.Substring(0, 7) + $commitList += "- $message by @$author ($shortSha)" } - - # Use short SHA (7 chars) for display only - $shortSha = $sha.Substring(0, 7) - $commitList += "- $message by @$author ($shortSha)" } - $commits = $commitList -join "`n" - Write-Host "βœ… Successfully fetched commits with GitHub usernames" + if ($commitList.Count -gt 0) { + $commits = $commitList -join "`n" + Write-Host "βœ… Successfully fetched $($commitList.Count) commits with GitHub usernames" + } + else { + Write-Host "⚠️ No commits returned from GitHub API, falling back to git log..." + throw "No commits in API response" + } } catch { - Write-Host "⚠️ Failed to get commits from GitHub API: $_" + Write-Host "⚠️ GitHub API fetch issue: $_" Write-Host "Falling back to git log..." # Fallback to simple git log if ([string]::IsNullOrEmpty($lastTag)) { - $commits = git log --first-parent --pretty=format:"- %s (%h)" --reverse + $commits = git log --first-parent --pretty=format:"- %s (%h)" --reverse --grep="^chore: bump version" --invert-grep } else { - $commits = git log --first-parent "$lastTag..HEAD" --pretty=format:"- %s (%h)" --reverse + $commits = git log --first-parent "$lastTag..HEAD" --pretty=format:"- %s (%h)" --reverse --grep="^chore: bump version" --invert-grep + } + + if (![string]::IsNullOrEmpty($commits)) { + Write-Host "βœ… Successfully fetched commits using git log" } } @@ -338,18 +387,18 @@ jobs: $commits = "- Initial release" } - # Build installation instructions once (reduce duplication) - $installation = "## Installation`n" - $installation += "1. Download the `.vsix` file below`n" + # Build installation instructions (fixed newlines) + $installation = "## Installation`n`n" + $installation += "1. Download the ``.vsix`` file below`n" $installation += "2. Double-click to install in Visual Studio`n" - $installation += "3. Or use: ``Extensions > Manage Extensions > Install from file``n" + $installation += "3. Or use: ``Extensions > Manage Extensions > Install from file```n" - # Build release body (using string concatenation to avoid YAML parsing issues) + # Build release body $releaseBody = "" if ("${{ steps.version.outputs.IS_PRERELEASE }}" -eq "true") { $releaseBody += "## Pre-release`n`n" - $releaseBody += "**Version:** v${{ steps.version.outputs.DISPLAY_VERSION }}`n" + $releaseBody += "**Version:** v$displayVersion`n" $releaseBody += "**Commit:** ${{ github.sha }}`n`n" $releaseBody += "**Changes ($tagInfo):**`n" $releaseBody += "$commits`n`n" @@ -363,7 +412,7 @@ jobs: } $releaseBody += "## $emoji Version Bump`n`n" - $releaseBody += "**Version:** v${{ steps.version.outputs.DISPLAY_VERSION }}`n`n" + $releaseBody += "**Version:** v$displayVersion`n`n" $releaseBody += "**Changes ($tagInfo):**`n" $releaseBody += "$commits`n`n" $releaseBody += $installation diff --git a/InterfaceExtractor.Extension/source.extension.vsixmanifest b/InterfaceExtractor.Extension/source.extension.vsixmanifest index 4fc9c87..a305b3c 100644 --- a/InterfaceExtractor.Extension/source.extension.vsixmanifest +++ b/InterfaceExtractor.Extension/source.extension.vsixmanifest @@ -1,29 +1,29 @@ - - - Interface Extractor - Extract interfaces from C# files directly from Solution Explorer. Right-click any C# file to automatically generate interface files with all public methods and properties. - interface, extract, refactor, C#, productivity - - - - amd64 - - - amd64 - - - amd64 - - - - - - - - - - - + + + Interface Extractor + Extract interfaces from C# files directly from Solution Explorer. Right-click any C# file to automatically generate interface files with all public methods and properties. + interface, extract, refactor, C#, productivity + + + + amd64 + + + amd64 + + + amd64 + + + + + + + + + + + \ No newline at end of file From b4513a0cd29de899fdcad881601bf1729d97a5a3 Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:12:27 +1100 Subject: [PATCH 12/34] Add 'more-automation' branch to release workflow The release workflow will now trigger on pushes to the 'more-automation' branch in addition to 'main' and 'dev'. --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e83a4de..869f6f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,7 @@ on: branches: - main - dev + - more-automation paths: - '**.cs' - '**.csproj' From 6a51c773dcbcb7c06ca9f27f884513bce1a23b7e Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:13:04 +1100 Subject: [PATCH 13/34] Revert "Add 'more-automation' branch to release workflow" This reverts commit b4513a0cd29de899fdcad881601bf1729d97a5a3. --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 869f6f3..e83a4de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,6 @@ on: branches: - main - dev - - more-automation paths: - '**.cs' - '**.csproj' From 82db83d0da7a0a5798220cc4ec25308d3a0c1b14 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 18 Oct 2025 11:38:05 +0000 Subject: [PATCH 14/34] chore: bump version to 1.1.3.00 [skip ci] --- .../source.extension.vsixmanifest | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/InterfaceExtractor.Extension/source.extension.vsixmanifest b/InterfaceExtractor.Extension/source.extension.vsixmanifest index a305b3c..f40aa31 100644 --- a/InterfaceExtractor.Extension/source.extension.vsixmanifest +++ b/InterfaceExtractor.Extension/source.extension.vsixmanifest @@ -1,29 +1,29 @@ - +ο»Ώ - - - Interface Extractor - Extract interfaces from C# files directly from Solution Explorer. Right-click any C# file to automatically generate interface files with all public methods and properties. - interface, extract, refactor, C#, productivity - - - - amd64 - - - amd64 - - - amd64 - - - - - - - - - - - + + + Interface Extractor + Extract interfaces from C# files directly from Solution Explorer. Right-click any C# file to automatically generate interface files with all public methods and properties. + interface, extract, refactor, C#, productivity + + + + amd64 + + + amd64 + + + amd64 + + + + + + + + + + + \ No newline at end of file From 5a0cfad140ca158f45d8a3fb2b8555c90aaa3f4a Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:44:20 +1100 Subject: [PATCH 15/34] Improve fallback logic for release tag selection Adds fallback to select the last testing or dev tag of any version if no previous tag with the same base version exists. This ensures proper tag selection during release workflows for testing and dev releases. --- .github/workflows/release.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e83a4de..13a5171 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -288,9 +288,19 @@ jobs: # Find the last tag of the same type and base version if ($releaseType -eq "testing") { $lastTag = git tag -l "v$baseVersion-testing.*" --sort=-version:refname | Select-Object -Skip 1 -First 1 + + # If no previous tag with same base version, find last testing tag of any version + if ([string]::IsNullOrEmpty($lastTag)) { + $lastTag = git tag -l "v*-testing.*" --sort=-version:refname | Select-Object -First 1 + } } elseif ($releaseType -eq "dev") { $lastTag = git tag -l "v$baseVersion-dev.*" --sort=-version:refname | Select-Object -Skip 1 -First 1 + + # If no previous tag with same base version, find last dev tag of any version + if ([string]::IsNullOrEmpty($lastTag)) { + $lastTag = git tag -l "v*-dev.*" --sort=-version:refname | Select-Object -First 1 + } } else { $lastTag = git tag -l "v*" --sort=-version:refname | Where-Object { $_ -notmatch "-" } | Select-Object -Skip 1 -First 1 From 2198dd4bf34da80049f49874ff226ec735a461df Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:47:28 +1100 Subject: [PATCH 16/34] Remove testing prerelease logic from release workflow Eliminates handling for 'testing' branches and tags in the release workflow. Only 'dev' prerelease logic is now supported, simplifying the versioning and tagging process. --- .github/workflows/release.yml | 36 ++--------------------------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13a5171..3eb6dc5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,31 +142,7 @@ jobs: $baseVersion = "$major.$minor.$patch" # Determine prerelease type based on branch - if ($branchName -like "*testing*") { - # Testing branches: find the next testing prerelease number - $lastTestingTag = git tag -l "v$baseVersion-testing.*" --sort=-version:refname | Select-Object -First 1 - - if ([string]::IsNullOrEmpty($lastTestingTag)) { - $prereleaseNum = 0 - } - elseif ($lastTestingTag -match 'testing\.(\d+)') { - $prereleaseNum = [int]$matches[1] + 1 - } - else { - Write-Host "⚠️ Warning: Could not parse testing tag '$lastTestingTag', starting from 0" - $prereleaseNum = 0 - } - - $prereleaseNum = $prereleaseNum.ToString("00") - $newVersion = "$baseVersion.$prereleaseNum" - $displayVersion = "$baseVersion-testing.$prereleaseNum" - $isPrerelease = "true" - $tagName = "v$displayVersion" - $releaseName = "v$displayVersion πŸ§ͺ Testing Release" - $releaseType = "testing" - Write-Host "πŸ§ͺ Testing version: $displayVersion" - } - elseif ($branchName -eq "dev") { + if ($branchName -eq "dev") { # Dev branch: find the next dev prerelease number $lastDevTag = git tag -l "v$baseVersion-dev.*" --sort=-version:refname | Select-Object -First 1 @@ -286,15 +262,7 @@ jobs: $baseVersion = $displayVersion -replace '-.*$', '' # Find the last tag of the same type and base version - if ($releaseType -eq "testing") { - $lastTag = git tag -l "v$baseVersion-testing.*" --sort=-version:refname | Select-Object -Skip 1 -First 1 - - # If no previous tag with same base version, find last testing tag of any version - if ([string]::IsNullOrEmpty($lastTag)) { - $lastTag = git tag -l "v*-testing.*" --sort=-version:refname | Select-Object -First 1 - } - } - elseif ($releaseType -eq "dev") { + if ($releaseType -eq "dev") { $lastTag = git tag -l "v$baseVersion-dev.*" --sort=-version:refname | Select-Object -Skip 1 -First 1 # If no previous tag with same base version, find last dev tag of any version From 248ccd6bf002d4c5fe5c9d90b68eb3e351fe5b64 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 18 Oct 2025 11:48:45 +0000 Subject: [PATCH 17/34] chore: bump version to 1.1.4.00 [skip ci] --- InterfaceExtractor.Extension/source.extension.vsixmanifest | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InterfaceExtractor.Extension/source.extension.vsixmanifest b/InterfaceExtractor.Extension/source.extension.vsixmanifest index f40aa31..735498c 100644 --- a/InterfaceExtractor.Extension/source.extension.vsixmanifest +++ b/InterfaceExtractor.Extension/source.extension.vsixmanifest @@ -1,7 +1,7 @@ ο»Ώ - + Interface Extractor Extract interfaces from C# files directly from Solution Explorer. Right-click any C# file to automatically generate interface files with all public methods and properties. interface, extract, refactor, C#, productivity From b12fec2e8e571fcd5f77e872969e3e9daa566539 Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:56:12 +1100 Subject: [PATCH 18/34] Improve version bump logic in release workflow Refines the release workflow to only increment the base version when conventional commits are detected. Adds a flag to track conventional commit presence and updates messaging and logic for both dev and production releases, ensuring prerelease increments occur when no conventional commits are found. --- .github/workflows/release.yml | 56 +++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3eb6dc5..076803a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,31 +73,38 @@ jobs: echo "$COMMITS" # Determine bump type based on conventional commits - BUMP_TYPE="patch" + BUMP_TYPE="none" + CONVENTIONAL_COMMIT_FOUND="false" # Check for breaking changes (major version) if echo "$COMMITS" | grep -qiE "^(BREAKING CHANGE|.*!:)"; then BUMP_TYPE="major" + CONVENTIONAL_COMMIT_FOUND="true" echo "🚨 Breaking change detected β†’ MAJOR version bump" # Check for features (minor version) elif echo "$COMMITS" | grep -qiE "^feat(\(.*\))?:"; then BUMP_TYPE="minor" + CONVENTIONAL_COMMIT_FOUND="true" echo "✨ New feature detected β†’ MINOR version bump" # Check for fixes (patch version) elif echo "$COMMITS" | grep -qiE "^fix(\(.*\))?:"; then BUMP_TYPE="patch" + CONVENTIONAL_COMMIT_FOUND="true" echo "πŸ› Bug fix detected β†’ PATCH version bump" # Check for other conventional commit types elif echo "$COMMITS" | grep -qiE "^(chore|docs|style|refactor|perf|test|build|ci)(\(.*\))?:"; then BUMP_TYPE="patch" + CONVENTIONAL_COMMIT_FOUND="true" echo "πŸ”§ Maintenance commit detected β†’ PATCH version bump" else - BUMP_TYPE="patch" - echo "πŸ“ No conventional commit detected β†’ Default PATCH version bump" + BUMP_TYPE="none" + CONVENTIONAL_COMMIT_FOUND="false" + echo "πŸ“ No conventional commit detected β†’ Will only increment prerelease number" fi echo "BUMP_TYPE=$BUMP_TYPE" >> $GITHUB_OUTPUT - + echo "CONVENTIONAL_COMMIT_FOUND=$CONVENTIONAL_COMMIT_FOUND" >> $GITHUB_OUTPUT + - name: Auto-increment version id: version shell: pwsh @@ -117,6 +124,7 @@ jobs: Write-Host "Current version: $currentVersion" $bumpType = "${{ steps.version-bump.outputs.BUMP_TYPE }}" + $conventionalCommitFound = "${{ steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND }}" $branchName = "${{ github.ref_name }}" # Parse version components @@ -125,18 +133,23 @@ jobs: $minor = [int]$versionParts[1] $patch = [int]$versionParts[2] - # Determine new version based on bump type - if ($bumpType -eq "major") { - $major++ - $minor = 0 - $patch = 0 - } - elseif ($bumpType -eq "minor") { - $minor++ - $patch = 0 + # Only bump base version if conventional commits were found + if ($conventionalCommitFound -eq "true") { + if ($bumpType -eq "major") { + $major++ + $minor = 0 + $patch = 0 + } + elseif ($bumpType -eq "minor") { + $minor++ + $patch = 0 + } + elseif ($bumpType -eq "patch") { + $patch++ + } } else { - $patch++ + Write-Host "ℹ️ No conventional commits found - keeping base version at $major.$minor.$patch" } $baseVersion = "$major.$minor.$patch" @@ -164,10 +177,23 @@ jobs: $tagName = "v$displayVersion" $releaseName = "v$displayVersion πŸ”§ Dev Release" $releaseType = "dev" - Write-Host "πŸ”§ Dev version: $displayVersion" + + if ($conventionalCommitFound -eq "true") { + Write-Host "πŸ”§ Dev version: $displayVersion ($bumpType bump)" + } + else { + Write-Host "πŸ”§ Dev version: $displayVersion (prerelease increment only)" + } } else { # Main branch: production release + if ($conventionalCommitFound -eq "false") { + Write-Host "⚠️ Warning: No conventional commits found on main branch" + Write-Host "Proceeding with patch bump for production release" + $patch++ + $baseVersion = "$major.$minor.$patch" + } + $newVersion = "$baseVersion.0" $displayVersion = $baseVersion $isPrerelease = "false" From 8cad736251df60d47db1c172adbecf6d5d0df919 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 18 Oct 2025 12:00:25 +0000 Subject: [PATCH 19/34] chore: bump version to 1.1.4.01 [skip ci] --- InterfaceExtractor.Extension/source.extension.vsixmanifest | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InterfaceExtractor.Extension/source.extension.vsixmanifest b/InterfaceExtractor.Extension/source.extension.vsixmanifest index 735498c..fe393ef 100644 --- a/InterfaceExtractor.Extension/source.extension.vsixmanifest +++ b/InterfaceExtractor.Extension/source.extension.vsixmanifest @@ -1,7 +1,7 @@ ο»Ώ - + Interface Extractor Extract interfaces from C# files directly from Solution Explorer. Right-click any C# file to automatically generate interface files with all public methods and properties. interface, extract, refactor, C#, productivity From 71983cf17ce699b03db06ba8f39db761bd242eea Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 23:05:04 +1100 Subject: [PATCH 20/34] Run release steps only for conventional commits Added conditional checks to ensure version bump, build, tagging, and release steps only execute when a conventional commit is detected. Improved messaging to guide contributors on using the conventional commit format to trigger releases. --- .github/workflows/release.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 076803a..9b81ef7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,13 +99,20 @@ jobs: else BUMP_TYPE="none" CONVENTIONAL_COMMIT_FOUND="false" - echo "πŸ“ No conventional commit detected β†’ Will only increment prerelease number" + echo "⏭️ No conventional commit detected β†’ Skipping build" + echo "" + echo "ℹ️ Use conventional commit format to trigger a release:" + echo " β€’ feat: for new features (minor version bump)" + echo " β€’ fix: for bug fixes (patch version bump)" + echo " β€’ chore/docs/style/refactor/perf/test: for maintenance (patch version bump)" + echo " β€’ BREAKING CHANGE or !: for breaking changes (major version bump)" fi echo "BUMP_TYPE=$BUMP_TYPE" >> $GITHUB_OUTPUT echo "CONVENTIONAL_COMMIT_FOUND=$CONVENTIONAL_COMMIT_FOUND" >> $GITHUB_OUTPUT - + - name: Auto-increment version + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' id: version shell: pwsh run: | @@ -229,6 +236,7 @@ jobs: "RELEASE_TYPE=$releaseType" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - name: Commit and push version bump + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' shell: bash run: | git config user.name "github-actions[bot]" @@ -246,9 +254,11 @@ jobs: fi - name: Build Extension + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' run: msbuild InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj /p:Configuration=Release /p:DeployExtension=false /p:ZipPackageCompressionLevel=normal /v:minimal /m - name: Locate VSIX file + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' id: locate-vsix shell: bash run: | @@ -270,6 +280,7 @@ jobs: echo "VSIX_NAME=$NEW_VSIX_NAME" >> $GITHUB_OUTPUT - name: Create and push git tag + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' shell: bash run: | git config user.name "github-actions[bot]" @@ -278,6 +289,7 @@ jobs: git push origin "${{ steps.version.outputs.TAG_NAME }}" - name: Generate release notes + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' id: release-notes shell: pwsh run: | @@ -427,6 +439,7 @@ jobs: GH_TOKEN: ${{ github.token }} - name: Create GitHub Release with VSIX + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.version.outputs.TAG_NAME }} From 96d8a0287fc2b6735771d7d679c93d7f5b2f73db Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 23:09:21 +1100 Subject: [PATCH 21/34] Simplify version bump logic in release workflow Removes checks for conventional commits and streamlines version bumping based solely on the bump type. Refactors prerelease handling for the dev branch and improves logic for finding previous tags, making the workflow more predictable and easier to maintain. --- .github/workflows/release.yml | 81 ++++++++++------------------------- 1 file changed, 23 insertions(+), 58 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b81ef7..11ee7ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,7 +131,6 @@ jobs: Write-Host "Current version: $currentVersion" $bumpType = "${{ steps.version-bump.outputs.BUMP_TYPE }}" - $conventionalCommitFound = "${{ steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND }}" $branchName = "${{ github.ref_name }}" # Parse version components @@ -140,67 +139,35 @@ jobs: $minor = [int]$versionParts[1] $patch = [int]$versionParts[2] - # Only bump base version if conventional commits were found - if ($conventionalCommitFound -eq "true") { - if ($bumpType -eq "major") { - $major++ - $minor = 0 - $patch = 0 - } - elseif ($bumpType -eq "minor") { - $minor++ - $patch = 0 - } - elseif ($bumpType -eq "patch") { - $patch++ - } + # Bump version based on conventional commit type + if ($bumpType -eq "major") { + $major++ + $minor = 0 + $patch = 0 } - else { - Write-Host "ℹ️ No conventional commits found - keeping base version at $major.$minor.$patch" + elseif ($bumpType -eq "minor") { + $minor++ + $patch = 0 + } + elseif ($bumpType -eq "patch") { + $patch++ } $baseVersion = "$major.$minor.$patch" - # Determine prerelease type based on branch + # Determine release type based on branch if ($branchName -eq "dev") { - # Dev branch: find the next dev prerelease number - $lastDevTag = git tag -l "v$baseVersion-dev.*" --sort=-version:refname | Select-Object -First 1 - - if ([string]::IsNullOrEmpty($lastDevTag)) { - $prereleaseNum = 0 - } - elseif ($lastDevTag -match 'dev\.(\d+)') { - $prereleaseNum = [int]$matches[1] + 1 - } - else { - Write-Host "⚠️ Warning: Could not parse dev tag '$lastDevTag', starting from 0" - $prereleaseNum = 0 - } - - $prereleaseNum = $prereleaseNum.ToString("00") - $newVersion = "$baseVersion.$prereleaseNum" - $displayVersion = "$baseVersion-dev.$prereleaseNum" + # Dev branch: simple prerelease suffix + $newVersion = "$baseVersion.0" + $displayVersion = "$baseVersion-dev" $isPrerelease = "true" $tagName = "v$displayVersion" $releaseName = "v$displayVersion πŸ”§ Dev Release" $releaseType = "dev" - - if ($conventionalCommitFound -eq "true") { - Write-Host "πŸ”§ Dev version: $displayVersion ($bumpType bump)" - } - else { - Write-Host "πŸ”§ Dev version: $displayVersion (prerelease increment only)" - } + Write-Host "πŸ”§ Dev version: $displayVersion ($bumpType bump)" } else { # Main branch: production release - if ($conventionalCommitFound -eq "false") { - Write-Host "⚠️ Warning: No conventional commits found on main branch" - Write-Host "Proceeding with patch bump for production release" - $patch++ - $baseVersion = "$major.$minor.$patch" - } - $newVersion = "$baseVersion.0" $displayVersion = $baseVersion $isPrerelease = "false" @@ -295,21 +262,19 @@ jobs: run: | $releaseType = "${{ steps.version.outputs.RELEASE_TYPE }}" $displayVersion = "${{ steps.version.outputs.DISPLAY_VERSION }}" + $currentTag = "${{ steps.version.outputs.TAG_NAME }}" - # Extract base version (e.g., "1.0.1" from "1.0.1-dev.02") + # Extract base version (e.g., "1.0.1" from "1.0.1-dev") $baseVersion = $displayVersion -replace '-.*$', '' - # Find the last tag of the same type and base version + # Find the last tag of the same type (excluding current tag) if ($releaseType -eq "dev") { - $lastTag = git tag -l "v$baseVersion-dev.*" --sort=-version:refname | Select-Object -Skip 1 -First 1 - - # If no previous tag with same base version, find last dev tag of any version - if ([string]::IsNullOrEmpty($lastTag)) { - $lastTag = git tag -l "v*-dev.*" --sort=-version:refname | Select-Object -First 1 - } + # Get last dev tag (excluding current) + $lastTag = git tag -l "v*-dev" --sort=-version:refname | Where-Object { $_ -ne $currentTag } | Select-Object -First 1 } else { - $lastTag = git tag -l "v*" --sort=-version:refname | Where-Object { $_ -notmatch "-" } | Select-Object -Skip 1 -First 1 + # Production releases: exclude current tag and any prerelease tags + $lastTag = git tag -l "v*" --sort=-version:refname | Where-Object { $_ -notmatch "-" -and $_ -ne $currentTag } | Select-Object -First 1 } # Get tag info and commit SHA From 949636cce9107800320713434bfc840458bcaacb Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 23:12:43 +1100 Subject: [PATCH 22/34] Support dev to main promotion in release workflow Enhances the release workflow to detect merges from dev to main and promote the dev version to production without incrementing the version. Updates versioning and release notes logic to handle promotion events and improve clarity between dev and production releases. --- .github/workflows/release.yml | 113 +++++++++++++++++++++++----------- 1 file changed, 76 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11ee7ed..ddae6fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,8 +63,26 @@ jobs: id: version-bump shell: bash run: | - # Get the last tag - LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + BRANCH="${{ github.ref_name }}" + + # Check if this is a merge from dev to main + if [ "$BRANCH" == "main" ]; then + MERGE_MESSAGE=$(git log -1 --pretty=%B) + + if echo "$MERGE_MESSAGE" | grep -qiE "Merge pull request.*(from|dev)"; then + echo "πŸ”€ Detected dev β†’ main merge, will promote dev version to production" + echo "BUMP_TYPE=promotion" >> $GITHUB_OUTPUT + echo "CONVENTIONAL_COMMIT_FOUND=true" >> $GITHUB_OUTPUT + exit 0 + fi + + # For main branch, check against last production tag + LAST_TAG=$(git tag -l "v*" --sort=-version:refname | grep -v "-" | head -1 || echo "v0.0.0") + else + # For dev branch, check against last tag (any type) + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + fi + echo "Last tag: $LAST_TAG" # Get commits since last tag @@ -139,42 +157,57 @@ jobs: $minor = [int]$versionParts[1] $patch = [int]$versionParts[2] - # Bump version based on conventional commit type - if ($bumpType -eq "major") { - $major++ - $minor = 0 - $patch = 0 - } - elseif ($bumpType -eq "minor") { - $minor++ - $patch = 0 - } - elseif ($bumpType -eq "patch") { - $patch++ - } - - $baseVersion = "$major.$minor.$patch" - - # Determine release type based on branch - if ($branchName -eq "dev") { - # Dev branch: simple prerelease suffix - $newVersion = "$baseVersion.0" - $displayVersion = "$baseVersion-dev" - $isPrerelease = "true" - $tagName = "v$displayVersion" - $releaseName = "v$displayVersion πŸ”§ Dev Release" - $releaseType = "dev" - Write-Host "πŸ”§ Dev version: $displayVersion ($bumpType bump)" - } - else { - # Main branch: production release + # Handle promotion vs normal bump + if ($bumpType -eq "promotion") { + # Promotion: keep current version, just remove -dev suffix for display + Write-Host "πŸ”€ Promoting dev version to production" + $baseVersion = "$major.$minor.$patch" $newVersion = "$baseVersion.0" $displayVersion = $baseVersion $isPrerelease = "false" $tagName = "v$displayVersion" $releaseName = "v$displayVersion Release" $releaseType = "production" - Write-Host "πŸ“¦ Production version: $displayVersion ($bumpType bump)" + Write-Host "πŸ“¦ Production version: $displayVersion (promoted from dev)" + } + else { + # Normal bump: increment version based on conventional commit type + if ($bumpType -eq "major") { + $major++ + $minor = 0 + $patch = 0 + } + elseif ($bumpType -eq "minor") { + $minor++ + $patch = 0 + } + elseif ($bumpType -eq "patch") { + $patch++ + } + + $baseVersion = "$major.$minor.$patch" + + # Determine release type based on branch + if ($branchName -eq "dev") { + # Dev branch: add -dev suffix + $newVersion = "$baseVersion.0" + $displayVersion = "$baseVersion-dev" + $isPrerelease = "true" + $tagName = "v$displayVersion" + $releaseName = "v$displayVersion πŸ”§ Dev Release" + $releaseType = "dev" + Write-Host "πŸ”§ Dev version: $displayVersion ($bumpType bump)" + } + else { + # Main branch: production release + $newVersion = "$baseVersion.0" + $displayVersion = $baseVersion + $isPrerelease = "false" + $tagName = "v$displayVersion" + $releaseName = "v$displayVersion Release" + $releaseType = "production" + Write-Host "πŸ“¦ Production version: $displayVersion ($bumpType bump)" + } } Write-Host "New version will be: $newVersion (display: $displayVersion)" @@ -263,6 +296,7 @@ jobs: $releaseType = "${{ steps.version.outputs.RELEASE_TYPE }}" $displayVersion = "${{ steps.version.outputs.DISPLAY_VERSION }}" $currentTag = "${{ steps.version.outputs.TAG_NAME }}" + $bumpType = "${{ steps.version-bump.outputs.BUMP_TYPE }}" # Extract base version (e.g., "1.0.1" from "1.0.1-dev") $baseVersion = $displayVersion -replace '-.*$', '' @@ -386,13 +420,18 @@ jobs: $releaseBody += $installation } else { - $emoji = switch ("${{ steps.version-bump.outputs.BUMP_TYPE }}") { - "major" { "🚨 Major" } - "minor" { "✨ Minor" } - default { "πŸ› Patch" } + if ($bumpType -eq "promotion") { + $releaseBody += "## πŸš€ Promoted from Dev`n`n" + } + else { + $emoji = switch ($bumpType) { + "major" { "🚨 Major" } + "minor" { "✨ Minor" } + default { "πŸ› Patch" } + } + $releaseBody += "## $emoji Version Bump`n`n" } - $releaseBody += "## $emoji Version Bump`n`n" $releaseBody += "**Version:** v$displayVersion`n`n" $releaseBody += "**Changes ($tagInfo):**`n" $releaseBody += "$commits`n`n" From 46ce93b262a3e45f95b44db90bbcb8fe9abb4f53 Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sat, 18 Oct 2025 23:14:35 +1100 Subject: [PATCH 23/34] Run .NET setup steps only for conventional commits Moved MSBuild, NuGet setup, and package restore steps to run only when a conventional commit is detected. This optimizes the workflow by skipping unnecessary .NET setup for non-release commits. --- .github/workflows/release.yml | 44 +++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddae6fa..26dd29a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,26 +39,6 @@ jobs: fetch-depth: 0 fetch-tags: true - - name: Setup MSBuild - uses: microsoft/setup-msbuild@v2 - - - name: Setup NuGet - uses: NuGet/setup-nuget@v2 - - # Cache NuGet packages - - name: Cache NuGet packages - uses: actions/cache@v4 - with: - path: | - ~/.nuget/packages - ${{ github.workspace }}/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Restore NuGet packages - run: nuget restore InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj - - name: Determine version bump type id: version-bump shell: bash @@ -129,6 +109,30 @@ jobs: echo "BUMP_TYPE=$BUMP_TYPE" >> $GITHUB_OUTPUT echo "CONVENTIONAL_COMMIT_FOUND=$CONVENTIONAL_COMMIT_FOUND" >> $GITHUB_OUTPUT + - name: Setup MSBuild + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' + uses: microsoft/setup-msbuild@v2 + + - name: Setup NuGet + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' + uses: NuGet/setup-nuget@v2 + + # Cache NuGet packages + - name: Cache NuGet packages + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.nuget/packages + ${{ github.workspace }}/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore NuGet packages + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' + run: nuget restore InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj + - name: Auto-increment version if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' id: version From cc2d9b39d4f2ccedea3d778435fd5900de1d4365 Mon Sep 17 00:00:00 2001 From: Abdul-Kadir Coskun <59434446+Chizaruu@users.noreply.github.com> Date: Sun, 19 Oct 2025 00:10:46 +1100 Subject: [PATCH 24/34] feat: add preview dialog, partial class support, records, and implementation stubs (v1.2.0) Major new features: - Preview generated interfaces before saving - Multi-file partial class analysis - Record type support with init accessor handling - Optional internal member extraction - Implementation stub generation Includes comprehensive tests (~75+), updated documentation, and code quality improvements. --- CHANGELOG.md | 148 ++++++ .../Commands/ExtractInterfaceCommand.cs | 128 ++++- .../InterfaceExtractor.Extension.csproj | 7 + .../Options/OptionsPage.cs | 61 +++ .../Services/InterfaceExtractorService.cs | 453 +++++++++++++---- .../UI/OverwriteDialog.xaml | 4 +- .../UI/PreviewDialog.xaml | 88 ++++ .../UI/PreviewDialog.xaml.cs | 34 ++ .../Helpers/TestHelpers.cs | 89 ++++ .../Integration/IntegrationTests.cs | 123 ++++- .../Services/ImplementationStubTests.cs | 476 ++++++++++++++++++ .../Services/InternalMemberTests.cs | 390 ++++++++++++++ .../Services/PartialClassTests.cs | 416 +++++++++++++++ .../Services/RecordTypeTests.cs | 337 +++++++++++++ .../UI/ExtractInterfaceDialogTests.cs | 104 ---- .../UI/MemberSelectionItemTests.cs | 83 +++ .../UI/OverwriteDialogTests.cs | 31 ++ .../UI/PreviewDialogTests.cs | 100 ++++ README.md | 270 +++++----- 19 files changed, 2980 insertions(+), 362 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 InterfaceExtractor.Extension/UI/PreviewDialog.xaml create mode 100644 InterfaceExtractor.Extension/UI/PreviewDialog.xaml.cs create mode 100644 InterfaceExtractor.Tests/Services/ImplementationStubTests.cs create mode 100644 InterfaceExtractor.Tests/Services/InternalMemberTests.cs create mode 100644 InterfaceExtractor.Tests/Services/PartialClassTests.cs create mode 100644 InterfaceExtractor.Tests/Services/RecordTypeTests.cs create mode 100644 InterfaceExtractor.Tests/UI/MemberSelectionItemTests.cs create mode 100644 InterfaceExtractor.Tests/UI/OverwriteDialogTests.cs create mode 100644 InterfaceExtractor.Tests/UI/PreviewDialogTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d4c24b2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,148 @@ +# Changelog + +All notable changes to the Interface Extractor extension will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.2.0] - 2025-10-18 + +### Added + +- **Interface Preview Dialog**: New preview window showing the complete generated interface before saving to disk + - View the full interface code with syntax highlighting + - See the exact file path where the interface will be saved + - Option to save or cancel the operation + - Can be disabled in options (Tools β†’ Options β†’ Interface Extractor β†’ Behavior β†’ Show Preview Before Saving) + +- **Multi-file Partial Class Support**: Automatically analyzes all partial class files across the project + - Scans project directory for other partial class definitions + - Combines members from all partial files into a single interface + - Avoids duplicate member signatures + - Controlled by option: "Analyze Partial Classes" (enabled by default) + - Logs which partial files were discovered and analyzed + +- **Record Type Support**: Extract interfaces from C# record types + - Works with both `record` and `record class` declarations + - Properly handles record properties and methods + - Auto-detects record types and includes them in analysis + - Updates records to implement interfaces (respects Auto Update Class option) + - Converts `init` accessors to `get` in interfaces (since `init` is not valid in interface declarations) + +- **Internal Member Support**: Optionally include internal members in interface extraction + - New option: "Include Internal Members" (disabled by default) + - Includes both explicitly internal and implicitly internal (no accessor) members + - Works for methods, properties, events, and indexers + - Useful for internal-facing interfaces + +- **Implementation Stub Generation**: Automatically create skeleton implementation classes + - New option: "Generate Implementation Stubs" (disabled by default) + - Generates class with NotImplementedException for methods + - Auto-implements properties with { get; set; } + - Configurable suffix for stub class names (default: "Implementation") + - Stubs placed in same Interfaces folder as the interface + - Preserves XML documentation from interface + +### Changed + +- Enhanced logging output with version number and detailed operation tracking +- Improved type declaration handling to support both classes and records uniformly +- Service method `AnalyzeClassesAsync` now accepts optional `projectDirectory` parameter for partial class scanning +- Better accessibility detection with `IsAccessible()` helper method +- Command execution now passes project directory to service for enhanced analysis + +### Fixed + +- Better handling of implicit internal accessibility for type declarations +- Improved project item addition for generated stub files +- More robust error handling during partial file analysis + +### Technical Details + +- Added `PreviewDialog.xaml` and `PreviewDialog.xaml.cs` for interface preview UI +- Added `IsPartial` and `IsRecord` properties to `ExtractedClassInfo` +- New `GenerateImplementationStub` method in `InterfaceExtractorService` +- New `AnalyzePartialClassFiles` private method for multi-file scanning +- Enhanced `ExtractPublicMembers` to handle both classes and records via `TypeDeclarationSyntax` +- Options model extended with 5 new settings + +## [1.1.0] - 2025-10-18 + +### Added + +- Comprehensive options page accessible through Tools β†’ Options β†’ Interface Extractor β†’ General +- Support for operator overloads (optional, disabled by default) +- Custom file header templates with `{FileName}`, `{Date}`, `{Time}` placeholders +- Member sorting options (sort alphabetically by type and name) +- Member grouping options (group by Properties, Methods, Events, etc.) +- Configurable member separator lines (0-3 blank lines between members) +- "Add Using Directive" option for interface namespace imports +- "Warn If No 'I' Prefix" option for interface naming validation +- Automatic class update option (add interface to class declaration) + +### Changed + +- Moved configuration from hardcoded constants to user-editable options +- Improved namespace handling to avoid self-referencing using statements +- Enhanced interface generation with optional sorting and grouping +- Better documentation preservation for all member types + +### Technical Details + +- Added `OptionsPage.cs` with `GeneralOptionsPage` and `ExtractorOptions` classes +- Added `OptionsProvider` static class for options access throughout the extension +- Enhanced `InterfaceExtractorService` constructor to accept options +- Modified `GenerateInterface` to use template options +- Added support for `OperatorDeclarationSyntax` and `ConversionOperatorDeclarationSyntax` + +## [1.0.0] - 2025-10-18 + +### Added + +- Initial release of Interface Extractor extension +- Right-click context menu integration in Solution Explorer for .cs files +- Interactive member selection dialog with checkboxes +- Automatic interface name suggestion with 'I' prefix +- Support for extracting: + - Public methods (including generic methods with constraints) + - Public properties (with correct { get; set; } detection) + - Public events + - Public indexers +- XML documentation comment preservation +- Generic method constraint handling +- Automatic interface file creation in "Interfaces" subfolder +- Automatic namespace suffix (.Interfaces) +- File overwrite protection with Yes/No/Yes to All/No to All options +- Optional automatic class update to implement generated interface +- Detailed logging to Visual Studio Output Window +- Batch processing support for multiple files + +### Technical Details + +- Built on Visual Studio SDK 17.14 +- Uses Microsoft.CodeAnalysis.CSharp (Roslyn) for syntax analysis +- Targets .NET Framework 4.8 +- WPF-based user interface dialogs +- VSIX package format for distribution + +### Known Limitations + +- Only processes public, non-static members +- Does not analyze nested classes +- Partial classes: only processes current file +- Operator overloads not included by default + +--- + +## Version Number Scheme + +Interface Extractor follows [Semantic Versioning](https://semver.org/): + +- **MAJOR** version for incompatible API changes +- **MINOR** version for new functionality in a backward compatible manner +- **PATCH** version for backward compatible bug fixes + +Example: Version 1.2.0 +- **1** = Major version (initial release) +- **2** = Minor version (new features: preview, partial classes, records, stubs, internal) +- **0** = Patch version (no patches yet for this minor version) \ No newline at end of file diff --git a/InterfaceExtractor.Extension/Commands/ExtractInterfaceCommand.cs b/InterfaceExtractor.Extension/Commands/ExtractInterfaceCommand.cs index 8aebbd8..ec4e8ed 100644 --- a/InterfaceExtractor.Extension/Commands/ExtractInterfaceCommand.cs +++ b/InterfaceExtractor.Extension/Commands/ExtractInterfaceCommand.cs @@ -28,7 +28,6 @@ private ExtractInterfaceCommand(AsyncPackage package, OleMenuCommandService comm this.dte = dte ?? throw new ArgumentNullException(nameof(dte)); commandService = commandService ?? throw new ArgumentNullException(nameof(commandService)); - // Get options from the package options = OptionsProvider.GetOptions(package); extractorService = new Services.InterfaceExtractorService(options); @@ -119,9 +118,12 @@ private async Task ExecuteAsync() { await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + LogMessage("=== Interface Extractor v1.2.0 ==="); LogMessage("Starting interface extraction..."); - LogMessage($"Options: Folder={options.InterfacesFolderName}, Prefix={options.InterfacePrefix}, " + - $"AutoUpdate={options.AutoUpdateClass}, IncludeOperators={options.IncludeOperatorOverloads}"); + LogMessage($"Options: Folder={options.InterfacesFolderName}, Prefix={options.InterfacePrefix}"); + LogMessage($" AutoUpdate={options.AutoUpdateClass}, IncludeOperators={options.IncludeOperatorOverloads}"); + LogMessage($" ShowPreview={options.ShowPreviewBeforeSaving}, IncludeInternal={options.IncludeInternalMembers}"); + LogMessage($" AnalyzePartial={options.AnalyzePartialClasses}, GenerateStubs={options.GenerateImplementationStubs}"); if (dte?.SelectedItems == null) { @@ -138,9 +140,13 @@ private async Task ExecuteAsync() .Select(item => { ThreadHelper.ThrowIfNotOnUIThread(); - return item.ProjectItem.FileNames[1]; + return new + { + Path = item.ProjectItem.FileNames[1], + item.ProjectItem + }; }) - .Where(path => Path.GetExtension(path).Equals(Constants.CSharpExtension, StringComparison.OrdinalIgnoreCase)) + .Where(f => Path.GetExtension(f.Path).Equals(Constants.CSharpExtension, StringComparison.OrdinalIgnoreCase)) .ToList(); if (!selectedFiles.Any()) @@ -157,28 +163,40 @@ private async Task ExecuteAsync() OverwriteChoice overwriteChoice = OverwriteChoice.Ask; - foreach (var filePath in selectedFiles) + foreach (var file in selectedFiles) { + var filePath = file.Path; LogMessage($"Analyzing: {Path.GetFileName(filePath)}"); try { - var classInfos = await extractorService.AnalyzeClassesAsync(filePath); + // Get project directory for partial class analysis + string projectDirectory = null; + if (file.ProjectItem?.ContainingProject != null) + { + var projectPath = file.ProjectItem.ContainingProject.FullName; + projectDirectory = Path.GetDirectoryName(projectPath); + } + + var classInfos = await extractorService.AnalyzeClassesAsync(filePath, projectDirectory); if (!classInfos.Any()) { - LogMessage($" No public classes with members found in {Path.GetFileName(filePath)}"); + LogMessage($" No public classes/records with members found in {Path.GetFileName(filePath)}"); skippedCount++; continue; } foreach (var classInfo in classInfos) { - LogMessage($" Found class: {classInfo.ClassName} with {classInfo.Members.Count} public member(s)"); + var typeKind = classInfo.IsRecord ? "record" : "class"; + var partialInfo = classInfo.IsPartial ? " (partial)" : ""; + + LogMessage($" Found {typeKind}: {classInfo.ClassName}{partialInfo} with {classInfo.Members.Count} member(s)"); if (!classInfo.Members.Any()) { - LogMessage($" No public members found in class {classInfo.ClassName}"); + LogMessage($" No accessible members found in {typeKind} {classInfo.ClassName}"); continue; } @@ -231,6 +249,27 @@ private async Task ExecuteAsync() var interfaceFilePath = Path.Combine(interfacesFolder, $"{dialog.InterfaceName}{Constants.CSharpExtension}"); + // Show preview if enabled (v1.2.0) + if (options.ShowPreviewBeforeSaving) + { + var previewDialog = new UI.PreviewDialog( + dialog.InterfaceName, + interfaceFilePath, + interfaceCode); + + var previewResult = previewDialog.ShowDialog(); + + if (previewResult != true || !previewDialog.UserApproved) + { + LogMessage($" User cancelled after preview"); + skippedCount++; + continue; + } + + LogMessage($" User approved preview"); + } + + // Handle existing file if (File.Exists(interfaceFilePath)) { bool shouldOverwrite = false; @@ -284,9 +323,34 @@ private async Task ExecuteAsync() } } + // Write interface file File.WriteAllText(interfaceFilePath, interfaceCode); LogMessage($" Created: {interfaceFilePath}"); + // Generate implementation stub if enabled (v1.2.0) + if (options.GenerateImplementationStubs) + { + var stubClassName = $"{classInfo.ClassName}{options.ImplementationStubSuffix}"; + var stubCode = extractorService.GenerateImplementationStub( + dialog.InterfaceName, + stubClassName, + classInfo, + selectedMembers); + + var stubFilePath = Path.Combine(interfacesFolder, $"{stubClassName}{Constants.CSharpExtension}"); + + if (!File.Exists(stubFilePath) || overwriteChoice == OverwriteChoice.YesToAll) + { + File.WriteAllText(stubFilePath, stubCode); + LogMessage($" Created implementation stub: {stubFilePath}"); + } + else + { + LogMessage($" Skipped implementation stub (file exists): {stubClassName}"); + } + } + + // Update class to implement interface if (options.AutoUpdateClass) { try @@ -301,23 +365,24 @@ private async Task ExecuteAsync() if (updatedCode != originalCode) { File.WriteAllText(filePath, updatedCode); - LogMessage($" Updated class to implement {dialog.InterfaceName}"); + LogMessage($" Updated {typeKind} to implement {dialog.InterfaceName}"); } else { - LogMessage($" Class already implements {dialog.InterfaceName}"); + LogMessage($" {typeKind.Substring(0, 1).ToUpper()}{typeKind.Substring(1)} already implements {dialog.InterfaceName}"); } } catch (Exception ex) { - LogMessage($" Warning: Could not update class to implement interface: {ex.Message}"); + LogMessage($" Warning: Could not update {typeKind} to implement interface: {ex.Message}"); } } else { - LogMessage($" Skipped class update (disabled in options)"); + LogMessage($" Skipped {typeKind} update (disabled in options)"); } + // Add to project var projectItem = dte.Solution.FindProjectItem(filePath); if (projectItem?.ContainingProject != null) { @@ -331,26 +396,50 @@ private async Task ExecuteAsync() return pi.Name == options.InterfacesFolderName; }) ?? projectItems.AddFolder(options.InterfacesFolderName); - var existingItem = interfacesFolderItem?.ProjectItems.Cast() + // Add interface file + var existingInterfaceItem = interfacesFolderItem?.ProjectItems.Cast() .FirstOrDefault(pi => { ThreadHelper.ThrowIfNotOnUIThread(); return pi.Name == $"{dialog.InterfaceName}{Constants.CSharpExtension}"; }); - if (existingItem == null) + if (existingInterfaceItem == null) { interfacesFolderItem?.ProjectItems.AddFromFile(interfaceFilePath); - LogMessage($" Added to project"); + LogMessage($" Added interface to project"); } else { - LogMessage($" File already in project"); + LogMessage($" Interface file already in project"); + } + + // Add implementation stub file if generated + if (options.GenerateImplementationStubs) + { + var stubClassName = $"{classInfo.ClassName}{options.ImplementationStubSuffix}"; + var stubFilePath = Path.Combine(interfacesFolder, $"{stubClassName}{Constants.CSharpExtension}"); + + if (File.Exists(stubFilePath)) + { + var existingStubItem = interfacesFolderItem?.ProjectItems.Cast() + .FirstOrDefault(pi => + { + ThreadHelper.ThrowIfNotOnUIThread(); + return pi.Name == $"{stubClassName}{Constants.CSharpExtension}"; + }); + + if (existingStubItem == null) + { + interfacesFolderItem?.ProjectItems.AddFromFile(stubFilePath); + LogMessage($" Added implementation stub to project"); + } + } } } catch (Exception ex) { - LogMessage($" Warning: Could not add file to project: {ex.Message}"); + LogMessage($" Warning: Could not add file(s) to project: {ex.Message}"); } } @@ -377,6 +466,7 @@ private async Task ExecuteAsync() $"Failed: {failCount}\n" + $"Skipped: {skippedCount}"; + LogMessage("=== Extraction Complete ==="); LogMessage(summary.Replace("\n", " ")); if (successCount > 0 || failCount > 0) diff --git a/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj b/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj index c2a1172..43b12d8 100644 --- a/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj +++ b/InterfaceExtractor.Extension/InterfaceExtractor.Extension.csproj @@ -57,6 +57,9 @@ ExtractInterfaceDialog.xaml + + PreviewDialog.xaml + OverwriteDialog.xaml @@ -72,6 +75,10 @@ Designer MSBuild:Compile + + MSBuild:Compile + Designer + Designer MSBuild:Compile diff --git a/InterfaceExtractor.Extension/Options/OptionsPage.cs b/InterfaceExtractor.Extension/Options/OptionsPage.cs index ff8eb74..3f56cbf 100644 --- a/InterfaceExtractor.Extension/Options/OptionsPage.cs +++ b/InterfaceExtractor.Extension/Options/OptionsPage.cs @@ -83,6 +83,56 @@ public bool IncludeOperatorOverloads set => _options.IncludeOperatorOverloads = value; } + [Category("Behavior")] + [DisplayName("Show Preview Before Saving")] + [Description("Display a preview of the generated interface before saving to disk.")] + [DefaultValue(true)] + public bool ShowPreviewBeforeSaving + { + get => _options.ShowPreviewBeforeSaving; + set => _options.ShowPreviewBeforeSaving = value; + } + + [Category("Behavior")] + [DisplayName("Include Internal Members")] + [Description("Include internal members in addition to public members when extracting interfaces.")] + [DefaultValue(false)] + public bool IncludeInternalMembers + { + get => _options.IncludeInternalMembers; + set => _options.IncludeInternalMembers = value; + } + + [Category("Behavior")] + [DisplayName("Analyze Partial Classes")] + [Description("When extracting from partial classes, analyze all partial files to include all members.")] + [DefaultValue(true)] + public bool AnalyzePartialClasses + { + get => _options.AnalyzePartialClasses; + set => _options.AnalyzePartialClasses = value; + } + + [Category("Code Generation")] + [DisplayName("Generate Implementation Stubs")] + [Description("Generate a file with empty implementation stubs for the interface members.")] + [DefaultValue(false)] + public bool GenerateImplementationStubs + { + get => _options.GenerateImplementationStubs; + set => _options.GenerateImplementationStubs = value; + } + + [Category("Code Generation")] + [DisplayName("Implementation Stub Suffix")] + [Description("Suffix to append to implementation stub class names (e.g., 'Implementation', 'Service').")] + [DefaultValue("Implementation")] + public string ImplementationStubSuffix + { + get => _options.ImplementationStubSuffix; + set => _options.ImplementationStubSuffix = value; + } + [Category("Templates")] [DisplayName("Include File Header")] [Description("Include a header comment at the top of generated interface files.")] @@ -166,6 +216,12 @@ protected override void OnApply(PageApplyEventArgs e) MemberSeparatorLines = 1; } + // Validate implementation stub suffix + if (string.IsNullOrWhiteSpace(ImplementationStubSuffix)) + { + ImplementationStubSuffix = "Implementation"; + } + base.OnApply(e); } } @@ -182,6 +238,11 @@ public class ExtractorOptions public bool AddUsingDirective { get; set; } = true; public bool WarnIfNoIPrefix { get; set; } = true; public bool IncludeOperatorOverloads { get; set; } = false; + public bool ShowPreviewBeforeSaving { get; set; } = true; + public bool IncludeInternalMembers { get; set; } = false; + public bool AnalyzePartialClasses { get; set; } = true; + public bool GenerateImplementationStubs { get; set; } = false; + public string ImplementationStubSuffix { get; set; } = "Implementation"; public bool IncludeFileHeader { get; set; } = false; public string FileHeaderTemplate { get; set; } = "// Generated by Interface Extractor on {Date} at {Time}\n// File: {FileName}"; public int MemberSeparatorLines { get; set; } = 1; diff --git a/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs b/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs index ab5cd90..68e2f54 100644 --- a/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs +++ b/InterfaceExtractor.Extension/Services/InterfaceExtractorService.cs @@ -20,12 +20,12 @@ public InterfaceExtractorService(ExtractorOptions options = null) _options = options ?? new ExtractorOptions(); } - public async Task> AnalyzeClassesAsync(string filePath) + public async Task> AnalyzeClassesAsync(string filePath, string projectDirectory = null) { - return await Task.Run(() => AnalyzeClasses(filePath)); + return await Task.Run(() => AnalyzeClasses(filePath, projectDirectory)); } - private List AnalyzeClasses(string filePath) + private List AnalyzeClasses(string filePath, string projectDirectory = null) { try { @@ -33,13 +33,20 @@ private List AnalyzeClasses(string filePath) var tree = CSharpSyntaxTree.ParseText(sourceCode); var root = tree.GetRoot(); - // Find all public classes - var classDeclarations = root.DescendantNodes() + // Find all public (and optionally internal) classes and records + var typeDeclarations = new List(); + + // Add classes + typeDeclarations.AddRange(root.DescendantNodes() .OfType() - .Where(c => c.Modifiers.Any(m => m.IsKind(SyntaxKind.PublicKeyword))) - .ToList(); + .Where(c => IsAccessible(c.Modifiers))); + + // Add records (v1.2.0 feature) + typeDeclarations.AddRange(root.DescendantNodes() + .OfType() + .Where(r => IsAccessible(r.Modifiers))); - if (!classDeclarations.Any()) + if (!typeDeclarations.Any()) { return new List(); } @@ -53,29 +60,47 @@ private List AnalyzeClasses(string filePath) .Distinct() .ToList(); - foreach (var classDeclaration in classDeclarations) + foreach (var typeDeclaration in typeDeclarations) { - var className = classDeclaration.Identifier.Text; + var typeName = typeDeclaration.Identifier.Text; + var isPartial = typeDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)); + var isRecord = typeDeclaration is RecordDeclarationSyntax; // Extract namespace - var namespaceDeclaration = classDeclaration.Ancestors() + var namespaceDeclaration = typeDeclaration.Ancestors() .OfType() .FirstOrDefault(); var namespaceName = namespaceDeclaration?.Name.ToString() ?? "DefaultNamespace"; - // Extract public members - var members = ExtractPublicMembers(classDeclaration); + // Extract public members from current file + var members = ExtractPublicMembers(typeDeclaration); + + // Handle partial classes (v1.2.0 feature) + if (isPartial && _options.AnalyzePartialClasses && !string.IsNullOrEmpty(projectDirectory)) + { + var partialMembers = AnalyzePartialClassFiles( + typeName, + namespaceName, + projectDirectory, + filePath); + + // Merge members, avoiding duplicates + var newMembers = partialMembers.Where(pm => !members.Any(m => m.Signature == pm.Signature)); + members.AddRange(newMembers); + } if (members.Any()) { results.Add(new ExtractedClassInfo { - ClassName = className, + ClassName = typeName, Namespace = namespaceName, Members = members, Usings = usings, - FilePath = filePath + FilePath = filePath, + IsPartial = isPartial, + IsRecord = isRecord }); } } @@ -88,6 +113,85 @@ private List AnalyzeClasses(string filePath) } } + private bool IsAccessible(SyntaxTokenList modifiers) + { + var isPublic = modifiers.Any(m => m.IsKind(SyntaxKind.PublicKeyword)); + var isInternal = modifiers.Any(m => m.IsKind(SyntaxKind.InternalKeyword)); + + if (isPublic) + return true; + + // Include internal if option is enabled + if (_options.IncludeInternalMembers && isInternal) + return true; + + // If no explicit accessibility, it's internal by default for types + if (_options.IncludeInternalMembers && !modifiers.Any(m => + m.IsKind(SyntaxKind.PublicKeyword) || + m.IsKind(SyntaxKind.PrivateKeyword) || + m.IsKind(SyntaxKind.ProtectedKeyword) || + m.IsKind(SyntaxKind.InternalKeyword))) + { + return true; + } + + return false; + } + + private List AnalyzePartialClassFiles(string className, string namespaceName, string projectDirectory, string currentFile) + { + var additionalMembers = new List(); + + try + { + // Search for other .cs files in the project + var csFiles = Directory.GetFiles(projectDirectory, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Equals(currentFile, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var file in csFiles) + { + try + { + var sourceCode = File.ReadAllText(file); + var tree = CSharpSyntaxTree.ParseText(sourceCode); + var root = tree.GetRoot(); + + // Find partial class/record with matching name and namespace + var partialTypes = root.DescendantNodes() + .OfType() + .Where(t => t.Identifier.Text == className && + t.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)) && + IsAccessible(t.Modifiers)) + .ToList(); + + foreach (var partialType in partialTypes) + { + var ns = partialType.Ancestors() + .OfType() + .FirstOrDefault()?.Name.ToString() ?? "DefaultNamespace"; + + if (ns == namespaceName) + { + var members = ExtractPublicMembers(partialType); + additionalMembers.AddRange(members); + } + } + } + catch + { + // Skip files that can't be parsed + } + } + } + catch + { + // If we can't scan for partial files, just continue with what we have + } + + return additionalMembers; + } + public string GenerateInterface(string interfaceName, ExtractedClassInfo classInfo, List selectedMembers) { var sb = new StringBuilder(); @@ -108,7 +212,7 @@ public string GenerateInterface(string interfaceName, ExtractedClassInfo classIn // Calculate target namespace var targetNamespace = $"{classInfo.Namespace}{_options.InterfacesNamespaceSuffix}"; - // Filter out usings that match the target namespace (avoid self-referencing) + // Filter out usings that match the target namespace var filteredUsings = classInfo.Usings .Where(u => !u.Contains($"using {targetNamespace};")) .ToList(); @@ -156,7 +260,7 @@ public string GenerateInterface(string interfaceName, ExtractedClassInfo classIn { var member = membersToGenerate[i]; - // Add separator lines between members (except before first) + // Add separator lines between members if (i > 0) { for (int j = 0; j < _options.MemberSeparatorLines; j++) @@ -172,7 +276,7 @@ public string GenerateInterface(string interfaceName, ExtractedClassInfo classIn sb.AppendLine($" // {GetMemberTypeGroupName(member.Type)}"); } - // Add XML documentation comment if available + // Add XML documentation if (!string.IsNullOrWhiteSpace(member.Documentation)) { foreach (var line in member.Documentation.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) @@ -201,32 +305,188 @@ public string GenerateInterface(string interfaceName, ExtractedClassInfo classIn else { var needsSemicolon = !member.Signature.TrimEnd().EndsWith("}"); - if (needsSemicolon) - { - sb.AppendLine($" {member.Signature};"); - } - else + sb.AppendLine(needsSemicolon + ? $" {member.Signature};" + : $" {member.Signature}"); + } + } + + // Close interface and namespace + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + public string GenerateImplementationStub(string interfaceName, string className, ExtractedClassInfo classInfo, List selectedMembers) + { + var sb = new StringBuilder(); + + // Add file header if enabled + if (_options.IncludeFileHeader && !string.IsNullOrWhiteSpace(_options.FileHeaderTemplate)) + { + var header = _options.FileHeaderTemplate + .Replace("{FileName}", $"{className}.cs") + .Replace("{Date}", DateTime.Now.ToString("yyyy-MM-dd")) + .Replace("{Time}", DateTime.Now.ToString("HH:mm:ss")) + .Replace("\\n", "\n"); + + sb.AppendLine(header); + sb.AppendLine(); + } + + var targetNamespace = $"{classInfo.Namespace}{_options.InterfacesNamespaceSuffix}"; + + // Add usings + foreach (var usingDirective in classInfo.Usings) + { + sb.AppendLine(usingDirective); + } + + // Add using for interface namespace if different + if (classInfo.Namespace != targetNamespace) + { + sb.AppendLine($"using {targetNamespace};"); + } + + if (classInfo.Usings.Any()) + { + sb.AppendLine(); + } + + // Start namespace + sb.AppendLine($"namespace {classInfo.Namespace}"); + sb.AppendLine("{"); + + // Start class + sb.AppendLine($" public class {className} : {interfaceName}"); + sb.AppendLine(" {"); + + // Generate stubs for each member + foreach (var member in selectedMembers) + { + sb.AppendLine(); + + // Add XML documentation + if (!string.IsNullOrWhiteSpace(member.Documentation)) + { + foreach (var line in member.Documentation.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) { - sb.AppendLine($" {member.Signature}"); + var trimmedLine = line.Trim(); + if (!string.IsNullOrWhiteSpace(trimmedLine)) + { + if (trimmedLine.StartsWith("///")) + { + sb.AppendLine($" {trimmedLine}"); + } + else + { + sb.AppendLine($" /// {trimmedLine}"); + } + } } } + + switch (member.Type) + { + case MemberType.Method: + GenerateMethodStub(sb, member); + break; + + case MemberType.Property: + GeneratePropertyStub(sb, member); + break; + + case MemberType.Event: + GenerateEventStub(sb, member); + break; + + case MemberType.Indexer: + GenerateIndexerStub(sb, member); + break; + } } - // Close interface and namespace + // Close class and namespace sb.AppendLine(" }"); sb.AppendLine("}"); return sb.ToString(); } - private List ExtractPublicMembers(ClassDeclarationSyntax classDeclaration) + private static void GenerateMethodStub(StringBuilder sb, MemberInfo member) + { + if (!string.IsNullOrWhiteSpace(member.Constraints)) + { + sb.AppendLine($" public {member.Signature}"); + sb.AppendLine($" {member.Constraints}"); + } + else + { + sb.AppendLine($" public {member.Signature}"); + } + + sb.AppendLine(" {"); + + // Add appropriate return statement or throw NotImplementedException + if (member.ReturnType == "Task") + { + sb.AppendLine($" return Task.CompletedTask;"); + } + else if (member.ReturnType != "void") + { + sb.AppendLine($" throw new System.NotImplementedException();"); + } + + sb.AppendLine(" }"); + } + + private static void GeneratePropertyStub(StringBuilder sb, MemberInfo member) + { + sb.AppendLine($" public {member.Signature}"); + } + + private static void GenerateEventStub(StringBuilder sb, MemberInfo member) + { + sb.AppendLine($" public {member.Signature};"); + } + + private static void GenerateIndexerStub(StringBuilder sb, MemberInfo member) + { + sb.AppendLine($" public {member.Signature}"); + } + + private List ExtractPublicMembers(TypeDeclarationSyntax typeDeclaration) { var members = new List(); - // Extract public methods - var methods = classDeclaration.Members + // Extract primary constructor parameters from records (v1.2.0) + if (typeDeclaration is RecordDeclarationSyntax recordDeclaration && + recordDeclaration.ParameterList != null) + { + foreach (var parameter in recordDeclaration.ParameterList.Parameters) + { + var paramType = parameter.Type.ToString(); + var paramName = parameter.Identifier.Text; + + // Extract documentation from parameter if available + var documentation = ExtractDocumentation(parameter); + + members.Add(new MemberInfo + { + Type = MemberType.Property, + Signature = $"{paramType} {paramName} {{ get; }}", + Name = paramName, + ReturnType = paramType, + Documentation = documentation + }); + } + } + + // Extract methods + var methods = typeDeclaration.Members .OfType() - .Where(m => m.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword)) && + .Where(m => IsAccessible(m.Modifiers) && !m.Modifiers.Any(mod => mod.IsKind(SyntaxKind.StaticKeyword))); foreach (var method in methods) @@ -249,10 +509,10 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar }); } - // Extract public properties - var properties = classDeclaration.Members + // Extract properties + var properties = typeDeclaration.Members .OfType() - .Where(p => p.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword)) && + .Where(p => IsAccessible(p.Modifiers) && !p.Modifiers.Any(mod => mod.IsKind(SyntaxKind.StaticKeyword))); foreach (var property in properties) @@ -266,7 +526,13 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar { accessors = property.AccessorList.Accessors .Where(accessor => !accessor.Modifiers.Any(m => m.IsKind(SyntaxKind.PrivateKeyword))) - .Select(accessor => accessor.Keyword.Text) + .Select(accessor => + { + var keyword = accessor.Keyword.Text; + // Convert 'init' to 'get' for interface (init not valid in interfaces) + return keyword == "init" ? "get" : keyword; + }) + .Distinct() // Remove duplicates if both get and init exist .ToList(); } else if (property.ExpressionBody != null) @@ -288,10 +554,10 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar }); } - // Extract public events - var events = classDeclaration.Members + // Extract events + var events = typeDeclaration.Members .OfType() - .Where(e => e.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword)) && + .Where(e => IsAccessible(e.Modifiers) && !e.Modifiers.Any(mod => mod.IsKind(SyntaxKind.StaticKeyword))); foreach (var eventField in events) @@ -314,10 +580,10 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar } } - // Extract public indexers - var indexers = classDeclaration.Members + // Extract indexers + var indexers = typeDeclaration.Members .OfType() - .Where(i => i.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword)) && + .Where(i => IsAccessible(i.Modifiers) && !i.Modifiers.Any(mod => mod.IsKind(SyntaxKind.StaticKeyword))); foreach (var indexer in indexers) @@ -349,12 +615,12 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar }); } - // Extract operator overloads (if enabled in options) + // Extract operator overloads (if enabled) if (_options.IncludeOperatorOverloads) { - var operators = classDeclaration.Members + var operators = typeDeclaration.Members .OfType() - .Where(o => o.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword))); + .Where(o => IsAccessible(o.Modifiers)); foreach (var op in operators) { @@ -374,9 +640,9 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar } // Extract conversion operators - var conversions = classDeclaration.Members + var conversions = typeDeclaration.Members .OfType() - .Where(c => c.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword))); + .Where(c => IsAccessible(c.Modifiers)); foreach (var conversion in conversions) { @@ -399,40 +665,52 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar return members; } + private static string ExtractDocumentation(ParameterSyntax parameter) + { + var trivia = parameter.GetLeadingTrivia() + .FirstOrDefault(t => t.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) || + t.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia)); + + return trivia != default ? trivia.ToString().Trim() : string.Empty; + } + private static string ExtractDocumentation(MemberDeclarationSyntax member) { var trivia = member.GetLeadingTrivia() .FirstOrDefault(t => t.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) || t.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia)); - if (trivia != default) - { - return trivia.ToString().Trim(); - } - - return string.Empty; + return trivia != default ? trivia.ToString().Trim() : string.Empty; } public string AppendInterfaceToClass(string sourceCode, string className, string interfaceName, string interfaceNamespace) { if (!_options.AutoUpdateClass) { - return sourceCode; // Don't update if disabled + return sourceCode; } var tree = CSharpSyntaxTree.ParseText(sourceCode); var root = (CompilationUnitSyntax)tree.GetRoot(); - var classDeclaration = root.DescendantNodes() + // Find class or record + TypeDeclarationSyntax typeDeclaration = root.DescendantNodes() .OfType() .FirstOrDefault(c => c.Identifier.Text == className); - if (classDeclaration == null) + if (typeDeclaration == null) + { + typeDeclaration = root.DescendantNodes() + .OfType() + .FirstOrDefault(r => r.Identifier.Text == className); + } + + if (typeDeclaration == null) { return sourceCode; } - var classNamespace = classDeclaration.Ancestors() + var classNamespace = typeDeclaration.Ancestors() .OfType() .FirstOrDefault(); @@ -444,23 +722,20 @@ public string AppendInterfaceToClass(string sourceCode, string className, string if (sameNamespace) { - // Same namespace, use simple name interfaceToAdd = interfaceName; } else if (_options.AddUsingDirective) { - // Different namespace but adding using directive, use simple name interfaceToAdd = interfaceName; } else { - // Different namespace and not adding using, use fully qualified name interfaceToAdd = $"{interfaceNamespace}.{interfaceName}"; } - if (classDeclaration.BaseList != null) + if (typeDeclaration.BaseList != null) { - var existingBases = classDeclaration.BaseList.Types + var existingBases = typeDeclaration.BaseList.Types .Select(t => t.ToString()) .ToList(); @@ -470,9 +745,9 @@ public string AppendInterfaceToClass(string sourceCode, string className, string } } - ClassDeclarationSyntax newClassDeclaration; + TypeDeclarationSyntax newTypeDeclaration; - if (classDeclaration.BaseList == null) + if (typeDeclaration.BaseList == null) { var baseType = SyntaxFactory.SimpleBaseType( SyntaxFactory.ParseTypeName(interfaceToAdd)); @@ -480,18 +755,18 @@ public string AppendInterfaceToClass(string sourceCode, string className, string var baseList = SyntaxFactory.BaseList( SyntaxFactory.SingletonSeparatedList(baseType)); - newClassDeclaration = classDeclaration.WithBaseList(baseList); + newTypeDeclaration = typeDeclaration.WithBaseList(baseList); } else { var baseType = SyntaxFactory.SimpleBaseType( SyntaxFactory.ParseTypeName(interfaceToAdd)); - var newBaseList = classDeclaration.BaseList.AddTypes(baseType); - newClassDeclaration = classDeclaration.WithBaseList(newBaseList); + var newBaseList = typeDeclaration.BaseList.AddTypes(baseType); + newTypeDeclaration = typeDeclaration.WithBaseList(newBaseList); } - var newRoot = root.ReplaceNode(classDeclaration, newClassDeclaration); + var newRoot = root.ReplaceNode(typeDeclaration, newTypeDeclaration); if (_options.AddUsingDirective && !sameNamespace && @@ -517,23 +792,12 @@ private static int GetMemberTypeOrder(MemberType type) { switch (type) { - case MemberType.Property: - return 1; - - case MemberType.Method: - return 2; - - case MemberType.Event: - return 3; - - case MemberType.Indexer: - return 4; - - case MemberType.Operator: - return 5; - - default: - return 99; + case MemberType.Property: return 1; + case MemberType.Method: return 2; + case MemberType.Event: return 3; + case MemberType.Indexer: return 4; + case MemberType.Operator: return 5; + default: return 99; } } @@ -541,23 +805,12 @@ private static string GetMemberTypeGroupName(MemberType type) { switch (type) { - case MemberType.Property: - return "Properties"; - - case MemberType.Method: - return "Methods"; - - case MemberType.Event: - return "Events"; - - case MemberType.Indexer: - return "Indexers"; - - case MemberType.Operator: - return "Operators"; - - default: - return "Members"; + case MemberType.Property: return "Properties"; + case MemberType.Method: return "Methods"; + case MemberType.Event: return "Events"; + case MemberType.Indexer: return "Indexers"; + case MemberType.Operator: return "Operators"; + default: return "Members"; } } } @@ -569,6 +822,8 @@ public class ExtractedClassInfo public List Members { get; set; } public List Usings { get; set; } public string FilePath { get; set; } + public bool IsPartial { get; set; } + public bool IsRecord { get; set; } } public class MemberInfo diff --git a/InterfaceExtractor.Extension/UI/OverwriteDialog.xaml b/InterfaceExtractor.Extension/UI/OverwriteDialog.xaml index e7b370a..e5b3fb2 100644 --- a/InterfaceExtractor.Extension/UI/OverwriteDialog.xaml +++ b/InterfaceExtractor.Extension/UI/OverwriteDialog.xaml @@ -2,7 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="File Exists" - Height="220" Width="450" + Height="240" Width="450" WindowStartupLocation="CenterOwner" ResizeMode="NoResize" ShowInTaskbar="False" @@ -39,7 +39,7 @@ diff --git a/InterfaceExtractor.Extension/UI/PreviewDialog.xaml b/InterfaceExtractor.Extension/UI/PreviewDialog.xaml new file mode 100644 index 0000000..ac04832 --- /dev/null +++ b/InterfaceExtractor.Extension/UI/PreviewDialog.xaml @@ -0,0 +1,88 @@ +ο»Ώ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +