Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/DotnetInspector.Metadata/PdbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,23 @@ public IEnumerable<SourceDocument> EnumerateSourceDocuments()
public string? ExtractRepositoryUrl()
=> _resolver?.ExtractRepositoryUrl();

/// <summary>
/// Resolves source file and line number from a method token and IL offset.
/// Works even without SourceLink (returns file path + line, no URL).
/// </summary>
public SourceLinkResolver.ILOffsetSourceInfo? ResolveByILOffset(int methodToken, int ilOffset)
{
if (_pdbReader == null || !_peReader.HasMetadata)
return null;

var metadataReader = _peReader.GetMetadataReader();

if (_resolver != null)
return _resolver.ResolveByILOffset(metadataReader, _pdbReader, methodToken, ilOffset);

return SourceLinkResolver.ResolveByILOffsetDirect(metadataReader, _pdbReader, methodToken, ilOffset);
}

/// <summary>
/// Gets the SourceLinkResolver for batch operations (e.g. resolving multiple types).
/// Returns null if no PDB/SourceLink is available.
Expand Down
71 changes: 71 additions & 0 deletions src/DotnetInspector.Metadata/SourceLinkResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,77 @@ private static PartialSourceFile SelectPrimarySourceFile(List<PartialSourceFile>
}
}

/// <summary>
/// Source location resolved from a method token and IL offset.
/// </summary>
public record ILOffsetSourceInfo(
string? MethodName,
string FilePath,
string? SourceUrl,
int Line,
int MatchedOffset,
string? GitHubBrowseUrl);

/// <summary>
/// Resolves source file and line number from a method token and IL offset
/// by walking PDB sequence points. Applies SourceLink URL mapping when available.
/// </summary>
public ILOffsetSourceInfo? ResolveByILOffset(MetadataReader metadata, MetadataReader pdb, int methodToken, int ilOffset)
{
if (ResolveByILOffsetDirect(metadata, pdb, methodToken, ilOffset) is not { } info)
return null;

string? sourceUrl = ApplySourceLinkMapping(info.FilePath);
return info with { SourceUrl = sourceUrl, GitHubBrowseUrl = ConvertToGitHubBrowseUrl(sourceUrl) };
}

/// <summary>
/// Resolves source file and line number from a method token and IL offset by walking
/// PDB sequence points, returning the last visible point at or before the requested offset.
/// Uses only the PDB reader (no SourceLink URL mapping); used when no resolver is available.
/// </summary>
public static ILOffsetSourceInfo? ResolveByILOffsetDirect(MetadataReader metadata, MetadataReader pdb, int methodToken, int ilOffset)
{
try
{
var handle = MetadataTokens.Handle(methodToken);
if (handle.Kind != HandleKind.MethodDefinition)
return null;

var methodDefHandle = (MethodDefinitionHandle)handle;

var methodDef = metadata.GetMethodDefinition(methodDefHandle);
var typeDef = metadata.GetTypeDefinition(methodDef.GetDeclaringType());
string methodName = $"{metadata.GetFullTypeName(typeDef)}.{metadata.GetString(methodDef.Name)}";

var debugInfo = pdb.GetMethodDebugInformation(methodDefHandle.ToDebugInformationHandle());
if (debugInfo.SequencePointsBlob.IsNil)
return null;

SequencePoint? bestPoint = null;
foreach (var sp in debugInfo.GetSequencePoints())
{
if (sp.Offset > ilOffset)
break;

if (!sp.IsHidden)
bestPoint = sp;
}

if (bestPoint is not { } point)
return null;

var document = pdb.GetDocument(point.Document);
string filePath = pdb.GetString(document.Name);

return new ILOffsetSourceInfo(methodName, filePath, null, point.StartLine, point.Offset, null);
}
catch
{
return null;
}
}

/// <summary>
/// Applies SourceLink URL pattern to convert a file path to a source URL.
/// </summary>
Expand Down
9 changes: 9 additions & 0 deletions src/DotnetInspector.Metadata/SourceLinkService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ public IReadOnlyList<SourceDocument> GetEmbeddedFiles()
return _context.ResolveMethodSource(typeName, methodName, overloadIndex, publicOnly);
}

/// <summary>
/// Resolves source file and line number from a method token and IL offset.
/// Works even without SourceLink (returns file path + line, no URL).
/// </summary>
public SourceLinkResolver.ILOffsetSourceInfo? ResolveByILOffset(int methodToken, int ilOffset)
{
return _context.ResolveByILOffset(methodToken, ilOffset);
}

/// <summary>
/// Gets the source file paths for a type, including all partial class files.
/// Uses a cached index built from PDB sequence point data.
Expand Down
43 changes: 43 additions & 0 deletions src/dotnet-inspect.Tests/ILOffsetParserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using DotnetInspector.Commands;

namespace DotnetInspector.Tests;

/// <summary>
/// Tests for the IL offset token+offset parser used by the source --il-offset option.
/// </summary>
public class ILOffsetParserTests
{
[Theory]
[InlineData("0x6000001+0x5", 0x6000001, 0x5)]
[InlineData("0x06000001+0x0005", 0x06000001, 0x0005)]
[InlineData("0x6000004+0x15", 0x6000004, 0x15)]
[InlineData("0x6000002+0x0", 0x6000002, 0x0)]
public void ValidTokenAndOffset_ParsesCorrectly(string input, int expectedToken, int expectedOffset)
{
Assert.True(SourceCommand.TryParseILOffset(input, out var token, out var offset));
Assert.Equal(expectedToken, token);
Assert.Equal(expectedOffset, offset);
}

[Theory]
[InlineData("")]
[InlineData("0x6000001")] // missing +offset
[InlineData("0x6000001-0x5")] // wrong separator
[InlineData("0x6000001:0x5")] // wrong separator
[InlineData("0x2000001+0x5")] // TypeDef table (0x02), not MethodDef (0x06)
[InlineData("0x0A000001+0x5")] // MemberRef table (0x0A), not MethodDef
[InlineData("garbage+0x5")] // non-numeric token
[InlineData("0x6000001+garbage")] // non-numeric offset
public void InvalidInput_ReturnsFalse(string input)
{
Assert.False(SourceCommand.TryParseILOffset(input, out _, out _));
}

[Fact]
public void ZeroILOffset_IsValid()
{
Assert.True(SourceCommand.TryParseILOffset("0x6000001+0x0", out var token, out var offset));
Assert.Equal(0x6000001, token);
Assert.Equal(0, offset);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public static Command CreateSourceCommand(SharedOptions opts)
var auditOption = new Option<bool>("--audit") { Description = "Full source audit: verify all PDB source files are accessible" };
var browsableUrlsOption = new Option<bool>("--browsable-urls") { Description = "Use /blob/ URLs for browser viewing (default: /raw/ for LLM consumption)" };
var catOption = new Option<bool>("--cat") { Description = "Print source file contents to stdout" };
var ilOffsetOption = new Option<string?>("--il-offset") { Description = "Method token+IL offset to resolve (e.g., 0x6000001+0x5)" };
var compactOption = new Option<bool>("--compact") { Description = "Output as minified JSON (use with --json)" };
var oneLineOption = new Option<bool>("--oneline") { Description = "One result per line, columnar output" };
var noHeaderOption = new Option<bool>("--no-header") { Description = "Suppress column headers (use with --oneline)" };
Expand All @@ -55,6 +56,7 @@ public static Command CreateSourceCommand(SharedOptions opts)
sourceCommand.Options.Add(auditOption);
sourceCommand.Options.Add(browsableUrlsOption);
sourceCommand.Options.Add(catOption);
sourceCommand.Options.Add(ilOffsetOption);
sourceCommand.Options.Add(opts.Json);
sourceCommand.Options.Add(compactOption);
sourceCommand.Options.Add(oneLineOption);
Expand All @@ -68,6 +70,7 @@ public static Command CreateSourceCommand(SharedOptions opts)
var commandArgs = new SourceOptionsParser.SourceCommandArgs(
argsArg, packageOption, assemblyOption, platformOption, frameworkOption, tfmOption,
allOption, memberOption, typeFilterOption, verifyOption, auditOption, browsableUrlsOption, catOption,
ilOffsetOption,
compactOption, oneLineOption, noHeaderOption);

sourceCommand.SetAction(async (parseResult, ct) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public record SourceCommandArgs(
Option<bool> AuditOption,
Option<bool> BrowsableUrlsOption,
Option<bool> CatOption,
Option<string?> ILOffsetOption,
Option<bool> CompactOption,
Option<bool> OneLineOption,
Option<bool> NoHeaderOption);
Expand Down Expand Up @@ -74,11 +75,12 @@ public static async Task<SourceParseResult> ParseAsync(
var explicitPackage = parseResult.GetValue(args.PackageOption);
var explicitAssembly = parseResult.GetValue(args.AssemblyOption);
var explicitPlatform = parseResult.GetValue(args.PlatformOption);
var ilOffset = parseResult.GetValue(args.ILOffsetOption);
bool isLibrarySelector = SourceResolver.IsLibrarySelector(explicitAssembly, explicitPackage);
bool hasExplicitSource = SourceResolver.HasExplicitSource(explicitPackage, explicitAssembly, explicitPlatform, isLibrarySelector);

// Handle projection discovery or help
if (argsValue.Length == 0 && !hasExplicitSource)
if (argsValue.Length == 0 && !hasExplicitSource && ilOffset == null)
{
if (opts.IsDiscoveryMode(parseResult))
return new Discovery(opts.ParseDiscover(parseResult), opts.ParseTree(parseResult));
Expand Down Expand Up @@ -148,6 +150,7 @@ public static async Task<SourceParseResult> ParseAsync(
TypeName = source.TypeName,
MemberName = memberName,
OverloadIndex = overloadIndex,
ILOffset = ilOffset,
PackagePath = source.PackagePath,
AssemblyPath = source.AssemblyPath,
PlatformAssembly = source.PlatformAssembly,
Expand Down
Loading
Loading