.NET Decompiler with support for PDB generation, ReadyToRun, Metadata (&more) - cross-platform!
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

296 lines
12 KiB

// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// 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;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Reflection;
using System.Text.RegularExpressions;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using ILSpy.Metadata;
namespace ILSpy.ViewModels
{
/// <summary>
/// Tab content for a metadata-table view: an item list bound to a DataGrid plus the
/// column descriptors the view rebinds on every <c>DataContext</c> change. The DataGrid's
/// <c>Columns</c> collection isn't an Avalonia property, so the model can't bind it
/// declaratively — the view assigns it imperatively from <see cref="Columns"/>.
/// </summary>
public sealed partial class MetadataTablePageModel : TabPageModel
{
[ObservableProperty]
private IReadOnlyList<object> items = Array.Empty<object>();
[ObservableProperty]
private IReadOnlyList<DataGridColumn> columns = Array.Empty<DataGridColumn>();
/// <summary>
/// When set, the view scrolls the grid so the row at this 0-based index is visible
/// (Phase 3 token navigation). Cleared back to <see langword="null"/> after the view
/// honours it so back-navigation can re-trigger the same scroll.
/// </summary>
[ObservableProperty]
private int? scrollToRow;
/// <summary>
/// One filter input per column, in the same order as <see cref="Columns"/>. The view
/// renders each <see cref="ColumnFilter.Text"/> as a TextBox baked into that column's
/// header; the filter predicate ANDs every non-empty entry, requiring each row's
/// matching property's stringified value to contain its filter (case-insensitive).
/// </summary>
public ObservableCollection<ColumnFilter> ColumnFilters { get; } = new();
/// <summary>
/// Raised whenever any column's filter text changes. The view subscribes here to
/// refresh its <c>DataGridCollectionView</c>; tests use it as the canonical filter
/// signal too. The page wires this internally via <c>ColumnFilters.CollectionChanged</c>
/// + each filter's <see cref="INotifyPropertyChanged"/> so callers don't have to chase
/// individual filter instances.
/// </summary>
public event Action? ColumnFilterChanged;
public MetadataTablePageModel()
{
ColumnFilters.CollectionChanged += OnColumnFiltersCollectionChanged;
}
void OnColumnFiltersCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.OldItems is not null)
foreach (ColumnFilter f in e.OldItems)
f.PropertyChanged -= OnColumnFilterTextChanged;
if (e.NewItems is not null)
foreach (ColumnFilter f in e.NewItems)
f.PropertyChanged += OnColumnFilterTextChanged;
}
void OnColumnFilterTextChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(ColumnFilter.Text))
ColumnFilterChanged?.Invoke();
}
/// <summary>
/// Raised when the user clicks a hyperlink-styled token cell. The host (the dock
/// workspace) resolves the (row, columnName) pair to a metadata token and navigates
/// to the target table row.
/// </summary>
public event Action<MetadataCellNavigationEventArgs>? NavigateToCellRequested;
internal void RaiseNavigateToCell(object row, string columnName)
=> NavigateToCellRequested?.Invoke(new MetadataCellNavigationEventArgs(row, columnName));
static readonly ConcurrentDictionary<(Type Type, string Column), PropertyInfo?> propertyLookupCache = new();
static readonly ConcurrentDictionary<string, Regex?> regexCache = new();
/// <summary>
/// Returns true when every <see cref="ColumnFilter"/> in <paramref name="filters"/>
/// either has an empty value or the matching property on <paramref name="item"/>
/// satisfies the filter. The predicate is type-aware:
/// <list type="bullet">
/// <item>regex when the filter is wrapped in <c>/.../</c></item>
/// <item><see cref="FlagsAttribute"/> enums: comma-separated flag names with
/// <c>!</c> prefix for negation; all named flags must match (AND)</item>
/// <item>integer columns: optional comparison operator (<c>&gt;</c>, <c>&lt;</c>,
/// <c>&gt;=</c>, <c>&lt;=</c>, <c>=</c>) followed by a hex (<c>0x…</c>) or
/// decimal literal</item>
/// <item>otherwise: case-insensitive substring on the stringified value</item>
/// </list>
/// Type-aware modes silently fall through to the substring path on a parse failure
/// so a typo can't take down the filter.
/// </summary>
public static bool MatchesFilters(object item, IEnumerable<ColumnFilter> filters)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(filters);
foreach (var f in filters)
{
if (string.IsNullOrEmpty(f.Text))
continue;
var prop = propertyLookupCache.GetOrAdd((item.GetType(), f.ColumnName),
static key => key.Type.GetProperty(key.Column,
BindingFlags.Public | BindingFlags.Instance));
if (prop is null)
return false;
if (!MatchesOne(item, prop, f.Text))
return false;
}
return true;
}
static bool MatchesOne(object item, PropertyInfo prop, string filter)
{
object? value;
try
{ value = prop.GetValue(item); }
catch { return false; }
if (value is null)
return false;
if (TryGetRegex(filter) is { } rx)
return rx.IsMatch(value.ToString() ?? string.Empty);
if (prop.PropertyType.IsEnum
&& Attribute.IsDefined(prop.PropertyType, typeof(FlagsAttribute))
&& TryMatchFlags(prop.PropertyType, value, filter, out var flagsResult))
return flagsResult;
if ((IsIntegerType(prop.PropertyType) || prop.PropertyType.IsEnum)
&& TryMatchNumeric(value, filter, out var numericResult))
return numericResult;
// Substring matches the formatted display value so a typed "06000010" hits a
// "06000010" cell — the predicate sees what the user sees, not the raw int.
var info = prop.GetCustomAttribute<ColumnInfoAttribute>();
var stringified = FormatForDisplay(value, info?.Format);
return stringified.Contains(filter, StringComparison.OrdinalIgnoreCase);
}
static string FormatForDisplay(object value, string? format)
{
if (string.IsNullOrEmpty(format))
return value.ToString() ?? string.Empty;
object boxed = value is Enum e
? Convert.ChangeType(e, e.GetTypeCode(), System.Globalization.CultureInfo.InvariantCulture)
: value;
return boxed is IFormattable f
? f.ToString(format, System.Globalization.CultureInfo.InvariantCulture)
: (value.ToString() ?? string.Empty);
}
static bool TryMatchFlags(Type enumType, object value, string filter, out bool result)
{
result = false;
var actual = Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture);
foreach (var raw in filter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var token = raw;
bool negate = false;
if (token.StartsWith('!'))
{
negate = true;
token = token[1..].Trim();
}
if (token.Length == 0)
return false;
if (!Enum.TryParse(enumType, token, ignoreCase: true, out var parsed) || parsed is null)
return false;
var bits = Convert.ToInt64(parsed, System.Globalization.CultureInfo.InvariantCulture);
bool isSet = bits == 0 ? actual == 0 : (actual & bits) == bits;
if (negate ? isSet : !isSet)
{
result = false;
return true;
}
}
result = true;
return true;
}
static bool TryMatchNumeric(object value, string filter, out bool result)
{
result = false;
var (op, operandText) = SplitComparison(filter);
if (op is null)
return false;
if (!TryParseInt64(operandText, out var operand))
return false;
long actual = value is Enum e
? Convert.ToInt64(e, System.Globalization.CultureInfo.InvariantCulture)
: Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture);
result = op switch {
"=" => actual == operand,
"<" => actual < operand,
">" => actual > operand,
"<=" => actual <= operand,
">=" => actual >= operand,
_ => false,
};
return true;
}
static (string? Op, string Operand) SplitComparison(string filter)
{
// Numeric mode requires an explicit comparison operator. A bare hex / decimal
// literal stays on the substring path so "06000010" still matches the formatted
// "06000010" the column displays.
if (filter.StartsWith(">=", StringComparison.Ordinal))
return (">=", filter[2..].Trim());
if (filter.StartsWith("<=", StringComparison.Ordinal))
return ("<=", filter[2..].Trim());
if (filter.Length > 0 && filter[0] is '>' or '<' or '=')
return (filter[..1], filter[1..].Trim());
return (null, filter);
}
static bool TryParseInt64(string text, out long value)
{
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return long.TryParse(text.AsSpan(2), System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out value);
return long.TryParse(text, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out value);
}
static bool IsIntegerType(Type type) => Type.GetTypeCode(type) is
TypeCode.SByte or TypeCode.Byte or TypeCode.Int16 or TypeCode.UInt16
or TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int64 or TypeCode.UInt64;
static Regex? TryGetRegex(string filter)
{
// `/pattern/` opts a filter into regex mode (case-insensitive). Anything else,
// or an unparseable pattern, leaves the predicate on the substring path.
if (filter.Length < 2 || filter[0] != '/' || filter[^1] != '/')
return null;
return regexCache.GetOrAdd(filter, static text => {
try
{ return new Regex(text[1..^1], RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); }
catch { return null; }
});
}
}
/// <summary>One column's filter entry — the column name plus its current filter text.</summary>
public sealed partial class ColumnFilter : ObservableObject
{
public ColumnFilter(string columnName)
{
ColumnName = columnName;
}
public string ColumnName { get; }
// Backing field is `text` rather than `value` because the latter collides with C#'s
// contextual identifier for property setters and trips Avalonia's INPC binding
// plugin on write (the cached accessor's target reference comes back null).
[ObservableProperty]
private string? text;
}
/// <summary>The (row, column) pair clicked in a token cell.</summary>
public sealed record MetadataCellNavigationEventArgs(object Row, string ColumnName);
}