diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 6fa06bf..0abc2ae 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -27,6 +27,7 @@ concurrency: jobs: test: runs-on: windows-latest + timeout-minutes: 30 # Job-level timeout steps: - name: Checkout code @@ -84,6 +85,7 @@ jobs: - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action/windows@v2 if: always() + timeout-minutes: 5 # Step-level timeout with: files: | TestResults/**/*.trx @@ -141,6 +143,7 @@ jobs: code-quality: runs-on: windows-latest + timeout-minutes: 20 # Job-level timeout steps: - name: Checkout code @@ -181,6 +184,7 @@ jobs: runs-on: ubuntu-latest needs: [test, code-quality] if: always() + timeout-minutes: 5 # Job-level timeout steps: - name: Check Test Status diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0b455f..4a9797a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,32 +39,30 @@ 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 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 @@ -73,32 +71,70 @@ 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 β†’ 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: 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 shell: pwsh run: | @@ -125,80 +161,57 @@ 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 + # 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 (promoted from dev)" } else { - $patch++ - } - - $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 + # Normal bump: increment version based on conventional commit type + if ($bumpType -eq "major") { + $major++ + $minor = 0 + $patch = 0 } - elseif ($lastTestingTag -match 'testing\.(\d+)') { - $prereleaseNum = [int]$matches[1] + 1 + elseif ($bumpType -eq "minor") { + $minor++ + $patch = 0 } - else { - Write-Host "⚠️ Warning: Could not parse testing tag '$lastTestingTag', starting from 0" - $prereleaseNum = 0 + elseif ($bumpType -eq "patch") { + $patch++ } - $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") { - # Dev branch: find the next dev prerelease number - $lastDevTag = git tag -l "v$baseVersion-dev.*" --sort=-version:refname | Select-Object -First 1 + $baseVersion = "$major.$minor.$patch" - if ([string]::IsNullOrEmpty($lastDevTag)) { - $prereleaseNum = 0 - } - elseif ($lastDevTag -match 'dev\.(\d+)') { - $prereleaseNum = [int]$matches[1] + 1 + # 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 { - Write-Host "⚠️ Warning: Could not parse dev tag '$lastDevTag', starting from 0" - $prereleaseNum = 0 + # 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)" } - - $prereleaseNum = $prereleaseNum.ToString("00") - $newVersion = "$baseVersion.$prereleaseNum" - $displayVersion = "$baseVersion-dev.$prereleaseNum" - $isPrerelease = "true" - $tagName = "v$displayVersion" - $releaseName = "v$displayVersion πŸ”§ Dev Release" - $releaseType = "dev" - Write-Host "πŸ”§ Dev version: $displayVersion" - } - 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)" @@ -225,11 +238,31 @@ 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: Commit and push version bump + if: steps.version-bump.outputs.CONVENTIONAL_COMMIT_FOUND == 'true' + 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 + 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: | @@ -251,6 +284,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]" @@ -259,59 +293,157 @@ 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: bash + shell: pwsh run: | - RELEASE_TYPE="${{ steps.version.outputs.RELEASE_TYPE }}" + $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 }}" - # 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 + # Extract base version (e.g., "1.0.1" from "1.0.1-dev") + $baseVersion = $displayVersion -replace '-.*$', '' - # 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 + # Find the last tag of the same type (excluding current tag) + if ($releaseType -eq "dev") { + # Get last dev tag (excluding current) + $lastTag = git tag -l "v*-dev*" --sort=-version:refname | Where-Object { $_ -ne $currentTag } | Select-Object -First 1 + } + else { + # 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 + } - if [[ "${{ steps.version.outputs.IS_PRERELEASE }}" == "true" ]]; then - RELEASE_BODY="## Pre-release - - **Version:** v${{ steps.version.outputs.DISPLAY_VERSION }} - **Commit:** ${{ github.sha }} + Write-Host "Current tag: $currentTag" + Write-Host "Last tag: $lastTag" - **Changes ($TAG_INFO):** - $COMMITS + # Get commits using git log first (more reliable for commit ranges) + $commits = "" + if ([string]::IsNullOrEmpty($lastTag)) { + Write-Host "No previous tag found - using all commits" + $tagInfo = "First $releaseType release" + $gitCommits = git log --first-parent --pretty=format:"%H||%s" --reverse --grep="^chore: bump version" --invert-grep + } + else { + Write-Host "Getting commits between $lastTag and HEAD" + $tagInfo = "Since $lastTag" + $gitCommits = git log --first-parent "$lastTag..HEAD" --pretty=format:"%H||%s" --reverse --grep="^chore: bump version" --invert-grep + } - ## 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 + # Try to enrich with GitHub usernames via API + $commitList = @() + try { + Write-Host "Enriching commits with GitHub usernames..." + + # Build SHA list for lookup + $shaMap = @{} + foreach ($line in $gitCommits) { + if ([string]::IsNullOrEmpty($line)) { continue } + + $parts = $line -split '\|\|' + if ($parts.Count -lt 2) { continue } + + $sha = $parts[0] + $message = $parts[1] + $shaMap[$sha] = $message + } + + if ($shaMap.Count -eq 0) { + throw "No commits to process" + } + + Write-Host "Found $($shaMap.Count) commits to enrich" + + # Fetch commit details from GitHub API (in batches if needed) + foreach ($sha in $shaMap.Keys) { + try { + $apiResponse = gh api "repos/${{ github.repository }}/commits/$sha" --jq '"\(.author.login // "unknown")"' 2>$null + + if ($LASTEXITCODE -eq 0 -and ![string]::IsNullOrEmpty($apiResponse)) { + $author = $apiResponse.Trim() + $message = $shaMap[$sha] + $shortSha = $sha.Substring(0, 7) + $commitList += "- $message by @$author ($shortSha)" + } + else { + # Fallback to without username + $message = $shaMap[$sha] + $shortSha = $sha.Substring(0, 7) + $commitList += "- $message ($shortSha)" + } + } + catch { + # Fallback to without username + $message = $shaMap[$sha] + $shortSha = $sha.Substring(0, 7) + $commitList += "- $message ($shortSha)" + } + } + + $commits = $commitList -join "`n" + Write-Host "βœ… Successfully processed $($commitList.Count) commits" + } + catch { + Write-Host "⚠️ Could not enrich with GitHub usernames: $_" + Write-Host "Using plain git log format" - **Version:** v${{ steps.version.outputs.DISPLAY_VERSION }} + # Fallback to simple git log format + if ([string]::IsNullOrEmpty($lastTag)) { + $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 --grep="^chore: bump version" --invert-grep + } + } - **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 installation instructions + $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" + + # Build release body + $releaseBody = "" + + if ("${{ steps.version.outputs.IS_PRERELEASE }}" -eq "true") { + $releaseBody += "## Pre-release`n`n" + $releaseBody += "**Version:** v$displayVersion`n" + $releaseBody += "**Commit:** ${{ github.sha }}`n`n" + $releaseBody += "**Changes ($tagInfo):**`n" + $releaseBody += "$commits`n`n" + $releaseBody += $installation + } + else { + 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 += "**Version:** v$displayVersion`n`n" + $releaseBody += "**Changes ($tagInfo):**`n" + $releaseBody += "$commits`n`n" + $releaseBody += $installation + } - echo "$RELEASE_BODY" > release-notes.txt + $releaseBody | Out-File -FilePath release-notes.txt -Encoding utf8 + env: + 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 }} 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 2c208b5..ec4e8ed 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,8 @@ 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(); + options = OptionsProvider.GetOptions(package); + extractorService = new Services.InterfaceExtractorService(options); var menuCommandID = new CommandID(CommandSet, CommandId); var menuItem = new OleMenuCommand(this.Execute, menuCommandID); @@ -40,13 +43,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 +74,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 +98,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 @@ -119,7 +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}"); + 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) { @@ -136,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()) @@ -153,37 +161,45 @@ 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) + foreach (var file in selectedFiles) { + var filePath = file.Path; LogMessage($"Analyzing: {Path.GetFileName(filePath)}"); try { - // Analyze the class(es) - 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; } - // 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)"); + 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; } - // Convert to selection items var selectionItems = classInfo.Members.Select(m => new UI.MemberSelectionItem { DisplayText = m.Signature, @@ -193,8 +209,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 +219,6 @@ private async Task ExecuteAsync() continue; } - // Validate interface name if (!IsValidInterfaceName(dialog.InterfaceName, out string validationError)) { ShowMessage($"Invalid interface name: {validationError}"); @@ -212,7 +226,6 @@ private async Task ExecuteAsync() continue; } - // Get selected members var selectedMembers = classInfo.Members .Where((m, i) => selectionItems[i].IsSelected) .ToList(); @@ -226,19 +239,37 @@ 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 + // 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; @@ -292,33 +323,63 @@ private async Task ExecuteAsync() } } + // Write interface file File.WriteAllText(interfaceFilePath, interfaceCode); LogMessage($" Created: {interfaceFilePath}"); - // Update the original class to implement the interface - try + // Generate implementation stub if enabled (v1.2.0) + if (options.GenerateImplementationStubs) { - var originalCode = File.ReadAllText(filePath); - var updatedCode = Services.InterfaceExtractorService.AppendInterfaceToClass( - originalCode, - classInfo.ClassName, + var stubClassName = $"{classInfo.ClassName}{options.ImplementationStubSuffix}"; + var stubCode = extractorService.GenerateImplementationStub( dialog.InterfaceName, - $"{classInfo.Namespace}{Constants.InterfacesNamespaceSuffix}"); + stubClassName, + classInfo, + selectedMembers); + + var stubFilePath = Path.Combine(interfacesFolder, $"{stubClassName}{Constants.CSharpExtension}"); - if (updatedCode != originalCode) + if (!File.Exists(stubFilePath) || overwriteChoice == OverwriteChoice.YesToAll) { - File.WriteAllText(filePath, updatedCode); - LogMessage($" Updated class to implement {dialog.InterfaceName}"); + File.WriteAllText(stubFilePath, stubCode); + LogMessage($" Created implementation stub: {stubFilePath}"); } else { - LogMessage($" Class already implements {dialog.InterfaceName}"); + LogMessage($" Skipped implementation stub (file exists): {stubClassName}"); } } - catch (Exception ex) + + // Update class to implement interface + if (options.AutoUpdateClass) { - LogMessage($" Warning: Could not update class to implement interface: {ex.Message}"); - // Continue - interface was still created successfully + try + { + 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 {typeKind} to implement {dialog.InterfaceName}"); + } + else + { + LogMessage($" {typeKind.Substring(0, 1).ToUpper()}{typeKind.Substring(1)} already implements {dialog.InterfaceName}"); + } + } + catch (Exception ex) + { + LogMessage($" Warning: Could not update {typeKind} to implement interface: {ex.Message}"); + } + } + else + { + LogMessage($" Skipped {typeKind} update (disabled in options)"); } // Add to project @@ -332,31 +393,53 @@ 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() + // 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}"); - // File created successfully, just couldn't add to project + LogMessage($" Warning: Could not add file(s) to project: {ex.Message}"); } } @@ -383,6 +466,7 @@ private async Task ExecuteAsync() $"Failed: {failCount}\n" + $"Skipped: {skippedCount}"; + LogMessage("=== Extraction Complete ==="); LogMessage(summary.Replace("\n", " ")); if (successCount > 0 || failCount > 0) @@ -401,14 +485,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 +523,6 @@ private static UI.OverwriteChoice ShowOverwriteConfirmation(string fileName) } } - /// - /// Represents the user's choice for overwriting files (internal tracking) - /// internal enum OverwriteChoice { Ask, @@ -451,24 +530,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..43b12d8 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,11 +49,17 @@ + + Component + ExtractInterfaceDialog.xaml + + PreviewDialog.xaml + OverwriteDialog.xaml @@ -68,6 +75,10 @@ Designer MSBuild:Compile + + MSBuild:Compile + Designer + Designer MSBuild:Compile 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..3f56cbf --- /dev/null +++ b/InterfaceExtractor.Extension/Options/OptionsPage.cs @@ -0,0 +1,274 @@ +ο»Ώusing Microsoft.VisualStudio.Shell; +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace InterfaceExtractor.Options +{ + /// + /// 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 the configured prefix.")] + [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("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.")] + [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, Operators).")] + [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; + } + + // Validate implementation stub suffix + if (string.IsNullOrWhiteSpace(ImplementationStubSuffix)) + { + ImplementationStubSuffix = "Implementation"; + } + + base.OnApply(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 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; + public bool SortMembers { get; set; } = false; + public bool GroupByMemberType { get; set; } = false; + } + + /// + /// 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..78c8e03 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,12 +13,19 @@ namespace InterfaceExtractor.Services { public class InterfaceExtractorService { - public async Task> AnalyzeClassesAsync(string filePath) + private readonly ExtractorOptions _options; + + public InterfaceExtractorService(ExtractorOptions options = null) + { + _options = options ?? new ExtractorOptions(); + } + + 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 { @@ -25,15 +33,22 @@ 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(); // Return empty collection instead of null + return new List(); } var results = new List(); @@ -45,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 - simplified condition - var namespaceDeclaration = classDeclaration.Ancestors() + // Extract namespace + 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 }); } } @@ -80,41 +113,170 @@ private List AnalyzeClasses(string filePath) } } - public static string GenerateInterface(string interfaceName, ExtractedClassInfo classInfo, List selectedMembers) + 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(); - // Add usings - foreach (var usingDirective in classInfo.Usings) + // 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(); + } + + // Calculate target namespace + var targetNamespace = $"{classInfo.Namespace}{_options.InterfacesNamespaceSuffix}"; + + // Filter out usings that match the target namespace + 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(); } // 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) { - var member = selectedMembers[i]; + membersToGenerate = membersToGenerate + .OrderBy(m => m.Type) + .ThenBy(m => m.Name) + .ToList(); + } - // Add blank line between members except for first one + 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 = membersToGenerate[i]; + + // Add separator lines between members 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 + // Add XML documentation if (!string.IsNullOrWhiteSpace(member.Documentation)) { foreach (var line in member.Documentation.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) @@ -122,7 +284,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,36 +304,189 @@ 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("}"); + sb.AppendLine(needsSemicolon + ? $" {member.Signature};" + : $" {member.Signature}"); + } + } - if (needsSemicolon) - { - sb.AppendLine($" {member.Signature};"); - } - else + // 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 - simplified with Where clause - 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) @@ -195,10 +509,10 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar }); } - // Extract public properties - simplified with Where clause - 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) @@ -207,24 +521,28 @@ 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) { 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 broader compatibility (init accessors in interfaces require implementing types to use init accessors; not supported in all C# versions) + return keyword == "init" ? "get" : keyword; + }) + .Distinct() // Remove duplicates if both get and init exist .ToList(); } 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,10 +554,10 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar }); } - // Extract public events - simplified with Where clause - 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) @@ -262,10 +580,10 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar } } - // Extract public indexers - simplified with Where clause - 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) @@ -274,7 +592,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,110 +615,166 @@ private List ExtractPublicMembers(ClassDeclarationSyntax classDeclar }); } + // Extract operator overloads (if enabled) + if (_options.IncludeOperatorOverloads) + { + var operators = typeDeclaration.Members + .OfType() + .Where(o => IsAccessible(o.Modifiers)); + + 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 = typeDeclaration.Members + .OfType() + .Where(c => IsAccessible(c.Modifiers)); + + 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; } + 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; } - /// - /// 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; + } + var tree = CSharpSyntaxTree.ParseText(sourceCode); var root = (CompilationUnitSyntax)tree.GetRoot(); - // Find the target class - 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; // Class not found, return original + return sourceCode; } - // Get the class's namespace - var classNamespace = classDeclaration.Ancestors() + var classNamespace = typeDeclaration.Ancestors() .OfType() .FirstOrDefault(); var classNamespaceName = classNamespace?.Name.ToString() ?? ""; - // Determine if we need to use fully qualified name + // Determine interface name to use string interfaceToAdd; - if (classNamespaceName == interfaceNamespace || - string.IsNullOrEmpty(interfaceNamespace)) + bool sameNamespace = classNamespaceName == interfaceNamespace || string.IsNullOrEmpty(interfaceNamespace); + + if (sameNamespace) + { + interfaceToAdd = interfaceName; + } + else if (_options.AddUsingDirective) { - // 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) + if (typeDeclaration.BaseList != null) { - var existingBases = classDeclaration.BaseList.Types + var existingBases = typeDeclaration.BaseList.Types .Select(t => t.ToString()) .ToList(); if (existingBases.Any(b => b.Contains(interfaceName))) { - return sourceCode; // Already implements this interface + return sourceCode; } } - // Add the interface to the base list - ClassDeclarationSyntax newClassDeclaration; + TypeDeclarationSyntax newTypeDeclaration; - if (classDeclaration.BaseList == null) + if (typeDeclaration.BaseList == null) { - // No base list, create one var baseType = SyntaxFactory.SimpleBaseType( SyntaxFactory.ParseTypeName(interfaceToAdd)); var baseList = SyntaxFactory.BaseList( SyntaxFactory.SingletonSeparatedList(baseType)); - newClassDeclaration = classDeclaration.WithBaseList(baseList); + newTypeDeclaration = typeDeclaration.WithBaseList(baseList); } else { - // Add to existing base list 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); } - // Replace the old class with the new one - var newRoot = root.ReplaceNode(classDeclaration, newClassDeclaration); + var newRoot = root.ReplaceNode(typeDeclaration, newTypeDeclaration); - // Add using directive if needed (for different namespace) - if (classNamespaceName != interfaceNamespace && + if (_options.AddUsingDirective && + !sameNamespace && !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 +785,34 @@ 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 @@ -424,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 @@ -441,6 +841,7 @@ public enum MemberType Method, Property, Event, - Indexer + Indexer, + Operator } } \ 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.Extension/UI/OverwriteDialog.xaml b/InterfaceExtractor.Extension/UI/OverwriteDialog.xaml index e7b370a..53d1457 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" 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 @@ +ο»Ώ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +