Last active
August 20, 2025 12:08
-
-
Save jborean93/bc2e75efacd2e99b78b50976034efe91 to your computer and use it in GitHub Desktop.
Compiles C# code that can be debugged through a .NET Debugger
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Copyright: (c) 2025, Jordan Borean (@jborean93) <[email protected]> | |
# MIT License (see LICENSE or https://opensource.org/licenses/MIT) | |
using namespace Microsoft.CodeAnalysis | |
using namespace Microsoft.CodeAnalysis.CSharp | |
using namespace Microsoft.CodeAnalysis.Emit | |
using namespace Microsoft.CodeAnalysis.Text | |
using namespace Microsoft.PowerShell.Commands | |
using namespace System.Collections.Generic | |
using namespace System.IO | |
using namespace System.Management.Automation | |
using namespace System.Reflection | |
using namespace System.Runtime.Loader | |
using namespace System.Text | |
#Requires -Version 7.4 | |
Function Add-DebuggableType { | |
<# | |
DO NOT USE THIS ANYMORE | |
Instead just do | |
Add-Type -Path $pathToCSFile -CompilerOptions '-debug:embedded' | |
When combined with the launch configuration below these options include | |
the debug symbols needed for the .NET debugger to just debug the code. | |
{ | |
"name": "PowerShell Launch Script", | |
"type": "PowerShell", | |
"request": "launch", | |
"script": "${workspaceFolder}/MyScript.ps1", | |
"attachDotnetDebugger": true, | |
"createTemporaryIntegratedConsole": true | |
} | |
.SYNOPSIS | |
Compiles C# code with debug symbols and loads it into the current process. | |
.DESCRIPTION | |
Compiles C# code like Add-Type but also with the debug symbols present that | |
allows it to be debugged using a .NET debugger. | |
This only works on PowerShell 7 and cannot be used on Windows PowerShell | |
5.1 due to restrictions on the compiler and debug tools available for | |
.NET Framework. | |
To test this out with C# make sure to launch the PowerShell debugger with | |
the following JSON launch configuration: | |
{ | |
"name": "PowerShell Launch Script", | |
"type": "PowerShell", | |
"request": "launch", | |
"script": "${workspaceFolder}/MyScript.ps1", | |
"attachDotnetDebugger": true, | |
"createTemporaryIntegratedConsole": true | |
} | |
The attachDotnetDebugger will attach itself to the PowerShell process and | |
automatically pick up the loaded assembly when it is loaded in the process. | |
.PARAMETER Path | |
The path(s) to the C# source code files to compile. This accepts wildcard | |
characters. | |
.PARAMETER LiteralPath | |
The literal path(s) to the C# source code files to compile. This does not | |
accept wildcard characters. | |
.PARAMETER AssemblyName | |
Override the name of the assembly being built. By default it will use the | |
name of the file being compiled or a random set of characters if multiple | |
files are being compiled. | |
.PARAMETER DllPath | |
If set, the compiled assembly will be stored as a .dll at this path. | |
Otherwise the dll is compiled in memory. | |
.PARAMETER PdbPath | |
If set, the PDB will be created at this location. Otherwise the PDB is | |
embedded in the assembly. | |
.PARAMETER PassThru | |
If specified, the cmdlet will return the loaded assembly. | |
.EXAMPLE | |
Add-DebuggableType -Path MyCode.cs | |
# Waits for the .NET debugger to attach to the | |
# process | |
while (-not ([System.Diagnostics.Debugger]::IsAttached)) { | |
Start-Sleep -Milliseconds 200 | |
} | |
# Calls the method compiled from MyCode.cs | |
[MyNamespace.MyClass]::Method("args") | |
This example will compile the code in the file MyCode.cs, wait for the .NET | |
debugger to attach to the process before calling a static method from that | |
compiled code. | |
.NOTES | |
A separate file for the C# code must be used in order for debug clients | |
like VSCode to set breakpoints properly. | |
#> | |
[OutputType([Assembly])] | |
[CmdletBinding(DefaultParameterSetName = 'Path')] | |
param ( | |
[Parameter( | |
Mandatory = $true, | |
Position = 0, | |
ValueFromPipeline = $true, | |
ValueFromPipelineByPropertyName = $true, | |
ParameterSetName = "Path" | |
)] | |
[SupportsWildcards()] | |
[ValidateNotNullOrEmpty()] | |
[String[]] | |
$Path, | |
[Parameter( | |
Mandatory = $true, | |
Position = 0, | |
ValueFromPipelineByPropertyName = $true, | |
ParameterSetName = "LiteralPath" | |
)] | |
[Alias('PSPath')] | |
[ValidateNotNullOrEmpty()] | |
[String[]] | |
$LiteralPath, | |
[Parameter()] | |
[string] | |
$AssemblyName, | |
[Parameter()] | |
[string] | |
$DllPath, | |
[Parameter()] | |
[string] | |
$PdbPath, | |
[Parameter()] | |
[switch] | |
$PassThru | |
) | |
begin { | |
$assemblies = [HashSet[MetadataReference]]@( | |
[CompilationReference]::CreateFromFile([PSObject].Assembly.Location)) | |
$pwshRefDir = [Path]::Combine( | |
[Path]::GetDirectoryName([PSObject].Assembly.Location), | |
"ref") | |
foreach ($file in [Directory]::EnumerateFiles($pwshRefDir, "*.dll", [SearchOption]::TopDirectoryOnly)) { | |
$null = $assemblies.Add([MetadataReference]::CreateFromFile($file)) | |
} | |
$parseOptions = [CSharpParseOptions]::Default | |
$compilerOptions = [CSharpCompilationOptions]::new( | |
[OutputKind]::DynamicallyLinkedLibrary). | |
WithOptimizationLevel([OptimizationLevel]::Debug) | |
$syntaxTrees = [List[SyntaxTree]]::new() | |
$firstPath = $null | |
$multiplePaths = $false | |
} | |
process { | |
if ($PSCmdlet.ParameterSetName -eq 'Path') { | |
$allPaths = $Path | ForEach-Object -Process { | |
$provider = $null | |
try { | |
$fullPath = $PSCmdlet.SessionState.Path.GetResolvedProviderPathFromPSPath($_, [ref]$provider) | |
[PSCustomObject]@{ | |
Path = $fullPath | |
Provider = $provider | |
} | |
} | |
catch [ItemNotFoundException] { | |
$PSCmdlet.WriteError($_) | |
} | |
} | |
} | |
elseif ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { | |
$allPaths = $LiteralPath | ForEach-Object -Process { | |
$provider = $null | |
$resolvedPath = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($_, [ref]$provider, [ref]$null) | |
if (-not (Test-Path -LiteralPath $resolvedPath)) { | |
$msg = "Cannot find path '$resolvedPath' because it does not exist" | |
$err = [ErrorRecord]::new( | |
[ItemNotFoundException]::new($msg), | |
"PathNotFound", | |
"ObjectNotFound", | |
$resolvedPath) | |
$PSCmdlet.WriteError($err) | |
return | |
} | |
[PSCustomObject]@{ | |
Path = $resolvedPath | |
Provider = $provider | |
} | |
} | |
} | |
foreach ($pathInfo in $allPaths) { | |
$filePath = $pathInfo.Path | |
$pathProvider = $pathInfo.Provider | |
if ($pathProvider.ImplementingType -ne [FileSystemProvider]) | |
{ | |
$err = [ErrorRecord]::new( | |
[ArgumentException]::new("The resolved path '$filePath' is not a FileSystem path but $($pathProvider.Name)"), | |
"PathNotFileSystem", | |
[ErrorCategory]::InvalidArgument, | |
$filePath) | |
$PSCmdlet.WriteError($err) | |
continue | |
} | |
if ($firstPath) { | |
# We have already seen a path, so this is a second or subsequent path. | |
$multiplePaths = $true | |
} | |
else { | |
$firstPath = $filePath | |
} | |
$codeFS = [File]::OpenRead($filePath) | |
try { | |
# Do an initial read with StreamReader to determine the encoding. This | |
# handles files with BOMs and defaults to UTF-8 if no BOM is present. | |
$reader = [StreamReader]::new($codeFS, $true) | |
$null = $reader.Read() | |
# Reset back to the beginning before loading source text. | |
$null = $codeFS.Seek(0, [SeekOrigin]::Begin) | |
$sourceText = [SourceText]::From( | |
$codeFS, | |
$reader.CurrentEncoding, | |
[SourceHashAlgorithm]::SHA256) | |
$codeSyntaxTree = [CSharpSyntaxTree]::ParseText( | |
$sourceText, | |
$parseOptions, | |
$filePath) | |
$syntaxTrees.Add($codeSyntaxTree) | |
} | |
catch { | |
$PSCmdlet.WriteError($_) | |
} | |
finally { | |
$codeFS.Dispose() | |
} | |
} | |
} | |
end { | |
if ($syntaxTrees.Count -eq 0) { | |
return | |
} | |
if (-not $AssemblyName) { | |
# If no explicit assembly was provided use the filename if only one | |
# path was given, otherwise generate a random name. | |
if ($multiplePaths) { | |
$AssemblyName = [Path]::GetRandomFileName() | |
} | |
else { | |
$AssemblyName = [Path]::GetFileNameWithoutExtension($firstPath) | |
} | |
} | |
$compilation = [CSharpCompilation]::Create( | |
$AssemblyName, | |
$syntaxTrees, | |
$assemblies, | |
$compilerOptions) | |
$codeStream = $pdbStream = $null | |
try { | |
$emitOptions = [EmitOptions]::new() | |
if ($DllPath) { | |
$codeStream = [File]::Open( | |
$DllPath, | |
[FileMode]::Create, | |
[FileAccess]::ReadWrite, | |
[FileShare]::Read) | |
} | |
else { | |
$codeStream = [MemoryStream]::new() | |
} | |
if ($PdbPath) { | |
$emitOptions = $emitOptions. | |
WithDebugInformationFormat([DebugInformationFormat]::PortablePdb). | |
WithPdbFilePath($PdbPath) | |
$pdbStream = [File]::Open( | |
$PdbPath, | |
[FileMode]::Create, | |
[FileAccess]::ReadWrite, | |
[FileShare]::Read) | |
} | |
else { | |
$emitOptions = $emitOptions.WithDebugInformationFormat([DebugInformationFormat]::Embedded) | |
} | |
$emitResult = $compilation.Emit( | |
$codeStream, | |
$pdbStream, | |
$null, | |
$null, | |
$null, | |
$emitOptions) | |
if (-not $emitResult.Success) { | |
foreach ($e in $emitResult.Diagnostics) { | |
$PSCmdlet.WriteError([ErrorRecord]::new( | |
[Exception]::new($e.ToString()), | |
"CompilationError", | |
[ErrorCategory]::NotSpecified, | |
$null)) | |
} | |
return | |
} | |
$compiledAssembly = if ($DllPath) { | |
$codeStream.Dispose() | |
$codeStream = $null | |
[AssemblyLoadContext]::Default.LoadFromAssemblyPath($DllPath) | |
} | |
else { | |
$null = $codeStream.Seek(0, [SeekOrigin]::Begin) | |
$null = ${pdbStream}?.Seek(0, [SeekOrigin]::Begin) | |
[AssemblyLoadContext]::Default.LoadFromStream( | |
$codeStream, | |
$pdbStream) | |
} | |
if ($PassThru) { | |
$compiledAssembly | |
} | |
} | |
finally { | |
${codeStream}?.Dispose() | |
${pdbStream}?.Dispose() | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment