11using System ;
22using System . Collections . Generic ;
3- using System . ComponentModel . DataAnnotations ;
3+ using System . Collections . Immutable ;
4+ using System . Diagnostics ;
45using System . IO ;
56using System . Linq ;
6- using System . Runtime . CompilerServices ;
7+ using System . Text ;
78using System . Threading ;
89using 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 ;
1710using Microsoft . CodeAnalysis ;
18- using Microsoft . CodeAnalysis . Host . Mef ;
1911using Microsoft . CodeAnalysis . MSBuild ;
20- using Microsoft . VisualStudio . Threading ;
12+ using Microsoft . CodeAnalysis . Diagnostics ;
13+ using ICSharpCode . CodeConverter ;
14+ using ICSharpCode . CodeConverter . Common ;
2115
2216namespace 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}
0 commit comments