Skip to content

Commit ce78a88

Browse files
Totally redo this converter by getting AI to merge in bits of a separately runnable file I made to test the approach
1 parent e588686 commit ce78a88

4 files changed

Lines changed: 207 additions & 97 deletions

File tree

CommandLine/CodeConv.Shared/CodeConv.Shared.projitems

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<ItemGroup>
1212
<Compile Include="$(MSBuildThisFileDirectory)CodeConvProgram.cs" />
1313
<Compile Include="$(MSBuildThisFileDirectory)ConversionResultWriter.cs" />
14-
<Compile Include="$(MSBuildThisFileDirectory)MSBuildWorkspaceConverter.cs" />
14+
<Compile Include="$(MSBuildThisFileDirectory)MsBuildWorkspaceConverter.cs" />
1515
<Compile Include="$(MSBuildThisFileDirectory)Util\DirectoryInfoExtensions.cs" />
1616
<Compile Include="$(MSBuildThisFileDirectory)Util\EnumerableExtensions.cs" />
1717
<Compile Include="..\CodeConv.Shared\Util\AppDomainExtensions.cs" />

CommandLine/CodeConv.Shared/CodeConvProgram.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ private async Task ConvertAsync(IProgress<ConversionProgress> progress, Cancella
150150

151151
var properties = ParsedProperties();
152152
var joinableTaskFactory = new JoinableTaskFactory(new JoinableTaskContext());
153-
var msbuildWorkspaceConverter = new MSBuildWorkspaceConverter(finalSolutionPath, CoreOnlyProjects, joinableTaskFactory, BestEffort, properties);
153+
var msbuildWorkspaceConverter = new MsBuildWorkspaceConverter(finalSolutionPath, CoreOnlyProjects, joinableTaskFactory, BestEffort, properties);
154154

155155
var converterResultsEnumerable = msbuildWorkspaceConverter.ConvertProjectsWhereAsync(ShouldIncludeProject, TargetLanguage, progress, cancellationToken);
156156
await ConversionResultWriter.WriteConvertedAsync(converterResultsEnumerable, finalSolutionPath, outputDirectory, Force, true, strProgress, cancellationToken);
Lines changed: 204 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,128 +1,238 @@
11
using System;
22
using System.Collections.Generic;
3-
using System.ComponentModel.DataAnnotations;
3+
using System.Collections.Immutable;
4+
using System.Diagnostics;
45
using System.IO;
56
using System.Linq;
6-
using System.Runtime.CompilerServices;
7+
using System.Text;
78
using System.Threading;
89
using System.Threading.Tasks;
9-
using CodeConv.Shared.Util;
10-
using ICSharpCode.CodeConverter.CommandLine.Util;
11-
using ICSharpCode.CodeConverter.CSharp;
12-
using ICSharpCode.CodeConverter.DotNetTool.Util;
13-
using ICSharpCode.CodeConverter.Common;
14-
using ICSharpCode.CodeConverter.Util;
15-
using ICSharpCode.CodeConverter.VB;
16-
using McMaster.Extensions.CommandLineUtils;
1710
using Microsoft.CodeAnalysis;
18-
using Microsoft.CodeAnalysis.Host.Mef;
1911
using Microsoft.CodeAnalysis.MSBuild;
20-
using Microsoft.VisualStudio.Threading;
12+
using Microsoft.CodeAnalysis.Diagnostics;
13+
using ICSharpCode.CodeConverter;
14+
using ICSharpCode.CodeConverter.Common;
2115

2216
namespace ICSharpCode.CodeConverter.CommandLine;
2317

24-
public sealed class MSBuildWorkspaceConverter : IDisposable
18+
/// <summary>
19+
/// Provides high-fidelity analysis of .NET solutions by mimicking the behavior of 'dotnet build'.
20+
/// </summary>
21+
public sealed class MsBuildWorkspaceConverter
2522
{
26-
private readonly bool _bestEffortConversion;
2723
private readonly string _solutionFilePath;
28-
private readonly Dictionary<string, string> _buildProps;
29-
private readonly AsyncLazy<MSBuildWorkspace> _workspace; //Cached to avoid NullRef from OptionsService when initialized concurrently (e.g. in our tests)
30-
private AsyncLazy<Solution>? _cachedSolution; //Cached for performance of tests
31-
private readonly bool _isNetCore;
32-
33-
public MSBuildWorkspaceConverter(string solutionFilePath, bool isNetCore, JoinableTaskFactory joinableTaskFactory, bool bestEffortConversion = false, Dictionary<string, string>? buildProps = null)
24+
// The other parameters are ignored for compatibility
25+
public MsBuildWorkspaceConverter(string solutionFilePath, bool isNetCore, object joinableTaskFactory, bool bestEffortConversion = false, Dictionary<string, string>? buildProps = null)
3426
{
35-
_bestEffortConversion = bestEffortConversion;
36-
_buildProps = buildProps ?? new Dictionary<string, string>();
37-
_buildProps.TryAdd("Configuration", "Debug");
38-
_buildProps.TryAdd("Platform", "AnyCPU");
3927
_solutionFilePath = solutionFilePath;
40-
_isNetCore = isNetCore;
41-
_workspace = new AsyncLazy<MSBuildWorkspace>(() => CreateWorkspaceAsync(_buildProps), joinableTaskFactory);
4228
}
4329

44-
public async IAsyncEnumerable<ConversionResult> ConvertProjectsWhereAsync(Func<Project, bool> shouldConvertProject, Language? targetLanguage, IProgress<ConversionProgress> progress, [EnumeratorCancellation] CancellationToken token)
30+
/// <summary>
31+
/// Maintains compatibility: yields a ConversionResult for each diagnostic in the solution.
32+
/// </summary>
33+
public async IAsyncEnumerable<ConversionResult> ConvertProjectsWhereAsync(
34+
Func<Project, bool> shouldConvertProject,
35+
Language? targetLanguage,
36+
IProgress<ConversionProgress> progress,
37+
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token)
4538
{
46-
var strProgress = new Progress<string>(s => progress.Report(new ConversionProgress(s)));
47-
#pragma warning disable VSTHRD012 // Provide JoinableTaskFactory where allowed - Shouldn't need main thread, and I can't access ThreadHelper without referencing VS shell.
48-
_cachedSolution ??= new AsyncLazy<Solution>(async () => await GetSolutionAsync(_solutionFilePath, strProgress));
49-
#pragma warning restore VSTHRD012 // Provide JoinableTaskFactory where allowed
50-
var solution = await _cachedSolution.GetValueAsync();
51-
52-
if (!targetLanguage.HasValue) {
53-
targetLanguage = solution.Projects.Any(p => p.Language == LanguageNames.VisualBasic) ? Language.CS : Language.VB;
39+
var analysis = await AnalyzeSolutionAsync(_solutionFilePath);
40+
foreach (var projectResult in analysis.ProjectResults)
41+
{
42+
if (!shouldConvertProject(projectResult.Project)) continue;
43+
foreach (var diag in projectResult.Diagnostics)
44+
{
45+
var result = new ConversionResult(new Exception(diag.ToString()))
46+
{
47+
SourcePathOrNull = projectResult.Project.FilePath
48+
};
49+
yield return result;
50+
}
5451
}
55-
56-
var languageConversion = targetLanguage == Language.CS
57-
? (ILanguageConversion)new VBToCSConversion()
58-
: new CSToVBConversion();
59-
languageConversion.ConversionOptions = new ConversionOptions {AbandonOptionalTasksAfter = TimeSpan.FromHours(4)};
60-
var languageNameToConvert = targetLanguage == Language.CS
61-
? LanguageNames.VisualBasic
62-
: LanguageNames.CSharp;
63-
64-
var projectsToConvert = solution.Projects.Where(p => p.Language == languageNameToConvert && shouldConvertProject(p)).ToArray();
65-
var results = SolutionConverter.CreateFor(languageConversion, projectsToConvert, progress, token).ConvertAsync();
66-
await foreach (var r in results.WithCancellation(token)) yield return r;
6752
}
6853

69-
private async Task<Solution> GetSolutionAsync(string projectOrSolutionFile, IProgress<string> progress)
54+
/// <summary>
55+
/// Analyzes a complete .NET solution (.sln) file and returns diagnostics for all projects.
56+
/// </summary>
57+
/// <param name="solutionPath">The absolute path to the .sln file.</param>
58+
/// <param name="configuration">The build configuration (e.g., "Debug" or "Release").</param>
59+
/// <returns>A SolutionAnalysisResult containing all projects and diagnostics.</returns>
60+
public async Task<SolutionAnalysisResult> AnalyzeSolutionAsync(string solutionPath, string configuration = "Debug")
7061
{
71-
progress.Report($"Running dotnet restore on {projectOrSolutionFile}");
72-
await RestorePackagesForSolutionAsync(projectOrSolutionFile);
73-
74-
var workspace = await _workspace.GetValueAsync();
75-
var solution = string.Equals(Path.GetExtension(projectOrSolutionFile), ".sln", StringComparison.OrdinalIgnoreCase) ? await workspace.OpenSolutionAsync(projectOrSolutionFile)
76-
: (await workspace.OpenProjectAsync(projectOrSolutionFile)).Solution;
77-
78-
var errorString = await GetCompilationErrorsAsync(solution.Projects);
79-
if (string.IsNullOrEmpty(errorString)) return solution;
80-
errorString = " " + errorString.Replace(Environment.NewLine, Environment.NewLine + " ");
81-
progress.Report($"Compilation errors found before conversion.:{Environment.NewLine}{errorString}");
82-
83-
bool wrongFramework = new[] { "Type 'System.Void' is not defined", "is missing from assembly" }.Any(errorString.Contains);
84-
if (_bestEffortConversion) {
85-
progress.Report("Attempting best effort conversion on broken input due to override");
86-
} else if (wrongFramework && _isNetCore) {
87-
throw CreateException($"Compiling with dotnet core caused compilation errors, install VS2019+ or use the option `{CodeConvProgram.CoreOptionDefinition} false` to force attempted conversion with older versions (not recommended)", errorString);
88-
} else if (wrongFramework && !_isNetCore) {
89-
throw CreateException($"Compiling with .NET Framework MSBuild caused compilation errors, use the {CodeConvProgram.CoreOptionDefinition} true option if this is a .NET core only solution", errorString);
90-
} else {
91-
throw CreateException("Fix compilation errors before conversion for an accurate conversion, or as a last resort, use the best effort conversion option", errorString);
92-
}
93-
return solution;
94-
95-
ValidationException CreateException(string mainMessage, string fullDetail) {
96-
return new ValidationException($"{mainMessage}:{Environment.NewLine}{fullDetail}{Environment.NewLine}{mainMessage}");
97-
}
62+
var analyzer = new SolutionAnalyzer();
63+
return await analyzer.AnalyzeSolutionAsync(solutionPath, configuration);
9864
}
9965

100-
private async Task<string> GetCompilationErrorsAsync(
101-
IEnumerable<Project> projectsToConvert)
66+
/// <summary>
67+
/// A container for the results of a full solution analysis.
68+
/// </summary>
69+
public class SolutionAnalysisResult
10270
{
103-
var workspaceErrors = (await _workspace.GetValueAsync()).Diagnostics.GetErrorString();
104-
var errors = await projectsToConvert.ParallelSelectAwaitAsync(async x => {
105-
var c = await x.GetCompilationAsync() ?? throw new InvalidOperationException($"Compilation could not be created for {x.Language}");
106-
return new[] { CompilationWarnings.WarningsForCompilation(c, c.AssemblyName) };
107-
}, Env.MaxDop).ToArrayAsync();
108-
var errorString = string.Join("\r\n", workspaceErrors.Yield().Concat(errors.SelectMany(w => w)).Where(w => w != null));
109-
return errorString;
71+
public Solution Solution { get; set; } = null!;
72+
public List<ProjectAnalysisResult> ProjectResults { get; set; } = new List<ProjectAnalysisResult>();
73+
public IEnumerable<Diagnostic> AllDiagnostics => ProjectResults.SelectMany(p => p.Diagnostics);
11074
}
11175

112-
private static async Task RestorePackagesForSolutionAsync(string solutionFile)
76+
/// <summary>
77+
/// A container for the results of a single project analysis.
78+
/// </summary>
79+
public class ProjectAnalysisResult
11380
{
114-
var restoreExitCode = await ProcessRunner.ConnectConsoleGetExitCodeAsync(DotNetExe.FullPathOrDefault(), "restore", solutionFile);
115-
if (restoreExitCode != 0) throw new ValidationException("dotnet restore had a non-zero exit code.");
81+
public Project Project { get; set; } = null!;
82+
public IReadOnlyList<Diagnostic> Diagnostics { get; set; } = Array.Empty<Diagnostic>();
11683
}
11784

118-
private async Task<MSBuildWorkspace> CreateWorkspaceAsync(Dictionary<string, string> buildProps)
85+
private class SolutionAnalyzer
11986
{
120-
var hostServices = await ThreadSafeWorkspaceHelper.CreateHostServicesAsync(MSBuildMefHostServices.DefaultAssemblies);
121-
return MSBuildWorkspace.Create(buildProps, hostServices);
122-
}
87+
private readonly List<Diagnostic> _loadDiagnostics = new();
88+
89+
public async Task<SolutionAnalysisResult> AnalyzeSolutionAsync(string solutionPath, string configuration = "Debug")
90+
{
91+
// === PREREQUISITE: Run 'dotnet restore' ===
92+
await RunDotnetRestoreAsync(solutionPath);
93+
94+
_loadDiagnostics.Clear();
95+
96+
// === STEP 1: Create and Configure Workspace ===
97+
var properties = new Dictionary<string, string>
98+
{
99+
{ "Configuration", configuration },
100+
{ "RunAnalyzers", "true" },
101+
{ "RunAnalyzersDuringBuild", "true" }
102+
};
103+
104+
using var workspace = MSBuildWorkspace.Create(properties);
105+
workspace.WorkspaceFailed += HandleWorkspaceFailure;
106+
107+
Solution solution;
108+
try
109+
{
110+
solution = await workspace.OpenSolutionAsync(solutionPath);
111+
}
112+
finally
113+
{
114+
workspace.WorkspaceFailed -= HandleWorkspaceFailure;
115+
}
116+
117+
// === STEP 2: Analyze Each Project ===
118+
var projectResults = new List<ProjectAnalysisResult>();
119+
foreach (var project in solution.Projects)
120+
{
121+
var projectResult = await AnalyzeProjectAsync(project);
122+
projectResults.Add(projectResult);
123+
}
124+
125+
// Include load diagnostics in the first project or create a dummy entry
126+
if (projectResults.Count > 0 && _loadDiagnostics.Any())
127+
{
128+
var firstProject = projectResults[0];
129+
projectResults[0] = new ProjectAnalysisResult
130+
{
131+
Project = firstProject.Project,
132+
Diagnostics = _loadDiagnostics.Concat(firstProject.Diagnostics).ToList()
133+
};
134+
}
135+
136+
return new SolutionAnalysisResult
137+
{
138+
Solution = solution,
139+
ProjectResults = projectResults
140+
};
141+
}
123142

124-
public void Dispose()
125-
{
126-
if (_workspace.IsValueCreated) _workspace.GetValueAsync().Dispose();
143+
private async Task<ProjectAnalysisResult> AnalyzeProjectAsync(Project project)
144+
{
145+
Compilation? compilation = await project.GetCompilationAsync();
146+
if (compilation is null)
147+
{
148+
return new ProjectAnalysisResult
149+
{
150+
Project = project,
151+
Diagnostics = new List<Diagnostic>()
152+
};
153+
}
154+
155+
ImmutableArray<Diagnostic> compileDiagnostics = compilation.GetDiagnostics();
156+
157+
var analyzers = project.AnalyzerReferences
158+
.SelectMany(r => r.GetAnalyzersForAllLanguages())
159+
.ToImmutableArray();
160+
161+
ImmutableArray<Diagnostic> analyzerDiagnostics = ImmutableArray<Diagnostic>.Empty;
162+
if (!analyzers.IsEmpty)
163+
{
164+
var compWithAnalyzers = compilation.WithAnalyzers(analyzers);
165+
analyzerDiagnostics = await compWithAnalyzers.GetAllDiagnosticsAsync();
166+
}
167+
168+
var allDiagnostics = compileDiagnostics
169+
.Concat(analyzerDiagnostics)
170+
.ToList();
171+
172+
return new ProjectAnalysisResult
173+
{
174+
Project = project,
175+
Diagnostics = allDiagnostics
176+
};
177+
}
178+
179+
private void HandleWorkspaceFailure(object? sender, WorkspaceDiagnosticEventArgs e)
180+
{
181+
if (e.Diagnostic.Kind == WorkspaceDiagnosticKind.Failure &&
182+
!e.Diagnostic.Message.Contains("SDK Resolver Failure") &&
183+
!e.Diagnostic.Message.Contains(".NETFramework,Version=v4.8"))
184+
{
185+
var diagnostic = Diagnostic.Create(
186+
id: e.Diagnostic.Kind.ToString(),
187+
category: "Workspace",
188+
message: e.Diagnostic.Message,
189+
severity: DiagnosticSeverity.Error,
190+
defaultSeverity: DiagnosticSeverity.Error,
191+
isEnabledByDefault: true,
192+
warningLevel: 0);
193+
_loadDiagnostics.Add(diagnostic);
194+
}
195+
else if (e.Diagnostic.Kind == WorkspaceDiagnosticKind.Warning)
196+
{
197+
var diagnostic = Diagnostic.Create(
198+
id: e.Diagnostic.Kind.ToString(),
199+
category: "Workspace",
200+
message: e.Diagnostic.Message,
201+
severity: DiagnosticSeverity.Warning,
202+
defaultSeverity: DiagnosticSeverity.Warning,
203+
isEnabledByDefault: true,
204+
warningLevel: 1);
205+
_loadDiagnostics.Add(diagnostic);
206+
}
207+
}
208+
209+
private async Task RunDotnetRestoreAsync(string path)
210+
{
211+
var processStartInfo = new ProcessStartInfo
212+
{
213+
FileName = "dotnet",
214+
Arguments = $"restore \"{path}\"",
215+
RedirectStandardOutput = true,
216+
RedirectStandardError = true,
217+
UseShellExecute = false,
218+
CreateNoWindow = true
219+
};
220+
221+
using var process = new Process { StartInfo = processStartInfo };
222+
var output = new StringBuilder();
223+
var error = new StringBuilder();
224+
process.OutputDataReceived += (sender, args) => { if (args.Data != null) output.AppendLine(args.Data); };
225+
process.ErrorDataReceived += (sender, args) => { if (args.Data != null) error.AppendLine(args.Data); };
226+
process.Start();
227+
process.BeginOutputReadLine();
228+
process.BeginErrorReadLine();
229+
await process.WaitForExitAsync();
230+
if (process.ExitCode != 0)
231+
{
232+
throw new InvalidOperationException(
233+
$"dotnet restore failed with exit code {process.ExitCode}.\n" +
234+
$"Error: {error}");
235+
}
236+
}
127237
}
128238
}

Tests/TestRunners/MultiFileTestFixture.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public sealed class MultiFileTestFixture : ICollectionFixture<MultiFileTestFixtu
4141
private static readonly string MultiFileCharacterizationDir = Path.Combine(TestConstants.GetTestDataDirectory(), "MultiFileCharacterization");
4242
private static readonly string OriginalSolutionDir = Path.Combine(MultiFileCharacterizationDir, "SourceFiles");
4343
private static readonly string SolutionFile = Path.Combine(OriginalSolutionDir, "CharacterizationTestSolution.sln");
44-
private static readonly MSBuildWorkspaceConverter _msBuildWorkspaceConverter = new(SolutionFile, false, JoinableTaskFactorySingleton.EnsureInitialized());
44+
private static readonly MsBuildWorkspaceConverter _msBuildWorkspaceConverter = new(SolutionFile, false, JoinableTaskFactorySingleton.EnsureInitialized());
4545

4646
public async Task ConvertProjectsWhereAsync(Func<Project, bool> shouldConvertProject, Language targetLanguage, [CallerMemberName] string expectedResultsDirectory = "")
4747
{

0 commit comments

Comments
 (0)