Skip to content

Instantly share code, notes, and snippets.

@jborean93
Last active August 20, 2025 12:08
Show Gist options
  • Save jborean93/bc2e75efacd2e99b78b50976034efe91 to your computer and use it in GitHub Desktop.
Save jborean93/bc2e75efacd2e99b78b50976034efe91 to your computer and use it in GitHub Desktop.
Compiles C# code that can be debugged through a .NET Debugger
# 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