diff --git a/src/DotnetInspector.Metadata/PdbContext.cs b/src/DotnetInspector.Metadata/PdbContext.cs index 605414b8..6f1780c6 100644 --- a/src/DotnetInspector.Metadata/PdbContext.cs +++ b/src/DotnetInspector.Metadata/PdbContext.cs @@ -321,6 +321,23 @@ public IEnumerable EnumerateSourceDocuments() public string? ExtractRepositoryUrl() => _resolver?.ExtractRepositoryUrl(); + /// + /// Resolves source file and line number from a method token and IL offset. + /// Works even without SourceLink (returns file path + line, no URL). + /// + 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); + } + /// /// Gets the SourceLinkResolver for batch operations (e.g. resolving multiple types). /// Returns null if no PDB/SourceLink is available. diff --git a/src/DotnetInspector.Metadata/SourceLinkResolver.cs b/src/DotnetInspector.Metadata/SourceLinkResolver.cs index 3ef69690..84256188 100644 --- a/src/DotnetInspector.Metadata/SourceLinkResolver.cs +++ b/src/DotnetInspector.Metadata/SourceLinkResolver.cs @@ -363,6 +363,77 @@ private static PartialSourceFile SelectPrimarySourceFile(List } } + /// + /// Source location resolved from a method token and IL offset. + /// + public record ILOffsetSourceInfo( + string? MethodName, + string FilePath, + string? SourceUrl, + int Line, + int MatchedOffset, + string? GitHubBrowseUrl); + + /// + /// Resolves source file and line number from a method token and IL offset + /// by walking PDB sequence points. Applies SourceLink URL mapping when available. + /// + 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) }; + } + + /// + /// 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. + /// + 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; + } + } + /// /// Applies SourceLink URL pattern to convert a file path to a source URL. /// diff --git a/src/DotnetInspector.Metadata/SourceLinkService.cs b/src/DotnetInspector.Metadata/SourceLinkService.cs index c142a446..518de19d 100644 --- a/src/DotnetInspector.Metadata/SourceLinkService.cs +++ b/src/DotnetInspector.Metadata/SourceLinkService.cs @@ -132,6 +132,15 @@ public IReadOnlyList GetEmbeddedFiles() return _context.ResolveMethodSource(typeName, methodName, overloadIndex, publicOnly); } + /// + /// Resolves source file and line number from a method token and IL offset. + /// Works even without SourceLink (returns file path + line, no URL). + /// + public SourceLinkResolver.ILOffsetSourceInfo? ResolveByILOffset(int methodToken, int ilOffset) + { + return _context.ResolveByILOffset(methodToken, ilOffset); + } + /// /// Gets the source file paths for a type, including all partial class files. /// Uses a cached index built from PDB sequence point data. diff --git a/src/dotnet-inspect.Tests/ILOffsetParserTests.cs b/src/dotnet-inspect.Tests/ILOffsetParserTests.cs new file mode 100644 index 00000000..f10f20c8 --- /dev/null +++ b/src/dotnet-inspect.Tests/ILOffsetParserTests.cs @@ -0,0 +1,43 @@ +using DotnetInspector.Commands; + +namespace DotnetInspector.Tests; + +/// +/// Tests for the IL offset token+offset parser used by the source --il-offset option. +/// +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); + } +} diff --git a/src/dotnet-inspect/CommandLine/Commands/SourceCommandDefinitions.cs b/src/dotnet-inspect/CommandLine/Commands/SourceCommandDefinitions.cs index f4ace514..9ec47c98 100644 --- a/src/dotnet-inspect/CommandLine/Commands/SourceCommandDefinitions.cs +++ b/src/dotnet-inspect/CommandLine/Commands/SourceCommandDefinitions.cs @@ -37,6 +37,7 @@ public static Command CreateSourceCommand(SharedOptions opts) var auditOption = new Option("--audit") { Description = "Full source audit: verify all PDB source files are accessible" }; var browsableUrlsOption = new Option("--browsable-urls") { Description = "Use /blob/ URLs for browser viewing (default: /raw/ for LLM consumption)" }; var catOption = new Option("--cat") { Description = "Print source file contents to stdout" }; + var ilOffsetOption = new Option("--il-offset") { Description = "Method token+IL offset to resolve (e.g., 0x6000001+0x5)" }; var compactOption = new Option("--compact") { Description = "Output as minified JSON (use with --json)" }; var oneLineOption = new Option("--oneline") { Description = "One result per line, columnar output" }; var noHeaderOption = new Option("--no-header") { Description = "Suppress column headers (use with --oneline)" }; @@ -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); @@ -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) => diff --git a/src/dotnet-inspect/CommandLine/Parsers/SourceOptionsParser.cs b/src/dotnet-inspect/CommandLine/Parsers/SourceOptionsParser.cs index 6bea959b..3b359a00 100644 --- a/src/dotnet-inspect/CommandLine/Parsers/SourceOptionsParser.cs +++ b/src/dotnet-inspect/CommandLine/Parsers/SourceOptionsParser.cs @@ -28,6 +28,7 @@ public record SourceCommandArgs( Option AuditOption, Option BrowsableUrlsOption, Option CatOption, + Option ILOffsetOption, Option CompactOption, Option OneLineOption, Option NoHeaderOption); @@ -74,11 +75,12 @@ public static async Task 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)); @@ -148,6 +150,7 @@ public static async Task ParseAsync( TypeName = source.TypeName, MemberName = memberName, OverloadIndex = overloadIndex, + ILOffset = ilOffset, PackagePath = source.PackagePath, AssemblyPath = source.AssemblyPath, PlatformAssembly = source.PlatformAssembly, diff --git a/src/dotnet-inspect/Commands/SourceCommand.cs b/src/dotnet-inspect/Commands/SourceCommand.cs index 56f5afec..1db46674 100644 --- a/src/dotnet-inspect/Commands/SourceCommand.cs +++ b/src/dotnet-inspect/Commands/SourceCommand.cs @@ -51,6 +51,12 @@ public static async Task ExecuteAsync(SourceOptions options) try { + // IL offset mode: resolve method token + offset directly, skip API extraction + if (!string.IsNullOrEmpty(options.ILOffset)) + { + return await ExecuteILOffsetAsync(source, options, logger); + } + // Extract all types var (api, apiDllPath) = ApiServices.ExtractFullApi(searchPath, logger, options.IncludeAll); if (api == null) @@ -548,6 +554,171 @@ await SourceEnricher.AcquirePdbAsync(implService.Context, httpClient, } } + // ===== IL Offset Mode ===== + + /// + /// Parses an IL offset string in the format "0x6000001+0x5" into method token and IL offset. + /// + internal static bool TryParseILOffset(string value, out int methodToken, out int ilOffset) + { + methodToken = 0; + ilOffset = 0; + + var plusIndex = value.IndexOf('+'); + if (plusIndex < 0) + return false; + + var tokenPart = value[..plusIndex].Trim(); + var offsetPart = value[(plusIndex + 1)..].Trim(); + + if (!TryParseHexOrDecimal(tokenPart, out methodToken)) + return false; + + if (!TryParseHexOrDecimal(offsetPart, out ilOffset)) + return false; + + // Validate the token is in the MethodDef table (table 0x06) + int table = (methodToken >> 24) & 0xFF; + if (table != 0x06) + return false; + + return ilOffset >= 0; + } + + private static bool TryParseHexOrDecimal(string value, out int result) + { + if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return int.TryParse(value[2..], System.Globalization.NumberStyles.HexNumber, null, out result); + return int.TryParse(value, out result); + } + + private static async Task ExecuteILOffsetAsync( + ApiCommand.SourceResult source, SourceOptions options, + VerboseLogger logger) + { + var dllPath = source.RuntimeAssemblyPath; + + // If no runtime assembly path, try to find a DLL in the search path + if (dllPath == null && source.SearchPath != null) + { + var (_, apiDllPath) = ApiServices.ExtractFullApi(source.SearchPath, logger, options.IncludeAll); + dllPath = apiDllPath; + } + + if (dllPath == null) + { + Console.Error.WriteLine("Error: No library found."); + return 1; + } + + if (!TryParseILOffset(options.ILOffset!, out var methodToken, out var ilOffset)) + { + Console.Error.WriteLine($"Error: Invalid --il-offset format '{options.ILOffset}'."); + Console.Error.WriteLine("Expected format: 0x6000001+0x5 (method token + IL offset)"); + return 1; + } + + using var service = SourceLinkService.Open(dllPath, logger.Log); + var pdbContext = service.Context; + + if (!pdbContext.HasMetadata) + { + Console.Error.WriteLine("Error: No metadata in library."); + return 1; + } + + await SourceEnricher.AcquirePdbAsync(pdbContext, source.Context.HttpClient, + source.PackageName, source.PackageVersion, + isPlatformAssembly: !string.IsNullOrEmpty(options.PlatformAssembly), logger.Log); + + if (!pdbContext.HasPdb) + { + WritePdbWarning(pdbContext); + return 1; + } + + if (!service.HasSourceLink) + { + logger.Log("Warning: No SourceLink information found. URLs will not be available."); + } + + var result = service.ResolveByILOffset(methodToken, ilOffset); + + if (result == null) + { + Console.Error.WriteLine($"Error: Could not resolve source location for token 0x{methodToken:X}+0x{ilOffset:X}."); + Console.Error.WriteLine("The method token may be invalid or the PDB may not contain sequence points for this method."); + return 1; + } + + // Build URL with line fragment + string? url = options.BrowsableUrls ? result.GitHubBrowseUrl : result.SourceUrl; + if (url != null) + url += $"#L{result.Line}"; + + // Output + if (options.JsonOutput) + { + var jsonResult = new ILOffsetResult + { + Method = result.MethodName, + Token = $"0x{methodToken:X}", + ILOffset = $"0x{ilOffset:X}", + MatchedOffset = $"0x{result.MatchedOffset:X}", + File = result.FilePath, + Line = result.Line, + Url = url + }; + string json = options.CompactJson + ? System.Text.Json.JsonSerializer.Serialize(jsonResult, SourceCompactJsonContext.Default.ILOffsetResult) + : System.Text.Json.JsonSerializer.Serialize(jsonResult, SourceJsonContext.Default.ILOffsetResult); + Console.WriteLine(json); + } + else + { + // Verbosity discipline (mirrors the library-info view): + // -v:q → compact inline line only + // -v:m → "Offset" section only + // -v:n+ → "Offset" + "Source" sections + bool showSections = options.Verbosity >= Verbosity.Minimal; + bool showSource = options.Verbosity >= Verbosity.Normal; + string token = $"0x{methodToken:X}"; + string ilOffsetHex = $"0x{ilOffset:X}"; + string? matchedOffset = result.MatchedOffset != ilOffset ? $"0x{result.MatchedOffset:X}" : null; + + var view = new SourceILOffsetView + { + Title = result.MethodName ?? token, + + // Compact inline fields (only at -v:q) — token + offsets only; + // source location is reserved for the "Source" section at -v:n+. + Token = showSections ? null : token, + ILOffset = showSections ? null : ilOffsetHex, + MatchedOffset = showSections ? null : matchedOffset, + + // Offset section (-v:m+) + Offset = showSections + ? new ILOffsetInfoSection { Token = token, ILOffset = ilOffsetHex, MatchedOffset = matchedOffset } + : null, + + // Source section (-v:n+) + Location = showSource ? [new ILOffsetSourceRow(result.FilePath, result.Line, url)] : null, + }; + + if (options.IsDefaultInvocation || (options.OneLine && !options.JsonOutput)) + { + // Oneline: just the URL (or file:line if no URL) + Console.WriteLine(url ?? $"{result.FilePath}:{result.Line}"); + } + else + { + WriteMarkdown(view, options); + } + } + + return 0; + } + // ===== Helpers ===== private static void WriteOneLine(T view, SourceOptions options) where T : class @@ -1009,3 +1180,14 @@ public class SampleEntry public string? Name { get; init; } public string? Url { get; init; } } + +public class ILOffsetResult +{ + public string? Method { get; init; } + public string? Token { get; init; } + public string? ILOffset { get; init; } + public string? MatchedOffset { get; init; } + public string? File { get; init; } + public int? Line { get; init; } + public string? Url { get; init; } +} diff --git a/src/dotnet-inspect/JsonContext.cs b/src/dotnet-inspect/JsonContext.cs index 59374d3a..8cc58550 100644 --- a/src/dotnet-inspect/JsonContext.cs +++ b/src/dotnet-inspect/JsonContext.cs @@ -124,6 +124,7 @@ internal partial class TypeFindResultJsonlContext : JsonSerializerContext { } DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(SourceListResult))] [JsonSerializable(typeof(SourceDetailResult))] +[JsonSerializable(typeof(ILOffsetResult))] internal partial class SourceJsonContext : JsonSerializerContext { } [JsonSourceGenerationOptions( @@ -132,6 +133,7 @@ internal partial class SourceJsonContext : JsonSerializerContext { } DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(SourceListResult))] [JsonSerializable(typeof(SourceDetailResult))] +[JsonSerializable(typeof(ILOffsetResult))] internal partial class SourceCompactJsonContext : JsonSerializerContext { } static class JsonOutputHelper diff --git a/src/dotnet-inspect/Options/SourceOptions.cs b/src/dotnet-inspect/Options/SourceOptions.cs index e6b876a8..3865464c 100644 --- a/src/dotnet-inspect/Options/SourceOptions.cs +++ b/src/dotnet-inspect/Options/SourceOptions.cs @@ -23,6 +23,12 @@ public record SourceOptions /// public int? OverloadIndex { get; init; } + /// + /// IL offset in "token+offset" format (e.g., "0x6000001+0x5"). + /// When set, resolves a method token and IL offset to a source location. + /// + public string? ILOffset { get; init; } + // Source resolution public string? PackagePath { get; init; } public string? AssemblyPath { get; init; } diff --git a/src/dotnet-inspect/Views/SourceViews.cs b/src/dotnet-inspect/Views/SourceViews.cs index cca722eb..dfba675d 100644 --- a/src/dotnet-inspect/Views/SourceViews.cs +++ b/src/dotnet-inspect/Views/SourceViews.cs @@ -162,6 +162,64 @@ public record MemberDocRow(string Member, string? Summary); [MarkoutSerializable] public record MissingFileRow(string File); +/// +/// Row in the IL offset resolution "Source" section: the resolved source location. +/// +[MarkoutSerializable] +public record ILOffsetSourceRow( + string File, + int Line, + [property: MarkoutSkipNull] string? Source); + +/// +/// Key/value section for the IL offset query: the method token and the requested/matched offsets. +/// +[MarkoutSerializable(NamingPolicy = NamingPolicy.PascalCaseWords, FieldLayout = FieldLayout.Table)] +[MarkoutSkipNull] +public class ILOffsetInfoSection +{ + public string? Token { get; init; } + + [MarkoutPropertyName("IL Offset")] + public string? ILOffset { get; init; } + + [MarkoutPropertyName("Matched Offset")] + public string? MatchedOffset { get; init; } +} + +/// +/// View model for the source command: IL offset resolution mode. +/// Mirrors the library-info view's verbosity discipline: +/// -v:q → a single compact inline line (no sections) +/// -v:m → the "Offset" section only (token + offsets) +/// -v:n+ → the "Offset" section plus the "Source" section (resolved file/line/URL) +/// At section verbosity there are no loose inline fields above the sections. +/// +[MarkoutSerializable(TitleProperty = nameof(Title), FieldLayout = FieldLayout.Inline)] +public class SourceILOffsetView +{ + [MarkoutIgnore] public string Title { get; set; } = ""; + + // Compact representation (-v:q): token + offsets rendered inline on a single line. + [MarkoutSkipNull] public string? Token { get; set; } + + [MarkoutSkipNull] + [MarkoutPropertyName("IL Offset")] + public string? ILOffset { get; set; } + + [MarkoutSkipNull] + [MarkoutPropertyName("Matched Offset")] + public string? MatchedOffset { get; set; } + + // Offset section (-v:m+): token + requested/matched offsets. + [MarkoutSection(Name = "Offset")] + public ILOffsetInfoSection? Offset { get; set; } + + // Source section (-v:n+): resolved file, line, and URL. + [MarkoutSection(Name = "Source")] + public List? Location { get; set; } +} + [MarkoutContextOptions(SuppressTableWarnings = true)] [MarkoutContext(typeof(SourceListView))] [MarkoutContext(typeof(SourceOneLineView))] @@ -173,6 +231,9 @@ public record MissingFileRow(string File); [MarkoutContext(typeof(VerifiedSourceUrlRow))] [MarkoutContext(typeof(MemberDocRow))] [MarkoutContext(typeof(MissingFileRow))] +[MarkoutContext(typeof(SourceILOffsetView))] +[MarkoutContext(typeof(ILOffsetSourceRow))] +[MarkoutContext(typeof(ILOffsetInfoSection))] public partial class SourceViewContext : MarkoutSerializerContext { } diff --git a/src/dotnet-inspect/dotnet-inspect.csproj b/src/dotnet-inspect/dotnet-inspect.csproj index 093f0949..d4cabbaa 100644 --- a/src/dotnet-inspect/dotnet-inspect.csproj +++ b/src/dotnet-inspect/dotnet-inspect.csproj @@ -21,7 +21,7 @@ Richard Lander true dotnet-inspect - 0.7.10 + 0.7.11 dotnet-inspect A CLI tool for inspecting .NET assemblies and NuGet packages MIT diff --git a/tests/DotnetInspector.Metadata.Tests/ILOffsetResolutionTests.cs b/tests/DotnetInspector.Metadata.Tests/ILOffsetResolutionTests.cs new file mode 100644 index 00000000..bafe5ded --- /dev/null +++ b/tests/DotnetInspector.Metadata.Tests/ILOffsetResolutionTests.cs @@ -0,0 +1,139 @@ +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using DotnetInspector.Metadata; + +namespace DotnetInspector.Metadata.Tests; + +/// +/// Exercises the sequence-point walk in +/// against this test assembly's own portable PDB. +/// +public sealed class ILOffsetResolutionTests +{ + // Several statements on distinct lines, so the compiler emits multiple + // sequence points at increasing IL offsets with gaps between them. + static int SampleMethod(int a, int b) + { + int x = a + b; + int y = x * 2; + int z = y - a; + return z + x; + } + + [Fact] + public void SampleMethod_Sanity() => Assert.Equal(13, SampleMethod(2, 3)); + + [Fact] + public void EachSequencePointOffset_ResolvesToItsOwnLine() + { + using var img = new SelfImage(); + var method = FindMethod(img.Metadata, nameof(SampleMethod)); + int token = MetadataTokens.GetToken(method); + var points = VisibleSequencePoints(img.Pdb, method); + Assert.True(points.Count >= 2, "fixture should emit multiple sequence points"); + + foreach (var (offset, line) in points) + { + var result = SourceLinkResolver.ResolveByILOffsetDirect(img.Metadata, img.Pdb, token, offset); + Assert.NotNull(result); + Assert.Equal(line, result!.Line); + Assert.Equal(offset, result.MatchedOffset); + Assert.EndsWith("ILOffsetResolutionTests.cs", result.FilePath); + Assert.EndsWith("." + nameof(SampleMethod), result.MethodName); + } + } + + [Fact] + public void OffsetBetweenSequencePoints_ResolvesToPrecedingPoint() + { + using var img = new SelfImage(); + var method = FindMethod(img.Metadata, nameof(SampleMethod)); + int token = MetadataTokens.GetToken(method); + var points = VisibleSequencePoints(img.Pdb, method); + + // Pick an adjacent pair with a gap, then query an offset strictly between them. + var pair = points.Zip(points.Skip(1)).First(p => p.Second.Offset - p.First.Offset >= 2); + int between = pair.First.Offset + 1; + + var result = SourceLinkResolver.ResolveByILOffsetDirect(img.Metadata, img.Pdb, token, between); + Assert.NotNull(result); + Assert.Equal(pair.First.Offset, result!.MatchedOffset); + Assert.Equal(pair.First.Line, result.Line); + } + + [Fact] + public void NonMethodDefToken_ReturnsNull() + { + using var img = new SelfImage(); + var method = FindMethod(img.Metadata, nameof(SampleMethod)); + var typeHandle = img.Metadata.GetMethodDefinition(method).GetDeclaringType(); + int typeToken = MetadataTokens.GetToken(typeHandle); // TypeDef table (0x02), not MethodDef + + Assert.Null(SourceLinkResolver.ResolveByILOffsetDirect(img.Metadata, img.Pdb, typeToken, 0)); + } + + [Fact] + public void OutOfRangeMethodToken_ReturnsNull() + { + using var img = new SelfImage(); + // MethodDef table (0x06) but a row that does not exist. + Assert.Null(SourceLinkResolver.ResolveByILOffsetDirect(img.Metadata, img.Pdb, 0x06FFFFFF, 0)); + } + + static MethodDefinitionHandle FindMethod(MetadataReader md, string methodName) + { + foreach (var handle in md.MethodDefinitions) + { + if (md.GetString(md.GetMethodDefinition(handle).Name) == methodName) + return handle; + } + throw new InvalidOperationException($"Method '{methodName}' not found in metadata."); + } + + static List<(int Offset, int Line)> VisibleSequencePoints(MetadataReader pdb, MethodDefinitionHandle method) + { + var debugInfo = pdb.GetMethodDebugInformation(method.ToDebugInformationHandle()); + var points = new List<(int, int)>(); + foreach (var sp in debugInfo.GetSequencePoints()) + { + if (!sp.IsHidden) + points.Add((sp.Offset, sp.StartLine)); + } + return points; + } + + /// Opens this test assembly's PE image and associated portable PDB (embedded or beside the DLL). + sealed class SelfImage : IDisposable + { + public PEReader Pe { get; } + public MetadataReader Metadata { get; } + public MetadataReader Pdb { get; } + readonly MetadataReaderProvider _pdbProvider; + + public SelfImage() + { + string dllPath = typeof(ILOffsetResolutionTests).Assembly.Location; + using (var peStream = File.OpenRead(dllPath)) + Pe = new PEReader(peStream, PEStreamOptions.PrefetchEntireImage); + Metadata = Pe.GetMetadataReader(); + + if (!Pe.TryOpenAssociatedPortablePdb(dllPath, + p => File.Exists(p) ? File.OpenRead(p) : null, + out var provider, out _) || provider is null) + { + Pe.Dispose(); + throw new InvalidOperationException("No portable PDB found for the test assembly."); + } + + _pdbProvider = provider; + Pdb = provider.GetMetadataReader(); + } + + public void Dispose() + { + _pdbProvider.Dispose(); + Pe.Dispose(); + } + } +}