From 9a1d3df3ebb774befcf4de15fa343eb720d4d4c8 Mon Sep 17 00:00:00 2001 From: Graham Date: Sun, 1 Mar 2026 16:37:55 +0000 Subject: [PATCH 1/9] Modernize VSIX project: Convert to SDK style and add extensibility SDK support --- Vsix/Extension.cs | 12 +++ Vsix/VisualStudioInteraction.cs | 9 ++- Vsix/Vsix.csproj | 137 ++++++-------------------------- 3 files changed, 42 insertions(+), 116 deletions(-) create mode 100644 Vsix/Extension.cs diff --git a/Vsix/Extension.cs b/Vsix/Extension.cs new file mode 100644 index 000000000..5694252d9 --- /dev/null +++ b/Vsix/Extension.cs @@ -0,0 +1,12 @@ +using Microsoft.VisualStudio.Extensibility; + +namespace ICSharpCode.CodeConverter.VsExtension; + +[VisualStudioContribution] +public class CodeConverterExtension : Extension +{ + public override ExtensionConfiguration ExtensionConfiguration => new() { + RequiresInProcessHosting = true, + Metadata = null + }; +} diff --git a/Vsix/VisualStudioInteraction.cs b/Vsix/VisualStudioInteraction.cs index a1e498658..4ac8a8187 100644 --- a/Vsix/VisualStudioInteraction.cs +++ b/Vsix/VisualStudioInteraction.cs @@ -32,8 +32,13 @@ internal static class VisualStudioInteraction { private static DTE2 _dte; - /// All calls and usages must be from the main thread> - internal static DTE2 Dte => _dte ??= Package.GetGlobalService(typeof(DTE)) as DTE2; + /// All calls and usages must be from the main thread + internal static DTE2 Dte { + get { + ThreadHelper.ThrowIfNotOnUIThread(); + return _dte ??= Package.GetGlobalService(typeof(DTE)) as DTE2; + } + } private static CancellationToken _cancelAllToken; private static readonly Version LowestSupportedVersion = new(16, 10, 0, 0); diff --git a/Vsix/Vsix.csproj b/Vsix/Vsix.csproj index 93d31a7af..7645b6add 100644 --- a/Vsix/Vsix.csproj +++ b/Vsix/Vsix.csproj @@ -1,145 +1,54 @@ - - + - 16.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - Debug - AnyCPU - 2.0 - ICSharpCode.CodeConverter.VsExtension - ICSharpCode.CodeConverter.VsExtension - {99498EF8-C9E0-433B-8D7B-EA8E9E66F0C7} - {82B43B9B-A64C-4715-B499-D71E9CA2BD60};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} - Library - v4.7.2 + net472 + disable + disable + 12 + true + + true true - true - true true true - Roslyn - Program - $(DevEnvDir)devenv.exe - /rootsuffix $(VSSDKTargetPlatformRegRootSuffix) - false - False - win - 12.0 - - - - true - true - true - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - $(LocalAppData)\Microsoft\VisualStudio\15.0_8bd9890a\Extensions\frc3cnfo.23z - True - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 + - - 1.8.4 - - - 10.0.2 - - + + + - - compile; build; native; contentfiles; analyzers; buildtransitive - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + + + + + + + Menus.ctmenu - Designer - true PreserveNewest + true - true PreserveNewest + true Always true - - - - - - - - - - - Component - - - - - - - - - - - - - - - + true VSPackage - Designer - - - {7ea075c6-6406-445c-ab77-6c47aff88d58} - CodeConverter - - + - - - - - - - - - - - \ No newline at end of file From 88fcf266e187b29d3b5d1592e05d2bcc402e8e30 Mon Sep 17 00:00:00 2001 From: Graham Date: Sun, 1 Mar 2026 16:55:18 +0000 Subject: [PATCH 2/9] Phase 2: Migrate CodeConverterPackage to include Extension contribution --- Vsix/CodeConverterPackage.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Vsix/CodeConverterPackage.cs b/Vsix/CodeConverterPackage.cs index e23f42539..623878a81 100644 --- a/Vsix/CodeConverterPackage.cs +++ b/Vsix/CodeConverterPackage.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.VisualStudio.ComponentModelHost; +using Microsoft.VisualStudio.Extensibility; using Microsoft.VisualStudio.LanguageServices; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Threading; From 3f26986a4619ce27824556c411796d64c779c9cb Mon Sep 17 00:00:00 2001 From: Graham Date: Sun, 1 Mar 2026 17:38:19 +0000 Subject: [PATCH 3/9] Phase 2: Add initial Extensibility SDK command contributions --- Vsix/ConvertCSToVBExtensibilityCommand.cs | 25 +++++++++++++++++++++++ Vsix/ConvertVBToCSExtensibilityCommand.cs | 25 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 Vsix/ConvertCSToVBExtensibilityCommand.cs create mode 100644 Vsix/ConvertVBToCSExtensibilityCommand.cs diff --git a/Vsix/ConvertCSToVBExtensibilityCommand.cs b/Vsix/ConvertCSToVBExtensibilityCommand.cs new file mode 100644 index 000000000..b8ebae3ee --- /dev/null +++ b/Vsix/ConvertCSToVBExtensibilityCommand.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Extensibility; +using Microsoft.VisualStudio.Extensibility.Commands; + +namespace ICSharpCode.CodeConverter.VsExtension; + +[VisualStudioContribution] +public class ConvertCSToVBExtensibilityCommand : Command +{ + private readonly CodeConverterPackage _package; + + public ConvertCSToVBExtensibilityCommand(CodeConverterPackage package) + { + _package = package; + } + + public override CommandConfiguration CommandConfiguration => new("%a3378a21-e939-40c9-9e4b-eb0cec7b7854%"); + + public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken cancellationToken) + { + // This will be wired up to the existing logic in Phase 2 + await Task.CompletedTask; + } +} diff --git a/Vsix/ConvertVBToCSExtensibilityCommand.cs b/Vsix/ConvertVBToCSExtensibilityCommand.cs new file mode 100644 index 000000000..93ba48dfc --- /dev/null +++ b/Vsix/ConvertVBToCSExtensibilityCommand.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Extensibility; +using Microsoft.VisualStudio.Extensibility.Commands; + +namespace ICSharpCode.CodeConverter.VsExtension; + +[VisualStudioContribution] +public class ConvertVBToCSExtensibilityCommand : Command +{ + private readonly CodeConverterPackage _package; + + public ConvertVBToCSExtensibilityCommand(CodeConverterPackage package) + { + _package = package; + } + + public override CommandConfiguration CommandConfiguration => new("%a3378a21-e939-40c9-9e4b-eb0cec7b7854%"); + + public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken cancellationToken) + { + // This will be wired up to the existing logic in Phase 2 + await Task.CompletedTask; + } +} From 5c39c42af98764bd67340b3cd16de0f1cf2a3b4c Mon Sep 17 00:00:00 2001 From: Graham Date: Sun, 1 Mar 2026 17:55:57 +0000 Subject: [PATCH 4/9] Phase 2: Add initial Extensibility SDK settings contribution --- Vsix/ConverterSettings.cs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 Vsix/ConverterSettings.cs diff --git a/Vsix/ConverterSettings.cs b/Vsix/ConverterSettings.cs new file mode 100644 index 000000000..5b7acf5a9 --- /dev/null +++ b/Vsix/ConverterSettings.cs @@ -0,0 +1,23 @@ +using System.Runtime.Serialization; +using Microsoft.VisualStudio.Extensibility; + +namespace ICSharpCode.CodeConverter.VsExtension; + +[DataContract] +public class ConverterSettings +{ + [DataMember] + public bool CopyResultToClipboardForSingleDocument { get; set; } + + [DataMember] + public bool AlwaysOverwriteFiles { get; set; } + + [DataMember] + public bool CreateBackups { get; set; } = true; + + [DataMember] + public int FormattingTimeoutMinutes { get; set; } = 15; + + [DataMember] + public bool BypassAssemblyLoadingErrors { get; set; } +} From afd4567bb50b8489dbf02ccbef832e532a7fb10f Mon Sep 17 00:00:00 2001 From: Graham Date: Sun, 1 Mar 2026 23:48:04 +0000 Subject: [PATCH 5/9] Open up version compatibility --- Vsix/source.extension.vsixmanifest | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Vsix/source.extension.vsixmanifest b/Vsix/source.extension.vsixmanifest index 961647bf9..0325f30f9 100644 --- a/Vsix/source.extension.vsixmanifest +++ b/Vsix/source.extension.vsixmanifest @@ -13,10 +13,10 @@ code converter vb csharp visual basic csharp net translate - + amd64 - + arm64 From b0481d1a4d77e57b0153eecb41441202edb43d2e Mon Sep 17 00:00:00 2001 From: Graham Date: Sun, 1 Mar 2026 23:53:37 +0000 Subject: [PATCH 6/9] Remove this reference which struggles to resolve --- Vsix/Vsix.csproj | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Vsix/Vsix.csproj b/Vsix/Vsix.csproj index 7645b6add..02e64e82d 100644 --- a/Vsix/Vsix.csproj +++ b/Vsix/Vsix.csproj @@ -1,4 +1,4 @@ - + net472 disable @@ -18,7 +18,6 @@ - From c39c03163b99a85950a596cad361cf88e135eb10 Mon Sep 17 00:00:00 2001 From: Graham Date: Sun, 1 Mar 2026 23:56:43 +0000 Subject: [PATCH 7/9] Phase 2: Wired up Extensibility commands to CodeConversion logic --- Vsix/CodeConverterPackage.cs | 5 +++++ Vsix/ConvertCSToVBExtensibilityCommand.cs | 24 +++++++++++++++++++++-- Vsix/ConvertVBToCSExtensibilityCommand.cs | 24 +++++++++++++++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/Vsix/CodeConverterPackage.cs b/Vsix/CodeConverterPackage.cs index 623878a81..cbcd058f4 100644 --- a/Vsix/CodeConverterPackage.cs +++ b/Vsix/CodeConverterPackage.cs @@ -86,6 +86,9 @@ public sealed class CodeConverterPackage : AsyncPackage public const string ConvertableSolutionMenuVisibilityGuid = "8e7192d0-28b7-4fe7-8d84-82c1db98d459"; internal Cancellation PackageCancellation { get; } = new(); + + internal static CodeConversion CodeConversionInstance { get; private set; } + internal static CodeConverterPackage Instance { get; private set; } /// /// Initializes a new instance of package class. @@ -110,6 +113,8 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); var visualStudioWorkspace = componentModel.GetService(); var codeConversion = await CodeConversion.CreateAsync(this, visualStudioWorkspace, this.GetDialogPageAsync); + CodeConversionInstance = codeConversion; + Instance = this; ConvertCSToVBCommand.Initialize(this, oleMenuCommandService, codeConversion); ConvertVBToCSCommand.Initialize(this, oleMenuCommandService, codeConversion); PasteAsVB.Initialize(this, oleMenuCommandService, codeConversion); diff --git a/Vsix/ConvertCSToVBExtensibilityCommand.cs b/Vsix/ConvertCSToVBExtensibilityCommand.cs index b8ebae3ee..3d1bc3a19 100644 --- a/Vsix/ConvertCSToVBExtensibilityCommand.cs +++ b/Vsix/ConvertCSToVBExtensibilityCommand.cs @@ -19,7 +19,27 @@ public ConvertCSToVBExtensibilityCommand(CodeConverterPackage package) public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken cancellationToken) { - // This will be wired up to the existing logic in Phase 2 - await Task.CompletedTask; + var codeConversion = CodeConverterPackage.CodeConversionInstance; + var serviceProvider = CodeConverterPackage.Instance; + if (codeConversion == null || serviceProvider == null) return; + + var itemsPath = await VisualStudioInteraction.GetSelectedItemsPathAsync(CodeConversion.IsCSFileName); + if (itemsPath.Count > 0) + { + await codeConversion.ConvertDocumentsAsync(itemsPath, cancellationToken); + return; + } + + var projects = await VisualStudioInteraction.GetSelectedProjectsAsync(".csproj"); + if (projects.Count > 0) + { + await codeConversion.ConvertProjectsAsync(projects, cancellationToken); + return; + } + + (string filePath, var selection) = await VisualStudioInteraction.GetCurrentFilenameAndSelectionAsync(serviceProvider, CodeConversion.IsCSFileName, false); + if (filePath != null && selection != null) { + await codeConversion.ConvertDocumentAsync(filePath, selection.Value, cancellationToken); + } } } diff --git a/Vsix/ConvertVBToCSExtensibilityCommand.cs b/Vsix/ConvertVBToCSExtensibilityCommand.cs index 93ba48dfc..1cc9fe3e7 100644 --- a/Vsix/ConvertVBToCSExtensibilityCommand.cs +++ b/Vsix/ConvertVBToCSExtensibilityCommand.cs @@ -19,7 +19,27 @@ public ConvertVBToCSExtensibilityCommand(CodeConverterPackage package) public override async Task ExecuteCommandAsync(IClientContext context, CancellationToken cancellationToken) { - // This will be wired up to the existing logic in Phase 2 - await Task.CompletedTask; + var codeConversion = CodeConverterPackage.CodeConversionInstance; + var serviceProvider = CodeConverterPackage.Instance; + if (codeConversion == null || serviceProvider == null) return; + + var itemsPath = await VisualStudioInteraction.GetSelectedItemsPathAsync(CodeConversion.IsVBFileName); + if (itemsPath.Count > 0) + { + await codeConversion.ConvertDocumentsAsync(itemsPath, cancellationToken); + return; + } + + var projects = await VisualStudioInteraction.GetSelectedProjectsAsync(".vbproj"); + if (projects.Count > 0) + { + await codeConversion.ConvertProjectsAsync(projects, cancellationToken); + return; + } + + (string filePath, var selection) = await VisualStudioInteraction.GetCurrentFilenameAndSelectionAsync(serviceProvider, CodeConversion.IsVBFileName, false); + if (filePath != null && selection != null) { + await codeConversion.ConvertDocumentAsync(filePath, selection.Value, cancellationToken); + } } } From e596706b3e2b8c74a7d1cc10a3742ed1cd4a7211 Mon Sep 17 00:00:00 2001 From: Graham Date: Mon, 2 Mar 2026 00:19:41 +0000 Subject: [PATCH 8/9] Remove unused --- Vsix/ConverterSettings.cs | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 Vsix/ConverterSettings.cs diff --git a/Vsix/ConverterSettings.cs b/Vsix/ConverterSettings.cs deleted file mode 100644 index 5b7acf5a9..000000000 --- a/Vsix/ConverterSettings.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Runtime.Serialization; -using Microsoft.VisualStudio.Extensibility; - -namespace ICSharpCode.CodeConverter.VsExtension; - -[DataContract] -public class ConverterSettings -{ - [DataMember] - public bool CopyResultToClipboardForSingleDocument { get; set; } - - [DataMember] - public bool AlwaysOverwriteFiles { get; set; } - - [DataMember] - public bool CreateBackups { get; set; } = true; - - [DataMember] - public int FormattingTimeoutMinutes { get; set; } = 15; - - [DataMember] - public bool BypassAssemblyLoadingErrors { get; set; } -} From 8eb9df95c918fb79009c941f5156a2496fffa7d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 07:48:19 +0000 Subject: [PATCH 9/9] Fix Vsix Microsoft.Bcl.AsyncInterfaces version conflict with VS 17.x VS 17.14 ships Microsoft.Bcl.AsyncInterfaces 9.0.0.0 and devenv.exe.config binding-redirects 0-9.0.0.0 to that version. Anything in the Vsix that references >9.0.0.0 falls outside the redirect, so devenv loads a second copy from the extension folder, producing duplicate IAsyncDisposable type identities and silent runtime failures. CodeConverter was transitively pulling in Microsoft.Bcl.AsyncInterfaces 10.0.0.0 via System.Linq.AsyncEnumerable (which only ships as a 10.x package) and System.Text.Json 10.0.0. Drop those package references from CodeConverter and hand-roll the small subset of System.Linq.AsyncEnumerable we actually used (ToArraySafeAsync, AsAsyncEnumerable, SelectSafe). The helpers are deliberately renamed so they do not clash with .NET 10's BCL System.Linq.AsyncEnumerable extension methods when referenced by a multi-targeted consumer. System.Linq.AsyncEnumerable is added directly to CodeConv.csproj instead - the CLI tool never loads into devenv.exe so the binding-redirect ceiling does not apply there. Add VsixAssemblyCompatibilityTests that statically walks the Vsix output via PEReader and asserts every referenced version of a known VS-owned polyfill (currently Microsoft.Bcl.AsyncInterfaces) is satisfiable by the version the oldest supported VS ships. The test reproduces the original bug and now passes. --- CodeConv/CodeConv.csproj | 9 + CodeConverter/CSharp/HandledEventsAnalyzer.cs | 6 +- .../ProjectMergedDeclarationExtensions.cs | 2 +- CodeConverter/CodeConverter.csproj | 3 - .../Common/AsyncEnumerableTaskExtensions.cs | 64 ++++++ CodeConverter/Common/ProjectConversion.cs | 6 +- CodeConverter/Common/SolutionConverter.cs | 21 +- Tests/Tests.csproj | 9 + Tests/Vsix/VsixAssemblyCompatibilityTests.cs | 211 ++++++++++++++++++ Vsix/CodeConversion.cs | 2 +- 10 files changed, 314 insertions(+), 19 deletions(-) create mode 100644 Tests/Vsix/VsixAssemblyCompatibilityTests.cs diff --git a/CodeConv/CodeConv.csproj b/CodeConv/CodeConv.csproj index 6a1f9230a..7a82d4d6a 100644 --- a/CodeConv/CodeConv.csproj +++ b/CodeConv/CodeConv.csproj @@ -31,6 +31,15 @@ + + diff --git a/CodeConverter/CSharp/HandledEventsAnalyzer.cs b/CodeConverter/CSharp/HandledEventsAnalyzer.cs index f15efb17f..4c9c1e387 100644 --- a/CodeConverter/CSharp/HandledEventsAnalyzer.cs +++ b/CodeConverter/CSharp/HandledEventsAnalyzer.cs @@ -37,8 +37,10 @@ private async Task AnalyzeAsync() #pragma warning restore RS1024 // Compare symbols correctly - var writtenWithEventsProperties = await ancestorPropsMembersByName.Values.OfType().ToAsyncEnumerable() - .ToDictionaryAsync(async (p, _) => p.Name, async (p, cancellationToken) => (p, await IsNeverWrittenOrOverriddenAsync(p, cancellationToken))); + var writtenWithEventsProperties = new Dictionary(); + foreach (var prop in ancestorPropsMembersByName.Values.OfType()) { + writtenWithEventsProperties[prop.Name] = (prop, await IsNeverWrittenOrOverriddenAsync(prop)); + } var eventContainerToMethods = _type.GetMembers().OfType() .SelectMany(HandledEvents) diff --git a/CodeConverter/CSharp/ProjectMergedDeclarationExtensions.cs b/CodeConverter/CSharp/ProjectMergedDeclarationExtensions.cs index ac42c2107..deab051cf 100644 --- a/CodeConverter/CSharp/ProjectMergedDeclarationExtensions.cs +++ b/CodeConverter/CSharp/ProjectMergedDeclarationExtensions.cs @@ -63,7 +63,7 @@ public static async Task WithRenamedMergedMyNamespaceAsync(this Project var projectDir = Path.Combine(vbProject.GetDirectoryPath(), "My Project"); var compilation = await vbProject.GetCompilationAsync(cancellationToken); - var embeddedSourceTexts = await GetAllEmbeddedSourceTextAsync(compilation).Select((r, i) => (Text: r, Suffix: $".Static.{i+1}")).ToArrayAsync(cancellationToken); + var embeddedSourceTexts = await GetAllEmbeddedSourceTextAsync(compilation).SelectSafe((r, i) => (Text: r, Suffix: $".Static.{i+1}")).ToArraySafeAsync(cancellationToken); var generatedSourceTexts = (Text: await GetDynamicallyGeneratedSourceTextAsync(compilation), Suffix: ".Dynamic").Yield(); foreach (var (text, suffix) in embeddedSourceTexts.Concat(generatedSourceTexts)) { diff --git a/CodeConverter/CodeConverter.csproj b/CodeConverter/CodeConverter.csproj index 3f2b3a804..43cd44088 100644 --- a/CodeConverter/CodeConverter.csproj +++ b/CodeConverter/CodeConverter.csproj @@ -54,9 +54,6 @@ - - - all diff --git a/CodeConverter/Common/AsyncEnumerableTaskExtensions.cs b/CodeConverter/Common/AsyncEnumerableTaskExtensions.cs index 58d3a367f..fb7d786cd 100644 --- a/CodeConverter/Common/AsyncEnumerableTaskExtensions.cs +++ b/CodeConverter/Common/AsyncEnumerableTaskExtensions.cs @@ -92,4 +92,68 @@ public static async Task SelectAsync(this IEnumerable< return partitionResults.ToArray(); } + + /// + /// Hand-rolled to avoid depending on System.Linq.AsyncEnumerable, which only ships as a 10.x + /// package and so transitively forces Microsoft.Bcl.AsyncInterfaces 10.0.0.0 into the Vsix output — + /// a version Visual Studio 17.x cannot bind to. See VsixAssemblyCompatibilityTests. + /// + /// + /// Deliberately not named ToArrayAsync so it does not clash with + /// .ToArrayAsync on .NET 10+ when this assembly is + /// referenced by a project whose target framework provides the BCL version. + /// + public static async Task ToArraySafeAsync(this IAsyncEnumerable source, + CancellationToken cancellationToken = default) + { + var list = new List(); + await foreach (var item in source.WithCancellation(cancellationToken)) { + list.Add(item); + } + return list.ToArray(); + } + + /// + /// Adapts a synchronous sequence to . Hand-rolled for the same + /// reason as , and likewise renamed to avoid clashing with + /// .ToAsyncEnumerable on .NET 10+. + /// +#pragma warning disable 1998 // async method without await; required for the iterator to compile to IAsyncEnumerable. +#pragma warning disable VSTHRD200 // The method returns IAsyncEnumerable, not a Task; "Async" suffix would be misleading. + public static async IAsyncEnumerable AsAsyncEnumerable(this IEnumerable source) + { + foreach (var item in source) { + yield return item; + } + } +#pragma warning restore 1998 + + /// + /// Lazy projection over an . Hand-rolled for the same reason + /// as , and renamed to avoid clashing with + /// .Select on .NET 10+. + /// + public static async IAsyncEnumerable SelectSafe(this IAsyncEnumerable source, + Func selector, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var item in source.WithCancellation(cancellationToken)) { + yield return selector(item); + } + } + + /// + /// Lazy projection (with index) over an . Hand-rolled for the same + /// reason as . + /// + public static async IAsyncEnumerable SelectSafe(this IAsyncEnumerable source, + Func selector, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var index = 0; + await foreach (var item in source.WithCancellation(cancellationToken)) { + yield return selector(item, index++); + } + } +#pragma warning restore VSTHRD200 } \ No newline at end of file diff --git a/CodeConverter/Common/ProjectConversion.cs b/CodeConverter/Common/ProjectConversion.cs index 287c386e6..bfd9cad66 100644 --- a/CodeConverter/Common/ProjectConversion.cs +++ b/CodeConverter/Common/ProjectConversion.cs @@ -50,7 +50,7 @@ private ProjectConversion(IProjectContentsConverter projectContentsConverter, IE if (conversionOptions.SelectedTextSpan is { Length: > 0 } span) { document = await WithAnnotatedSelectionAsync(document, span); } - var conversionResults = await ConvertDocumentsAsync(new[] {document}, conversionOptions, progress, cancellationToken).ToArrayAsync(cancellationToken); + var conversionResults = await ConvertDocumentsAsync(new[] {document}, conversionOptions, progress, cancellationToken).ToArraySafeAsync(cancellationToken); var codeResult = conversionResults.First(r => r.SourcePathOrNull == document.FilePath); codeResult.Exceptions = conversionResults.SelectMany(x => x.Exceptions).ToArray(); return codeResult; @@ -183,7 +183,7 @@ private async IAsyncEnumerable ConvertAsync(IProgress FirstPassLoggedAsync(d, phaseProgress), Env.MaxDop, _cancellationToken); - var (proj1, docs1) = await _projectContentsConverter.GetConvertedProjectAsync(await firstPassResults.ToArrayAsync(_cancellationToken)); + var (proj1, docs1) = await _projectContentsConverter.GetConvertedProjectAsync(await firstPassResults.ToArraySafeAsync(_cancellationToken)); var warnings = await GetProjectWarningsAsync(_projectContentsConverter.SourceProject, proj1); if (!string.IsNullOrWhiteSpace(warnings)) { @@ -193,7 +193,7 @@ private async IAsyncEnumerable ConvertAsync(IProgress SecondPassLoggedAsync(d, phaseProgress), Env.MaxDop, _cancellationToken); - await foreach (var result in secondPassResults.Select(CreateConversionResult).WithCancellation(_cancellationToken)) { + await foreach (var result in secondPassResults.SelectSafe(CreateConversionResult).WithCancellation(_cancellationToken)) { yield return result; } await foreach (var result in _projectContentsConverter.GetAdditionalConversionResultsAsync(_additionalDocumentsToConvert, _cancellationToken)) { diff --git a/CodeConverter/Common/SolutionConverter.cs b/CodeConverter/Common/SolutionConverter.cs index 2d5b8bda9..19d7316f4 100644 --- a/CodeConverter/Common/SolutionConverter.cs +++ b/CodeConverter/Common/SolutionConverter.cs @@ -71,17 +71,20 @@ private SolutionConverter(string solutionFilePath, public async IAsyncEnumerable ConvertAsync() { var projectsToUpdateReferencesOnly = _projectsToConvert.First().Solution.Projects.Except(_projectsToConvert); - var solutionResult = string.IsNullOrWhiteSpace(_sourceSolutionContents) ? Enumerable.Empty() : ConvertSolutionFile().Yield(); - var convertedProjects = await ConvertProjectsAsync(); - var projectsAndSolutionResults = UpdateProjectReferences(projectsToUpdateReferencesOnly).Concat(solutionResult).ToAsyncEnumerable(); - await foreach (var p in convertedProjects.Concat(projectsAndSolutionResults)) { - yield return p; + + foreach (var project in _projectsToConvert) { + await foreach (var result in ConvertProjectAsync(project).WithCancellation(_cancellationToken)) { + yield return result; + } } - } - private async Task> ConvertProjectsAsync() - { - return _projectsToConvert.ToAsyncEnumerable().SelectMany(ConvertProjectAsync); + foreach (var result in UpdateProjectReferences(projectsToUpdateReferencesOnly)) { + yield return result; + } + + if (!string.IsNullOrWhiteSpace(_sourceSolutionContents)) { + yield return ConvertSolutionFile(); + } } private IAsyncEnumerable ConvertProjectAsync(Project project) diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index f25cb7b9f..321c92735 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -41,4 +41,13 @@ + + + + diff --git a/Tests/Vsix/VsixAssemblyCompatibilityTests.cs b/Tests/Vsix/VsixAssemblyCompatibilityTests.cs new file mode 100644 index 000000000..2b2f46460 --- /dev/null +++ b/Tests/Vsix/VsixAssemblyCompatibilityTests.cs @@ -0,0 +1,211 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Xunit; +using Xunit.Abstractions; + +namespace ICSharpCode.CodeConverter.Tests.Vsix; + +/// +/// When the Vsix loads into Visual Studio, every assembly it references must be resolvable +/// by the running devenv.exe. For a subset of BCL polyfill assemblies (Microsoft.Bcl.AsyncInterfaces +/// and friends) VS ships a specific assembly version and defines a binding redirect in devenv.exe.config +/// covering 0.0.0.0-<shippedVersion>. Any reference whose assembly version exceeds that range +/// is *not* redirected, so the CLR probes the extension folder instead. That path succeeds only if we +/// happen to ship exactly the requested version, which in turn leads to two different copies of +/// (say) System.IAsyncDisposable loaded side by side and silent +/// / failures at runtime. +/// +/// This test reproduces that class of issue without needing a real Visual Studio by statically +/// walking the Vsix output directory. For each supported VS baseline it asserts that every +/// referenced version of a known VS-owned polyfill can be satisfied by the version VS ships plus +/// the binding redirect it declares. The test fails loudly when the Vsix pulls in a newer BCL +/// polyfill than the oldest supported VS. +/// +public class VsixAssemblyCompatibilityTests +{ + private readonly ITestOutputHelper _output; + + public VsixAssemblyCompatibilityTests(ITestOutputHelper output) + { + _output = output; + } + + /// + /// The set of BCL polyfill assemblies that VS ships and binding-redirects itself. We must + /// not reference a newer version than the oldest supported VS ships, otherwise VS's + /// binding redirect can't cover our reference and we end up with duplicate type identities. + /// + /// + /// Versions taken from the BCL polyfill shipped by the corresponding Microsoft.VisualStudio.Threading + /// package (which is what the devenv.exe.config binding redirect tracks): + /// VS 17.14 (the oldest VS2022 we claim to support): Microsoft.VisualStudio.Threading 17.14 → Microsoft.Bcl.AsyncInterfaces 9.0.0.0 + /// VS 18.x (VS2026 preview): Microsoft.VisualStudio.Threading 18.x → Microsoft.Bcl.AsyncInterfaces 10.0.0.0 + /// The oldest supported VS sets the ceiling on what we can reference. + /// + private static readonly IReadOnlyDictionary OldestSupportedVsPolyfillVersions = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Microsoft.Bcl.AsyncInterfaces"] = new Version(9, 0, 0, 0), + }; + + [Fact] + public void VsixDoesNotReferenceNewerBclPolyfillsThanOldestSupportedVs() + { + var vsixOutput = FindVsixOutputDirectory(); + Assert.True(Directory.Exists(vsixOutput), + $"Expected Vsix output at '{vsixOutput}'. Build the Vsix project first (msbuild Vsix\\Vsix.csproj)."); + + var references = CollectReferencesByAssemblyName(vsixOutput); + var files = CollectFileVersionsByAssemblyName(vsixOutput); + + var failures = new List(); + foreach (var (polyfillName, maxSupportedVersion) in OldestSupportedVsPolyfillVersions) { + if (!references.TryGetValue(polyfillName, out var refs) || refs.Count == 0) { + continue; + } + + var sorted = refs.OrderBy(r => r.RequestedVersion).ToList(); + _output.WriteLine($"{polyfillName}: oldest supported VS ships {maxSupportedVersion}"); + foreach (var r in sorted) { + _output.WriteLine($" {r.ReferrerFileName} -> {polyfillName} {r.RequestedVersion}"); + } + + var maxRequested = sorted.Max(r => r.RequestedVersion); + if (maxRequested > maxSupportedVersion) { + var offenders = sorted.Where(r => r.RequestedVersion > maxSupportedVersion) + .Select(r => $"{r.ReferrerFileName} -> {r.RequestedVersion}"); + failures.Add( + $"{polyfillName}: oldest supported VS ships {maxSupportedVersion}, but {string.Join(", ", offenders)} " + + "reference a newer version that VS's devenv.exe.config binding redirect cannot unify. " + + "Either downgrade the referencing package so the compile-time reference is <= " + + $"{maxSupportedVersion} or add a [ProvideBindingRedirection] attribute to the CodeConverterPackage."); + } + + if (files.TryGetValue(polyfillName, out var onDisk) && onDisk.Version > maxSupportedVersion) { + failures.Add( + $"{polyfillName}: {Path.GetFileName(onDisk.Path)} in the Vsix output is {onDisk.Version} " + + $"but the oldest supported VS only ships {maxSupportedVersion}. " + + "When both copies load into devenv.exe the CLR will create duplicate type identities."); + } + } + + Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures)); + } + + [Fact] + public void VsixReferencesToSameAssemblyAgreeOrAreCoveredByShippedFile() + { + var vsixOutput = FindVsixOutputDirectory(); + if (!Directory.Exists(vsixOutput)) { + return; + } + + var references = CollectReferencesByAssemblyName(vsixOutput); + var files = CollectFileVersionsByAssemblyName(vsixOutput); + + var splits = references + .Where(kv => kv.Value.Select(r => r.RequestedVersion).Distinct().Count() > 1) + .Where(kv => OldestSupportedVsPolyfillVersions.ContainsKey(kv.Key)) + .ToList(); + + foreach (var kv in splits) { + var distinct = kv.Value.Select(r => r.RequestedVersion).Distinct().OrderBy(v => v).ToList(); + var hasFile = files.TryGetValue(kv.Key, out var fileEntry); + _output.WriteLine($"Split-version reference to {kv.Key}:"); + foreach (var v in distinct) { + var referrers = kv.Value.Where(r => r.RequestedVersion == v).Select(r => r.ReferrerFileName); + _output.WriteLine($" v{v}: {string.Join(", ", referrers)}"); + } + _output.WriteLine(hasFile + ? $" File on disk: {Path.GetFileName(fileEntry.Path)} v{fileEntry.Version}" + : " File on disk: (not shipped; relying on VS-installed copy)"); + } + } + + private static string FindVsixOutputDirectory() + { + // Tests/bin// -> Vsix/bin/ + var testAssembly = typeof(VsixAssemblyCompatibilityTests).Assembly.Location; + var dir = new DirectoryInfo(Path.GetDirectoryName(testAssembly)!); + // Walk up looking for the repo root (containing Vsix folder) + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Vsix", "bin"))) { + dir = dir.Parent; + } + if (dir == null) { + return Path.Combine(AppContext.BaseDirectory, "Vsix", "bin", "Release"); + } + var vsixBin = Path.Combine(dir.FullName, "Vsix", "bin"); + // Prefer the same configuration the tests were built with + var configDirs = Directory.EnumerateDirectories(vsixBin).ToList(); + // Prefer Release if it exists, otherwise any configuration. + var release = configDirs.FirstOrDefault(d => string.Equals(Path.GetFileName(d), "Release", StringComparison.OrdinalIgnoreCase)); + return release ?? configDirs.FirstOrDefault() ?? Path.Combine(vsixBin, "Release"); + } + + private record ReferenceEntry(string ReferrerFileName, Version RequestedVersion); + + private static Dictionary> CollectReferencesByAssemblyName(string directory) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var dll in Directory.EnumerateFiles(directory, "*.dll")) { + if (!TryOpenMetadata(dll, out var md)) { + continue; + } + using (md) { + var reader = md!.GetMetadataReader(); + if (!reader.IsAssembly) continue; + foreach (var handle in reader.AssemblyReferences) { + var reference = reader.GetAssemblyReference(handle); + var name = reader.GetString(reference.Name); + if (!result.TryGetValue(name, out var list)) { + list = new List(); + result[name] = list; + } + list.Add(new ReferenceEntry(Path.GetFileName(dll), reference.Version)); + } + } + } + return result; + } + + private static Dictionary CollectFileVersionsByAssemblyName(string directory) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var dll in Directory.EnumerateFiles(directory, "*.dll")) { + if (!TryOpenMetadata(dll, out var md)) { + continue; + } + using (md) { + var reader = md!.GetMetadataReader(); + if (!reader.IsAssembly) continue; + var def = reader.GetAssemblyDefinition(); + result[reader.GetString(def.Name)] = (def.Version, dll); + } + } + return result; + } + + private static bool TryOpenMetadata(string path, out PEReader? peReader) + { + peReader = null; + try { + var stream = File.OpenRead(path); + peReader = new PEReader(stream); + if (!peReader.HasMetadata) { + peReader.Dispose(); + peReader = null; + return false; + } + return true; + } catch { + peReader?.Dispose(); + peReader = null; + return false; + } + } +} diff --git a/Vsix/CodeConversion.cs b/Vsix/CodeConversion.cs index 2d70dd3d8..0720a15c2 100644 --- a/Vsix/CodeConversion.cs +++ b/Vsix/CodeConversion.cs @@ -91,7 +91,7 @@ await _joinableTaskFactory.RunAsync(async () => { await EnsureBuiltAsync(containingProject is null ? Array.Empty() : new[]{containingProject}); var conversionResult = await _joinableTaskFactory.RunAsync(async () => { var result = await ConvertDocumentUnhandledAsync(documentFilePath, selected, cancellationToken); - await WriteConvertedFilesAndShowSummaryAsync(new[] { result }.ToAsyncEnumerable()); + await WriteConvertedFilesAndShowSummaryAsync(new[] { result }.AsAsyncEnumerable()); return result; });