// Copyright (c) 2026 Siegfried Pammer
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using System.Reflection.PortableExecutable;
using System.Text;
namespace ICSharpCode.Decompiler.Tests.Helpers
{
///
/// Reads the sequence points out of a portable PDB and compares two PDBs (typically the one
/// the original C# compiler produced versus the one PortablePdbWriter reconstructs)
/// at the granularity that actually matters to a debugging user: the breakpoint map.
///
///
/// The decompiler reconstructs IL ranges, hidden sequence points and local scopes from the
/// ILAst, so those never match the original compiler byte-for-byte. What it can and should
/// get right is where the visible (non-hidden) breakpoints land in the source.
/// The comparison therefore projects each PDB onto the ordered list of visible sequence-point
/// source locations per method (keyed by method-definition row, which is shared between the
/// PDB and the PE it describes) and reports the residual difference.
///
static class PdbSequencePoints
{
internal readonly record struct RawSequencePoint(int Offset, bool IsHidden,
int StartLine, int StartColumn, int EndLine, int EndColumn)
{
/// Visible source location, formatted with or without columns.
public string Format(bool includeColumns)
{
return includeColumns
? $"({StartLine},{StartColumn})-({EndLine},{EndColumn})"
: $"L{StartLine}-{EndLine}";
}
}
///
/// Reads every method's sequence points (hidden ones included, in document order) from a
/// portable PDB, keyed by the row number of the owning method definition.
///
public static Dictionary> Read(Stream portablePdb)
{
portablePdb.Position = 0;
using var provider = MetadataReaderProvider.FromPortablePdbStream(portablePdb, MetadataStreamOptions.LeaveOpen);
var reader = provider.GetMetadataReader();
var result = new Dictionary>();
foreach (var handle in reader.MethodDebugInformation)
{
var info = reader.GetMethodDebugInformation(handle);
if (info.SequencePointsBlob.IsNil)
continue;
var list = new List();
foreach (var sp in info.GetSequencePoints())
{
list.Add(new RawSequencePoint(sp.Offset, sp.IsHidden,
sp.StartLine, sp.StartColumn, sp.EndLine, sp.EndColumn));
}
if (list.Count > 0)
{
// A MethodDebugInformation row is parallel to its MethodDefinition row.
result[MetadataTokens.GetRowNumber(handle)] = list;
}
}
return result;
}
///
/// Resolves a readable "Namespace.Type.Method" label for each method-definition row,
/// for use in comparison failure messages.
///
public static Dictionary ReadMethodNames(string peFileName)
{
using var peStream = File.OpenRead(peFileName);
using var peReader = new PEReader(peStream);
var md = peReader.GetMetadataReader();
var names = new Dictionary();
foreach (var handle in md.MethodDefinitions)
{
var method = md.GetMethodDefinition(handle);
var type = md.GetTypeDefinition(method.GetDeclaringType());
string typeName = md.GetString(type.Name);
if (!type.Namespace.IsNil && md.GetString(type.Namespace) is { Length: > 0 } ns)
typeName = ns + "." + typeName;
names[MetadataTokens.GetRowNumber(handle)] = typeName + "." + md.GetString(method.Name);
}
return names;
}
///
/// Compares the visible breakpoint maps of two PDBs and returns the residual difference as
/// a deterministic, human-readable report. An empty string means the maps are identical.
///
/// PDB used as the oracle (the original compiler's PDB).
/// PDB under test (the decompiler's reconstruction).
/// Row-to-label map from .
///
/// When true, breakpoints must match on line and column; when false, only the
/// line span is compared (use for methods where the decompiler's column placement within a
/// statement legitimately differs).
///
public static string CompareBreakpointMaps(
Dictionary> expected,
Dictionary> actual,
Dictionary methodNames,
bool includeColumns)
{
var report = new StringBuilder();
foreach (int row in expected.Keys.Union(actual.Keys).OrderBy(r => r))
{
var expectedPoints = Visible(expected, row, includeColumns);
var actualPoints = Visible(actual, row, includeColumns);
var expectedOnly = MultisetDifference(expectedPoints, actualPoints);
var actualOnly = MultisetDifference(actualPoints, expectedPoints);
if (expectedOnly.Count == 0 && actualOnly.Count == 0)
continue;
methodNames.TryGetValue(row, out var name);
report.Append($"0x{0x06000000 | row:x8} {name}").Append('\n');
foreach (var p in expectedOnly.OrderBy(p => p))
report.Append(" - compiler only: ").Append(p).Append('\n');
foreach (var p in actualOnly.OrderBy(p => p))
report.Append(" + decompiler only: ").Append(p).Append('\n');
}
return report.ToString();
}
static List Visible(Dictionary> map, int row, bool includeColumns)
{
if (!map.TryGetValue(row, out var list))
return new List();
return list.Where(sp => !sp.IsHidden).Select(sp => sp.Format(includeColumns)).ToList();
}
static List MultisetDifference(List a, List b)
{
var counts = new Dictionary();
foreach (var x in b)
counts[x] = counts.GetValueOrDefault(x) + 1;
var result = new List();
foreach (var x in a)
{
if (counts.TryGetValue(x, out int c) && c > 0)
counts[x] = c - 1;
else
result.Add(x);
}
return result;
}
///
/// Checks that the decompiler's own PDB is structurally well-formed, independent of any
/// oracle: within each method the sequence points' IL offsets must be strictly increasing,
/// i.e. no duplicate or overlapping sequence points (a duplicate is exactly the failure
/// that aborts PortablePdbWriter in debug builds). Returns the offending methods, or
/// an empty string when well-formed.
///
public static string CheckWellFormed(
Dictionary> pdb,
Dictionary methodNames)
{
var report = new StringBuilder();
foreach (int row in pdb.Keys.OrderBy(r => r))
{
var list = pdb[row];
for (int i = 1; i < list.Count; i++)
{
if (list[i].Offset <= list[i - 1].Offset)
{
methodNames.TryGetValue(row, out var name);
report.Append($"0x{0x06000000 | row:x8} {name}: non-increasing IL offset 0x{list[i].Offset:x} after 0x{list[i - 1].Offset:x}").Append('\n');
break;
}
}
}
return report.ToString();
}
}
}