When debugging or logging, you often need to know where a method is defined in your source code. While .NET provides caller information attributes like [CallerMemberName], [CallerFilePath], and [CallerLineNumber], these only work at compile-time and require you to add parameters to your methods. What if you want to retrieve source file information for any method at runtime?
In this post, I'll show you how to use Portable PDBs to retrieve the source file path and line number for any method.
The simplest way to capture source location information is using caller information attributes. These attributes are filled by the compiler at compile-time:
C#
void Log(string message,
[CallerMemberName] string memberName = "",
[CallerFilePath] string filePath = "",
[CallerLineNumber] int lineNumber = 0)
{
Console.WriteLine($"{filePath}:{lineNumber} ({memberName}): {message}");
}
// Usage: the attributes are automatically filled by the compiler
void DoWork()
{
Log("Starting work"); // Outputs: Program.cs:10 (DoWork): Starting work
}
This approach works great for logging scenarios, but has limitations:
- Only works at the call site
- Requires modifying method signatures
- Can't retrieve location information for arbitrary methods at runtime
#What's a PDB File?
A Program Database (PDB) file contains debugging information that maps compiled code back to your source code. It includes:
- Source file paths
- Line numbers for each instruction
- Local variable names
- Method names and signatures
There are two types of PDB files:
- Windows PDB: The traditional format, only supported on Windows
- Portable PDB: A cross-platform format that works on Windows, Linux, and macOS
Portable PDBs are smaller, can be embedded in assemblies, and are the recommended format for modern .NET applications.
#Configuring Your Build for Portable PDBs
By default, .NET projects generate Portable PDBs. However, you may want to control how they're distributed:
Separate PDB file (default):
XML
<PropertyGroup>
<DebugType>portable</DebugType>
</PropertyGroup>
The PDB is generated as a separate .pdb file next to your DLL.
Embedded PDB:
XML
<PropertyGroup>
<DebugType>embedded</DebugType>
</PropertyGroup>
The PDB is embedded directly into the assembly, making deployment simpler but increasing file size.
#Retrieving Method Source Locations at Runtime
Now let's look at how to extract source location information from Portable PDBs at runtime. The code uses the System.Reflection.Metadata and System.Reflection.PortableExecutable APIs to read PDB information.
First, add the required NuGet package:
XML
<PackageReference Include="System.Reflection.Metadata" Version="9.*" />
Here's the complete implementation:
C#
public static (string FilePath, SequencePoint SequencePoint)? GetMethodLocation(this MethodInfo methodInfo)
{
ArgumentNullException.ThrowIfNull(methodInfo);
var location = methodInfo.DeclaringType?.Assembly.Location;
if (string.IsNullOrEmpty(location))
return null;
using var fs = File.OpenRead(location);
using var reader = new PEReader(fs);
// Get the embedded PDB reader if available
var pdbReaderProvider = reader.ReadDebugDirectory()
.Where(entry => entry.Type == DebugDirectoryEntryType.EmbeddedPortablePdb)
.Select(entry => reader.ReadEmbeddedPortablePdbDebugDirectoryData(entry))
.FirstOrDefault();
try
{
if (pdbReaderProvider is null)
{
// Try to open the associated PDB file
if (!reader.TryOpenAssociatedPortablePdb(location, File.OpenRead, out pdbReaderProvider, out _))
{
pdbReaderProvider?.Dispose();
return null;
}
if (pdbReaderProvider is null)
return null;
}
var pdbReader = pdbReaderProvider.GetMetadataReader();
var methodHandle = MetadataTokens.MethodDefinitionHandle(methodInfo.MetadataToken);
var methodDebugInfo = pdbReader.GetMethodDebugInformation(methodHandle);
if (!methodDebugInfo.SequencePointsBlob.IsNil)
{
var sequencePoints = methodDebugInfo.GetSequencePoints();
var firstSequencePoint = sequencePoints.FirstOrDefault();
if (firstSequencePoint.Document.IsNil == false)
{
var document = pdbReader.GetDocument(firstSequencePoint.Document);
var filePath = pdbReader.GetString(document.Name);
return (filePath, firstSequencePoint);
}
}
return null;
}
finally
{
pdbReaderProvider?.Dispose();
}
}
#How the Code Works
Let me break down what each part does:
- Find the PDB: It first looks for an embedded PDB by checking debug directory entries. If not found, it tries to open an associated
.pdb file in the same directory. - Get method debug information: Using the method's metadata token, it retrieves the corresponding debug information from the PDB.
- Extract sequence points: Sequence points map IL instructions to source code lines. The first sequence point typically corresponds to the opening brace or first executable line of the method.
- Get the source file path: Finally, it retrieves the source file path from the document reference in the sequence point.
#Usage Example
Here's how you can use this extension method:
C#
using System.Reflection;
class Program
{
static void Main()
{
var method = typeof(Program).GetMethod(nameof(SampleMethod));
var location = method.GetMethodLocation();
if (location.HasValue)
{
Console.WriteLine($"Method: {method.Name}");
Console.WriteLine($"File: {location.Value.FilePath}");
Console.WriteLine($"Line: {location.Value.SequencePoint.StartLine}");
Console.WriteLine($"Column: {location.Value.SequencePoint.StartColumn}");
}
else
{
Console.WriteLine("Location information not available");
}
}
public static void SampleMethod()
{
Console.WriteLine("Hello, World!");
}
}
Output:
Method: SampleMethod
File: C:\Projects\MyApp\Program.cs
Line: 23
Column: 5
#Conclusion
While caller information attributes are perfect for compile-time scenarios, Portable PDBs give you an alternative method to retrieve source file locations at runtime.
Remember to handle cases where PDB information might not be available, especially in production environments where you might deliberately exclude debugging information for security or performance reasons.
Do you have a question or a suggestion about this post? Contact me!